kc-beta 0.6.2 → 0.7.1

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.
Files changed (52) hide show
  1. package/LICENSE +81 -0
  2. package/LICENSE-COMMERCIAL.md +125 -0
  3. package/README.md +21 -3
  4. package/package.json +14 -5
  5. package/src/agent/context-window.js +9 -12
  6. package/src/agent/context.js +14 -1
  7. package/src/agent/document-parser.js +169 -0
  8. package/src/agent/engine.js +382 -19
  9. package/src/agent/history/event-history.js +222 -0
  10. package/src/agent/llm-client.js +55 -0
  11. package/src/agent/message-utils.js +63 -0
  12. package/src/agent/pipelines/_milestone-derive.js +566 -0
  13. package/src/agent/pipelines/base.js +21 -0
  14. package/src/agent/pipelines/distillation.js +28 -15
  15. package/src/agent/pipelines/extraction.js +130 -36
  16. package/src/agent/pipelines/finalization.js +178 -11
  17. package/src/agent/pipelines/index.js +6 -1
  18. package/src/agent/pipelines/initializer.js +74 -8
  19. package/src/agent/pipelines/production-qc.js +31 -44
  20. package/src/agent/pipelines/skill-authoring.js +97 -80
  21. package/src/agent/pipelines/skill-testing.js +106 -23
  22. package/src/agent/retry.js +10 -2
  23. package/src/agent/scheduler.js +14 -2
  24. package/src/agent/session-state.js +18 -1
  25. package/src/agent/skill-loader.js +13 -7
  26. package/src/agent/skill-validator.js +19 -5
  27. package/src/agent/task-manager.js +61 -5
  28. package/src/agent/tools/document-chunk.js +21 -9
  29. package/src/agent/tools/phase-advance.js +37 -5
  30. package/src/agent/tools/release.js +51 -9
  31. package/src/agent/tools/rule-catalog.js +11 -1
  32. package/src/agent/tools/workspace-file.js +32 -0
  33. package/src/agent/workspace.js +39 -1
  34. package/src/cli/components.js +64 -14
  35. package/src/cli/index.js +62 -3
  36. package/src/cli/meme.js +26 -25
  37. package/src/config.js +65 -22
  38. package/src/model-tiers.json +24 -8
  39. package/src/providers.js +42 -0
  40. package/template/release/v1/README.md.tmpl +108 -0
  41. package/template/release/v1/catalog.json.tmpl +4 -0
  42. package/template/release/v1/kc_runtime/__init__.py +11 -0
  43. package/template/release/v1/kc_runtime/confidence.py +63 -0
  44. package/template/release/v1/kc_runtime/doc_parser.py +127 -0
  45. package/template/release/v1/manifest.json.tmpl +11 -0
  46. package/template/release/v1/render_dashboard.py +117 -0
  47. package/template/release/v1/run.py +212 -0
  48. package/template/release/v1/serve.sh +17 -0
  49. package/template/skills/en/meta-meta/work-decomposition/SKILL.md +326 -0
  50. package/template/skills/en/skill-creator/SKILL.md +1 -1
  51. package/template/skills/zh/meta-meta/work-decomposition/SKILL.md +321 -0
  52. package/template/skills/zh/skill-creator/SKILL.md +1 -1
@@ -16,12 +16,15 @@
16
16
  * still bypasses. The validator's job is to refuse the auto-advance,
17
17
  * not to trap the agent.
18
18
  *
19
- * Validation rules per `check_r###.py`:
19
+ * Validation rules per `check_*.py`:
20
20
  * 1. File ≥ 100 bytes (smoke test for empty stubs).
21
21
  * 2. Passes `python3 -c "import ast; ast.parse(open(F).read())"` (no
22
22
  * syntax errors).
23
- * 3. Defines a function reachable by name `check_rule` or `verify`
24
- * (regex match on file content).
23
+ * 3. Defines a function reachable by one of the names: `check_rule`,
24
+ * `verify`, OR `check_r<digits>` (e.g. `check_r014`, `check_r013_r017`).
25
+ * v0.7.0 A6 broadened the third pattern after E2E #5 audit found
26
+ * three sessions independently chose `def check_r###` over the
27
+ * canonical names — the validator was too strict.
25
28
  *
26
29
  * Disable mechanism: if `python3` is not on PATH, validator silently
27
30
  * passes everything and emits a one-time warning — we don't want the
@@ -32,7 +35,18 @@ import { execFileSync } from "node:child_process";
32
35
  import fs from "node:fs";
33
36
  import path from "node:path";
34
37
 
35
- const ENTRY_POINT_REGEX = /^\s*(?:async\s+)?def\s+(check_rule|verify)\b/m;
38
+ // v0.7.0 A6: entry-point check is a sanity probe, not a style enforcer.
39
+ // The validator's real signal comes from `≥ 100 bytes` + `ast.parse
40
+ // passes`. Restricting to specific verb names rejected 27/28 GLM
41
+ // scripts in E2E #5 — the cost outweighed the catch (every contestant
42
+ // converged on a different naming convention).
43
+ //
44
+ // New rule: any top-level `def \w+(...)` counts. Rejects pure-imports
45
+ // or comment-only stubs (which is what we actually wanted to catch),
46
+ // accepts anything with real logic. The check_*.py *filename* (matched
47
+ // by the path regex in `findCheckScripts`) carries the rule-id signal;
48
+ // the function name doesn't need to.
49
+ const ENTRY_POINT_REGEX = /^(?:async\s+)?def\s+\w+\s*\(/m;
36
50
  const MIN_BYTES = 100;
37
51
 
38
52
  export class SkillValidator {
@@ -141,7 +155,7 @@ export class SkillValidator {
141
155
  try { content = fs.readFileSync(filePath, "utf-8"); }
142
156
  catch { return { ok: false, error: "read failed after parse OK" }; }
143
157
  if (!ENTRY_POINT_REGEX.test(content)) {
144
- return { ok: false, error: "no entry point: expected `def check_rule(...)` or `def verify(...)`" };
158
+ return { ok: false, error: "no callable defined: file has imports/comments only, no top-level `def`" };
145
159
  }
146
160
 
147
161
  return { ok: true };
@@ -139,12 +139,23 @@ export class TaskManager {
139
139
  // --- Bulk creation from rule catalog ---
140
140
 
141
141
  /**
142
- * Phases where one-task-per-rule is the natural unit of work.
143
- * For BOOTSTRAP / EXTRACTION the unit is a regulation (one PDF → many rules);
144
- * ralph-loop shouldn't drive per-rule there because the rules don't exist yet
145
- * (or are the *output*, not the input) see E2E #3 coverage check.
142
+ * Phases where the engine auto-creates one-task-per-rule on phase entry.
143
+ *
144
+ * v0.7.0 B2: empty by default. Agent owns TaskBoard decisions per the
145
+ * work-decomposition meta-meta skillengine no longer assumes per-rule
146
+ * granularity is right. The agent reads the rule list from describeState
147
+ * and calls TaskCreate with whatever shape (single, grouped, range,
148
+ * non-rule) makes sense for the corpus.
149
+ *
150
+ * Override `KC_AGENT_OWNS_TASKBOARD=0` to restore v0.6.x behavior
151
+ * (engine auto-populates per-rule for skill_authoring + skill_testing).
152
+ * The override is a staged-rollout safety valve, not a long-lived
153
+ * config — slated for removal in v0.8.0 after E2E #6 validates the
154
+ * agent-owned default.
146
155
  */
147
- static PER_RULE_PHASES = new Set(["skill_authoring", "skill_testing"]);
156
+ static PER_RULE_PHASES = (process.env.KC_AGENT_OWNS_TASKBOARD === "0")
157
+ ? new Set(["skill_authoring", "skill_testing"])
158
+ : new Set();
148
159
 
149
160
  /**
150
161
  * Create one task per rule for a given phase — but only if the phase's unit
@@ -197,6 +208,51 @@ export class TaskManager {
197
208
  ).length;
198
209
  }
199
210
 
211
+ /**
212
+ * v0.7.0 A5: Reconcile per-rule tasks against disk artifacts.
213
+ *
214
+ * Background: E2E #5 DS audit found tasks.json showing 70/70 completed
215
+ * while only ~56 dirs / 36 with check_*.py existed on disk. The agent
216
+ * called markDone() optimistically but the artifacts didn't materialize
217
+ * (or were deleted later). The engine's phase gate trusted the count.
218
+ *
219
+ * Reconcile walks every "completed" task in PER_RULE_PHASES and checks
220
+ * whether the expected disk artifacts exist via a caller-supplied
221
+ * `expectsFn(task) -> boolean` predicate. Tasks whose artifacts are
222
+ * missing are flipped back to `pending` with a `reconcile_failed`
223
+ * note so the agent can re-do the work, and the gate can refuse
224
+ * advance if the per-rule artifact set is incomplete.
225
+ *
226
+ * Called from engine `_advancePhase` before `exitCriteriaMet()`.
227
+ *
228
+ * @param {(task: object) => boolean} expectsFn
229
+ * @returns {{ reconciled: number, flippedBack: string[] }}
230
+ * Number of tasks inspected, plus the IDs of tasks flipped back to
231
+ * pending. Caller logs to events.jsonl.
232
+ */
233
+ reconcileAgainstDisk(expectsFn) {
234
+ let reconciled = 0;
235
+ const flippedBack = [];
236
+ if (typeof expectsFn !== "function") return { reconciled, flippedBack };
237
+ for (const task of this._tasks) {
238
+ if (task.status !== "completed") continue;
239
+ if (!TaskManager.PER_RULE_PHASES.has(task.phase)) continue;
240
+ reconciled++;
241
+ let ok = false;
242
+ try { ok = !!expectsFn(task); }
243
+ catch { ok = false; }
244
+ if (!ok) {
245
+ task.status = "pending";
246
+ task.reconcile_failed = true;
247
+ task.summary = (task.summary ? task.summary + " | " : "") +
248
+ "v0.7.0 A5: artifacts missing on disk → flipped back to pending";
249
+ flippedBack.push(task.id);
250
+ }
251
+ }
252
+ if (flippedBack.length > 0) this.save();
253
+ return { reconciled, flippedBack };
254
+ }
255
+
200
256
  /**
201
257
  * Format task list for injection into system prompt context.
202
258
  * Compact checklist — not conversation history.
@@ -194,20 +194,32 @@ export class DocumentChunkTool extends BaseTool {
194
194
  };
195
195
  }
196
196
 
197
- // For other formats (.docx, .xlsx, etc): read as UTF-8 best-effort.
198
- // Upstream agent should call document_parse first and then document_chunk
199
- // on the parsed output directly current MVP keeps the tool surface small.
197
+ // v0.7.0 G (#91): route .docx / .doc / others through native parser
198
+ // dispatcher (mammoth / word-extractor / LibreOffice fallback).
199
+ // Replaces the prior "read as UTF-8" stub which produced binary
200
+ // garbage on .docx and forced agents to call document_parse + chunk
201
+ // separately. extractText() returns clean text or a structured
202
+ // failure that downstream can surface to the agent.
200
203
  try {
201
- const txt = fs.readFileSync(absPath, "utf-8");
204
+ const { extractText } = await import("../document-parser.js");
205
+ const result = await extractText(absPath);
206
+ if (result.ok && result.text) {
207
+ return {
208
+ source_file: baseName,
209
+ total_pages: 1,
210
+ blocks: [{ page: 1, markdown: result.text }],
211
+ parse_via: result.via,
212
+ };
213
+ }
202
214
  return {
203
- source_file: baseName,
204
- total_pages: 1,
205
- blocks: [{ page: 1, markdown: txt }],
215
+ source_file: baseName, total_pages: 0, blocks: [],
216
+ parse_error: result.error ||
217
+ `Unsupported format '${suffix}'. Install mammoth / word-extractor or rely on LibreOffice fallback.`,
206
218
  };
207
- } catch {
219
+ } catch (e) {
208
220
  return {
209
221
  source_file: baseName, total_pages: 0, blocks: [],
210
- parse_error: `Unsupported format '${suffix}'. Run document_parse first and use its output, or stick to .pdf / .md / .txt.`,
222
+ parse_error: `parse exception: ${e?.message || String(e)}`,
211
223
  };
212
224
  }
213
225
  }
@@ -15,7 +15,11 @@ const VALID_PHASES = new Set(Object.values(Phase));
15
15
  */
16
16
  export class PhaseAdvanceTool extends BaseTool {
17
17
  /**
18
- * @param {(to: string, reason: string, opts: {force?: boolean}) => boolean} advanceFn
18
+ * @param {(to: string, reason: string, opts: {force?: boolean}) => {advanced: boolean, engineCounts?: string}} advanceFn
19
+ * v0.7.1 2c: returns the rich object so the tool can surface engine
20
+ * telemetry in the refusal text. Internal engine callers of
21
+ * `_advancePhase` still get the bool; only this LLM-facing tool
22
+ * uses the wrapped form.
19
23
  * @param {() => string} getCurrentPhaseFn - H1: lets the tool read the
20
24
  * engine's phase BEFORE the call, so it can distinguish "already there"
21
25
  * (silent no-op, informational) from "non-adjacent refusal" (actionable).
@@ -91,7 +95,11 @@ export class PhaseAdvanceTool extends BaseTool {
91
95
  );
92
96
  }
93
97
 
94
- const advanced = this._advance(to, input.reason || "agent request", { force: !!input.force });
98
+ // v0.7.1 2c: advanceFn returns {advanced, engineCounts?} so we can
99
+ // surface telemetry in the refusal text below. Internal callers of
100
+ // _advancePhase still get bool; only this LLM-facing tool unwraps.
101
+ const advanceResult = this._advance(to, input.reason || "agent request", { force: !!input.force });
102
+ const advanced = !!advanceResult?.advanced;
95
103
  if (advanced) {
96
104
  // Log the ack so post-mortems can find phase advances that proceeded
97
105
  // with live subagents
@@ -104,10 +112,34 @@ export class PhaseAdvanceTool extends BaseTool {
104
112
  return new ToolResult(`Advanced${beforePhase ? ` from ${beforePhase}` : ""} to ${to}${input.force ? " (forced)" : ""}`);
105
113
  }
106
114
 
107
- // Truly refused — non-adjacent transition without force, or terminal-phase
108
- // forward attempt. Give the actionable hint.
115
+ // Truly refused — possible reasons: non-adjacent transition,
116
+ // terminal-phase forward attempt, or hard-tracking gate (source phase's
117
+ // exit criteria not met by engine telemetry).
118
+ //
119
+ // v0.7.0 A3: refusal text no longer advertises `force:true`. E2E #5
120
+ // showed every conductor reading the old refusal hint and force-bypassing
121
+ // immediately (12/12 transitions). The escape valve remains in the input
122
+ // schema (discoverable) but isn't hand-fed to the LLM here. Instead,
123
+ // direct the agent at the missing milestones it can satisfy.
124
+ //
125
+ // v0.7.1 2c: include engineCounts when available so the agent sees
126
+ // exactly which milestones the gate is reading and can satisfy them.
127
+ // E2E #6 v070 showed the generic "check /status" hint wasn't concrete
128
+ // enough — agents forced through. Naming the gap inline reduces that.
129
+ const engineCountsLine = advanceResult?.engineCounts
130
+ ? `\nEngine telemetry: ${advanceResult.engineCounts}`
131
+ : "";
132
+
109
133
  return new ToolResult(
110
- `Did not advance to ${to}. Transition is non-adjacent${beforePhase ? ` (currently in ${beforePhase})` : ""} set force:true to override, or advance to the immediate-next phase first.`,
134
+ `Did not advance to ${to} (currently in ${beforePhase || "?"}). ` +
135
+ `Likely cause: source-phase exit criteria not met.${engineCountsLine}\n\n` +
136
+ `Run /status (or read the phase describeState block in this turn's system reminder) ` +
137
+ `to see which milestones are missing, then produce the disk artifacts that satisfy them — ` +
138
+ `the engine derives milestones from filesystem facts (rule_skills/<id>/SKILL.md, check.py, ` +
139
+ `workflows/<id>/*.py, output/results/*.json, etc.). ` +
140
+ `If the transition is non-adjacent or this phase truly is done despite the gate, ` +
141
+ `re-call with the documented schema flag. The engine logged the precise reason in ` +
142
+ `events.jsonl as 'phase_advance_refused'.`,
111
143
  false,
112
144
  );
113
145
  }
@@ -151,8 +151,23 @@ export class ReleaseTool extends BaseTool {
151
151
  }
152
152
 
153
153
  if (ruleEntries.length === 0) {
154
+ // v0.7.0 #98: actionable error. The previous "no workflows found
155
+ // for any selected rule" message left agents confused (E2E #5 GLM
156
+ // dismissed it with "不影响系统功能" and shipped anyway, with a
157
+ // broken bundle). Spell out the canonical layout, the accepted
158
+ // alternatives, and where the agent's actual files are.
154
159
  return new ToolResult(
155
- `no workflows found for any selected rule. Missing: ${missingWorkflows.join(", ")}`,
160
+ `Release tool found no workflows for the selected rules.\n\n` +
161
+ `Missing: ${missingWorkflows.slice(0, 10).join(", ")}` +
162
+ (missingWorkflows.length > 10 ? ` (+ ${missingWorkflows.length - 10} more)` : "") + "\n\n" +
163
+ `Accepted layouts (release tool checks all three):\n` +
164
+ ` workflows/<ruleId>/workflow_v1.py (canonical)\n` +
165
+ ` workflows/<ruleId>_workflow.py (flat)\n` +
166
+ ` workflows/<ruleId>.json (regex_skill manifest with .entry path)\n\n` +
167
+ `If your workflow files exist under a different layout, either ` +
168
+ `relocate them or write a workflows/<ruleId>.json manifest pointing ` +
169
+ `at the actual file. Do NOT ship the release without workflows — ` +
170
+ `the run.py harness needs them at runtime.`,
156
171
  true,
157
172
  );
158
173
  }
@@ -251,15 +266,42 @@ export class ReleaseTool extends BaseTool {
251
266
  }
252
267
 
253
268
  _findLatestWorkflow(ruleId) {
269
+ // Canonical: workflows/<ruleId>/workflow_v#.py (subdirectory layout)
254
270
  const wfDir = path.join(this._workspace.cwd, "workflows", ruleId);
255
- if (!fs.existsSync(wfDir) || !fs.statSync(wfDir).isDirectory()) return null;
256
- const entries = fs.readdirSync(wfDir).sort();
257
- const versioned = entries.filter((f) => /^workflow_v\d+\.py$/.test(f));
258
- if (versioned.length > 0) return path.join(wfDir, versioned[versioned.length - 1]);
259
- const any = entries.find((f) => f.endsWith(".py") && f.toLowerCase().includes("workflow"));
260
- if (any) return path.join(wfDir, any);
261
- const py = entries.find((f) => f.endsWith(".py"));
262
- return py ? path.join(wfDir, py) : null;
271
+ if (fs.existsSync(wfDir) && fs.statSync(wfDir).isDirectory()) {
272
+ const entries = fs.readdirSync(wfDir).sort();
273
+ const versioned = entries.filter((f) => /^workflow_v\d+\.py$/.test(f));
274
+ if (versioned.length > 0) return path.join(wfDir, versioned[versioned.length - 1]);
275
+ const any = entries.find((f) => f.endsWith(".py") && f.toLowerCase().includes("workflow"));
276
+ if (any) return path.join(wfDir, any);
277
+ const py = entries.find((f) => f.endsWith(".py"));
278
+ if (py) return path.join(wfDir, py);
279
+ }
280
+
281
+ // v0.7.0 #98: fall back to flat layouts seen in E2E #5 — both DS
282
+ // and GLM produced workflows that release.js's strict per-dir check
283
+ // missed. Accept these so the release tool actually packages the
284
+ // agent's work instead of returning "no workflows found".
285
+ const flatRoot = path.join(this._workspace.cwd, "workflows");
286
+ if (fs.existsSync(flatRoot)) {
287
+ // GLM-style flat: workflows/R001_workflow.py
288
+ const flat = path.join(flatRoot, `${ruleId}_workflow.py`);
289
+ if (fs.existsSync(flat) && fs.statSync(flat).isFile()) return flat;
290
+ // DS-style manifest: workflows/R001.json (regex_skill pointer)
291
+ const manifest = path.join(flatRoot, `${ruleId}.json`);
292
+ if (fs.existsSync(manifest) && fs.statSync(manifest).isFile()) {
293
+ try {
294
+ const data = JSON.parse(fs.readFileSync(manifest, "utf-8"));
295
+ if (data?.entry) {
296
+ const entryPath = path.isAbsolute(data.entry)
297
+ ? data.entry
298
+ : path.join(this._workspace.cwd, data.entry);
299
+ if (fs.existsSync(entryPath)) return entryPath;
300
+ }
301
+ } catch { /* manifest unreadable; skip */ }
302
+ }
303
+ }
304
+ return null;
263
305
  }
264
306
 
265
307
  _resolveFixture(rel) {
@@ -56,7 +56,17 @@ export class RuleCatalogTool extends BaseTool {
56
56
  constructor(workspace) {
57
57
  super();
58
58
  this._workspace = workspace;
59
- this._catalogPath = path.join(workspace.cwd, "rules", "catalog.json");
59
+ // v0.6.3: do NOT cache the absolute path here. engine.renameSession()
60
+ // moves the workspace dir via fs.renameSync and updates this._workspace.cwd
61
+ // via Workspace.rename(), but tool instances live in the registry and
62
+ // never see the cascade — a cached _catalogPath would keep writing to the
63
+ // pre-rename absolute path, mkdir-recreating the orphaned directory and
64
+ // stranding all rule_catalog state. Resolve on every call instead.
65
+ }
66
+
67
+ /** Always read workspace.cwd at call time so /rename is followed. */
68
+ get _catalogPath() {
69
+ return path.join(this._workspace.cwd, "rules", "catalog.json");
60
70
  }
61
71
 
62
72
  get name() { return "rule_catalog"; }
@@ -113,6 +113,37 @@ export class WorkspaceFileTool extends BaseTool {
113
113
  }
114
114
  const resolved = this._resolveForScope(filePath, scope);
115
115
  fs.mkdirSync(path.dirname(resolved), { recursive: true });
116
+
117
+ // v0.7.0 Group M (#84 remainder): on case-insensitive filesystems
118
+ // (macOS/Windows defaults), warn when the target's basename collides
119
+ // with an existing sibling differing only in case. Write proceeds
120
+ // — agents may legitimately overwrite — but the agent gets visible
121
+ // signal so it doesn't end up confused like E2E #5 GLM ("SKILL.md
122
+ // disappeared" when the inode was shared with skill.md). Workspace-
123
+ // scope only; project-dir scope is the user's territory.
124
+ let collisionNote = "";
125
+ if (
126
+ scope === "workspace" &&
127
+ this._workspace.fsCaseSensitive === false
128
+ ) {
129
+ try {
130
+ const parent = path.dirname(resolved);
131
+ const targetBase = path.basename(resolved);
132
+ const targetLower = targetBase.toLowerCase();
133
+ const siblings = fs.readdirSync(parent);
134
+ const collision = siblings.find(
135
+ (s) => s !== targetBase && s.toLowerCase() === targetLower,
136
+ );
137
+ if (collision) {
138
+ collisionNote =
139
+ ` ⚠ case-collision: case-insensitive filesystem already has '${collision}'` +
140
+ ` at this path; both names resolve to the same inode. Pick one canonical case` +
141
+ ` (lowercase preferred for skill files) and use it consistently — otherwise` +
142
+ ` archive_file / Read on either name affects the other.`;
143
+ }
144
+ } catch { /* readdirSync may fail on a fresh dir; that's fine, no collision possible */ }
145
+ }
146
+
116
147
  fs.writeFileSync(resolved, content, "utf-8");
117
148
 
118
149
  // Auto-commit to git for workspace writes (silently no-ops if gitignored or git unavailable)
@@ -124,6 +155,7 @@ export class WorkspaceFileTool extends BaseTool {
124
155
  const label = scope === "project" ? `[project] ${filePath}` : filePath;
125
156
  let msg = `Wrote ${content.length} chars to ${label}`;
126
157
  if (traceId) msg += ` [trace: ${traceId}]`;
158
+ if (collisionNote) msg += collisionNote;
127
159
  return new ToolResult(msg);
128
160
  }
129
161
 
@@ -62,6 +62,38 @@ export class Workspace {
62
62
  this._gitAutoCommitEnabled = opts.gitAutoCommit !== false;
63
63
  this._gitAvailable = this._gitAutoCommitEnabled && Workspace.isGitInstalled();
64
64
  if (this._gitAvailable) this._initGitRepo();
65
+
66
+ // v0.7.0 F1: detect filesystem case-sensitivity once at construction.
67
+ // macOS HFS+/APFS default + Windows NTFS default are case-insensitive
68
+ // — `SKILL.md` and `skill.md` resolve to the same inode. E2E #5 GLM
69
+ // hit this exactly: wrote skill.md, then SKILL.md (overwrote it),
70
+ // then archive_file moved skill.md (moved both since same inode),
71
+ // then agent saw "SKILL.md disappeared". Surfacing the FS property
72
+ // here lets tools warn on case-collision attempts.
73
+ this.fsCaseSensitive = Workspace._probeCaseSensitivity(this.path);
74
+ }
75
+
76
+ /**
77
+ * v0.7.0 F1: cheap probe — write a known-case marker, stat its
78
+ * lowercased twin. If the stat succeeds, the FS folded the case →
79
+ * case-insensitive. Probe runs once per session at construction.
80
+ * Marker is deleted regardless. Returns true on case-sensitive (Linux
81
+ * default), false on case-insensitive (macOS/Windows defaults).
82
+ */
83
+ static _probeCaseSensitivity(dir) {
84
+ const probe = path.join(dir, ".kc-case-probe-MIXED");
85
+ const twin = path.join(dir, ".kc-case-probe-mixed");
86
+ try {
87
+ fs.writeFileSync(probe, "");
88
+ const sensitive = !fs.existsSync(twin);
89
+ try { fs.unlinkSync(probe); } catch { /* ignore */ }
90
+ return sensitive;
91
+ } catch {
92
+ // Couldn't probe (permission?); assume sensitive (Linux default)
93
+ // since false-positive on macOS doesn't break correctness, just
94
+ // misses the warning opportunity.
95
+ return true;
96
+ }
65
97
  }
66
98
 
67
99
  /** @returns {string} Current workspace directory */
@@ -249,7 +281,13 @@ export class Workspace {
249
281
  * `withFileLock` (async) for all paths that allow it.
250
282
  */
251
283
  withSyncFileLock(relPath, fn, { timeoutMs = 5_000, staleMs = 30_000 } = {}) {
252
- const lockPath = path.join(this.path, `${relPath}.lock`);
284
+ // v0.7.0 H3: route through resolvePath() so a caller passing
285
+ // "../../shared.json" can't write a lockfile outside the workspace
286
+ // root (the async sibling withFileLock already does this — sync
287
+ // version was the asymmetric weak spot). Cross-session lock-file
288
+ // contamination shut down.
289
+ const resolved = this.resolvePath(relPath);
290
+ const lockPath = `${resolved}.lock`;
253
291
  return this._withSyncLockAtPath(lockPath, fn, { timeoutMs, staleMs });
254
292
  }
255
293
 
@@ -52,19 +52,64 @@ const LENAT_QUOTE = "Intelligence is ten million rules.";
52
52
  // display jumpy. Peak stays at the highest seen this session.
53
53
  const CTX_SAMPLE_WINDOW = 30;
54
54
 
55
+ // Visual width of a string with CJK chars counted as 2 cells each.
56
+ // Used to truncate session IDs etc. so the status bar fits in a single
57
+ // terminal row regardless of terminal width.
58
+ function visualWidth(s) {
59
+ let w = 0;
60
+ for (const ch of s || "") {
61
+ const cp = ch.codePointAt(0);
62
+ // CJK + fullwidth + emoji rough heuristic — wide if codepoint ≥ 0x1100.
63
+ w += cp >= 0x1100 ? 2 : 1;
64
+ }
65
+ return w;
66
+ }
67
+
68
+ function truncateVisual(s, maxCells) {
69
+ if (!s) return s;
70
+ if (visualWidth(s) <= maxCells) return s;
71
+ // Middle-truncate so renamed sessions keep both ends visible
72
+ // (e.g. "资管新规测试062-GLM" → "资管新规…2-GLM" — model suffix preserved).
73
+ const headBudget = Math.floor((maxCells - 1) / 2);
74
+ const tailBudget = maxCells - 1 - headBudget;
75
+ const chars = [...s];
76
+ let w = 0; let head = "";
77
+ for (const ch of chars) {
78
+ const cw = ch.codePointAt(0) >= 0x1100 ? 2 : 1;
79
+ if (w + cw > headBudget) break;
80
+ w += cw; head += ch;
81
+ }
82
+ let tw = 0; let tail = "";
83
+ for (let i = chars.length - 1; i >= 0; i--) {
84
+ const ch = chars[i];
85
+ const cw = ch.codePointAt(0) >= 0x1100 ? 2 : 1;
86
+ if (tw + cw > tailBudget) break;
87
+ tw += cw; tail = ch + tail;
88
+ }
89
+ return head + "…" + tail;
90
+ }
91
+
55
92
  export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
56
93
  const samplesRef = useRef([]);
57
94
  const peakRef = useRef(0);
58
95
 
59
- // Push current sample + cap the ring
96
+ // v0.7.0 C3: ref mutations live inside useEffect, not in the render
97
+ // body. React may invoke renders multiple times (StrictMode, concurrent
98
+ // rendering, suspense replay); mutating refs inline duplicated the
99
+ // pushed sample on every replay and let peak drift higher than the
100
+ // real history. Effect runs once per commit.
101
+ useEffect(() => {
102
+ const samples = samplesRef.current;
103
+ samples.push(contextTokens || 0);
104
+ if (samples.length > CTX_SAMPLE_WINDOW) samples.shift();
105
+ if ((contextTokens || 0) > peakRef.current) peakRef.current = contextTokens || 0;
106
+ }, [contextTokens]);
107
+
60
108
  const samples = samplesRef.current;
61
- samples.push(contextTokens || 0);
62
- if (samples.length > CTX_SAMPLE_WINDOW) samples.shift();
63
109
  const smoothed = samples.length > 0
64
110
  ? Math.round(samples.reduce((a, b) => a + b, 0) / samples.length)
65
- : 0;
66
- if ((contextTokens || 0) > peakRef.current) peakRef.current = contextTokens || 0;
67
- const peak = peakRef.current;
111
+ : (contextTokens || 0);
112
+ const peak = Math.max(peakRef.current, contextTokens || 0);
68
113
 
69
114
  const pct = contextLimit ? Math.round((smoothed / contextLimit) * 100) : 0;
70
115
  const ctxColor = pct > 80 ? "red" : pct > 60 ? "yellow" : "green";
@@ -83,15 +128,19 @@ export function StatusBar({ sessionId, phase, contextTokens, contextLimit }) {
83
128
  : pct >= 60 ? " · 💾 建议 /compact"
84
129
  : "";
85
130
 
131
+ // Truncate session ID to keep the bar single-row even with CJK names.
132
+ // 14 visual cells covers most short UUIDs and ~6-7 CJK chars.
133
+ const displaySessionId = sessionId ? truncateVisual(sessionId, 14) : "";
134
+
86
135
  return h(Box, { marginTop: 0 },
87
- h(Text, { dimColor: true }, " ⏵⏵ KC Agent CLI "),
88
- h(Text, { dimColor: true }, sessionId ? `[${sessionId}]` : ""),
89
- phase ? h(Text, { color: "cyan" }, ` ${phase.toUpperCase()}`) : null,
90
- h(Text, { color: "green" }, " ● "),
91
- h(Text, { color: ctxColor }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
92
- showPeak ? h(Text, { dimColor: true }, ` · peak ${fmt(peak)}`) : null,
93
- compactHint ? h(Text, { color: ctxColor }, compactHint) : null,
94
- h(Text, { dimColor: true }, ` · ${LENAT_QUOTE}`),
136
+ h(Text, { dimColor: true, wrap: "truncate-end" }, " ⏵⏵ KC "),
137
+ h(Text, { dimColor: true, wrap: "truncate-end" }, displaySessionId ? `[${displaySessionId}]` : ""),
138
+ phase ? h(Text, { color: "cyan", wrap: "truncate-end" }, ` ${phase.toUpperCase()}`) : null,
139
+ h(Text, { color: "green", wrap: "truncate-end" }, " ● "),
140
+ h(Text, { color: ctxColor, wrap: "truncate-end" }, `CTX: ${ctxLabel}/${limitLabel} (${pct}%)`),
141
+ showPeak ? h(Text, { dimColor: true, wrap: "truncate-end" }, ` · peak ${fmt(peak)}`) : null,
142
+ compactHint ? h(Text, { color: ctxColor, wrap: "truncate-end" }, compactHint) : null,
143
+ h(Text, { dimColor: true, wrap: "truncate-end" }, ` · ${LENAT_QUOTE}`),
95
144
  );
96
145
  }
97
146
 
@@ -161,6 +210,7 @@ export function WelcomeBanner({ projectDir, pendingInputCount = 0 } = {}) {
161
210
  " rules/02 as core; rules/03-10 are supporting context only.\""),
162
211
  h(Text, null, ""),
163
212
  h(Text, { dimColor: true }, "Product of Memium / kitchen-engineer42"),
213
+ h(Text, { dimColor: true }, "PolyForm Noncommercial 1.0.0 — commercial use requires a separate license. See LICENSE-COMMERCIAL.md."),
164
214
  );
165
215
  }
166
216