@fenglimg/fabric-cli 2.2.0-rc.8 → 2.2.0-rc.9
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/dist/{chunk-FEOPLBGA.js → chunk-3D7B2UAZ.js} +1 -2
- package/dist/{chunk-CMDW3PYK.js → chunk-7ZDXBOOU.js} +78 -0
- package/dist/{chunk-JTHWLUD3.js → chunk-E7HJUU34.js} +1 -1
- package/dist/{doctor-REZDNH4A.js → doctor-MDTZWKBK.js} +2 -2
- package/dist/index.js +7 -7
- package/dist/{install-v2-2COC3DO3.js → install-v2-I6PJ6IFT.js} +5 -3
- package/dist/{uninstall-62F4LNKI.js → uninstall-IFN2KYBK.js} +10 -1
- package/package.json +3 -3
- package/templates/hooks/fabric-hint.cjs +28 -3
- package/templates/hooks/knowledge-hint-broad.cjs +59 -118
- package/templates/hooks/knowledge-hint-narrow.cjs +8 -21
- package/templates/hooks/lib/bindings-snapshot-reader.cjs +13 -12
- package/templates/skills/fabric/SKILL.md +15 -9
- package/templates/hooks/lib/summary-fallback.cjs +0 -273
|
@@ -22,9 +22,8 @@ var ROOT_AUTHORITATIVE_KEYS = /* @__PURE__ */ new Set([
|
|
|
22
22
|
"embed_model",
|
|
23
23
|
"embed_weight",
|
|
24
24
|
"plan_context_top_k",
|
|
25
|
+
"recall_relevance_ratio",
|
|
25
26
|
"mcpPayloadLimits",
|
|
26
|
-
"retrieval_budget_profile",
|
|
27
|
-
"recall_body_budget_bytes",
|
|
28
27
|
"selection_token_ttl_ms",
|
|
29
28
|
"orphan_demote_proven_days",
|
|
30
29
|
"orphan_demote_verified_days",
|
|
@@ -52,6 +52,11 @@ import {
|
|
|
52
52
|
BOOTSTRAP_MARKER_END,
|
|
53
53
|
BOOTSTRAP_REGEX
|
|
54
54
|
} from "@fenglimg/fabric-shared/templates/bootstrap-canonical";
|
|
55
|
+
var SKILL_ROUTER_TEMPLATE_REL = "skills/fabric/SKILL.md";
|
|
56
|
+
var ROUTER_INTENT_MARKER_BEGIN = "<!-- fabric:router-intent:begin -->";
|
|
57
|
+
var ROUTER_INTENT_MARKER_END = "<!-- fabric:router-intent:end -->";
|
|
58
|
+
var ROUTER_INTENT_GENERATED_NOTE = "<!-- \u672C\u5757\u7531 `fabric install` \u4ECE 7 \u4E2A leaf skill \u7684 description Triggers \u5B50\u53E5\u751F\u6210\u3002\u4E25\u7981\u624B\u7F16;\u6539 leaf description \u540E\u91CD\u8DD1 `fabric install`\u3002 -->";
|
|
59
|
+
var ROUTER_INTENT_REGEX = /<!-- fabric:router-intent:begin -->[\s\S]*?<!-- fabric:router-intent:end -->/u;
|
|
55
60
|
var SKILL_TEMPLATE_REL = "skills/fabric-archive/SKILL.md";
|
|
56
61
|
var SKILL_REVIEW_TEMPLATE_REL = "skills/fabric-review/SKILL.md";
|
|
57
62
|
var SKILL_IMPORT_TEMPLATE_REL = "skills/fabric-import/SKILL.md";
|
|
@@ -69,6 +74,12 @@ var HOOK_LIB_TEMPLATE_DIR_REL = "hooks/lib";
|
|
|
69
74
|
var CLAUDE_HOOK_CONFIG_TEMPLATE_REL = "hooks/configs/claude-code.json";
|
|
70
75
|
var CODEX_HOOK_CONFIG_TEMPLATE_REL = "hooks/configs/codex-hooks.json";
|
|
71
76
|
var SKILL_DESTINATIONS = {
|
|
77
|
+
// B2 skill-router: the fabric/ router skill — single-file (no ref/), installed
|
|
78
|
+
// alongside the 7 leaf skills as the human-facing dispatch entry point.
|
|
79
|
+
fabricRouter: [
|
|
80
|
+
".claude/skills/fabric/SKILL.md",
|
|
81
|
+
".codex/skills/fabric/SKILL.md"
|
|
82
|
+
],
|
|
72
83
|
fabricArchive: [
|
|
73
84
|
".claude/skills/fabric-archive/SKILL.md",
|
|
74
85
|
".codex/skills/fabric-archive/SKILL.md"
|
|
@@ -106,6 +117,12 @@ var SKILL_DESTINATIONS = {
|
|
|
106
117
|
]
|
|
107
118
|
};
|
|
108
119
|
var FABRIC_SKILL_INSTALL_SPECS = {
|
|
120
|
+
fabricRouter: {
|
|
121
|
+
slug: "fabric",
|
|
122
|
+
templateRel: SKILL_ROUTER_TEMPLATE_REL,
|
|
123
|
+
destinations: SKILL_DESTINATIONS.fabricRouter,
|
|
124
|
+
step: "skill-router"
|
|
125
|
+
},
|
|
109
126
|
fabricArchive: {
|
|
110
127
|
slug: "fabric-archive",
|
|
111
128
|
templateRel: SKILL_TEMPLATE_REL,
|
|
@@ -306,6 +323,66 @@ async function installFabricAuditSkill(projectRoot, _options = {}) {
|
|
|
306
323
|
async function installFabricConnectSkill(projectRoot, _options = {}) {
|
|
307
324
|
return installFabricSkill(projectRoot, FABRIC_SKILL_INSTALL_SPECS.fabricConnect);
|
|
308
325
|
}
|
|
326
|
+
function extractSkillMdDescription(skillMd) {
|
|
327
|
+
const fm = skillMd.match(/^---\n([\s\S]*?)\n---/u);
|
|
328
|
+
if (!fm) return "";
|
|
329
|
+
const desc = fm[1].match(/^description:\s*(.+?)\s*$/mu);
|
|
330
|
+
if (!desc) return "";
|
|
331
|
+
return desc[1].replace(/^["'](.+)["']$/u, "$1").trim();
|
|
332
|
+
}
|
|
333
|
+
function extractTriggersClause(description) {
|
|
334
|
+
const m = description.match(/Triggers?\s+([\s\S]+)$/u);
|
|
335
|
+
if (!m) return "";
|
|
336
|
+
return m[1].trim().replace(/[.。]\s*$/u, "").replace(/\|/gu, "\\|");
|
|
337
|
+
}
|
|
338
|
+
function renderRouterIntentBlock(leaves) {
|
|
339
|
+
const rows = leaves.map((l) => `| ${l.triggers} | \`${l.slug}\` |`).join("\n");
|
|
340
|
+
const enumVals = leaves.map((l) => l.slug.replace(/^fabric-/u, "")).join(" | ");
|
|
341
|
+
return [
|
|
342
|
+
ROUTER_INTENT_MARKER_BEGIN,
|
|
343
|
+
ROUTER_INTENT_GENERATED_NOTE,
|
|
344
|
+
"",
|
|
345
|
+
"| \u7528\u6237\u610F\u56FE(leaf description Triggers) | \u4E0B\u6E38 skill |",
|
|
346
|
+
"| --- | --- |",
|
|
347
|
+
rows,
|
|
348
|
+
"",
|
|
349
|
+
`\`S_CLASSIFY\` \u7684 \`task_type\` \u679A\u4E3E:\`${enumVals}\``,
|
|
350
|
+
ROUTER_INTENT_MARKER_END
|
|
351
|
+
].join("\n");
|
|
352
|
+
}
|
|
353
|
+
async function buildRouterSkillSource() {
|
|
354
|
+
const template = await readTemplate(SKILL_ROUTER_TEMPLATE_REL);
|
|
355
|
+
if (!ROUTER_INTENT_REGEX.test(template)) {
|
|
356
|
+
throw new Error(
|
|
357
|
+
`fabric/SKILL.md is missing the ${ROUTER_INTENT_MARKER_BEGIN} \u2026 ${ROUTER_INTENT_MARKER_END} marker pair \u2014 cannot regenerate the Intent Map. This is a Fabric release bug (router template was hand-edited away from the managed-block contract).`
|
|
358
|
+
);
|
|
359
|
+
}
|
|
360
|
+
const leafSpecs = Object.values(FABRIC_SKILL_INSTALL_SPECS).filter(
|
|
361
|
+
(spec) => spec.slug !== "fabric"
|
|
362
|
+
);
|
|
363
|
+
const leaves = [];
|
|
364
|
+
for (const spec of leafSpecs) {
|
|
365
|
+
const leafMd = await readTemplate(spec.templateRel);
|
|
366
|
+
leaves.push({ slug: spec.slug, triggers: extractTriggersClause(extractSkillMdDescription(leafMd)) });
|
|
367
|
+
}
|
|
368
|
+
return template.replace(ROUTER_INTENT_REGEX, renderRouterIntentBlock(leaves));
|
|
369
|
+
}
|
|
370
|
+
async function installFabricRouterSkill(projectRoot, _options = {}) {
|
|
371
|
+
const source = await buildRouterSkillSource();
|
|
372
|
+
validateSkillCanonicalSize(source, "fabric");
|
|
373
|
+
const spec = FABRIC_SKILL_INSTALL_SPECS.fabricRouter;
|
|
374
|
+
const targets = spec.destinations.map((rel) => join2(projectRoot, rel));
|
|
375
|
+
const results = [];
|
|
376
|
+
for (const target of targets) {
|
|
377
|
+
const staleMsg = inspectStaleInstall(target, source);
|
|
378
|
+
const result = await copyTextIdempotent(spec.step, source, target);
|
|
379
|
+
if (staleMsg && result.status === "written") {
|
|
380
|
+
result.message = result.message ? `${staleMsg}; ${result.message}` : staleMsg;
|
|
381
|
+
}
|
|
382
|
+
results.push(result);
|
|
383
|
+
}
|
|
384
|
+
return results;
|
|
385
|
+
}
|
|
309
386
|
async function cleanupDeprecatedSkills(projectRoot) {
|
|
310
387
|
const results = [];
|
|
311
388
|
for (const rel of DEPRECATED_SKILL_DIRS) {
|
|
@@ -914,6 +991,7 @@ export {
|
|
|
914
991
|
installFabricStoreSkill,
|
|
915
992
|
installFabricAuditSkill,
|
|
916
993
|
installFabricConnectSkill,
|
|
994
|
+
installFabricRouterSkill,
|
|
917
995
|
cleanupDeprecatedSkills,
|
|
918
996
|
installSharedSkillLib,
|
|
919
997
|
installArchiveHintHook,
|
|
@@ -5,8 +5,8 @@ import {
|
|
|
5
5
|
parseSinceDuration,
|
|
6
6
|
renderDoctorFilteredHelp,
|
|
7
7
|
renderTldrHeader
|
|
8
|
-
} from "./chunk-
|
|
9
|
-
import "./chunk-
|
|
8
|
+
} from "./chunk-E7HJUU34.js";
|
|
9
|
+
import "./chunk-3D7B2UAZ.js";
|
|
10
10
|
import "./chunk-WA3DYGSY.js";
|
|
11
11
|
import "./chunk-NLNH64A3.js";
|
|
12
12
|
import "./chunk-PTGQAZEW.js";
|
package/dist/index.js
CHANGED
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import {
|
|
3
3
|
renderDoctorFilteredHelp
|
|
4
|
-
} from "./chunk-
|
|
5
|
-
import "./chunk-
|
|
4
|
+
} from "./chunk-E7HJUU34.js";
|
|
5
|
+
import "./chunk-3D7B2UAZ.js";
|
|
6
6
|
import "./chunk-WA3DYGSY.js";
|
|
7
7
|
import "./chunk-NLNH64A3.js";
|
|
8
8
|
import "./chunk-PTGQAZEW.js";
|
|
@@ -23,7 +23,7 @@ import { defineCommand, renderUsage, runCommand, runMain } from "citty";
|
|
|
23
23
|
// src/commands/index.ts
|
|
24
24
|
var allCommands = {
|
|
25
25
|
// v2.2.0-rc.5: pipeline-based install with TUI renderer (EPIC-005/006/007/008)
|
|
26
|
-
install: () => import("./install-v2-
|
|
26
|
+
install: () => import("./install-v2-I6PJ6IFT.js").then((module) => module.installCommand),
|
|
27
27
|
// v2.1.0-rc.1 P3: multi-store lifecycle command group (list/add/remove/explain).
|
|
28
28
|
store: () => import("./store-HOCORVL3.js").then((module) => module.default),
|
|
29
29
|
// v2.1.0-rc.1 P3 (S9/S17/S37): multi-store pull --rebase + push, conflict resume.
|
|
@@ -35,8 +35,8 @@ var allCommands = {
|
|
|
35
35
|
whoami: () => import("./whoami-ITGEFWH4.js").then((module) => module.default),
|
|
36
36
|
status: () => import("./status-4R3TM4FJ.js").then((module) => module.default),
|
|
37
37
|
"scope-explain": () => import("./scope-explain-HLJZ2M33.js").then((module) => module.default),
|
|
38
|
-
doctor: () => import("./doctor-
|
|
39
|
-
uninstall: () => import("./uninstall-
|
|
38
|
+
doctor: () => import("./doctor-MDTZWKBK.js").then((module) => module.default),
|
|
39
|
+
uninstall: () => import("./uninstall-IFN2KYBK.js").then((module) => module.default),
|
|
40
40
|
config: () => import("./config-A3LTECAY.js").then((module) => module.default),
|
|
41
41
|
"plan-context-hint": () => import("./plan-context-hint-G75R4P4J.js").then((module) => module.default),
|
|
42
42
|
// v2.0.0-rc.23 TASK-014 (F8c): S5 onboard-slot coverage. Used by the
|
|
@@ -153,7 +153,7 @@ async function customShowUsageGrouped(cmd, parent, version) {
|
|
|
153
153
|
var main = defineCommand({
|
|
154
154
|
meta: {
|
|
155
155
|
name: "fabric",
|
|
156
|
-
version: "2.2.0-rc.
|
|
156
|
+
version: "2.2.0-rc.9",
|
|
157
157
|
description: t("cli.main.description")
|
|
158
158
|
},
|
|
159
159
|
subCommands: allCommands
|
|
@@ -165,7 +165,7 @@ async function customShowUsage(cmd, parent) {
|
|
|
165
165
|
return;
|
|
166
166
|
}
|
|
167
167
|
if (cmdMeta?.name === "fabric" && parent === void 0) {
|
|
168
|
-
await customShowUsageGrouped(cmd, parent, "2.2.0-rc.
|
|
168
|
+
await customShowUsageGrouped(cmd, parent, "2.2.0-rc.9");
|
|
169
169
|
return;
|
|
170
170
|
}
|
|
171
171
|
console.log(await renderUsage(cmd, parent) + "\n");
|
|
@@ -8,6 +8,7 @@ import {
|
|
|
8
8
|
installFabricConnectSkill,
|
|
9
9
|
installFabricImportSkill,
|
|
10
10
|
installFabricReviewSkill,
|
|
11
|
+
installFabricRouterSkill,
|
|
11
12
|
installFabricStoreSkill,
|
|
12
13
|
installFabricSyncSkill,
|
|
13
14
|
installHookLibs,
|
|
@@ -21,13 +22,13 @@ import {
|
|
|
21
22
|
writeClaudeBootstrapThinShell,
|
|
22
23
|
writeCodexBootstrapManagedBlock,
|
|
23
24
|
writeFabricAgentsSnapshot
|
|
24
|
-
} from "./chunk-
|
|
25
|
+
} from "./chunk-7ZDXBOOU.js";
|
|
25
26
|
import {
|
|
26
27
|
ensureStoreProjectBinding,
|
|
27
28
|
migrateRootConfig,
|
|
28
29
|
normalizeStoreProjectId,
|
|
29
30
|
suggestStoreProjectId
|
|
30
|
-
} from "./chunk-
|
|
31
|
+
} from "./chunk-3D7B2UAZ.js";
|
|
31
32
|
import {
|
|
32
33
|
createDebugLogger,
|
|
33
34
|
resolveDevMode
|
|
@@ -1737,7 +1738,7 @@ function readProjectName(target) {
|
|
|
1737
1738
|
return basename(target);
|
|
1738
1739
|
}
|
|
1739
1740
|
function getCliVersion() {
|
|
1740
|
-
return true ? "2.2.0-rc.
|
|
1741
|
+
return true ? "2.2.0-rc.9" : "unknown";
|
|
1741
1742
|
}
|
|
1742
1743
|
function sortRecord(record) {
|
|
1743
1744
|
return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
|
|
@@ -2356,6 +2357,7 @@ var HooksStage = class {
|
|
|
2356
2357
|
const target = context.target;
|
|
2357
2358
|
const installResults = [];
|
|
2358
2359
|
installResults.push(...await this.runBestEffort("skill-deprecated-cleanup", () => cleanupDeprecatedSkills(target)));
|
|
2360
|
+
installResults.push(...await this.runBestEffort("skill-router-install", () => installFabricRouterSkill(target)));
|
|
2359
2361
|
installResults.push(...await this.runBestEffort("skill-install", () => installFabricArchiveSkill(target)));
|
|
2360
2362
|
installResults.push(...await this.runBestEffort("skill-review-install", () => installFabricReviewSkill(target)));
|
|
2361
2363
|
installResults.push(...await this.runBestEffort("skill-import-install", () => installFabricImportSkill(target)));
|
|
@@ -7,7 +7,7 @@ import {
|
|
|
7
7
|
HOOK_SCRIPT_DESTINATIONS,
|
|
8
8
|
SKILL_DESTINATIONS,
|
|
9
9
|
fabricAgentsSnapshotPath
|
|
10
|
-
} from "./chunk-
|
|
10
|
+
} from "./chunk-7ZDXBOOU.js";
|
|
11
11
|
import {
|
|
12
12
|
createDebugLogger,
|
|
13
13
|
resolveDevMode
|
|
@@ -37,6 +37,9 @@ import { readdir, readFile, rm, rmdir } from "fs/promises";
|
|
|
37
37
|
import { dirname, join } from "path";
|
|
38
38
|
import { atomicWriteJson, atomicWriteText } from "@fenglimg/fabric-shared/node/atomic-write";
|
|
39
39
|
import { BOOTSTRAP_REGEX } from "@fenglimg/fabric-shared/templates/bootstrap-canonical";
|
|
40
|
+
async function uninstallFabricRouterSkill(projectRoot) {
|
|
41
|
+
return removeSkill("skill-router", SKILL_DESTINATIONS.fabricRouter, projectRoot);
|
|
42
|
+
}
|
|
40
43
|
async function uninstallFabricArchiveSkill(projectRoot) {
|
|
41
44
|
return removeSkill("skill", SKILL_DESTINATIONS.fabricArchive, projectRoot);
|
|
42
45
|
}
|
|
@@ -345,6 +348,12 @@ async function uninstallBootstrapStage(projectRoot, _opts = {}) {
|
|
|
345
348
|
projectRoot,
|
|
346
349
|
() => uninstallFabricArchiveSkill(projectRoot)
|
|
347
350
|
);
|
|
351
|
+
await runAndCollect(
|
|
352
|
+
results,
|
|
353
|
+
"skill-router",
|
|
354
|
+
projectRoot,
|
|
355
|
+
() => uninstallFabricRouterSkill(projectRoot)
|
|
356
|
+
);
|
|
348
357
|
return results;
|
|
349
358
|
}
|
|
350
359
|
async function runAndCollect(results, step, projectRoot, fn) {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@fenglimg/fabric-cli",
|
|
3
|
-
"version": "2.2.0-rc.
|
|
3
|
+
"version": "2.2.0-rc.9",
|
|
4
4
|
"description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code and Codex CLI; runs doctor / knowledge maintenance.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "wangzhichao <fenglimg90@gmail.com>",
|
|
@@ -46,8 +46,8 @@
|
|
|
46
46
|
"tree-sitter-javascript": "^0.25.0",
|
|
47
47
|
"tree-sitter-typescript": "^0.23.2",
|
|
48
48
|
"web-tree-sitter": "^0.26.8",
|
|
49
|
-
"@fenglimg/fabric-
|
|
50
|
-
"@fenglimg/fabric-
|
|
49
|
+
"@fenglimg/fabric-server": "2.2.0-rc.9",
|
|
50
|
+
"@fenglimg/fabric-shared": "2.2.0-rc.9"
|
|
51
51
|
},
|
|
52
52
|
"devDependencies": {
|
|
53
53
|
"@types/node": "^22.15.0",
|
|
@@ -117,6 +117,10 @@ function readSnapshotKnowledgeStats(projectRoot, now) {
|
|
|
117
117
|
}
|
|
118
118
|
try {
|
|
119
119
|
const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
|
|
120
|
+
// No snapshot file → empty corpus (KT-DEC-0007), preserving prior behavior.
|
|
121
|
+
if (!snapshot) {
|
|
122
|
+
return empty;
|
|
123
|
+
}
|
|
120
124
|
// LIVE recount off the snapshot's resolved store dirs. The cached
|
|
121
125
|
// knowledge_stats projection is frozen at snapshot-write time, so once the
|
|
122
126
|
// pending queue is reviewed (or store content syncs out-of-band) it goes
|
|
@@ -124,8 +128,16 @@ function readSnapshotKnowledgeStats(projectRoot, now) {
|
|
|
124
128
|
// (KT-PIT-0017). The authoritative count is the live *.md walk under the
|
|
125
129
|
// resolved store dirs.
|
|
126
130
|
const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
|
|
127
|
-
|
|
128
|
-
|
|
131
|
+
// #3: a snapshot predating knowledge_store_dirs makes liveKnowledgeStats
|
|
132
|
+
// return null — counts are undeterminable and the cached projection is
|
|
133
|
+
// unreliable. Return `undefined` (a marker distinct from the `null` that
|
|
134
|
+
// lib/binding-absent returns, which readPendingStats uses as its legacy-
|
|
135
|
+
// fallback signal) so countCanonicalNodes maps it to "unknown" and the
|
|
136
|
+
// underseed signal SKIPS rather than false-firing on a stale corpus (snapshot
|
|
137
|
+
// self-heals on the next install/sync). Distinguished from the missing-
|
|
138
|
+
// snapshot case above, which stays `empty` (genuine fresh-project zero).
|
|
139
|
+
if (live === null) {
|
|
140
|
+
return undefined;
|
|
129
141
|
}
|
|
130
142
|
const pendingCount =
|
|
131
143
|
Number.isFinite(live.pendingCount) && live.pendingCount > 0 ? Math.floor(live.pendingCount) : 0;
|
|
@@ -412,7 +424,10 @@ function readLedger(projectRoot) {
|
|
|
412
424
|
*/
|
|
413
425
|
function readPendingStats(projectRoot, now) {
|
|
414
426
|
const stats = readSnapshotKnowledgeStats(projectRoot, now);
|
|
415
|
-
|
|
427
|
+
// `!= null` (loose) also catches the `undefined` old-snapshot marker (#3) →
|
|
428
|
+
// fall through to the legacy reader (which degrades to 0 → no phantom review
|
|
429
|
+
// nudge), rather than dereferencing pendingCount on a non-object.
|
|
430
|
+
if (stats != null) {
|
|
416
431
|
return { count: stats.pendingCount, oldestAgeMs: stats.oldestPendingAgeMs };
|
|
417
432
|
}
|
|
418
433
|
return readLegacyPendingStats(projectRoot, now);
|
|
@@ -425,6 +440,12 @@ function readPendingStats(projectRoot, now) {
|
|
|
425
440
|
*/
|
|
426
441
|
function countCanonicalNodes(projectRoot) {
|
|
427
442
|
const stats = readSnapshotKnowledgeStats(projectRoot);
|
|
443
|
+
// #3: `undefined` = snapshot EXISTS but predates knowledge_store_dirs →
|
|
444
|
+
// undeterminable → return null so decide()'s underseed signal SKIPS rather than
|
|
445
|
+
// false-firing on a stale corpus. `null` = no reader / not bound → degrade to 0
|
|
446
|
+
// (KT-DEC-0007, preserved). The `empty` object (missing snapshot) → canonical 0,
|
|
447
|
+
// still firing correctly for a genuinely fresh corpus.
|
|
448
|
+
if (stats === undefined) return null;
|
|
428
449
|
return stats === null ? 0 : stats.canonicalCount;
|
|
429
450
|
}
|
|
430
451
|
|
|
@@ -1010,6 +1031,10 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
|
|
|
1010
1031
|
lastInitScanTs === null ? null : (nowMs - lastInitScanTs) / MS_PER_HOUR;
|
|
1011
1032
|
const hoursSinceProposed = hoursElapsed; // reuse archive-signal calc above
|
|
1012
1033
|
const triggerUnderseed =
|
|
1034
|
+
// #3: null = undeterminable canonical count (old snapshot) → skip. Guard
|
|
1035
|
+
// first because `null < threshold` coerces to true in JS and would else
|
|
1036
|
+
// false-fire the underseed nudge on a stale corpus.
|
|
1037
|
+
underseed.nodeCount != null &&
|
|
1013
1038
|
underseed.nodeCount < underseed.threshold &&
|
|
1014
1039
|
hoursSinceInit !== null &&
|
|
1015
1040
|
hoursSinceInit >= UNDERSEED_POST_INIT_QUIET_HOURS &&
|
|
@@ -16,9 +16,8 @@
|
|
|
16
16
|
*
|
|
17
17
|
* AI sink (additionalContext) — the dynamically generated "MEMORY.md":
|
|
18
18
|
* [fabric:SessionStart] <store>
|
|
19
|
-
* ALWAYS-ACTIVE RULES (no recall needed): # guideline/model —
|
|
20
|
-
* [guideline] team:KT-GLD-0001
|
|
21
|
-
* <full body> # over budget → degrade to index line
|
|
19
|
+
* ALWAYS-ACTIVE RULES (no recall needed): # guideline/model — INDEX line only
|
|
20
|
+
* [guideline] team:KT-GLD-0001 · <summary> # KT-DEC-0036: no eager body
|
|
22
21
|
* REFERENCE (read on demand / fab_recall): # decision/pitfall/process — title + hook
|
|
23
22
|
* [decision] team:KT-DEC-0001 — <must_read_if>
|
|
24
23
|
* … N more folded (broad index > backstop 50; run fabric-audit)
|
|
@@ -54,7 +53,6 @@ const { appendLockedLine } = require("./lib/injection-log.cjs");
|
|
|
54
53
|
// (TASK-002). Variant is resolved ONCE per main() invocation via
|
|
55
54
|
// readFabricLanguage(cwd) and threaded into renderBanner — no fs in render path.
|
|
56
55
|
const { renderBanner, readFabricLanguage } = require("./lib/banner-i18n.cjs");
|
|
57
|
-
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
58
56
|
// v2.0.0-rc.37 NEW-19: shared fabric-config reader + sidecar I/O. Replaces the
|
|
59
57
|
// five per-key readFileSync+parse config readers (one parse per fire now) and
|
|
60
58
|
// the bespoke last-emit sidecar helpers. The L78 "refactor into lib/ if a
|
|
@@ -112,15 +110,23 @@ function readWorkspaceBindingId(cwd) {
|
|
|
112
110
|
}
|
|
113
111
|
|
|
114
112
|
function readSnapshotCanonicalCount(projectRoot) {
|
|
113
|
+
// No reader / not bound → degrade to an empty corpus (0), the documented
|
|
114
|
+
// store-only behavior (KT-DEC-0007). Only the "snapshot EXISTS but predates
|
|
115
|
+
// knowledge_store_dirs" case below is undeterminable → null (skip).
|
|
115
116
|
if (bindingsSnapshotReader === null) {
|
|
116
|
-
return
|
|
117
|
+
return 0;
|
|
117
118
|
}
|
|
118
119
|
const bindingId = readWorkspaceBindingId(projectRoot);
|
|
119
120
|
if (bindingId === null) {
|
|
120
|
-
return
|
|
121
|
+
return 0;
|
|
121
122
|
}
|
|
122
123
|
try {
|
|
123
124
|
const snapshot = bindingsSnapshotReader.readBindingsSnapshot(bindingId);
|
|
125
|
+
// No snapshot file at all → treat as an empty corpus (KT-DEC-0007),
|
|
126
|
+
// preserving the fresh-project underseed nudge.
|
|
127
|
+
if (!snapshot) {
|
|
128
|
+
return 0;
|
|
129
|
+
}
|
|
124
130
|
// LIVE recount off the snapshot's resolved store dirs. The cached
|
|
125
131
|
// knowledge_stats.canonical_count is frozen at snapshot-write time and goes
|
|
126
132
|
// stale when store content syncs in out-of-band (e.g. the store grew from 1
|
|
@@ -128,13 +134,21 @@ function readSnapshotCanonicalCount(projectRoot) {
|
|
|
128
134
|
// THIS workspace's snapshot), which mis-fired the "knowledge sparse"
|
|
129
135
|
// underseed nudge (KT-PIT-0017, same stale-projection root cause).
|
|
130
136
|
const live = bindingsSnapshotReader.liveKnowledgeStats(snapshot);
|
|
131
|
-
|
|
132
|
-
|
|
137
|
+
// #3: a snapshot that predates knowledge_store_dirs makes liveKnowledgeStats
|
|
138
|
+
// return null — the count is undeterminable and the cached projection is
|
|
139
|
+
// unreliable. Return null (not 0) so countCanonicalNodes / shouldRecommendImport
|
|
140
|
+
// SKIP the nudge instead of false-firing on stale data; the snapshot
|
|
141
|
+
// self-heals on the next install/sync. A genuine live 0 (dirs present, no
|
|
142
|
+
// *.md) still returns 0 and fires correctly.
|
|
143
|
+
if (live === null) {
|
|
144
|
+
return null;
|
|
133
145
|
}
|
|
146
|
+
return Number.isFinite(live.canonicalCount) ? Math.floor(live.canonicalCount) : 0;
|
|
134
147
|
} catch {
|
|
135
|
-
//
|
|
148
|
+
// Read/parse fault → degrade to empty (0), preserving prior behavior. The
|
|
149
|
+
// only undeterminable→skip path is the explicit live===null above.
|
|
150
|
+
return 0;
|
|
136
151
|
}
|
|
137
|
-
return 0;
|
|
138
152
|
}
|
|
139
153
|
|
|
140
154
|
|
|
@@ -205,8 +219,10 @@ const DEFAULT_HINT_REMINDER_TO_CONTEXT = true;
|
|
|
205
219
|
* trees — a missing snapshot degrades to zero (KT-DEC-0007).
|
|
206
220
|
*/
|
|
207
221
|
function countCanonicalNodes(projectRoot) {
|
|
208
|
-
|
|
209
|
-
|
|
222
|
+
// #3: null = undeterminable (old snapshot lacking store dirs, or no binding
|
|
223
|
+
// context). Propagate it — shouldRecommendImport SKIPS on null rather than
|
|
224
|
+
// treating it as zero and false-firing the underseed nudge on a stale corpus.
|
|
225
|
+
return readSnapshotCanonicalCount(projectRoot);
|
|
210
226
|
}
|
|
211
227
|
|
|
212
228
|
/**
|
|
@@ -338,6 +354,10 @@ function shouldRecommendImport(projectRoot) {
|
|
|
338
354
|
|
|
339
355
|
const threshold = readUnderseedThreshold(projectRoot);
|
|
340
356
|
const nodeCount = countCanonicalNodes(projectRoot);
|
|
357
|
+
// #3: undeterminable count (old snapshot predating knowledge_store_dirs) →
|
|
358
|
+
// skip. `null < threshold` coerces to true in JS, so an explicit guard is
|
|
359
|
+
// required — otherwise the stale-snapshot case would still false-fire.
|
|
360
|
+
if (nodeCount === null) return false;
|
|
341
361
|
if (nodeCount >= threshold) return false;
|
|
342
362
|
|
|
343
363
|
if (isImportTouched(projectRoot) !== "absent") return false;
|
|
@@ -374,65 +394,6 @@ const CLI_TIMEOUT_MS = 2000;
|
|
|
374
394
|
// `hint_summary_max_len` in fabric-config overrides this default (range 40..240).
|
|
375
395
|
const DEFAULT_SUMMARY_MAX_LEN = 80;
|
|
376
396
|
|
|
377
|
-
// v2.2 HK2-degrade (W2-T2): char budget for the rendered broad-menu BODY. The
|
|
378
|
-
// hook already degrades by COUNT (hint_broad_top_k slice + TRUNCATION_THRESHOLD
|
|
379
|
-
// grouped mode), but nothing bounded the total rendered SIZE — a corpus with
|
|
380
|
-
// many types or long (near-maxLen) summaries could still emit a wall of text
|
|
381
|
-
// that displaces the agent's working memory. Borrowing the maestro
|
|
382
|
-
// context-budget idea, this is the final rung of the degradation ladder: once
|
|
383
|
-
// the body exceeds the budget, the tail collapses to a single "N more omitted"
|
|
384
|
-
// marker. Default 2000 chars ≈ one screenful. Overridable via
|
|
385
|
-
// fabric-config.json#hint_broad_budget_chars (range 200..20000); 0 disables.
|
|
386
|
-
const DEFAULT_HINT_BROAD_BUDGET_CHARS = 2000;
|
|
387
|
-
|
|
388
|
-
// v2.2 C5-budget (W2-T3): bind the injection char budget to the layered retrieval
|
|
389
|
-
// budget profile. Mirrors the injectionChars column of shared/retrieval-budget.ts
|
|
390
|
-
// PROFILES (kept in sync — the hook cannot require the TS resolver). The explicit
|
|
391
|
-
// `hint_broad_budget_chars` knob still wins; the profile only supplies the
|
|
392
|
-
// default. `balanced` (and an absent/unknown profile) keeps the historical 2000.
|
|
393
|
-
const RETRIEVAL_BUDGET_INJECTION_CHARS = {
|
|
394
|
-
conservative: 1000,
|
|
395
|
-
balanced: 2000,
|
|
396
|
-
generous: 4000,
|
|
397
|
-
};
|
|
398
|
-
|
|
399
|
-
function readBroadBudgetChars(projectRoot) {
|
|
400
|
-
const profile = readConfigString(projectRoot, "retrieval_budget_profile", "balanced");
|
|
401
|
-
const profileDefault =
|
|
402
|
-
RETRIEVAL_BUDGET_INJECTION_CHARS[profile] ?? DEFAULT_HINT_BROAD_BUDGET_CHARS;
|
|
403
|
-
return readConfigNumber(projectRoot, "hint_broad_budget_chars", profileDefault, {
|
|
404
|
-
min: 0,
|
|
405
|
-
max: 20000,
|
|
406
|
-
floor: true,
|
|
407
|
-
});
|
|
408
|
-
}
|
|
409
|
-
|
|
410
|
-
// v2.2 HK2-degrade (W2-T2): cap the rendered body to `budgetChars`, collapsing
|
|
411
|
-
// the overflow tail into one marker line. Structural lines (banner, revision_hash,
|
|
412
|
-
// footer) are appended by renderSummary AFTER this pass, so they always survive —
|
|
413
|
-
// only entry/group body lines are subject to the budget. `budgetChars` of 0 or
|
|
414
|
-
// undefined is a no-op (preserves the pre-HK2 unbounded behavior and all
|
|
415
|
-
// existing snapshot tests).
|
|
416
|
-
function capBodyToBudget(body, budgetChars) {
|
|
417
|
-
if (!budgetChars || budgetChars <= 0) return body;
|
|
418
|
-
const kept = [];
|
|
419
|
-
let total = 0;
|
|
420
|
-
for (let i = 0; i < body.length; i += 1) {
|
|
421
|
-
const line = body[i];
|
|
422
|
-
// +1 for the newline each line costs once joined.
|
|
423
|
-
if (kept.length > 0 && total + line.length + 1 > budgetChars) {
|
|
424
|
-
const remaining = body.length - i;
|
|
425
|
-
kept.push(
|
|
426
|
-
` … ${remaining} more entr${remaining === 1 ? "y" : "ies"} omitted (injection budget ${budgetChars} chars; raise hint_broad_budget_chars or narrow scope)`,
|
|
427
|
-
);
|
|
428
|
-
return kept;
|
|
429
|
-
}
|
|
430
|
-
kept.push(line);
|
|
431
|
-
total += line.length + 1;
|
|
432
|
-
}
|
|
433
|
-
return kept;
|
|
434
|
-
}
|
|
435
|
-
|
|
436
397
|
function readSummaryMaxLen(projectRoot) {
|
|
437
398
|
return readConfigNumber(projectRoot, "hint_summary_max_len", DEFAULT_SUMMARY_MAX_LEN, {
|
|
438
399
|
min: 40,
|
|
@@ -682,7 +643,7 @@ function renderTruncated(narrow, maxLen) {
|
|
|
682
643
|
* after writing exactly one stderr breadcrumb so operators grepping a stuck-
|
|
683
644
|
* banner report can diagnose the version drift without source-diving.
|
|
684
645
|
*/
|
|
685
|
-
function renderSummary(payload, maxLen
|
|
646
|
+
function renderSummary(payload, maxLen) {
|
|
686
647
|
if (!payload || payload.version !== 2) {
|
|
687
648
|
if (payload && payload.version !== undefined) {
|
|
688
649
|
try {
|
|
@@ -705,9 +666,9 @@ function renderSummary(payload, maxLen, budgetChars) {
|
|
|
705
666
|
? `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available (truncated):`
|
|
706
667
|
: `[fabric] Session start — ${entries.length} broad-scoped knowledge entries available:`;
|
|
707
668
|
|
|
708
|
-
|
|
709
|
-
//
|
|
710
|
-
const body =
|
|
669
|
+
// KT-DEC-0028 completeness: the rendered census is bounded by the per-line
|
|
670
|
+
// maxLen + TRUNCATION_THRESHOLD grouped mode, not by a body char budget.
|
|
671
|
+
const body = truncated ? renderTruncated(entries, maxLen) : renderFull(entries, maxLen);
|
|
711
672
|
|
|
712
673
|
const lines = [banner, ...body];
|
|
713
674
|
const revHash = typeof payload.revision_hash === "string" ? payload.revision_hash : null;
|
|
@@ -861,10 +822,10 @@ function renderHumanCensus(census, opts) {
|
|
|
861
822
|
// generated "MEMORY.md" spine injected into the SessionStart context. Two
|
|
862
823
|
// type-tiered sections over the BROAD knowledge (narrow stays silent — D0029):
|
|
863
824
|
//
|
|
864
|
-
// ALWAYS-ACTIVE RULES (guideline/model):
|
|
865
|
-
//
|
|
866
|
-
//
|
|
867
|
-
//
|
|
825
|
+
// ALWAYS-ACTIVE RULES (guideline/model): INDEX LINE only (title + summary) —
|
|
826
|
+
// never the eager body (KT-DEC-0036). The body is one cheap on-demand fetch
|
|
827
|
+
// away, so injecting it on every SessionStart is a permanent context tax
|
|
828
|
+
// (KT-GLD-0005) we no longer pay; each entry stays individually visible.
|
|
868
829
|
// REFERENCE (decision/pitfall/process): TITLE + must_read_if hook only
|
|
869
830
|
// (situational; the agent Reads the body on demand) — never the body.
|
|
870
831
|
//
|
|
@@ -876,7 +837,7 @@ function renderHumanCensus(census, opts) {
|
|
|
876
837
|
const REFERENCE_TYPES = new Set(["decision", "pitfall", "process"]);
|
|
877
838
|
|
|
878
839
|
function renderAiSink(opts) {
|
|
879
|
-
const { entries, alwaysBodies, storeLabel,
|
|
840
|
+
const { entries, alwaysBodies, storeLabel, broadIndexBackstop, summaryMaxLen, lang } =
|
|
880
841
|
opts || {};
|
|
881
842
|
const zh = lang === "zh-CN";
|
|
882
843
|
const bodies = Array.isArray(alwaysBodies) ? alwaysBodies : [];
|
|
@@ -895,30 +856,19 @@ function renderAiSink(opts) {
|
|
|
895
856
|
const lines = [];
|
|
896
857
|
lines.push(`[fabric:SessionStart] ${storeLabel || "store"}`);
|
|
897
858
|
|
|
898
|
-
// ALWAYS-ACTIVE RULES —
|
|
859
|
+
// ALWAYS-ACTIVE RULES — index-only (title + summary), never the eager body.
|
|
899
860
|
lines.push(zh ? "ALWAYS-ACTIVE RULES (无需再 recall):" : "ALWAYS-ACTIVE RULES (no recall needed):");
|
|
900
861
|
if (bodies.length === 0) {
|
|
901
862
|
lines.push(zh ? " (无 always-active 条目)" : " (none)");
|
|
902
863
|
} else {
|
|
903
|
-
|
|
904
|
-
|
|
905
|
-
|
|
864
|
+
// KT-DEC-0036: render each always-active entry as a single index line
|
|
865
|
+
// (title + summary). The body is one cheap on-demand fetch away (see footer),
|
|
866
|
+
// so injecting it on every SessionStart is a permanent context tax
|
|
867
|
+
// (KT-GLD-0005) we no longer pay.
|
|
906
868
|
for (const b of bodies) {
|
|
907
869
|
const label = `[${TYPE_SINGULAR[b.type] || b.type}] ${b.id}`;
|
|
908
|
-
const
|
|
909
|
-
|
|
910
|
-
const fullCost = label.length + body.length + 2;
|
|
911
|
-
if (!degraded && (budget === 0 || used + fullCost <= budget)) {
|
|
912
|
-
lines.push(` ${label}`);
|
|
913
|
-
if (body.length > 0) lines.push(body);
|
|
914
|
-
used += fullCost;
|
|
915
|
-
} else {
|
|
916
|
-
// D0028: degrade to an INDEX LINE (title + summary), never a folded count.
|
|
917
|
-
degraded = true;
|
|
918
|
-
lines.push(
|
|
919
|
-
` ${label} · ${summary}${zh ? " (超预算; fab_recall 取正文)" : " (over budget; fab_recall for body)"}`,
|
|
920
|
-
);
|
|
921
|
-
}
|
|
870
|
+
const summary = typeof b.summary === "string" ? b.summary.trim() : "";
|
|
871
|
+
lines.push(summary.length > 0 ? ` ${label} · ${summary}` : ` ${label}`);
|
|
922
872
|
indexCount += 1;
|
|
923
873
|
}
|
|
924
874
|
}
|
|
@@ -977,29 +927,20 @@ function renderAiSink(opts) {
|
|
|
977
927
|
// Returns:
|
|
978
928
|
// human — gated final human text (null when gated off / empty)
|
|
979
929
|
// ai — gated final AI text (null when reminder-to-context off / empty)
|
|
980
|
-
// resolvedPayload — payload
|
|
930
|
+
// resolvedPayload — the plan-context payload, passed through unchanged (for telemetry / --explain)
|
|
981
931
|
// hasRenderedContent — true when ANY sink rendered content (main's silent-exit gate)
|
|
982
932
|
// reminderToContext — readReminderToContext(cwd) (telemetry target-channel)
|
|
983
933
|
function buildSessionStartSinks(cwd, payload, env) {
|
|
984
|
-
// rc.35
|
|
985
|
-
//
|
|
986
|
-
|
|
987
|
-
|
|
988
|
-
|
|
989
|
-
|
|
990
|
-
|
|
991
|
-
cwd,
|
|
992
|
-
typeof payload.revision_hash === "string" ? payload.revision_hash : "",
|
|
993
|
-
);
|
|
994
|
-
resolvedPayload = { ...payload, entries: resolvedEntries };
|
|
995
|
-
}
|
|
996
|
-
} catch {
|
|
997
|
-
// resolveOpaqueSummaries swallows its own errors; belt + suspenders.
|
|
998
|
-
}
|
|
934
|
+
// KT-GLD-0006: the rc.35 opaque-summary runtime substitution
|
|
935
|
+
// (resolveOpaqueSummaries) is retired. The write-time mechanical floor in
|
|
936
|
+
// extractKnowledge (summary !== stable_id/slug + length floor) prevents
|
|
937
|
+
// degenerate summaries at the source, so SessionStart no longer band-aids them
|
|
938
|
+
// at render time; surviving legacy opaque summaries are fixed by the
|
|
939
|
+
// review-time cold-eval audit pass.
|
|
940
|
+
const resolvedPayload = payload;
|
|
999
941
|
|
|
1000
942
|
const recommendImport = shouldRecommendImport(cwd);
|
|
1001
943
|
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
1002
|
-
const broadBudgetChars = readBroadBudgetChars(cwd);
|
|
1003
944
|
const fabricLanguageForEmit = readFabricLanguage(cwd);
|
|
1004
945
|
|
|
1005
946
|
const census =
|
|
@@ -1023,7 +964,7 @@ function buildSessionStartSinks(cwd, payload, env) {
|
|
|
1023
964
|
// ---- HUMAN sink: §3 grouped census (+ verbose per-entry detail). ----
|
|
1024
965
|
const humanLines = renderHumanCensus(census, { lang: fabricLanguageForEmit });
|
|
1025
966
|
if (humanLines.length > 0 && humanGate.verbosity === "verbose") {
|
|
1026
|
-
const detail = renderSummary(resolvedPayload, summaryMaxLen
|
|
967
|
+
const detail = renderSummary(resolvedPayload, summaryMaxLen);
|
|
1027
968
|
humanLines.push(...detail);
|
|
1028
969
|
}
|
|
1029
970
|
if (bindingsSnapshotReader !== null && humanLines.length > 0) {
|
|
@@ -1056,12 +997,12 @@ function buildSessionStartSinks(cwd, payload, env) {
|
|
|
1056
997
|
);
|
|
1057
998
|
}
|
|
1058
999
|
|
|
1059
|
-
// ---- AI sink: spine — always-active
|
|
1000
|
+
// ---- AI sink: spine — always-active INDEX lines (no eager body, KT-DEC-0036)
|
|
1001
|
+
// + reference, bounded by the broad_index_backstop fold. ----
|
|
1060
1002
|
const broadIndexBackstop = readBroadIndexBackstop(cwd);
|
|
1061
1003
|
const aiText = renderAiSink({
|
|
1062
1004
|
entries: resolvedPayload && Array.isArray(resolvedPayload.entries) ? resolvedPayload.entries : [],
|
|
1063
1005
|
alwaysBodies,
|
|
1064
|
-
budgetChars: broadBudgetChars,
|
|
1065
1006
|
broadIndexBackstop,
|
|
1066
1007
|
summaryMaxLen,
|
|
1067
1008
|
lang: fabricLanguageForEmit,
|
|
@@ -75,11 +75,9 @@ const {
|
|
|
75
75
|
} = require("node:fs");
|
|
76
76
|
const { dirname, join } = require("node:path");
|
|
77
77
|
|
|
78
|
-
// rc.35
|
|
79
|
-
//
|
|
80
|
-
//
|
|
81
|
-
// `.fabric/.cache/summary-fallback.json` keyed by revision_hash.
|
|
82
|
-
const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
|
|
78
|
+
// KT-GLD-0006: the rc.35 opaque-summary substitution (resolveOpaqueSummaries) is
|
|
79
|
+
// retired — the write-time mechanical floor in extractKnowledge prevents
|
|
80
|
+
// degenerate summaries at the source, so the narrow hook no longer band-aids them.
|
|
83
81
|
// v2.0.0-rc.37 NEW-17: shared sidecar I/O for the plan-context-hint result
|
|
84
82
|
// cache (skips a redundant CLI cold-start spawn when the same path-set is
|
|
85
83
|
// re-edited within a session and the knowledge graph hasn't changed).
|
|
@@ -1492,21 +1490,10 @@ async function main(env, stdio) {
|
|
|
1492
1490
|
}
|
|
1493
1491
|
|
|
1494
1492
|
const summaryMaxLen = readSummaryMaxLen(cwd);
|
|
1495
|
-
// rc.35
|
|
1496
|
-
//
|
|
1497
|
-
//
|
|
1498
|
-
|
|
1499
|
-
let resolvedEntries = dedupDecision.filtered;
|
|
1500
|
-
try {
|
|
1501
|
-
resolvedEntries = resolveOpaqueSummaries(
|
|
1502
|
-
dedupDecision.filtered,
|
|
1503
|
-
cwd,
|
|
1504
|
-
currentRevisionHash,
|
|
1505
|
-
);
|
|
1506
|
-
} catch {
|
|
1507
|
-
// resolveOpaqueSummaries swallows its own errors; defensive catch.
|
|
1508
|
-
}
|
|
1509
|
-
const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
|
|
1493
|
+
// KT-GLD-0006: the rc.35 opaque-summary runtime substitution is retired — the
|
|
1494
|
+
// write-time mechanical floor in extractKnowledge prevents degenerate summaries
|
|
1495
|
+
// at the source, so the narrow hook renders the description summary as-is.
|
|
1496
|
+
const lines = renderSummary({ ...cliPayload, entries: dedupDecision.filtered }, summaryMaxLen);
|
|
1510
1497
|
if (lines.length === 0) return;
|
|
1511
1498
|
|
|
1512
1499
|
// v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
|
|
@@ -1564,7 +1551,7 @@ async function main(env, stdio) {
|
|
|
1564
1551
|
const surfaceClient = detectClient();
|
|
1565
1552
|
const fabricDir = join(cwd, FABRIC_DIR_REL);
|
|
1566
1553
|
if (surfaceClient !== undefined && existsSync(fabricDir)) {
|
|
1567
|
-
const renderedIds =
|
|
1554
|
+
const renderedIds = dedupDecision.filtered
|
|
1568
1555
|
.map((e) => (e && typeof e.id === "string" ? e.id : null))
|
|
1569
1556
|
.filter((x) => x !== null);
|
|
1570
1557
|
const realSessionId =
|
|
@@ -148,18 +148,19 @@ function liveKnowledgeStats(snapshot) {
|
|
|
148
148
|
}
|
|
149
149
|
return { pendingCount, canonicalCount, oldestPendingMtimeMs };
|
|
150
150
|
}
|
|
151
|
-
//
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
151
|
+
// #3 (GH issue): snapshot predates knowledge_store_dirs. The cached
|
|
152
|
+
// `knowledge_stats` projection is frozen at snapshot-write time and goes stale
|
|
153
|
+
// out-of-band (store grew via git pull / cross-workspace sync), so trusting it
|
|
154
|
+
// re-introduced exactly the false-nudge this whole field cures — observed a
|
|
155
|
+
// store with 61 live canonical entries whose cached count was frozen at 1,
|
|
156
|
+
// mis-firing the "knowledge sparse → /fabric-import" underseed nudge AND
|
|
157
|
+
// defeating the fabric-import `canonical > 50 → SKIP` guard. read_set carries
|
|
158
|
+
// no resolved store root either (alias/uuid only), so a live recount is
|
|
159
|
+
// impossible without re-resolution (which hooks must not do). Return null
|
|
160
|
+
// ("undeterminable") so callers SKIP the nudge rather than act on a stale
|
|
161
|
+
// count — old snapshots self-heal on the next install/sync/store-op (which
|
|
162
|
+
// regenerates the snapshot WITH knowledge_store_dirs). 宁可不弹也别误弹
|
|
163
|
+
// (KT-DEC-0007: hook = nudge, never a false-positive gate).
|
|
163
164
|
return null;
|
|
164
165
|
}
|
|
165
166
|
|
|
@@ -17,15 +17,21 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
|
|
|
17
17
|
|
|
18
18
|
## Intent Map
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
<!-- fabric:router-intent:begin -->
|
|
21
|
+
<!-- 本块由 `fabric install` 从 7 个 leaf skill 的 description Triggers 子句生成。严禁手编;改 leaf description 后重跑 `fabric install`。 -->
|
|
22
|
+
|
|
23
|
+
| 用户意图(leaf description Triggers) | 下游 skill |
|
|
21
24
|
| --- | --- |
|
|
22
|
-
|
|
|
23
|
-
|
|
|
24
|
-
|
|
|
25
|
-
|
|
|
26
|
-
|
|
|
27
|
-
|
|
|
28
|
-
|
|
|
25
|
+
| 以后/always/never/下次/记一下;wrong-turn-revert;decision-confirm;dismissal-reason;/fabric-archive | `fabric-archive` |
|
|
26
|
+
| 审批/驳回/复审/重审/approve/reject/review pending | `fabric-review` |
|
|
27
|
+
| 导入历史/bootstrap fabric/mine changelog/挖掘 commit | `fabric-import` |
|
|
28
|
+
| 同步知识库/sync stores/fabric-sync/解决 store 冲突/rebase 冲突 | `fabric-sync` |
|
|
29
|
+
| 创建 store/挂载 store/绑定知识库/store 列表/切换写库/set up knowledge store | `fabric-store` |
|
|
30
|
+
| 审计知识库/清理陈旧知识/知识库体检/deprecate 条目/prune stale knowledge/知识库瘦身/淘汰旧决策 | `fabric-audit` |
|
|
31
|
+
| 连接知识/找关联条目/建知识图谱/link related entries/补 related 边/知识库连通性 | `fabric-connect` |
|
|
32
|
+
|
|
33
|
+
`S_CLASSIFY` 的 `task_type` 枚举:`archive | review | import | sync | store | audit | connect`
|
|
34
|
+
<!-- fabric:router-intent:end -->
|
|
29
35
|
|
|
30
36
|
## State Machine
|
|
31
37
|
|
|
@@ -35,7 +41,7 @@ description: Fabric 入口层路由 — 参考 maestro 的顺序协调方式,
|
|
|
35
41
|
|
|
36
42
|
```json
|
|
37
43
|
{
|
|
38
|
-
"task_type": "
|
|
44
|
+
"task_type": "<Intent Map task_type 枚举之一>",
|
|
39
45
|
"scope": "project|store|entry|paths|null",
|
|
40
46
|
"write_intent": true,
|
|
41
47
|
"confidence": "high|medium|low"
|
|
@@ -1,273 +0,0 @@
|
|
|
1
|
-
/**
|
|
2
|
-
* rc.35 TASK-06 (P0-10.b) — summary-fallback library.
|
|
3
|
-
*
|
|
4
|
-
* Resolves opaque hint entries (where `entry.summary === entry.id` so the
|
|
5
|
-
* AI sees no information beyond the id) by reading the entry's markdown file
|
|
6
|
-
* from mounted store `knowledge/<type>/<id>--<slug>.md`, extracting the first
|
|
7
|
-
* paragraph under `## Summary`, and substituting that text into the entry
|
|
8
|
-
* before the hook renders it.
|
|
9
|
-
*
|
|
10
|
-
* Caching: results are stored in `.fabric/.cache/summary-fallback.json`
|
|
11
|
-
* keyed by the current `revision_hash` returned by plan-context-hint. The
|
|
12
|
-
* cache is wiped wholesale when the revision changes (cheap invariant —
|
|
13
|
-
* any meta rev bump implies entry text MAY have moved). Per-process call
|
|
14
|
-
* also benefits from in-memory dedup since the same opaque id may appear
|
|
15
|
-
* across narrow + broad paths.
|
|
16
|
-
*
|
|
17
|
-
* Design contract:
|
|
18
|
-
* - Never throw. ANY failure (cache read, fs scan, file read) degrades
|
|
19
|
-
* to a no-op — the original opaque summary is left untouched. Hooks
|
|
20
|
-
* must remain best-effort.
|
|
21
|
-
* - Idempotent over identical inputs. Two calls in succession with the
|
|
22
|
-
* same revision_hash + entries set produce zero disk reads on the
|
|
23
|
-
* second call.
|
|
24
|
-
*
|
|
25
|
-
* Public API (module.exports):
|
|
26
|
-
* resolveOpaqueSummaries(entries, projectRoot, revisionHash) — returns
|
|
27
|
-
* a NEW array of entries with `summary` substituted for opaque cases.
|
|
28
|
-
* Original `entry.id` is preserved verbatim.
|
|
29
|
-
*
|
|
30
|
-
* _extractFirstSummaryParagraph(md) — pure helper, exposed for testing.
|
|
31
|
-
*
|
|
32
|
-
* _readCache / _writeCache — exposed for testing.
|
|
33
|
-
*/
|
|
34
|
-
|
|
35
|
-
const { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } = require("node:fs");
|
|
36
|
-
const { homedir } = require("node:os");
|
|
37
|
-
const { join } = require("node:path");
|
|
38
|
-
|
|
39
|
-
const CACHE_DIR_REL = ".fabric/.cache";
|
|
40
|
-
const CACHE_FILE_REL = ".fabric/.cache/summary-fallback.json";
|
|
41
|
-
const GLOBAL_CONFIG_FILE = "fabric-global.json";
|
|
42
|
-
const PROJECT_CONFIG_REL = ".fabric/fabric-config.json";
|
|
43
|
-
const SUMMARY_MAX_LEN = 80;
|
|
44
|
-
const KNOWLEDGE_TYPE_DIRS = ["decisions", "pitfalls", "guidelines", "models", "processes"];
|
|
45
|
-
|
|
46
|
-
function _isOpaque(entry) {
|
|
47
|
-
if (!entry || typeof entry.id !== "string" || typeof entry.summary !== "string") {
|
|
48
|
-
return false;
|
|
49
|
-
}
|
|
50
|
-
return entry.summary.trim() === entry.id.trim();
|
|
51
|
-
}
|
|
52
|
-
|
|
53
|
-
/**
|
|
54
|
-
* Pure helper: extract the first paragraph under a `## Summary` heading.
|
|
55
|
-
*
|
|
56
|
-
* - `## Summary` is case-insensitive but level-sensitive (only H2).
|
|
57
|
-
* - First paragraph = lines until blank line or next heading.
|
|
58
|
-
* - Collapses whitespace + trims; returns `""` if no summary section or
|
|
59
|
-
* the section is empty.
|
|
60
|
-
*/
|
|
61
|
-
function _extractFirstSummaryParagraph(md) {
|
|
62
|
-
if (typeof md !== "string" || md.length === 0) return "";
|
|
63
|
-
const lines = md.split(/\r?\n/);
|
|
64
|
-
let i = 0;
|
|
65
|
-
while (i < lines.length) {
|
|
66
|
-
if (/^##\s+summary\s*$/i.test(lines[i].trim())) {
|
|
67
|
-
i += 1;
|
|
68
|
-
break;
|
|
69
|
-
}
|
|
70
|
-
i += 1;
|
|
71
|
-
}
|
|
72
|
-
if (i >= lines.length) return "";
|
|
73
|
-
// Skip blank lines after the heading
|
|
74
|
-
while (i < lines.length && lines[i].trim().length === 0) i += 1;
|
|
75
|
-
// Collect until the next blank line or next heading
|
|
76
|
-
const buf = [];
|
|
77
|
-
while (i < lines.length) {
|
|
78
|
-
const line = lines[i];
|
|
79
|
-
if (line.trim().length === 0) break;
|
|
80
|
-
if (/^#{1,6}\s/.test(line.trim())) break;
|
|
81
|
-
buf.push(line.trim());
|
|
82
|
-
i += 1;
|
|
83
|
-
}
|
|
84
|
-
const flat = buf.join(" ").replace(/\s+/g, " ").trim();
|
|
85
|
-
if (flat.length === 0) return "";
|
|
86
|
-
if (flat.length <= SUMMARY_MAX_LEN) return flat;
|
|
87
|
-
return `${flat.slice(0, SUMMARY_MAX_LEN - 1)}…`;
|
|
88
|
-
}
|
|
89
|
-
|
|
90
|
-
function _readCache(projectRoot) {
|
|
91
|
-
const cachePath = join(projectRoot, CACHE_FILE_REL);
|
|
92
|
-
if (!existsSync(cachePath)) return null;
|
|
93
|
-
try {
|
|
94
|
-
const raw = readFileSync(cachePath, "utf8");
|
|
95
|
-
const parsed = JSON.parse(raw);
|
|
96
|
-
if (parsed && typeof parsed === "object" && typeof parsed.revision === "string" && parsed.summaries && typeof parsed.summaries === "object") {
|
|
97
|
-
return parsed;
|
|
98
|
-
}
|
|
99
|
-
} catch {
|
|
100
|
-
// ignore — caller treats null as no-cache
|
|
101
|
-
}
|
|
102
|
-
return null;
|
|
103
|
-
}
|
|
104
|
-
|
|
105
|
-
function _writeCache(projectRoot, payload) {
|
|
106
|
-
try {
|
|
107
|
-
const cacheDir = join(projectRoot, CACHE_DIR_REL);
|
|
108
|
-
if (!existsSync(cacheDir)) mkdirSync(cacheDir, { recursive: true });
|
|
109
|
-
const cachePath = join(projectRoot, CACHE_FILE_REL);
|
|
110
|
-
writeFileSync(cachePath, JSON.stringify(payload), "utf8");
|
|
111
|
-
} catch {
|
|
112
|
-
// Best-effort — failing to persist cache is not an error
|
|
113
|
-
}
|
|
114
|
-
}
|
|
115
|
-
|
|
116
|
-
/**
|
|
117
|
-
* Return mounted store directories in the project's read-set
|
|
118
|
-
* (`required_stores` plus implicit personal). This hook helper is deliberately
|
|
119
|
-
* tiny and best-effort: malformed config degrades to an empty read-set rather
|
|
120
|
-
* than throwing during a shell hook.
|
|
121
|
-
*/
|
|
122
|
-
function _readJson(path) {
|
|
123
|
-
try {
|
|
124
|
-
return JSON.parse(readFileSync(path, "utf8"));
|
|
125
|
-
} catch {
|
|
126
|
-
return null;
|
|
127
|
-
}
|
|
128
|
-
}
|
|
129
|
-
|
|
130
|
-
function _globalRoot() {
|
|
131
|
-
return join(process.env.FABRIC_HOME || homedir(), ".fabric");
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
function _storeDir(globalRoot, store) {
|
|
135
|
-
return join(globalRoot, "stores", store.mount_name || store.store_uuid);
|
|
136
|
-
}
|
|
137
|
-
|
|
138
|
-
function _readSetStoreDirs(projectRoot) {
|
|
139
|
-
const globalRoot = _globalRoot();
|
|
140
|
-
const global = _readJson(join(globalRoot, GLOBAL_CONFIG_FILE));
|
|
141
|
-
if (!global || !Array.isArray(global.stores)) return [];
|
|
142
|
-
const project = _readJson(join(projectRoot, PROJECT_CONFIG_REL)) || {};
|
|
143
|
-
const required = Array.isArray(project.required_stores) ? project.required_stores : [];
|
|
144
|
-
const stores = [];
|
|
145
|
-
|
|
146
|
-
for (const req of required) {
|
|
147
|
-
if (!req || typeof req.id !== "string") continue;
|
|
148
|
-
const matched = global.stores.find(
|
|
149
|
-
(store) => store && !store.personal && (store.alias === req.id || store.store_uuid === req.id),
|
|
150
|
-
);
|
|
151
|
-
if (matched) stores.push(matched);
|
|
152
|
-
}
|
|
153
|
-
|
|
154
|
-
const personal = global.stores.find((store) => store && store.personal);
|
|
155
|
-
if (personal) stores.push(personal);
|
|
156
|
-
|
|
157
|
-
return stores.map((store) => ({
|
|
158
|
-
alias: typeof store.alias === "string" ? store.alias : "",
|
|
159
|
-
dir: _storeDir(globalRoot, store),
|
|
160
|
-
}));
|
|
161
|
-
}
|
|
162
|
-
|
|
163
|
-
function _splitQualifiedId(id) {
|
|
164
|
-
const idx = typeof id === "string" ? id.indexOf(":") : -1;
|
|
165
|
-
if (idx <= 0) return { alias: "", stableId: id };
|
|
166
|
-
return { alias: id.slice(0, idx), stableId: id.slice(idx + 1) };
|
|
167
|
-
}
|
|
168
|
-
|
|
169
|
-
/**
|
|
170
|
-
* Scan mounted store `knowledge/<type>/` for the canonical `<id>--<slug>.md`
|
|
171
|
-
* matching `stableId`. Tries the most likely type-dir first based on the
|
|
172
|
-
* entry's `type` hint, then falls back to scanning all canonical type
|
|
173
|
-
* directories. Returns the absolute path or null.
|
|
174
|
-
*
|
|
175
|
-
* The id→file mapping is unique by construction (stable_id is allocated
|
|
176
|
-
* once per file), so the first match wins.
|
|
177
|
-
*/
|
|
178
|
-
function _findEntryFile(projectRoot, stableId, typeHint) {
|
|
179
|
-
const parsedId = _splitQualifiedId(stableId);
|
|
180
|
-
const storeDirs = _readSetStoreDirs(projectRoot).filter(
|
|
181
|
-
(store) => parsedId.alias.length === 0 || store.alias === parsedId.alias,
|
|
182
|
-
);
|
|
183
|
-
if (storeDirs.length === 0) return null;
|
|
184
|
-
const tryOrder = [];
|
|
185
|
-
if (typeof typeHint === "string" && typeHint.length > 0) {
|
|
186
|
-
// Accept both singular and plural hints — find the plural form.
|
|
187
|
-
const lower = typeHint.toLowerCase();
|
|
188
|
-
const plural = KNOWLEDGE_TYPE_DIRS.find((d) => d === lower || d.startsWith(lower));
|
|
189
|
-
if (plural) tryOrder.push(plural);
|
|
190
|
-
}
|
|
191
|
-
for (const t of KNOWLEDGE_TYPE_DIRS) {
|
|
192
|
-
if (!tryOrder.includes(t)) tryOrder.push(t);
|
|
193
|
-
}
|
|
194
|
-
const prefix = `${parsedId.stableId}--`;
|
|
195
|
-
for (const store of storeDirs) {
|
|
196
|
-
const baseDir = join(store.dir, "knowledge");
|
|
197
|
-
if (!existsSync(baseDir)) continue;
|
|
198
|
-
for (const t of tryOrder) {
|
|
199
|
-
const typeDir = join(baseDir, t);
|
|
200
|
-
if (!existsSync(typeDir)) continue;
|
|
201
|
-
let files;
|
|
202
|
-
try {
|
|
203
|
-
files = readdirSync(typeDir);
|
|
204
|
-
} catch {
|
|
205
|
-
continue;
|
|
206
|
-
}
|
|
207
|
-
for (const f of files) {
|
|
208
|
-
if (f.startsWith(prefix) && f.endsWith(".md")) {
|
|
209
|
-
return join(typeDir, f);
|
|
210
|
-
}
|
|
211
|
-
}
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
return null;
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
function _resolveOne(projectRoot, entry) {
|
|
218
|
-
const filePath = _findEntryFile(projectRoot, entry.id, entry.type);
|
|
219
|
-
if (filePath === null) return "";
|
|
220
|
-
let md;
|
|
221
|
-
try {
|
|
222
|
-
md = readFileSync(filePath, "utf8");
|
|
223
|
-
} catch {
|
|
224
|
-
return "";
|
|
225
|
-
}
|
|
226
|
-
return _extractFirstSummaryParagraph(md);
|
|
227
|
-
}
|
|
228
|
-
|
|
229
|
-
/**
|
|
230
|
-
* Main API. Returns a new array of entries with `summary` swapped for
|
|
231
|
-
* the extracted fallback wherever the original summary was opaque AND
|
|
232
|
-
* the fallback extraction yielded a non-empty string. Non-opaque entries
|
|
233
|
-
* pass through unchanged.
|
|
234
|
-
*/
|
|
235
|
-
function resolveOpaqueSummaries(entries, projectRoot, revisionHash) {
|
|
236
|
-
if (!Array.isArray(entries) || entries.length === 0) return entries;
|
|
237
|
-
const cache = _readCache(projectRoot);
|
|
238
|
-
const cachedSummaries = cache && cache.revision === revisionHash && cache.summaries ? cache.summaries : {};
|
|
239
|
-
const nextCacheSummaries = { ...cachedSummaries };
|
|
240
|
-
let cacheChanged = cache === null || cache.revision !== revisionHash;
|
|
241
|
-
const result = entries.map((entry) => {
|
|
242
|
-
if (!_isOpaque(entry)) return entry;
|
|
243
|
-
const id = entry.id;
|
|
244
|
-
let fallback;
|
|
245
|
-
if (Object.prototype.hasOwnProperty.call(cachedSummaries, id)) {
|
|
246
|
-
fallback = cachedSummaries[id];
|
|
247
|
-
} else {
|
|
248
|
-
fallback = _resolveOne(projectRoot, entry);
|
|
249
|
-
nextCacheSummaries[id] = fallback;
|
|
250
|
-
cacheChanged = true;
|
|
251
|
-
}
|
|
252
|
-
if (typeof fallback === "string" && fallback.length > 0) {
|
|
253
|
-
return { ...entry, summary: fallback };
|
|
254
|
-
}
|
|
255
|
-
return entry;
|
|
256
|
-
});
|
|
257
|
-
if (cacheChanged) {
|
|
258
|
-
_writeCache(projectRoot, { revision: revisionHash, summaries: nextCacheSummaries });
|
|
259
|
-
}
|
|
260
|
-
return result;
|
|
261
|
-
}
|
|
262
|
-
|
|
263
|
-
module.exports = {
|
|
264
|
-
resolveOpaqueSummaries,
|
|
265
|
-
_extractFirstSummaryParagraph,
|
|
266
|
-
_readCache,
|
|
267
|
-
_writeCache,
|
|
268
|
-
_findEntryFile,
|
|
269
|
-
_readSetStoreDirs,
|
|
270
|
-
_isOpaque,
|
|
271
|
-
SUMMARY_MAX_LEN,
|
|
272
|
-
KNOWLEDGE_TYPE_DIRS,
|
|
273
|
-
};
|