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.
- package/LICENSE +81 -0
- package/LICENSE-COMMERCIAL.md +125 -0
- package/README.md +21 -3
- package/package.json +14 -5
- package/src/agent/context-window.js +9 -12
- package/src/agent/context.js +14 -1
- package/src/agent/document-parser.js +169 -0
- package/src/agent/engine.js +382 -19
- package/src/agent/history/event-history.js +222 -0
- package/src/agent/llm-client.js +55 -0
- package/src/agent/message-utils.js +63 -0
- package/src/agent/pipelines/_milestone-derive.js +566 -0
- package/src/agent/pipelines/base.js +21 -0
- package/src/agent/pipelines/distillation.js +28 -15
- package/src/agent/pipelines/extraction.js +130 -36
- package/src/agent/pipelines/finalization.js +178 -11
- package/src/agent/pipelines/index.js +6 -1
- package/src/agent/pipelines/initializer.js +74 -8
- package/src/agent/pipelines/production-qc.js +31 -44
- package/src/agent/pipelines/skill-authoring.js +97 -80
- package/src/agent/pipelines/skill-testing.js +106 -23
- package/src/agent/retry.js +10 -2
- package/src/agent/scheduler.js +14 -2
- package/src/agent/session-state.js +18 -1
- package/src/agent/skill-loader.js +13 -7
- package/src/agent/skill-validator.js +19 -5
- package/src/agent/task-manager.js +61 -5
- package/src/agent/tools/document-chunk.js +21 -9
- package/src/agent/tools/phase-advance.js +37 -5
- package/src/agent/tools/release.js +51 -9
- package/src/agent/tools/rule-catalog.js +11 -1
- package/src/agent/tools/workspace-file.js +32 -0
- package/src/agent/workspace.js +39 -1
- package/src/cli/components.js +64 -14
- package/src/cli/index.js +62 -3
- package/src/cli/meme.js +26 -25
- package/src/config.js +65 -22
- package/src/model-tiers.json +24 -8
- package/src/providers.js +42 -0
- package/template/release/v1/README.md.tmpl +108 -0
- package/template/release/v1/catalog.json.tmpl +4 -0
- package/template/release/v1/kc_runtime/__init__.py +11 -0
- package/template/release/v1/kc_runtime/confidence.py +63 -0
- package/template/release/v1/kc_runtime/doc_parser.py +127 -0
- package/template/release/v1/manifest.json.tmpl +11 -0
- package/template/release/v1/render_dashboard.py +117 -0
- package/template/release/v1/run.py +212 -0
- package/template/release/v1/serve.sh +17 -0
- package/template/skills/en/meta-meta/work-decomposition/SKILL.md +326 -0
- package/template/skills/en/skill-creator/SKILL.md +1 -1
- package/template/skills/zh/meta-meta/work-decomposition/SKILL.md +321 -0
- 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 `
|
|
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
|
|
24
|
-
*
|
|
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
|
-
|
|
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
|
|
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
|
|
143
|
-
*
|
|
144
|
-
*
|
|
145
|
-
*
|
|
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 skill — engine 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 =
|
|
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
|
-
//
|
|
198
|
-
//
|
|
199
|
-
//
|
|
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
|
|
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
|
-
|
|
205
|
-
|
|
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: `
|
|
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
|
-
|
|
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
|
|
108
|
-
// forward attempt
|
|
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}
|
|
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
|
|
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 (
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
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
|
-
|
|
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
|
|
package/src/agent/workspace.js
CHANGED
|
@@ -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
|
-
|
|
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
|
|
package/src/cli/components.js
CHANGED
|
@@ -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
|
-
//
|
|
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
|
-
|
|
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
|
|
88
|
-
h(Text, { dimColor: true },
|
|
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
|
|