@fenglimg/fabric-cli 2.0.1 → 2.1.0-rc.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,245 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ regenerateBindingsSnapshot
4
+ } from "./chunk-WU6GAPKH.js";
5
+ import "./chunk-L4Q55UC4.js";
6
+ import "./chunk-LFIKMVY7.js";
7
+ import {
8
+ loadGlobalConfig,
9
+ resolveGlobalRoot
10
+ } from "./chunk-RYAFBNES.js";
11
+
12
+ // src/commands/sync.ts
13
+ import { defineCommand } from "citty";
14
+
15
+ // src/sync/run-sync.ts
16
+ import { execFileSync } from "child_process";
17
+ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs";
18
+ import { join } from "path";
19
+ import { GLOBAL_STATE_DIR, storeRelativePath } from "@fenglimg/fabric-shared";
20
+
21
+ // src/sync/state-machine.ts
22
+ function syncTransition(state, event) {
23
+ switch (state) {
24
+ case "pending":
25
+ if (event === "rebase_clean") return "synced";
26
+ if (event === "rebase_conflict") return "conflict";
27
+ if (event === "network_unavailable") return "offline";
28
+ break;
29
+ case "conflict":
30
+ if (event === "user_continue") return "synced";
31
+ if (event === "user_abort") return "aborted";
32
+ break;
33
+ case "offline":
34
+ if (event === "retry" || event === "rebase_clean") return "synced";
35
+ if (event === "rebase_conflict") return "conflict";
36
+ if (event === "network_unavailable") return "offline";
37
+ break;
38
+ case "synced":
39
+ case "aborted":
40
+ break;
41
+ }
42
+ throw new Error(`invalid sync transition: '${state}' --${event}-->`);
43
+ }
44
+ function planSync(stores) {
45
+ return { stores: stores.map((s) => ({ ...s, state: "pending" })) };
46
+ }
47
+ function applySyncEvent(session, alias, event) {
48
+ return {
49
+ stores: session.stores.map(
50
+ (s) => s.alias === alias ? { ...s, state: syncTransition(s.state, event) } : s
51
+ )
52
+ };
53
+ }
54
+ function continueSync(session) {
55
+ const conflicted = session.stores.find((s) => s.state === "conflict");
56
+ if (conflicted === void 0) {
57
+ throw new Error("`sync --continue` with no conflicted store to resume");
58
+ }
59
+ return applySyncEvent(session, conflicted.alias, "user_continue");
60
+ }
61
+ function abortSync(session) {
62
+ const conflicted = session.stores.find((s) => s.state === "conflict");
63
+ if (conflicted === void 0) {
64
+ throw new Error("`sync --abort` with no conflicted store to abort");
65
+ }
66
+ return applySyncEvent(session, conflicted.alias, "user_abort");
67
+ }
68
+ function isSyncSettled(session) {
69
+ return session.stores.every((s) => s.state !== "pending" && s.state !== "conflict");
70
+ }
71
+ function deferredPushStores(session) {
72
+ return session.stores.filter((s) => s.state === "offline");
73
+ }
74
+
75
+ // src/sync/run-sync.ts
76
+ var NO_GLOBAL_CONFIG = "no global Fabric config \u2014 run `fabric install --global <url>` first";
77
+ var NO_SESSION = "no sync in progress \u2014 run `fabric sync` first";
78
+ var NO_CONFLICT = "no conflicted store to resume \u2014 sync is not paused";
79
+ function syncSessionPath(globalRoot) {
80
+ return join(globalRoot, GLOBAL_STATE_DIR, "sync-session.json");
81
+ }
82
+ function loadSession(globalRoot) {
83
+ const path = syncSessionPath(globalRoot);
84
+ if (!existsSync(path)) {
85
+ return null;
86
+ }
87
+ return JSON.parse(readFileSync(path, "utf8"));
88
+ }
89
+ function saveSession(globalRoot, session) {
90
+ const path = syncSessionPath(globalRoot);
91
+ mkdirSync(join(path, ".."), { recursive: true });
92
+ writeFileSync(path, `${JSON.stringify(session, null, 2)}
93
+ `, "utf8");
94
+ }
95
+ function clearSession(globalRoot) {
96
+ rmSync(syncSessionPath(globalRoot), { force: true });
97
+ }
98
+ function defaultPull(storeDir) {
99
+ try {
100
+ execFileSync("git", ["pull", "--rebase"], {
101
+ cwd: storeDir,
102
+ stdio: ["ignore", "pipe", "pipe"]
103
+ });
104
+ return "clean";
105
+ } catch (error) {
106
+ const detail = `${gitErrText(error, "stdout")}${gitErrText(error, "stderr")}`;
107
+ if (/CONFLICT|could not apply|needs merge|rebase --continue/i.test(detail)) {
108
+ return "conflict";
109
+ }
110
+ if (/could not resolve host|could not read from remote|unable to access|connection|network is unreachable|timed out/i.test(
111
+ detail
112
+ )) {
113
+ return "offline";
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+ function gitErrText(error, key) {
119
+ const value = error[key];
120
+ return typeof value === "string" || Buffer.isBuffer(value) ? String(value) : "";
121
+ }
122
+ function defaultRebaseContinue(storeDir) {
123
+ execFileSync("git", ["rebase", "--continue"], { cwd: storeDir, stdio: "ignore" });
124
+ }
125
+ function defaultRebaseAbort(storeDir) {
126
+ execFileSync("git", ["rebase", "--abort"], { cwd: storeDir, stdio: "ignore" });
127
+ }
128
+ var OUTCOME_EVENT = {
129
+ clean: "rebase_clean",
130
+ conflict: "rebase_conflict",
131
+ offline: "network_unavailable"
132
+ };
133
+ function walkPending(session, storeDirOf, pull) {
134
+ let next = session;
135
+ for (const store of session.stores) {
136
+ if (store.state !== "pending") {
137
+ continue;
138
+ }
139
+ const outcome = pull(storeDirOf(store));
140
+ next = applySyncEvent(next, store.alias, OUTCOME_EVENT[outcome]);
141
+ if (outcome === "conflict") {
142
+ break;
143
+ }
144
+ }
145
+ return next;
146
+ }
147
+ function finalize(session, options, globalRoot) {
148
+ const settled = isSyncSettled(session);
149
+ let snapshotWritten = false;
150
+ if (settled) {
151
+ clearSession(globalRoot);
152
+ const snapshot = regenerateBindingsSnapshot(options.projectRoot, {
153
+ globalRoot,
154
+ now: options.now,
155
+ ...options.writeScope === void 0 ? {} : { writeScope: options.writeScope }
156
+ });
157
+ snapshotWritten = snapshot !== null;
158
+ } else {
159
+ saveSession(globalRoot, session);
160
+ }
161
+ return { session, settled, deferred: deferredPushStores(session), snapshotWritten };
162
+ }
163
+ function runStartSync(options) {
164
+ const globalRoot = options.globalRoot ?? resolveGlobalRoot();
165
+ const config = loadGlobalConfig(globalRoot);
166
+ if (config === null) {
167
+ throw new Error(NO_GLOBAL_CONFIG);
168
+ }
169
+ const syncable = config.stores.filter((store) => store.remote !== void 0);
170
+ const session = planSync(
171
+ syncable.map((store) => ({ alias: store.alias, store_uuid: store.store_uuid }))
172
+ );
173
+ const storeDirOf = (status) => join(globalRoot, storeRelativePath(status.store_uuid));
174
+ const walked = walkPending(session, storeDirOf, options.pull ?? defaultPull);
175
+ return finalize(walked, options, globalRoot);
176
+ }
177
+ function runContinueSync(options) {
178
+ const globalRoot = options.globalRoot ?? resolveGlobalRoot();
179
+ const session = loadSession(globalRoot);
180
+ if (session === null) {
181
+ throw new Error(NO_SESSION);
182
+ }
183
+ const conflicted = session.stores.find((store) => store.state === "conflict");
184
+ if (conflicted === void 0) {
185
+ throw new Error(NO_CONFLICT);
186
+ }
187
+ const storeDirOf = (status) => join(globalRoot, storeRelativePath(status.store_uuid));
188
+ (options.rebaseContinue ?? defaultRebaseContinue)(storeDirOf(conflicted));
189
+ const resumed = walkPending(continueSync(session), storeDirOf, options.pull ?? defaultPull);
190
+ return finalize(resumed, options, globalRoot);
191
+ }
192
+ function runAbortSync(options) {
193
+ const globalRoot = options.globalRoot ?? resolveGlobalRoot();
194
+ const session = loadSession(globalRoot);
195
+ if (session === null) {
196
+ throw new Error(NO_SESSION);
197
+ }
198
+ const conflicted = session.stores.find((store) => store.state === "conflict");
199
+ if (conflicted === void 0) {
200
+ throw new Error(NO_CONFLICT);
201
+ }
202
+ const storeDirOf = (status) => join(globalRoot, storeRelativePath(status.store_uuid));
203
+ (options.rebaseAbort ?? defaultRebaseAbort)(storeDirOf(conflicted));
204
+ const resumed = walkPending(abortSync(session), storeDirOf, options.pull ?? defaultPull);
205
+ return finalize(resumed, options, globalRoot);
206
+ }
207
+
208
+ // src/commands/sync.ts
209
+ function report(result) {
210
+ for (const store of result.session.stores) {
211
+ console.log(`${store.alias} ${store.state}`);
212
+ }
213
+ if (result.deferred.length > 0) {
214
+ console.log(
215
+ `${result.deferred.length} store(s) offline \u2014 push deferred; re-run \`fabric sync\` when online`
216
+ );
217
+ }
218
+ if (!result.settled) {
219
+ console.log(
220
+ "sync paused on a conflict \u2014 resolve it, then run `fabric sync --continue` (or `--abort`)"
221
+ );
222
+ }
223
+ }
224
+ var sync_default = defineCommand({
225
+ meta: { name: "sync", description: "Pull --rebase + push every mounted store; resume conflicts" },
226
+ args: {
227
+ continue: { type: "boolean", description: "Resume after resolving a rebase conflict" },
228
+ abort: { type: "boolean", description: "Abort the conflicted store's rebase" }
229
+ },
230
+ run({ args }) {
231
+ const options = { projectRoot: process.cwd(), now: (/* @__PURE__ */ new Date()).toISOString() };
232
+ if (args.continue === true) {
233
+ report(runContinueSync(options));
234
+ return;
235
+ }
236
+ if (args.abort === true) {
237
+ report(runAbortSync(options));
238
+ return;
239
+ }
240
+ report(runStartSync(options));
241
+ }
242
+ });
243
+ export {
244
+ sync_default as default
245
+ };
@@ -7,11 +7,7 @@ import {
7
7
  HOOK_SCRIPT_DESTINATIONS,
8
8
  SKILL_DESTINATIONS,
9
9
  fabricAgentsSnapshotPath
10
- } from "./chunk-D25XJ4BC.js";
11
- import {
12
- detectClientSupports,
13
- resolveClients
14
- } from "./chunk-MF3OTILQ.js";
10
+ } from "./chunk-F46ORPOA.js";
15
11
  import {
16
12
  paint
17
13
  } from "./chunk-WWNXR34K.js";
@@ -19,6 +15,10 @@ import {
19
15
  createDebugLogger,
20
16
  resolveDevMode
21
17
  } from "./chunk-COI5VDFU.js";
18
+ import {
19
+ detectClientSupports,
20
+ resolveClients
21
+ } from "./chunk-MF3OTILQ.js";
22
22
  import {
23
23
  t
24
24
  } from "./chunk-PWLW3B57.js";
@@ -46,6 +46,9 @@ async function uninstallFabricReviewSkill(projectRoot) {
46
46
  async function uninstallFabricImportSkill(projectRoot) {
47
47
  return removeSkill("skill-import", SKILL_DESTINATIONS.fabricImport, projectRoot);
48
48
  }
49
+ async function uninstallFabricSyncSkill(projectRoot) {
50
+ return removeSkill("skill-sync", SKILL_DESTINATIONS.fabricSync, projectRoot);
51
+ }
49
52
  async function removeSkill(step, rels, projectRoot) {
50
53
  const results = [];
51
54
  for (const rel of rels) {
@@ -301,6 +304,12 @@ async function uninstallBootstrapStage(projectRoot, _opts = {}) {
301
304
  projectRoot,
302
305
  () => removeArchiveHintHook(projectRoot)
303
306
  );
307
+ await runAndCollect(
308
+ results,
309
+ "skill-sync",
310
+ projectRoot,
311
+ () => uninstallFabricSyncSkill(projectRoot)
312
+ );
304
313
  await runAndCollect(
305
314
  results,
306
315
  "skill-import",
@@ -0,0 +1,31 @@
1
+ #!/usr/bin/env node
2
+ import {
3
+ whoami
4
+ } from "./chunk-T5RPGCCM.js";
5
+ import "./chunk-LFIKMVY7.js";
6
+ import "./chunk-RYAFBNES.js";
7
+
8
+ // src/commands/whoami.ts
9
+ import { defineCommand } from "citty";
10
+ var whoami_default = defineCommand({
11
+ meta: { name: "whoami", description: "Show this machine's Fabric uid and mounted stores" },
12
+ run() {
13
+ const info = whoami();
14
+ if (info === null) {
15
+ console.log("no global Fabric config \u2014 run `fabric install --global <url>` first");
16
+ return;
17
+ }
18
+ console.log(`uid: ${info.uid}`);
19
+ if (info.stores.length === 0) {
20
+ console.log("stores: (none mounted)");
21
+ return;
22
+ }
23
+ console.log("stores:");
24
+ for (const store of info.stores) {
25
+ console.log(` ${store.alias} ${store.store_uuid}${store.local_only ? " (local-only)" : ""}`);
26
+ }
27
+ }
28
+ });
29
+ export {
30
+ whoami_default as default
31
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.0.1",
3
+ "version": "2.1.0-rc.2",
4
4
  "description": "Fabric CLI — installs the MCP server + skills + hooks for Claude Code, Cursor, and Codex CLI; runs doctor / knowledge maintenance.",
5
5
  "license": "MIT",
6
6
  "author": "wangzhichao <fenglimg90@gmail.com>",
@@ -45,8 +45,8 @@
45
45
  "tree-sitter-javascript": "^0.25.0",
46
46
  "tree-sitter-typescript": "^0.23.2",
47
47
  "web-tree-sitter": "^0.26.8",
48
- "@fenglimg/fabric-server": "2.0.1",
49
- "@fenglimg/fabric-shared": "2.0.1"
48
+ "@fenglimg/fabric-server": "2.1.0-rc.2",
49
+ "@fenglimg/fabric-shared": "2.1.0-rc.2"
50
50
  },
51
51
  "devDependencies": {
52
52
  "@types/node": "^22.15.0",
@@ -81,6 +81,27 @@ try {
81
81
  stateStore = null;
82
82
  }
83
83
 
84
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
85
+ // resolved-bindings snapshot. The Stop hint surfaces the read-set stores
86
+ // (per-store, NOT aggregated into one pile) without re-resolving / walking
87
+ // store trees. Best-effort — a missing lib/snapshot omits the store line.
88
+ let bindingsSnapshotReader = null;
89
+ try {
90
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
91
+ } catch {
92
+ bindingsSnapshotReader = null;
93
+ }
94
+
95
+ // Read the project's own `project_id` (the snapshot key) from its config.
96
+ function readProjectId(cwd) {
97
+ try {
98
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
99
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
100
+ } catch {
101
+ return null;
102
+ }
103
+ }
104
+
84
105
  // CONSTANTS — duplicated from packages/server/src/services/_shared.ts.
85
106
  // DRY violation accepted: this hook script runs in user repos WITHOUT
86
107
  // node_modules access, so it cannot import from @fenglimg/fabric-server.
@@ -1907,6 +1928,25 @@ function main(env, stdio) {
1907
1928
  result.reason = `${result.reason}\n${renderDismissOption(result.signal, variant)}`;
1908
1929
  }
1909
1930
 
1931
+ // v2.1.0-rc.1 P4 (F4/S63): surface the read-set stores on the Stop hint so
1932
+ // backlog/maintenance nudges are read per-store, not as one undifferentiated
1933
+ // pile. Best-effort; missing snapshot / single-store omits the line.
1934
+ if (bindingsSnapshotReader !== null && typeof result.reason === "string") {
1935
+ try {
1936
+ const projectId = readProjectId(cwd);
1937
+ if (projectId) {
1938
+ const label = bindingsSnapshotReader.formatStoreLabels(
1939
+ bindingsSnapshotReader.readBindingsSnapshot(projectId),
1940
+ );
1941
+ if (label) {
1942
+ result.reason = `${result.reason}\n${label}`;
1943
+ }
1944
+ }
1945
+ } catch {
1946
+ // store label is decorative provenance — never crash the hook
1947
+ }
1948
+ }
1949
+
1910
1950
  // v2.0.0-rc.7 T10: Signal D uses its own cooldown sidecar (day-based,
1911
1951
  // see MAINTENANCE_HINT_LAST_EMIT_FILE). The A/B/C shared cooldown cache
1912
1952
  // uses hours, so we branch here to avoid mixing semantics.
@@ -68,6 +68,28 @@ const { readTextState, writeTextState } = require("./lib/state-store.cjs");
68
68
  // v2.0.0-rc.37 NEW-30: shared client detection (replaces the inline
69
69
  // CLAUDE_PROJECT_DIR single-bit check below).
70
70
  const { isClaudeCode } = require("./lib/client-adapter.cjs");
71
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
72
+ // resolved-bindings snapshot. The hook NEVER re-resolves stores or walks store
73
+ // trees — it only echoes the read-set the CLI already computed. Best-effort.
74
+ let bindingsSnapshotReader = null;
75
+ try {
76
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
77
+ } catch {
78
+ // Lib missing (old install) — store labels degrade to silent absence.
79
+ }
80
+
81
+ // Read the project's own `project_id` from `.fabric/fabric-config.json` (the
82
+ // snapshot key). Reading the PROJECT config is not a store-tree read — it is how
83
+ // the hook learns which snapshot to fetch. Returns null on any failure.
84
+ function readProjectId(cwd) {
85
+ try {
86
+ const raw = readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8");
87
+ const parsed = JSON.parse(raw);
88
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
89
+ } catch {
90
+ return null;
91
+ }
92
+ }
71
93
 
72
94
  // -----------------------------------------------------------------------------
73
95
  // rc.12: SessionStart broad-menu is now unconditionally emitted on every
@@ -735,6 +757,24 @@ function main(env, stdio) {
735
757
 
736
758
  if (lines.length === 0) return; // nothing to say — silent exit
737
759
 
760
+ // v2.1.0-rc.1 P4 (F4/S63): append a per-store read-set label from the
761
+ // CLI-pre-generated bindings snapshot so the session opens aware of which
762
+ // stores it reads and where writes land. Best-effort, never blocks: a
763
+ // missing snapshot / single-store setup just omits the line.
764
+ if (bindingsSnapshotReader !== null) {
765
+ try {
766
+ const projectId = readProjectId(cwd);
767
+ if (projectId) {
768
+ const label = bindingsSnapshotReader.formatStoreLabels(
769
+ bindingsSnapshotReader.readBindingsSnapshot(projectId),
770
+ );
771
+ if (label) lines.push(label);
772
+ }
773
+ } catch {
774
+ // store labels are decorative provenance — never crash the hook
775
+ }
776
+ }
777
+
738
778
  // v2.0.0-rc.37 NEW-23: SessionStart 索引末尾"下一步"引导。Tail line that
739
779
  // tells the AI what to do with the broad index it just received. Without
740
780
  // this, the model often parses the index and moves on without ever calling
@@ -85,6 +85,26 @@ const { resolveOpaqueSummaries } = require("./lib/summary-fallback.cjs");
85
85
  // cache (skips a redundant CLI cold-start spawn when the same path-set is
86
86
  // re-edited within a session and the knowledge graph hasn't changed).
87
87
  const { readJsonState, writeJsonState } = require("./lib/state-store.cjs");
88
+ // v2.1.0-rc.1 P4 (F4/S63): hook-side reader for the CLI pre-generated
89
+ // resolved-bindings snapshot. Store-aware hint surfaces the write-target store
90
+ // for the edited file WITHOUT re-resolving or walking store trees. Best-effort.
91
+ let bindingsSnapshotReader = null;
92
+ try {
93
+ bindingsSnapshotReader = require("./lib/bindings-snapshot-reader.cjs");
94
+ } catch {
95
+ // Lib missing (old install) — store labels degrade to silent absence.
96
+ }
97
+
98
+ // Read the project's own `project_id` (the snapshot key) from its config. Not a
99
+ // store-tree read — it is how the hook learns which snapshot to fetch.
100
+ function readProjectId(cwd) {
101
+ try {
102
+ const parsed = JSON.parse(readFileSync(join(cwd, ".fabric", "fabric-config.json"), "utf8"));
103
+ return typeof parsed.project_id === "string" ? parsed.project_id : null;
104
+ } catch {
105
+ return null;
106
+ }
107
+ }
88
108
 
89
109
  // -----------------------------------------------------------------------------
90
110
  // CONSTANTS
@@ -1466,6 +1486,25 @@ function main(env, stdio) {
1466
1486
  const lines = renderSummary({ ...cliPayload, entries: resolvedEntries }, summaryMaxLen);
1467
1487
  if (lines.length === 0) return;
1468
1488
 
1489
+ // v2.1.0-rc.1 P4 (F4/S63): store-aware hint — append the write-target store
1490
+ // so the edit-time hint says WHERE a derived knowledge entry would land.
1491
+ // Best-effort; missing snapshot / single-store setup omits the line.
1492
+ if (bindingsSnapshotReader !== null) {
1493
+ try {
1494
+ const projectId = readProjectId(cwd);
1495
+ if (projectId) {
1496
+ const snapshot = bindingsSnapshotReader.readBindingsSnapshot(projectId);
1497
+ const writeAlias =
1498
+ snapshot && snapshot.write_target && snapshot.write_target.alias;
1499
+ if (writeAlias) {
1500
+ lines.push(`[fabric] writes here land in store '${writeAlias}'`);
1501
+ }
1502
+ }
1503
+ } catch {
1504
+ // store label is decorative provenance — never crash the hook
1505
+ }
1506
+ }
1507
+
1469
1508
  // Stderr: human-facing breadcrumb + legacy contract.
1470
1509
  for (const line of lines) {
1471
1510
  err.write(`${line}\n`);
@@ -0,0 +1,81 @@
1
+ #!/usr/bin/env node
2
+ // v2.1.0-rc.1 P4 — hook-side resolved-bindings snapshot reader (F4/S63/S65).
3
+ //
4
+ // Hooks are a REMINDER layer (KT-DEC-0007) and must never block. They are also
5
+ // FORBIDDEN from re-resolving stores or walking `.fabric` store trees directly
6
+ // — a hook reads ONLY the CLI-pre-generated snapshot at
7
+ // `~/.fabric/state/bindings/<project_id>_resolved.json` (written by P3
8
+ // install/sync/bind). This keeps the resolver logic in one place (the CLI) and
9
+ // keeps hooks a thin, store-unaware-by-construction projection. Missing /
10
+ // unreadable / malformed snapshot → null (harmless degrade; the hook proceeds
11
+ // without store labels). Zero-dep CJS so it inline-loads at hook runtime.
12
+
13
+ const { existsSync, readFileSync } = require("node:fs");
14
+ const { join } = require("node:path");
15
+ const { homedir } = require("node:os");
16
+
17
+ // `~/.fabric` (FABRIC_HOME override mirrors the CLI's resolveGlobalRoot).
18
+ function resolveGlobalRoot() {
19
+ return join(process.env.FABRIC_HOME || homedir(), ".fabric");
20
+ }
21
+
22
+ function bindingsSnapshotPath(projectId, globalRoot) {
23
+ return join(
24
+ globalRoot || resolveGlobalRoot(),
25
+ "state",
26
+ "bindings",
27
+ projectId + "_resolved.json",
28
+ );
29
+ }
30
+
31
+ // Read + shallow-validate the snapshot. Returns the parsed object, or null when
32
+ // absent / unreadable / not the expected shape. NEVER throws.
33
+ function readBindingsSnapshot(projectId, globalRoot) {
34
+ if (typeof projectId !== "string" || projectId.length === 0) {
35
+ return null;
36
+ }
37
+ const path = bindingsSnapshotPath(projectId, globalRoot);
38
+ if (!existsSync(path)) {
39
+ return null;
40
+ }
41
+ try {
42
+ const parsed = JSON.parse(readFileSync(path, "utf8"));
43
+ if (
44
+ parsed &&
45
+ typeof parsed === "object" &&
46
+ parsed.read_set &&
47
+ Array.isArray(parsed.read_set.stores)
48
+ ) {
49
+ return parsed;
50
+ }
51
+ return null;
52
+ } catch {
53
+ return null;
54
+ }
55
+ }
56
+
57
+ // Render a compact, per-store label line for a SessionStart / Stop hook from a
58
+ // snapshot. Empty string when there is nothing to show (degrade silently). The
59
+ // label is provenance only — it never re-resolves; it just echoes the read-set
60
+ // the CLI already computed, with the write-target flagged (F4 store labels).
61
+ function formatStoreLabels(snapshot) {
62
+ if (!snapshot || !snapshot.read_set || !Array.isArray(snapshot.read_set.stores)) {
63
+ return "";
64
+ }
65
+ const writeAlias = snapshot.write_target && snapshot.write_target.alias;
66
+ const parts = snapshot.read_set.stores.map((store) => {
67
+ const tag = store.alias === writeAlias ? " (write)" : store.writable ? "" : " (ro)";
68
+ return store.alias + tag;
69
+ });
70
+ if (parts.length === 0) {
71
+ return "";
72
+ }
73
+ return "[fabric] read-set stores: " + parts.join(", ");
74
+ }
75
+
76
+ module.exports = {
77
+ resolveGlobalRoot,
78
+ bindingsSnapshotPath,
79
+ readBindingsSnapshot,
80
+ formatStoreLabels,
81
+ };
@@ -3,15 +3,16 @@
3
3
  // Reads `.fabric/agents.meta.json` to build a stable_id → knowledge_type lookup
4
4
  // map, then scans summarised assistant turns (cite_ids + cite_tags +
5
5
  // cite_commitments parallel arrays produced by lib/cite-line-parser.cjs) for
6
- // turns that cited a decision-class or pitfall-class id with [recalled] tag
7
- // but no operator commitment and no skip:<reason>.
6
+ // turns that cited a decision-class or pitfall-class id with [applied] tag
7
+ // but no operator commitment and no skip:<reason>. (v2.1.0-rc.1 ADJ-P4-1:
8
+ // legacy [recalled] is remapped to [applied] by the parser upstream.)
8
9
  //
9
10
  // Emits one reminder line per offending id (deduplicated across the turn
10
11
  // summary). Non-blocking — caller writes the lines to stderr; failure to
11
12
  // load the meta file or absence of offenders means zero output.
12
13
  //
13
14
  // Reminder template (rc.24 lock B2 / L1 enforcement layer):
14
- // ⚠ KB: <id> cited as [recalled] but missing contract; add → edit:<glob>
15
+ // ⚠ KB: <id> cited as [applied] but missing contract; add → edit:<glob>
15
16
  // or → skip:<reason> next turn
16
17
  //
17
18
  // Type filter rationale: only `decision` and `pitfall` types are contract-
@@ -34,7 +35,7 @@ const { join } = require("node:path");
34
35
  const FABRIC_DIR = ".fabric";
35
36
  const AGENTS_META_FILE = "agents.meta.json";
36
37
 
37
- // Knowledge types that require contract commitments on [recalled] cites.
38
+ // Knowledge types that require contract commitments on [applied] cites.
38
39
  // Matches the singular form persisted by `withDerivedAgentsMetaNodeDefaults`
39
40
  // in packages/shared/src/schemas/agents-meta.ts. We accept both singular
40
41
  // and plural defensively so a future schema change to plurals doesn't
@@ -98,13 +99,17 @@ function readKnowledgeTypeMap(projectRoot) {
98
99
  * don't, returning the reminder lines to emit.
99
100
  *
100
101
  * Filter (all must hold for a given index i within a turn):
101
- * 1. cite_tags includes "recalled" (turn-level — applies to the cited id)
102
+ * 1. cite_tags includes "applied" (turn-level — applies to the cited id)
102
103
  * 2. cite_commitments[i].operators is empty AND cite_commitments[i].skip_reason is null
103
104
  * 3. idTypeMap.get(cite_ids[i]) is in {decision, pitfall}
104
105
  *
106
+ * v2.1.0-rc.1 (ADJ-P4-1, full remap): the gate is the rc.37 NEW-1 `applied`
107
+ * tag. Legacy [recalled] cites are remapped to [applied] by the parser before
108
+ * they reach here, so gating on "applied" covers both old and new authoring.
109
+ *
105
110
  * Tag-level filter clarification: rc.20 cite_tags is parallel to ALL parsed
106
111
  * lines (including sentinels), but for the contract-missing reminder we use
107
- * the turn-level semantic — if the assistant tagged the cite as [recalled],
112
+ * the turn-level semantic — if the assistant tagged the cite as [applied],
108
113
  * the operator-or-skip contract applies. Per TASK-04 invariant, cite_ids and
109
114
  * cite_commitments are parallel index-aligned arrays (length-N each).
110
115
  *
@@ -131,8 +136,9 @@ function formatContractMissingReminders({ assistant_turns, idTypeMap }) {
131
136
  const citeTags = Array.isArray(turn.cite_tags) ? turn.cite_tags : [];
132
137
  const commitments = Array.isArray(turn.cite_commitments) ? turn.cite_commitments : [];
133
138
 
134
- // Turn-level: the [recalled] tag must appear in the turn's tag set.
135
- if (!citeTags.includes("recalled")) continue;
139
+ // Turn-level: the [applied] tag must appear in the turn's tag set
140
+ // (v2.1.0-rc.1 ADJ-P4-1: legacy [recalled] is remapped to [applied]).
141
+ if (!citeTags.includes("applied")) continue;
136
142
 
137
143
  // Iterate by cite_ids.length — sentinel entries don't have ids so they
138
144
  // contribute zero iterations even if cite_tags carries "none".
@@ -160,7 +166,7 @@ function formatContractMissingReminders({ assistant_turns, idTypeMap }) {
160
166
  const reminders = [];
161
167
  for (const id of offenders) {
162
168
  reminders.push(
163
- `⚠ KB: ${id} cited as [recalled] but missing contract; add \`→ edit:<glob>\` or \`→ skip:<reason>\` next turn`,
169
+ `⚠ KB: ${id} cited as [applied] but missing contract; add \`→ edit:<glob>\` or \`→ skip:<reason>\` next turn`,
164
170
  );
165
171
  }
166
172
  return reminders;