@fenglimg/fabric-shared 2.3.0-rc.8 → 2.3.0

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.
@@ -49,16 +49,24 @@ var _knowledgeTypeEnum = z3.enum(["models", "decisions", "guidelines", "pitfalls
49
49
  var _maturityEnum = z3.enum(["draft", "verified", "proven"]);
50
50
  var _ruleDescriptionSchema = z3.object({
51
51
  summary: z3.string(),
52
- intent_clues: z3.array(z3.string()),
52
+ // TASK-005 wire thinning: intent_clues dropped from recall wire (0 hook
53
+ // consumers grep-verified). Selection signal is summary + must_read_if
54
+ // (when distinct) + knowledge_type; intent_clues is preserved in the on-disk
55
+ // .md frontmatter (KB source-of-truth) and reachable via read_path.
56
+ intent_clues: z3.array(z3.string()).optional(),
53
57
  // wire-slim (payload): fab_recall projects a LEAN description (summary +
54
- // must_read_if + intent_clues + knowledge_type — the selection signal), leaving
55
- // tech_stack/impact to be Read on demand via read_path (KT-DEC-0026 lean
58
+ // must_read_if + knowledge_type — the selection signal), leaving tech_stack/
59
+ // impact/intent_clues to be Read on demand via read_path (KT-DEC-0026 lean
56
60
  // contract at the field level). So they are optional on the wire. plan-context
57
- // still returns them in full — zod keeps optional-present values, only ABSENT is
58
- // now allowed, so no plan-context consumer regresses.
61
+ // still returns them in full — zod keeps optional-present values, only ABSENT
62
+ // is now allowed, so no plan-context consumer regresses.
59
63
  tech_stack: z3.array(z3.string()).optional(),
60
64
  impact: z3.array(z3.string()).optional(),
61
- must_read_if: z3.string(),
65
+ // TASK-002 wire dedup: omitted when identical to `summary` (~40% of KB entries
66
+ // — knowledge-meta-builder.ts:212/:248 `?? summary` fallback). Consumers may
67
+ // fall back to `summary` when absent. KB source-of-truth (the .md frontmatter)
68
+ // stays unchanged; this optionality is a WIRE-only projection.
69
+ must_read_if: z3.string().optional(),
62
70
  // v2.0: optional knowledge-entry fields. Absent for v1.x rules; present for
63
71
  // entries that declare frontmatter `id/type/maturity`. W4/Track1: the redundant
64
72
  // `knowledge_layer` field was removed — a candidate's layer is derived from its
@@ -310,28 +318,40 @@ var recallInputSchema = z3.object({
310
318
  // W1-3 / KT-DEC-0031: graph expansion (surface related read paths, no body).
311
319
  include_related: z3.boolean().optional().describe(
312
320
  "When true, also surface the one-hop `related` graph neighbours (of the surfaced entries) that are present in the candidate set \u2014 their descriptions and read paths, NOT their bodies."
321
+ ),
322
+ // TASK-006 (KT-PIT-0036 observability opt-in): score_breakdown carries the
323
+ // numbers-only signal decomposition (bm25/vector/salience/recency/locality/
324
+ // proximity/credibility → final). Emitted per-entry ONLY when this flag is
325
+ // true — the debug/tuning surface. Steady-state recall omits it to stay lean
326
+ // (~4.8KB saved on a 24-entry sample). final===score invariant still enforced
327
+ // at the plan-context service layer (candidate_scores Map) regardless.
328
+ include_score_breakdown: z3.boolean().optional().describe(
329
+ "When true, populate `entry.score_breakdown` (numbers-only signal decomposition \u2014 bm25/vector/salience/recency/locality/proximity/credibility \u2192 final). Off by default for wire efficiency. Enable when debugging ranking or tuning scoring weights."
313
330
  )
314
331
  });
332
+ var _recallEntryDescriptionSchema = z3.object({
333
+ summary: z3.string(),
334
+ // TASK-002: omitted when identical to summary (~40% dedup).
335
+ must_read_if: z3.string().optional(),
336
+ // Semantic-preservation (PLN-002 F1 restored): knowledge-hint-narrow.cjs
337
+ // consumes this for the "⚠️ 后果" narrow-hint line.
338
+ impact: z3.array(z3.string()).optional(),
339
+ // Semantic-preservation (PLN-002): cite-contract-reminder.cjs consumes it.
340
+ knowledge_type: _knowledgeTypeEnum.optional()
341
+ });
315
342
  var _recallEntrySchema = z3.object({
316
343
  stable_id: z3.string(),
317
- // 1-based relevance rank (entries are returned best-first). The surfaced
318
- // ranking signal entries are already sorted, rank makes the order explicit.
319
- rank: z3.number().int().positive(),
320
- // The DESCRIPTION (summary / intent_clues / must_read_if / related ...). No body.
321
- description: _ruleDescriptionSchema,
344
+ // The projected DESCRIPTION (recall-specific slim contract, not the shared
345
+ // frontmatter shape). Codex review F6: dedicated schema locks the wire contract.
346
+ description: _recallEntryDescriptionSchema,
322
347
  // on-disk knowledge file to Read for the full body. Omitted when the entry has
323
348
  // no resolvable file (description-only discovery) or was scoped out by `ids`.
324
349
  read_path: z3.string().optional(),
325
350
  // originating store alias (omitted for unqualified / single-store entries).
326
- store: z3.object({ alias: z3.string() }).optional(),
351
+ store_alias: z3.string().optional(),
327
352
  // true when this entry's body is ALSO injected at SessionStart (broad
328
353
  // model/guideline "ALWAYS-ACTIVE") — skip the Read, it is already in context.
329
354
  body_in_context: z3.boolean().optional(),
330
- // P1 recall-observability: the fused relevance score this entry scored during
331
- // the plan-context sort (was computed internally but dropped before this wave).
332
- // Optional + additive — backward-compatible. MUST be declared here or zod
333
- // .strip() silently drops it at the MCP boundary (KT-PIT-0005).
334
- score: z3.number().optional(),
335
355
  // P1 recall-observability: numbers-only decomposition of `score` into its
336
356
  // weighted signal contributions. NEVER carries body/description text — preserves
337
357
  // the lean read_path contract (KT-DEC-0019 / KT-GLD-0005). bm25_rank/vector_rank
@@ -355,25 +375,24 @@ var _recallEntrySchema = z3.object({
355
375
  }).optional()
356
376
  });
357
377
  var recallOutputSchema = z3.object({
378
+ // Retained: client hook cache key (packages/cli/.claude/hooks/knowledge-hint-narrow.cjs)
358
379
  revision_hash: z3.string(),
359
- stale: z3.boolean(),
360
380
  // ux-w2-4: single unified entry list (was candidates[] + paths[] + per-path
361
381
  // requirement-profile entries[]). Each item carries description + read_path +
362
382
  // rank + body_in_context, so the agent never joins two arrays on stable_id.
363
383
  entries: z3.array(_recallEntrySchema),
364
- // v2.2 payload de-dup: single top-level echo of the caller's `intent`.
365
- // Omitted when no intent.
366
- intent: z3.string().optional(),
367
384
  // K6 (W3-K): structured list of lower-ranked candidates dropped by the
368
- // retrieval pipeline, each tagged with WHY (`retrieval_budget` = top_k cap +
369
- // ratio-to-top floor; `payload_budget` = MCP payload-byte trim). Present (and
370
- // non-empty) ONLY when truncation fired — keeps the steady-state wire shape
371
- // unchanged while signalling which entries exist ("narrow your intent"), now
372
- // with a controlled reason instead of a bare count. Reuses the archive-scan
373
- // {key,reason} omission convention (_recallDropReasonSchema, keyed on id).
374
- dropped: z3.array(z3.object({ id: z3.string(), reason: _recallDropReasonSchema })).optional(),
375
- preflight_diagnostics: z3.array(_preflightDiagnosticSchema),
385
+ // retrieval pipeline. dropped_ids preserves per-id transparency (KT-DEC-0028);
386
+ // dropped_reasons hoists the reason to a top-level count map (68/68 same-reason
387
+ // observation from ANL-002). Present ONLY when truncation fired.
388
+ dropped_ids: z3.array(z3.string()).optional(),
389
+ dropped_reasons: z3.object({
390
+ retrieval_budget: z3.number().int().nonnegative().optional(),
391
+ payload_budget: z3.number().int().nonnegative().optional()
392
+ }).optional(),
393
+ preflight_diagnostics: z3.array(_preflightDiagnosticSchema).optional(),
376
394
  warnings: z3.array(structuredWarningSchema).optional(),
395
+ // Auto-heal banner pair (consumed by knowledge-hint-broad.cjs:711-729).
377
396
  auto_healed: z3.boolean().optional(),
378
397
  previous_revision_hash: z3.string().optional(),
379
398
  // v2.0.0-rc.37 NEW-24: parallel to planContextOutputSchema.redirects — stale
@@ -384,9 +403,6 @@ var recallOutputSchema = z3.object({
384
403
  // (appended id → surfaced source id). Present only when include_related
385
404
  // appended an in-corpus neighbour. Omitted on the steady-state path.
386
405
  related_appended: z3.record(z3.string()).optional(),
387
- // v2.2 MC1-recall-pack: standing behavioral directive (cite-before-edit) +
388
- // dynamic discovery hints, so the one-call recall is self-describing.
389
- directive: z3.string(),
390
406
  next_steps: z3.array(z3.string()).optional()
391
407
  });
392
408
  var recallAnnotations = {
@@ -681,7 +697,30 @@ var _fabReviewModifyChangesSchema = z3.object({
681
697
  // edges via fab_recall and sends the merged set. Absent this field the modify
682
698
  // path silently dropped `related` via zod .strip() (KT-PIT-0005 recurrence),
683
699
  // leaving the only programmatic related-write path non-functional.
684
- related: z3.array(z3.string()).optional()
700
+ related: z3.array(z3.string()).optional(),
701
+ // rc.9 (2026-07-06): discovery-signal scalar patches — must_read_if triggers
702
+ // Reference-type entry surfacing; intent_clues drives the AI's "should I Read
703
+ // the body?" judgment; impact enumerates consequence prose surfaced in the
704
+ // BM25F body slot. Before rc.9 these three fields were undeclared here, so
705
+ // fab_review modify silently .strip()'d them (KT-PIT-0005 recurrence) and the
706
+ // only path to fix a bad-shape must_read_if / missing intent_clues was direct
707
+ // Edit — bypassing the skill audit trail. All three are REPLACE semantics
708
+ // (mirror tags/related). must_read_if is a scalar string; intent_clues +
709
+ // impact are flow-arrays.
710
+ must_read_if: z3.string().optional(),
711
+ intent_clues: z3.array(z3.string()).optional(),
712
+ impact: z3.array(z3.string()).optional(),
713
+ // ISS-20260711-180: propose writes these; without them here zod .strip()
714
+ // silently drops modify patches (KT-PIT-0005 recurrence).
715
+ tech_stack: z3.array(z3.string()).optional(),
716
+ evidence_paths: z3.array(z3.string()).optional(),
717
+ onboard_slot: z3.enum([
718
+ "tech-stack-decision",
719
+ "architecture-pattern",
720
+ "code-style-tone",
721
+ "build-system-idiom",
722
+ "domain-vocabulary"
723
+ ]).optional()
685
724
  });
686
725
  var FabReviewInputSchema = z3.discriminatedUnion("action", [
687
726
  z3.object({
@@ -716,11 +755,45 @@ var FabReviewInputSchema = z3.discriminatedUnion("action", [
716
755
  layer: z3.enum(["team", "personal"])
717
756
  })
718
757
  }),
758
+ // v2.3 batch content-modify: array-native flush for the fabric-review maintain
759
+ // loop. Each item is an INDEPENDENT content edit — unlike approve/reject/defer
760
+ // (which share one reason/until over pending_paths[]), modify needs its OWN
761
+ // changes per entry, so the shape is items[] not paths[]. Layer is stripped
762
+ // per item (content-only; layer-flips stay on the single interactive
763
+ // modify-layer path). Per-item failure is isolated: a bad item reports
764
+ // {ok:false, error} in modified[] without aborting its siblings.
765
+ z3.object({
766
+ action: z3.literal("modify-content-batch"),
767
+ items: z3.array(
768
+ z3.object({
769
+ pending_path: z3.string().min(1),
770
+ changes: _fabReviewModifyChangesSchema
771
+ })
772
+ ).min(1)
773
+ }),
719
774
  z3.object({
720
775
  action: z3.literal("defer"),
721
776
  pending_paths: z3.array(z3.string()).min(1),
722
777
  until: z3.string().datetime().optional(),
723
778
  reason: z3.string().optional()
779
+ }),
780
+ // retire (W3-C: fabric-review retire-mode landing surface). Semantically
781
+ // deprecates one or more CANONICAL knowledge entries so they stop surfacing in
782
+ // recall candidates / broad SessionStart indexes — WITHOUT deleting the file
783
+ // (red line: deprecate-over-delete). The service writes `deprecated: true`
784
+ // (+ `superseded_by: <id>` when the entry is replaced) into the entry's
785
+ // frontmatter via the same in-place merge path modify uses; body + stable_id
786
+ // are preserved so the "当时为什么这么决策" rationale stays inspectable.
787
+ z3.object({
788
+ action: z3.literal("retire"),
789
+ // Canonical entry paths (store-absolute, from fab_pending list/search).
790
+ pending_paths: z3.array(z3.string()).min(1),
791
+ // Optional stable_id of the entry that supersedes these (bare `KT-DEC-0001`
792
+ // or store-qualified `alias:KT-DEC-0001`). Written as `superseded_by`
793
+ // frontmatter so the supersession chain is recoverable.
794
+ superseded_by: z3.string().optional(),
795
+ // Optional human reason recorded on the knowledge_modified ledger event.
796
+ reason: z3.string().optional()
724
797
  })
725
798
  ]);
726
799
  var FabPendingInputSchema = z3.discriminatedUnion("action", [
@@ -746,11 +819,20 @@ var FabPendingInputShape = {
746
819
  )
747
820
  };
748
821
  var FabReviewInputShape = {
749
- action: z3.enum(["approve", "reject", "modify", "modify-content", "modify-layer", "defer"]).describe(
750
- "Action selector. Discriminates the per-action fields below; required. modify-content edits scalars (no layer); modify-layer is the layer-flip path (changes.layer required); modify is the legacy combined alias. (list/search moved to the read-only fab_pending tool.)"
822
+ action: z3.enum([
823
+ "approve",
824
+ "reject",
825
+ "modify",
826
+ "modify-content",
827
+ "modify-layer",
828
+ "modify-content-batch",
829
+ "defer",
830
+ "retire"
831
+ ]).describe(
832
+ "Action selector. Discriminates the per-action fields below; required. modify-content edits scalars (no layer); modify-layer is the layer-flip path (changes.layer required); modify is the legacy combined alias; modify-content-batch flushes an array of independent content edits (items[]) in one call; retire marks canonical entries deprecated (deprecate-over-delete) so they stop surfacing. (list/search moved to the read-only fab_pending tool.)"
751
833
  ),
752
834
  pending_paths: z3.array(z3.string()).min(1).optional().describe(
753
- "Workspace-relative pending entry paths. Required when action=approve|reject|defer (non-empty array)."
835
+ "Workspace-relative pending entry paths (or canonical entry paths for action=retire). Required when action=approve|reject|defer|retire (non-empty array)."
754
836
  ),
755
837
  pending_path: z3.string().min(1).optional().describe(
756
838
  "Workspace-relative pending OR canonical entry path. Required when action=modify."
@@ -761,8 +843,19 @@ var FabReviewInputShape = {
761
843
  changes: _fabReviewModifyChangesSchema.optional().describe(
762
844
  "Frontmatter scalar patches (title/summary/layer/maturity/tags/relevance_*/semantic_scope/related). Required when action=modify. semantic_scope re-scopes the entry's resolution coordinate in place (e.g. team \u2192 project:<id>) without moving stores; personal-root coordinates are rejected (use modify-layer)."
763
845
  ),
846
+ items: z3.array(
847
+ z3.object({
848
+ pending_path: z3.string().min(1),
849
+ changes: _fabReviewModifyChangesSchema
850
+ })
851
+ ).min(1).optional().describe(
852
+ "Batch of independent content edits \u2014 required (non-empty) when action=modify-content-batch. Each item {pending_path, changes} is applied content-only (layer stripped); per-item failures surface in modified[] without aborting siblings."
853
+ ),
764
854
  until: z3.string().datetime().optional().describe(
765
855
  "ISO-8601 datetime upper bound for the deferral. Optional; used only when action=defer."
856
+ ),
857
+ superseded_by: z3.string().optional().describe(
858
+ "Stable_id (bare or store-qualified) of the entry that supersedes the retired one, written as `superseded_by` frontmatter. Optional; used only when action=retire."
766
859
  )
767
860
  };
768
861
  var _fabReviewListItemSchema = z3.object({
@@ -781,6 +874,8 @@ var _fabReviewListItemSchema = z3.object({
781
874
  tags: z3.array(z3.string()).optional(),
782
875
  title: z3.string().optional(),
783
876
  summary: z3.string().optional(),
877
+ // ISS-20260712-011: triage UI needs proposed_reason without a second Read.
878
+ proposed_reason: z3.string().optional(),
784
879
  // Store-only cutover: origin reflects the resolved store audience where the
785
880
  // pending file lives; layer reflects the declared classification that will
786
881
  // drive approval semantics.
@@ -820,10 +915,16 @@ var _fabReviewSearchItemSchema = z3.object({
820
915
  // hits in the same array.
821
916
  stable_id: z3.string().optional()
822
917
  });
918
+ var _fabReviewFailedItemSchema = z3.object({
919
+ pending_path: z3.string(),
920
+ reason: z3.string()
921
+ });
823
922
  var FabReviewOutputSchema = z3.discriminatedUnion("action", [
824
923
  z3.object({
825
924
  action: z3.literal("approve"),
826
925
  approved: z3.array(z3.object({ pending_path: z3.string(), stable_id: z3.string() })),
926
+ // ISS-20260712-012: partial-failure surface so empty approved[] is not false-success.
927
+ failed: z3.array(_fabReviewFailedItemSchema).optional(),
827
928
  warnings: z3.array(structuredWarningSchema).optional()
828
929
  }),
829
930
  z3.object({
@@ -839,10 +940,41 @@ var FabReviewOutputSchema = z3.discriminatedUnion("action", [
839
940
  new_stable_id: z3.string().optional(),
840
941
  warnings: z3.array(structuredWarningSchema).optional()
841
942
  }),
943
+ // v2.3 batch content-modify result: per-item {pending_path, ok, error?}. No
944
+ // prior/new_stable_id — content edits strip layer, so no id reallocation can
945
+ // occur. ok=false items carry the failure message; siblings still applied
946
+ // (partial failure is reported per-item, never thrown for the whole batch).
947
+ z3.object({
948
+ action: z3.literal("modify-content-batch"),
949
+ modified: z3.array(
950
+ z3.object({
951
+ pending_path: z3.string(),
952
+ ok: z3.boolean(),
953
+ error: z3.string().optional()
954
+ })
955
+ ),
956
+ warnings: z3.array(structuredWarningSchema).optional()
957
+ }),
842
958
  z3.object({
843
959
  action: z3.literal("defer"),
844
960
  deferred: z3.array(z3.string()),
845
961
  warnings: z3.array(structuredWarningSchema).optional()
962
+ }),
963
+ z3.object({
964
+ action: z3.literal("retire"),
965
+ // Each retired canonical entry: its echoed path + (when the frontmatter
966
+ // carried one) its stable_id, plus the superseded_by id when supplied. The
967
+ // file is NOT deleted — only marked `deprecated: true` in place.
968
+ retired: z3.array(
969
+ z3.object({
970
+ path: z3.string(),
971
+ stable_id: z3.string().optional(),
972
+ superseded_by: z3.string().optional()
973
+ })
974
+ ),
975
+ // ISS-20260712-012: partial-failure surface for skipped/unresolvable paths.
976
+ failed: z3.array(_fabReviewFailedItemSchema).optional(),
977
+ warnings: z3.array(structuredWarningSchema).optional()
846
978
  })
847
979
  ]);
848
980
  var FabPendingOutputSchema = z3.discriminatedUnion("action", [
@@ -867,12 +999,15 @@ var FabPendingOutputShape = {
867
999
  warnings: z3.array(structuredWarningSchema).optional()
868
1000
  };
869
1001
  var FabReviewOutputShape = {
870
- action: z3.enum(["approve", "reject", "modify", "defer"]).describe(
1002
+ action: z3.enum(["approve", "reject", "modify", "modify-content-batch", "defer", "retire"]).describe(
871
1003
  "Echoes the input action; clients can switch on it for per-variant fields below. (list/search results moved to the read-only fab_pending tool.)"
872
1004
  ),
873
1005
  approved: z3.array(z3.object({ pending_path: z3.string(), stable_id: z3.string() })).optional().describe(
874
1006
  "Allocated stable ids paired with their original pending paths. Present when action=approve."
875
1007
  ),
1008
+ failed: z3.array(z3.object({ pending_path: z3.string(), reason: z3.string() })).optional().describe(
1009
+ "Paths that did not apply (approve/retire). Present when any path was skipped or failed."
1010
+ ),
876
1011
  rejected: z3.array(z3.string()).optional().describe(
877
1012
  "Pending paths that were rejected (files retained on disk; doctor owns vacuum). Present when action=reject."
878
1013
  ),
@@ -885,9 +1020,27 @@ var FabReviewOutputShape = {
885
1020
  new_stable_id: z3.string().optional().describe(
886
1021
  "New stable id after reallocation. Present when action=modify AND a layer-flip reallocated the id."
887
1022
  ),
1023
+ modified: z3.array(
1024
+ z3.object({
1025
+ pending_path: z3.string(),
1026
+ ok: z3.boolean(),
1027
+ error: z3.string().optional()
1028
+ })
1029
+ ).optional().describe(
1030
+ "Per-item results for action=modify-content-batch: {pending_path, ok, error?}. ok=false items carry the error; siblings still applied."
1031
+ ),
888
1032
  deferred: z3.array(z3.string()).optional().describe(
889
1033
  "Pending paths that were deferred (files retained on disk). Present when action=defer."
890
1034
  ),
1035
+ retired: z3.array(
1036
+ z3.object({
1037
+ path: z3.string(),
1038
+ stable_id: z3.string().optional(),
1039
+ superseded_by: z3.string().optional()
1040
+ })
1041
+ ).optional().describe(
1042
+ "Canonical entries marked deprecated in place (files retained \u2014 deprecate-over-delete). Present when action=retire."
1043
+ ),
891
1044
  // v2.0.0-rc.23 TASK-009 (d): optional warnings surface for the first-reconcile
892
1045
  // gate (`meta_stale` / `reconcile_failed`). Absent on the steady-state path.
893
1046
  warnings: z3.array(structuredWarningSchema).optional()
@@ -1,3 +1,8 @@
1
+ import {
2
+ atomicWriteJson,
3
+ withFileLock
4
+ } from "./chunk-C7WZPYZE.js";
5
+
1
6
  // src/i18n/normalize-locale.ts
2
7
  function normalizeLocale(raw) {
3
8
  if (typeof raw !== "string") {
@@ -189,7 +194,8 @@ var globalConfigSchema = z.object({
189
194
  }).passthrough();
190
195
 
191
196
  // src/store/global-config-io.ts
192
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
197
+ import { existsSync, mkdirSync, readFileSync, renameSync, unlinkSync, writeFileSync } from "fs";
198
+ import { mkdir } from "fs/promises";
193
199
  import { homedir } from "os";
194
200
  import { join } from "path";
195
201
  function isTestRuntime() {
@@ -210,6 +216,9 @@ function resolveGlobalRoot() {
210
216
  function globalConfigPath(globalRoot = resolveGlobalRoot()) {
211
217
  return join(globalRoot, "fabric-global.json");
212
218
  }
219
+ function globalConfigLockPath(globalRoot) {
220
+ return `${globalConfigPath(globalRoot)}.lock`;
221
+ }
213
222
  function loadGlobalConfig(globalRoot = resolveGlobalRoot()) {
214
223
  const path = globalConfigPath(globalRoot);
215
224
  if (!existsSync(path)) {
@@ -217,11 +226,30 @@ function loadGlobalConfig(globalRoot = resolveGlobalRoot()) {
217
226
  }
218
227
  return globalConfigSchema.parse(JSON.parse(readFileSync(path, "utf8")));
219
228
  }
229
+ async function saveGlobalConfigAsync(config, globalRoot = resolveGlobalRoot()) {
230
+ const validated = globalConfigSchema.parse(config);
231
+ await mkdir(globalRoot, { recursive: true });
232
+ const path = globalConfigPath(globalRoot);
233
+ await withFileLock(globalConfigLockPath(globalRoot), async () => {
234
+ await atomicWriteJson(path, validated, { indent: 2 });
235
+ });
236
+ }
220
237
  function saveGlobalConfig(config, globalRoot = resolveGlobalRoot()) {
221
238
  const validated = globalConfigSchema.parse(config);
222
239
  mkdirSync(globalRoot, { recursive: true });
223
- writeFileSync(globalConfigPath(globalRoot), `${JSON.stringify(validated, null, 2)}
240
+ const path = globalConfigPath(globalRoot);
241
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.tmp`;
242
+ try {
243
+ writeFileSync(tmpPath, `${JSON.stringify(validated, null, 2)}
224
244
  `, "utf8");
245
+ renameSync(tmpPath, path);
246
+ } catch (error) {
247
+ try {
248
+ unlinkSync(tmpPath);
249
+ } catch {
250
+ }
251
+ throw error;
252
+ }
225
253
  }
226
254
 
227
255
  // src/i18n/resolve-global-locale.ts
@@ -269,6 +297,7 @@ export {
269
297
  resolveGlobalRoot,
270
298
  globalConfigPath,
271
299
  loadGlobalConfig,
300
+ saveGlobalConfigAsync,
272
301
  saveGlobalConfig,
273
302
  resolveGlobalLocale
274
303
  };
@@ -1,6 +1,6 @@
1
1
  import {
2
2
  resolveGlobalLocale
3
- } from "./chunk-ASS2KBB7.js";
3
+ } from "./chunk-OX6DWUK4.js";
4
4
 
5
5
  // src/templates/bootstrap-canonical.ts
6
6
  var BOOTSTRAP_MARKER_BEGIN = "<!-- fabric:bootstrap:begin -->";
@@ -36,8 +36,9 @@ var BOOTSTRAP_CANONICAL_ZH = `# Fabric Bootstrap
36
36
  \u5B8C\u6574 maintainer \u7248\u89C1 \`docs/USER-QUICKSTART.md\`\u3002
37
37
 
38
38
  ## \u884C\u4E3A\u89C4\u5219
39
- - **\u4FEE\u6539\u4EFB\u4F55\u6587\u4EF6\u524D**:\u5148 \`fab_recall(paths=[<\u88AB\u6539\u6587\u4EF6>])\` \u2014\u2014 \u4E00\u6B21\u8C03\u7528\u62FF\u56DE\u76F8\u5173 KB \u7684\u63CF\u8FF0 + \u539F\u751F\u8BFB\u53D6\u8DEF\u5F84(\`entries[].read_path\`)\u3002\`fab_recall\` \u4E0D\u518D\u6295\u9012\u6B63\u6587;\u9700\u8981\u67D0\u6761\u6B63\u6587\u65F6\u76F4\u63A5\u5BF9\u5176 \`entries[].read_path\` \u505A\u539F\u751F Read(\`Read <store>/knowledge/<type>/<id>--*.md\`),\u8FD9\u4F1A\u88AB PostToolUse hook \u8BB0\u4E3A \`knowledge_body_read\`\u3002lean \u9ED8\u8BA4:\u63CF\u8FF0+\u7D22\u5F15\u5DF2\u591F\u53D1\u73B0\u6761\u76EE,\u6B63\u6587\u6309\u9700\u8BFB\u4E00\u6B21,\u4E0D\u6BCF\u8F6E\u91CD\u704C(KT-GLD-0005)\u3002
40
- - **\`.fabric/agents.meta.json\` \u4E25\u7981\u624B\u52A8\u7F16\u8F91**;engine \u4F1A\u81EA\u52A8\u540C\u6B65\u6D3E\u751F\u72B6\u6001,\u663E\u5F0F reconcile \u8DD1 \`fabric doctor --fix\`\u3002
39
+ - **Pre-action gating (\u4FEE\u6539\u4EFB\u4F55\u6587\u4EF6\u524D MUST)**: \u2460 \u5148 \`fab_recall(paths=[<\u88AB\u6539\u6587\u4EF6>], session_id=<client-session-id>)\`\uFF1B\u2461 \u6309\u9700\u539F\u751F Read \u547D\u4E2D\u7684 \`entries[].read_path\` \u6B63\u6587(PostToolUse \u8BB0\u4E3A \`knowledge_body_read\`)\uFF1B\u2462 \u8DF3\u8FC7\u4E0D\u9002\u7528\u547D\u4E2D\u65F6\u8BF4 \`dismissed: <id> (<reason>)\`\u3002\`fab_recall\` \u4E0D\u518D\u6295\u9012\u6B63\u6587;lean \u9ED8\u8BA4\u63CF\u8FF0+\u7D22\u5F15\u5DF2\u591F\u53D1\u73B0\u6761\u76EE(KT-GLD-0005)\u3002PreToolUse \u4EC5\u8F6F nudge,\u5B88 KT-DEC-0007(\u975E permanent gate / \u65E0 \`decision:block\`)\u3002
40
+ - **Archive-as-truth**: \u8FDB canonical **\u552F\u4E00\u8DEF\u5F84** \u662F \`fab_propose\` \u2192 pending \u2192 \`fabric-review\`/\`fab_review\` \u5BA1\u6279\uFF1B\u7981\u6B62\u628A chat/log \u81EA\u52A8 promote \u8FDB canonical\u3002
41
+ - **\`.fabric/agents.meta.json\` \u4E25\u7981\u624B\u52A8\u7F16\u8F91**\uFF08co-location index \u5DF2\u9000\u5F79;\u77E5\u8BC6\u5728 mounted stores\uFF09\u3002\u5F02\u5E38\u65F6\u8DD1 \`fabric doctor\` / \`fabric doctor --fix\` \u81EA\u6108\u53EF\u4FEE\u590D\u9879\u3002
41
42
 
42
43
  ## \u77E5\u8BC6\u5E93(KB)
43
44
  - **Discovery**:SessionStart hook \u5217 broad-scoped \u6761\u76EE(\u6761\u76EE\u6309 \`semantic_scope\` \u5206\u4E09\u5C42:\`team\` \u56E2\u961F\u901A\u7528 / \`project:<id>\` \u672C\u9879\u76EE\u4E13\u5C5E(\u4EC5\u5728\u7ED1\u5B9A\u8BE5\u9879\u76EE\u7684\u4ED3\u5E93\u6D6E\u73B0)/ \`personal\` \u4E2A\u4EBA \`KP-*\`,\u4E09\u8005\u5F15\u7528\u65B9\u5F0F\u76F8\u540C);edit \u6587\u4EF6\u65F6 PreToolUse hook \u53EF\u80FD\u89E6\u53D1 narrow hint\u3002
@@ -46,7 +47,7 @@ var BOOTSTRAP_CANONICAL_ZH = `# Fabric Bootstrap
46
47
  - **session_id**: \u8C03\u7528 \`fab_recall\` \u65F6, \u52A1\u5FC5\u628A\u5F53\u524D client session id \u4F5C\u4E3A \`session_id\` \u53C2\u6570\u4F20\u5165(Claude Code \u7684 session id \u5728 stdin payload \u4E2D, Codex \u7684\u5BF9\u5E94 identifier \u540C\u7406)\u3002\u8FD9\u80FD\u8BA9 \`fabric doctor --archive-history\` \u4E0E \`fabric-hint.cjs\` Stop hook \u51C6\u786E\u8BC6\u522B\u8DE8\u4F1A\u8BDD debt \u72B6\u6001\u3002
47
48
  - **Skills (4)**:\u5199\u6D41\u7A0B \`fabric-archive\`(\u542B source mode \u51B7\u542F\u52A8\u4ECE git/docs \u56DE\u704C)/ \`fabric-review\`(\u542B retire \u8BED\u4E49\u6DD8\u6C70 + relate \u5173\u8054\u5EFA\u8FB9 \u5B50\u6D41\u7A0B);store \u8FD0\u7EF4 \`fabric-store\` / \`fabric-sync\`\u3002
48
49
  - **Language**:\u6E32\u67D3\u6309 \`~/.fabric/fabric-global.json\` \u7684 \`language\` \u5B57\u6BB5(machine-wide tone)\u3002
49
- - **Archive cadence nudge** (rc.36): \u6BCF\u5B8C\u6210\u4E00\u6279 Edit(\u9ED8\u8BA4 ~20 \u6B21, \u4E0E Stop hook \u9608\u503C config \`archive_edit_threshold\` \u4E00\u81F4)/ \u663E\u8457 decision \u540E,\u5728\u5408\u9002\u56DE\u5408\u4E3B\u52A8 propose \u8C03 \`fabric-archive\` skill \u2014 archive \u6CA1\u5EFA\u7ACB\u9891\u7387\u4F1A\u8BA9 KB \u6162\u901F\u6B7B\u6389\u3002
50
+ - **Archive cadence nudge** (rc.36 / finish\u2192archive): \u663E\u8457 decision \u6536\u53E3\u6216\u4E00\u6279 Edit \u8FBE\u5230 config \`archive_edit_threshold\`(\u9ED8\u8BA4 20) \u540E,\u5728\u5408\u9002\u56DE\u5408\u8F7B\u91CF\u81EA\u8C03 \`fabric-archive\`(\u540C turn \u6700\u591A 1 \u6B21;\u975E task engine / \u975E Stop-hook flood)\u3002Stop hook \u4EC5 soft threshold nudge,\u5B88 KT-DEC-0007 \u2014 archive \u6CA1\u5EFA\u7ACB\u9891\u7387\u4F1A\u8BA9 KB \u6162\u901F\u6B7B\u6389\u3002
50
51
  - **Review backlog nudge** (rc.36): \u9700\u8981\u5224\u65AD pending backlog \u65F6\u8D70 \`fab_pending action="list"\` \u6216 \`fabric-review\` \u8FD4\u56DE\u7684 \`pending_path\`;\u4E0D\u8981 glob \u9879\u76EE\u672C\u5730 \`.fabric/knowledge/pending\`\u3002\u5F53\u53EF\u89C1 pending \u7D2F\u79EF >10 \u6761\u65F6,\u5728\u5408\u9002\u56DE\u5408\u4E3B\u52A8 propose \u8C03 \`fabric-review\` skill \u6279\u91CF\u5BA1,\u907F\u514D draft \u5361\u6B7B\u3002
51
52
 
52
53
  ## Self-archive policy (v2.2 C1: \u7CBE\u7B80\u8BF4\u660E\u4E66)
@@ -95,8 +96,9 @@ As a dev you only need to: run \`fabric install\` once per repo, use \`fabric st
95
96
  See \`docs/USER-QUICKSTART.md\` for the full maintainer version.
96
97
 
97
98
  ## Behavior Rules
98
- - **Before modifying any file**: first \`fab_recall(paths=[<file-being-edited>])\` \u2014\u2014 a single call returns the relevant KB descriptions + native read paths (\`entries[].read_path\`). \`fab_recall\` no longer delivers bodies; when you need a body, do a native Read of its \`entries[].read_path\` (\`Read <store>/knowledge/<type>/<id>--*.md\`), which the PostToolUse hook records as \`knowledge_body_read\`. Lean default: descriptions + index already suffice to discover entries; read a body once on demand, don't re-inject it every turn (KT-GLD-0005).
99
- - **Never hand-edit \`.fabric/agents.meta.json\`**; the engine syncs derived state automatically \u2014 run \`fabric doctor --fix\` for an explicit reconcile.
99
+ - **Pre-action gating (MUST before Edit/Write)**: (1) call \`fab_recall(paths=[<file-being-edited>], session_id=<client-session-id>)\`; (2) optionally native-Read selected \`entries[].read_path\` bodies (PostToolUse records \`knowledge_body_read\`); (3) when skipping a hit, say \`dismissed: <id> (<reason>)\`. \`fab_recall\` no longer delivers bodies; lean default is description+index discovery (KT-GLD-0005). PreToolUse is soft nudge only per KT-DEC-0007 (never permanent gate / no \`decision:block\`).
100
+ - **Archive-as-truth**: the **only** path into canonical is \`fab_propose\` \u2192 pending \u2192 \`fabric-review\`/\`fab_review\` approve; never auto-promote chat/logs into canonical.
101
+ - **Never hand-edit \`.fabric/agents.meta.json\`** (co-location index retired; knowledge lives in mounted stores). For anomalies run \`fabric doctor\` / \`fabric doctor --fix\` on fixable state.
100
102
 
101
103
  ## Knowledge Base (KB)
102
104
  - **Discovery**: the SessionStart hook lists broad-scoped entries (scoped by \`semantic_scope\` across three tiers: \`team\` team-wide / \`project:<id>\` this-project-only (surfaces only in repos bound to that project) / \`personal\` \`KP-*\`, all three referenced the same way); editing a file may trigger a narrow hint via the PreToolUse hook.
@@ -105,7 +107,7 @@ See \`docs/USER-QUICKSTART.md\` for the full maintainer version.
105
107
  - **session_id**: when calling \`fab_recall\`, always pass the current client session id as the \`session_id\` argument (Claude Code's session id is in the stdin payload; Codex's corresponding identifier likewise). This lets \`fabric doctor --archive-history\` and the \`fabric-hint.cjs\` Stop hook track cross-session debt accurately.
106
108
  - **Skills (4)**: write flow \`fabric-archive\` (with source-mode cold-start backfill from git/docs) / \`fabric-review\` (with retire-deprecation + relate-edge sub-flows); store ops \`fabric-store\` / \`fabric-sync\`.
107
109
  - **Language**: rendered per the \`language\` field in \`~/.fabric/fabric-global.json\` (machine-wide tone).
108
- - **Archive cadence nudge** (rc.36): after each batch of edits (default ~20, matching the Stop hook threshold config \`archive_edit_threshold\`) / a significant decision, proactively propose the \`fabric-archive\` skill at a suitable turn \u2014 without an archive cadence the KB slowly dies.
110
+ - **Archive cadence nudge** (rc.36 / finish\u2192archive): after a significant decision lands or an edit batch reaches config \`archive_edit_threshold\` (default 20), lightly self-trigger \`fabric-archive\` at a suitable turn (max 1 per turn; not a task engine / not Stop-hook flood). Stop hook remains a soft threshold nudge per KT-DEC-0007 \u2014 without an archive cadence the KB slowly dies.
109
111
  - **Review backlog nudge** (rc.36): to judge the pending backlog, go through \`fab_pending action="list"\` or the \`pending_path\` returned by \`fabric-review\`; don't glob the project-local \`.fabric/knowledge/pending\`. When the visible pending count exceeds 10, proactively propose the \`fabric-review\` skill at a suitable turn to batch-review and avoid draft deadlock.
110
112
 
111
113
  ## Self-archive policy (v2.2 C1: lean spec)