@fenglimg/fabric-cli 2.0.0-rc.25 → 2.0.0-rc.27

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,18 @@
1
+ #!/usr/bin/env node
2
+
3
+ // src/i18n.ts
4
+ import {
5
+ createTranslator,
6
+ detectNodeLocale,
7
+ resolveFabricLocale
8
+ } from "@fenglimg/fabric-shared";
9
+ var locale = detectNodeLocale();
10
+ var t = createTranslator(locale);
11
+ function getDoctorTranslator(projectRoot) {
12
+ return createTranslator(resolveFabricLocale(projectRoot));
13
+ }
14
+
15
+ export {
16
+ t,
17
+ getDoctorTranslator
18
+ };
@@ -4,7 +4,7 @@ import {
4
4
  } from "./chunk-MF3OTILQ.js";
5
5
  import {
6
6
  t
7
- } from "./chunk-6ICJICVU.js";
7
+ } from "./chunk-PWLW3B57.js";
8
8
 
9
9
  // src/commands/config.ts
10
10
  import { existsSync, statSync } from "fs";
@@ -513,9 +513,83 @@ async function copyTextIdempotent(step, source, target) {
513
513
  await atomicWriteText2(target, source);
514
514
  return { step, path: target, status: "written" };
515
515
  }
516
+ var FABRIC_HOOK_SCRIPT_BASENAMES = /* @__PURE__ */ new Set([
517
+ "fabric-hint.cjs",
518
+ "knowledge-hint-broad.cjs",
519
+ "knowledge-hint-narrow.cjs",
520
+ // rc.5 TASK-010 rename — old hook scripts that pre-upgrade workspaces
521
+ // may still have registered. Sweeping them prevents the double-fire
522
+ // documented in audit §2.6.
523
+ "archive-hint.cjs"
524
+ ]);
525
+ function commandBasename(command) {
526
+ const trimmed = command.trim().replace(/^"+|"+$/g, "");
527
+ const match = /([^/\\]+\.cjs)$/u.exec(trimmed);
528
+ return match === null ? null : match[1];
529
+ }
530
+ function stripStaleHookEntries(existing, arrayAppendPaths) {
531
+ const swept = JSON.parse(JSON.stringify(existing));
532
+ let removed = 0;
533
+ for (const dottedPath of arrayAppendPaths) {
534
+ const segments = dottedPath.split(".");
535
+ let cursor = swept;
536
+ for (let i = 0; i < segments.length - 1; i++) {
537
+ const seg = segments[i];
538
+ if (cursor === null || typeof cursor !== "object" || Array.isArray(cursor)) {
539
+ cursor = void 0;
540
+ break;
541
+ }
542
+ cursor = cursor[seg];
543
+ }
544
+ if (cursor === null || cursor === void 0 || typeof cursor !== "object" || Array.isArray(cursor)) {
545
+ continue;
546
+ }
547
+ const finalSeg = segments[segments.length - 1];
548
+ const arr = cursor[finalSeg];
549
+ if (!Array.isArray(arr)) continue;
550
+ const filtered = [];
551
+ for (const item of arr) {
552
+ if (item === null || typeof item !== "object") {
553
+ filtered.push(item);
554
+ continue;
555
+ }
556
+ const entry = item;
557
+ const hooks = entry.hooks;
558
+ let isFabricOwned = false;
559
+ if (Array.isArray(hooks)) {
560
+ for (const h of hooks) {
561
+ if (h !== null && typeof h === "object") {
562
+ const cmd = h.command;
563
+ if (typeof cmd === "string") {
564
+ const base = commandBasename(cmd);
565
+ if (base !== null && FABRIC_HOOK_SCRIPT_BASENAMES.has(base)) {
566
+ isFabricOwned = true;
567
+ break;
568
+ }
569
+ }
570
+ }
571
+ }
572
+ }
573
+ if (!isFabricOwned && typeof entry.command === "string") {
574
+ const base = commandBasename(entry.command);
575
+ if (base !== null && FABRIC_HOOK_SCRIPT_BASENAMES.has(base)) {
576
+ isFabricOwned = true;
577
+ }
578
+ }
579
+ if (isFabricOwned) {
580
+ removed += 1;
581
+ } else {
582
+ filtered.push(item);
583
+ }
584
+ }
585
+ cursor[finalSeg] = filtered;
586
+ }
587
+ return { swept, removed };
588
+ }
516
589
  async function mergeJsonIdempotent(step, target, fragment, arrayAppendPaths) {
517
590
  const existing = await readJsonObjectOrEmpty(target);
518
- const merged = deepMerge(existing, fragment, { arrayAppendPaths });
591
+ const { swept } = stripStaleHookEntries(existing, arrayAppendPaths);
592
+ const merged = deepMerge(swept, fragment, { arrayAppendPaths });
519
593
  if (jsonEqual(existing, merged)) {
520
594
  return { step, path: target, status: "skipped", message: "up-to-date" };
521
595
  }
@@ -3,9 +3,9 @@ import {
3
3
  configCmd,
4
4
  config_default,
5
5
  installMcpClients
6
- } from "./chunk-STLR2GHP.js";
6
+ } from "./chunk-SRX7WZUG.js";
7
7
  import "./chunk-MF3OTILQ.js";
8
- import "./chunk-6ICJICVU.js";
8
+ import "./chunk-PWLW3B57.js";
9
9
  export {
10
10
  configCmd,
11
11
  config_default as default,
@@ -6,8 +6,9 @@ import {
6
6
  symbol
7
7
  } from "./chunk-G2CIOLD4.js";
8
8
  import {
9
+ getDoctorTranslator,
9
10
  t
10
- } from "./chunk-6ICJICVU.js";
11
+ } from "./chunk-PWLW3B57.js";
11
12
  import {
12
13
  resolveDevMode
13
14
  } from "./chunk-COI5VDFU.js";
@@ -129,6 +130,7 @@ var doctorCommand = defineCommand({
129
130
  async run({ args }) {
130
131
  const workspaceRoot = process.cwd();
131
132
  const resolution = resolveDevMode(args.target, workspaceRoot);
133
+ const dt = getDoctorTranslator(resolution.target);
132
134
  try {
133
135
  checkLockOrThrow(resolution.target);
134
136
  } catch (err) {
@@ -145,7 +147,7 @@ var doctorCommand = defineCommand({
145
147
  const archiveHistory = args["archive-history"] === true;
146
148
  if (archiveHistory) {
147
149
  if (fix || fixKnowledge || citeCoverage || enrichDesc) {
148
- writeStderr(t("cli.doctor.errors.archive-history-mutex"));
150
+ writeStderr(dt("cli.doctor.errors.archive-history-mutex"));
149
151
  process.exitCode = 1;
150
152
  return;
151
153
  }
@@ -154,7 +156,7 @@ var doctorCommand = defineCommand({
154
156
  try {
155
157
  sinceMs = parseSinceDuration(sinceInput);
156
158
  } catch {
157
- writeStderr(t("cli.doctor.errors.invalid-since", { input: sinceInput }));
159
+ writeStderr(dt("cli.doctor.errors.invalid-since", { input: sinceInput }));
158
160
  process.exitCode = 1;
159
161
  return;
160
162
  }
@@ -164,13 +166,13 @@ var doctorCommand = defineCommand({
164
166
  if (args.json === true) {
165
167
  writeStdout(JSON.stringify(report2, null, 2));
166
168
  } else {
167
- renderArchiveHistoryReport(report2, sinceInput);
169
+ renderArchiveHistoryReport(report2, sinceInput, dt);
168
170
  }
169
171
  return;
170
172
  }
171
173
  if (enrichDesc) {
172
174
  if (fix || fixKnowledge || citeCoverage) {
173
- writeStderr(t("cli.doctor.errors.enrich-descriptions-mutex"));
175
+ writeStderr(dt("cli.doctor.errors.enrich-descriptions-mutex"));
174
176
  process.exitCode = 1;
175
177
  return;
176
178
  }
@@ -183,13 +185,13 @@ var doctorCommand = defineCommand({
183
185
  if (args.json === true) {
184
186
  writeStdout(JSON.stringify(report2, null, 2));
185
187
  } else {
186
- renderEnrichDescriptionsReport(report2);
188
+ renderEnrichDescriptionsReport(report2, dt);
187
189
  }
188
190
  return;
189
191
  }
190
192
  if (citeCoverage) {
191
193
  if (fix || fixKnowledge) {
192
- writeStderr(t("cli.doctor.errors.cite-coverage-mutex"));
194
+ writeStderr(dt("cli.doctor.errors.cite-coverage-mutex"));
193
195
  process.exitCode = 1;
194
196
  return;
195
197
  }
@@ -197,19 +199,19 @@ var doctorCommand = defineCommand({
197
199
  try {
198
200
  sinceMs = parseSinceDuration(args.since ?? "7d");
199
201
  } catch {
200
- writeStderr(t("cli.doctor.errors.invalid-since", { input: args.since ?? "7d" }));
202
+ writeStderr(dt("cli.doctor.errors.invalid-since", { input: args.since ?? "7d" }));
201
203
  process.exitCode = 1;
202
204
  return;
203
205
  }
204
206
  const clientFilter = args.client ?? "all";
205
207
  if (!isValidClientFilter(clientFilter)) {
206
- writeStderr(t("cli.doctor.errors.invalid-client", { input: clientFilter }));
208
+ writeStderr(dt("cli.doctor.errors.invalid-client", { input: clientFilter }));
207
209
  process.exitCode = 1;
208
210
  return;
209
211
  }
210
212
  const layerFilter = args.layer ?? "all";
211
213
  if (!isValidLayerFilter(layerFilter)) {
212
- writeStderr(t("cli.doctor.errors.invalid-layer", { input: layerFilter }));
214
+ writeStderr(dt("cli.doctor.errors.invalid-layer", { input: layerFilter }));
213
215
  process.exitCode = 1;
214
216
  return;
215
217
  }
@@ -218,11 +220,11 @@ var doctorCommand = defineCommand({
218
220
  client: clientFilter,
219
221
  layer: layerFilter
220
222
  });
221
- renderCiteCoverageReport(report2, args.json === true);
223
+ renderCiteCoverageReport(report2, args.json === true, dt);
222
224
  return;
223
225
  }
224
226
  if (fixKnowledge && fix) {
225
- writeStderr(t("cli.doctor.errors.fix-knowledge-fix-mutually-exclusive"));
227
+ writeStderr(dt("cli.doctor.errors.fix-knowledge-fix-mutually-exclusive"));
226
228
  process.exitCode = 1;
227
229
  return;
228
230
  }
@@ -263,11 +265,11 @@ var doctorCommand = defineCommand({
263
265
  if (fixKnowledgeReport.aborted && fixKnowledgeReport.abort_reason !== void 0) {
264
266
  writeStderr(fixKnowledgeReport.abort_reason);
265
267
  }
266
- renderFixKnowledgeMutations(fixKnowledgeReport);
268
+ renderFixKnowledgeMutations(fixKnowledgeReport, dt);
267
269
  } else if (fixReport !== null) {
268
270
  writeStdout(fixReport.message);
269
271
  }
270
- renderHumanReport(report);
272
+ renderHumanReport(report, dt);
271
273
  }
272
274
  await emitDoctorRunEventBestEffort(resolution.target, {
273
275
  mode: fixKnowledge ? "fix-knowledge" : "lint",
@@ -290,21 +292,21 @@ var doctorCommand = defineCommand({
290
292
  }
291
293
  });
292
294
  var doctor_default = doctorCommand;
293
- function renderHumanReport(report) {
295
+ function renderHumanReport(report, dt) {
294
296
  writeStdout(`${renderStatus(report.status)} ${paint.ai("fabric doctor")} ${paint.human(report.summary.target)}`);
295
297
  for (const check of report.checks) {
296
298
  writeStdout(`${renderStatus(check.status)} ${check.name}: ${check.message}`);
297
299
  }
298
- writeIssueSection(t("doctor.section.fixable"), report.fixable_errors);
299
- writeIssueSection(t("doctor.section.manual"), report.manual_errors);
300
- writeIssueSection(t("doctor.section.warnings"), report.warnings);
300
+ writeIssueSection(dt("doctor.section.fixable"), report.fixable_errors);
301
+ writeIssueSection(dt("doctor.section.manual"), report.manual_errors);
302
+ writeIssueSection(dt("doctor.section.warnings"), report.warnings);
301
303
  }
302
- function renderFixKnowledgeMutations(fixKnowledgeReport) {
304
+ function renderFixKnowledgeMutations(fixKnowledgeReport, dt) {
303
305
  if (fixKnowledgeReport.mutations.length === 0) {
304
306
  return;
305
307
  }
306
308
  writeStdout("");
307
- writeStdout(t("doctor.section.fix-knowledge-mutations"));
309
+ writeStdout(dt("doctor.section.fix-knowledge-mutations"));
308
310
  for (const mutation of fixKnowledgeReport.mutations) {
309
311
  const marker = mutation.applied ? symbol.ok : symbol.error;
310
312
  const errSuffix = mutation.applied || mutation.error === void 0 ? "" : ` (${mutation.error})`;
@@ -319,6 +321,9 @@ function writeIssueSection(title, issues) {
319
321
  writeStdout(title);
320
322
  for (const issue of issues) {
321
323
  writeStdout(`- ${issue.code}: ${issue.message}`);
324
+ if (issue.actionHint !== void 0 && issue.actionHint.length > 0) {
325
+ writeStdout(` \u2192 ${issue.actionHint}`);
326
+ }
322
327
  }
323
328
  }
324
329
  function renderStatus(status) {
@@ -436,35 +441,35 @@ var CITE_COVERAGE_LAYER_FILTERS = /* @__PURE__ */ new Set([
436
441
  function isValidLayerFilter(input) {
437
442
  return CITE_COVERAGE_LAYER_FILTERS.has(input);
438
443
  }
439
- function renderCiteCoverageReport(report, jsonMode) {
444
+ function renderCiteCoverageReport(report, jsonMode, dt) {
440
445
  if (jsonMode) {
441
446
  writeStdout(JSON.stringify(report, null, 2));
442
447
  return;
443
448
  }
444
449
  if (report.status === "skipped") {
445
- writeStdout(t("doctor.cite.status.skipped"));
450
+ writeStdout(dt("doctor.cite.status.skipped"));
446
451
  return;
447
452
  }
448
453
  const lines = [];
449
- lines.push(t("doctor.section.cite-coverage"));
454
+ lines.push(dt("doctor.section.cite-coverage"));
450
455
  lines.push(
451
- t("doctor.cite.header", {
456
+ dt("doctor.cite.header", {
452
457
  since: new Date(report.since_ts).toISOString(),
453
458
  marker: new Date(report.marker_ts).toISOString()
454
459
  })
455
460
  );
456
461
  if (report.marker_emitted_now) {
457
- lines.push(t("doctor.cite.warning.justActivated"));
462
+ lines.push(dt("doctor.cite.warning.justActivated"));
458
463
  }
459
464
  lines.push("");
460
- lines.push(` ${t("doctor.cite.metric.editsTouched")}: ${report.metrics.edits_touched}`);
461
- lines.push(` ${t("doctor.cite.metric.qualifyingCites")}: ${report.metrics.qualifying_cites}`);
462
- lines.push(` ${t("doctor.cite.metric.recalledUnverified")}: ${report.metrics.recalled_unverified}`);
463
- lines.push(` ${t("doctor.cite.metric.expectedButMissed")}: ${report.metrics.expected_but_missed}`);
464
- lines.push(` ${t("doctor.cite.metric.totalTurns")}: ${report.metrics.total_turns}`);
465
+ lines.push(` ${dt("doctor.cite.metric.editsTouched")}: ${report.metrics.edits_touched}`);
466
+ lines.push(` ${dt("doctor.cite.metric.qualifyingCites")}: ${report.metrics.qualifying_cites}`);
467
+ lines.push(` ${dt("doctor.cite.metric.recalledUnverified")}: ${report.metrics.recalled_unverified}`);
468
+ lines.push(` ${dt("doctor.cite.metric.expectedButMissed")}: ${report.metrics.expected_but_missed}`);
469
+ lines.push(` ${dt("doctor.cite.metric.totalTurns")}: ${report.metrics.total_turns}`);
465
470
  if (report.per_client !== void 0 && Object.keys(report.per_client).length > 1) {
466
471
  lines.push("");
467
- lines.push(`### ${t("doctor.cite.section.perClient")}`);
472
+ lines.push(`### ${dt("doctor.cite.section.perClient")}`);
468
473
  for (const [client, metrics] of Object.entries(report.per_client)) {
469
474
  const summary = Object.entries(metrics).map(([k, v]) => `${k}=${v}`).join(" / ");
470
475
  lines.push(` ${client}: ${summary}`);
@@ -472,24 +477,24 @@ function renderCiteCoverageReport(report, jsonMode) {
472
477
  }
473
478
  if (report.dismissed_reason_histogram !== void 0 && Object.keys(report.dismissed_reason_histogram).length > 0) {
474
479
  lines.push("");
475
- lines.push(`### ${t("doctor.cite.section.dismissedReasons")}`);
480
+ lines.push(`### ${dt("doctor.cite.section.dismissedReasons")}`);
476
481
  for (const [reason, count] of Object.entries(report.dismissed_reason_histogram)) {
477
- const label = t(`doctor.cite.dismissed.${reason}`);
482
+ const label = dt(`doctor.cite.dismissed.${reason}`);
478
483
  lines.push(` ${label}: ${count}`);
479
484
  }
480
485
  }
481
486
  if (report.none_reason_histogram !== void 0 && Object.keys(report.none_reason_histogram).length > 0) {
482
487
  lines.push("");
483
- lines.push(`### ${t("doctor.cite.section.noneReasons")}`);
488
+ lines.push(`### ${dt("doctor.cite.section.noneReasons")}`);
484
489
  for (const [reason, count] of Object.entries(report.none_reason_histogram)) {
485
- const label = t(`doctor.cite.none.${reason}`);
490
+ const label = dt(`doctor.cite.none.${reason}`);
486
491
  lines.push(` ${label}: ${count}`);
487
492
  }
488
493
  }
489
- appendContractSection(lines, report);
494
+ appendContractSection(lines, report, dt);
490
495
  writeStdout(lines.join("\n"));
491
496
  }
492
- function appendContractSection(lines, report) {
497
+ function appendContractSection(lines, report, dt) {
493
498
  const status = report.contract_metrics_status;
494
499
  if (status === void 0) {
495
500
  return;
@@ -501,13 +506,13 @@ function appendContractSection(lines, report) {
501
506
  return;
502
507
  }
503
508
  lines.push("");
504
- lines.push(`### ${t("cite-coverage.contract.header")}`);
509
+ lines.push(`### ${dt("cite-coverage.contract.header")}`);
505
510
  if (status === "skipped:bootstrap_drift") {
506
- lines.push(` ${t("cite-coverage.contract.status.skipped_bootstrap_drift")}`);
511
+ lines.push(` ${dt("cite-coverage.contract.status.skipped_bootstrap_drift")}`);
507
512
  return;
508
513
  }
509
514
  const statusKey = status === "ok" ? "cite-coverage.contract.status.ok" : "cite-coverage.contract.status.awaiting_marker";
510
- lines.push(` status: ${t(statusKey)}`);
515
+ lines.push(` status: ${dt(statusKey)}`);
511
516
  if (typeof report.contract_marker_ts === "number" && report.contract_marker_ts > 0) {
512
517
  lines.push(` since: ${new Date(report.contract_marker_ts).toISOString()}`);
513
518
  }
@@ -515,14 +520,14 @@ function appendContractSection(lines, report) {
515
520
  lines.push(` layer filter: ${report.layer_filter}`);
516
521
  }
517
522
  if (metrics !== void 0) {
518
- lines.push(` ${t("cite-coverage.contract.decisions_cited")}: ${metrics.decisions_cited}`);
519
- lines.push(` ${t("cite-coverage.contract.pitfalls_cited")}: ${metrics.pitfalls_cited}`);
520
- lines.push(` ${t("cite-coverage.contract.with")}: ${metrics.contract_with}`);
521
- lines.push(` ${t("cite-coverage.contract.missing")}: ${metrics.contract_missing}`);
523
+ lines.push(` ${dt("cite-coverage.contract.decisions_cited")}: ${metrics.decisions_cited}`);
524
+ lines.push(` ${dt("cite-coverage.contract.pitfalls_cited")}: ${metrics.pitfalls_cited}`);
525
+ lines.push(` ${dt("cite-coverage.contract.with")}: ${metrics.contract_with}`);
526
+ lines.push(` ${dt("cite-coverage.contract.missing")}: ${metrics.contract_missing}`);
522
527
  if (metrics.hard_violated > 0) {
523
- const layerSuffix = report.layer_filter === "personal" ? t("cite-coverage.layer.personal_fyi") : t("cite-coverage.layer.team_review");
528
+ const layerSuffix = report.layer_filter === "personal" ? dt("cite-coverage.layer.personal_fyi") : dt("cite-coverage.layer.team_review");
524
529
  lines.push(
525
- ` ${t("cite-coverage.contract.hard_violated")} ${layerSuffix}: ${metrics.hard_violated}`
530
+ ` ${dt("cite-coverage.contract.hard_violated")} ${layerSuffix}: ${metrics.hard_violated}`
526
531
  );
527
532
  }
528
533
  }
@@ -535,39 +540,39 @@ function appendContractSection(lines, report) {
535
540
  );
536
541
  if (teamKeys.length > 0 || personalKeys.length > 0) {
537
542
  lines.push("");
538
- lines.push(`#### ${t("cite-coverage.layer.team")} \xD7 ${t("cite-coverage.layer.personal")}`);
543
+ lines.push(`#### ${dt("cite-coverage.layer.team")} \xD7 ${dt("cite-coverage.layer.personal")}`);
539
544
  for (const key of teamKeys) {
540
- const label = t(`cite-coverage.contract.type.${key}`);
541
- lines.push(` ${t("cite-coverage.layer.team")} \u2014 ${label}: ${perLayerType.team[key]}`);
545
+ const label = dt(`cite-coverage.contract.type.${key}`);
546
+ lines.push(` ${dt("cite-coverage.layer.team")} \u2014 ${label}: ${perLayerType.team[key]}`);
542
547
  }
543
548
  for (const key of personalKeys) {
544
- const label = t(`cite-coverage.contract.type.${key}`);
549
+ const label = dt(`cite-coverage.contract.type.${key}`);
545
550
  lines.push(
546
- ` ${t("cite-coverage.layer.personal")} \u2014 ${label}: ${perLayerType.personal[key]}`
551
+ ` ${dt("cite-coverage.layer.personal")} \u2014 ${label}: ${perLayerType.personal[key]}`
547
552
  );
548
553
  }
549
554
  }
550
555
  }
551
556
  if (metrics !== void 0 && Object.keys(metrics.skip_count).length > 0) {
552
557
  lines.push("");
553
- lines.push(`#### ${t("cite-coverage.contract.skip_count")}`);
558
+ lines.push(`#### ${dt("cite-coverage.contract.skip_count")}`);
554
559
  for (const [reason, count] of Object.entries(metrics.skip_count)) {
555
- const label = t(`cite-coverage.skip.${reason}`);
560
+ const label = dt(`cite-coverage.skip.${reason}`);
556
561
  lines.push(` ${label}: ${count}`);
557
562
  }
558
563
  }
559
564
  if (metrics !== void 0 && metrics.cite_id_unresolved > 0) {
560
565
  lines.push("");
561
566
  lines.push(
562
- `${symbol.warn} ${t("cite-coverage.contract.cite_id_unresolved")}: ${metrics.cite_id_unresolved}`
567
+ `${symbol.warn} ${dt("cite-coverage.contract.cite_id_unresolved")}: ${metrics.cite_id_unresolved}`
563
568
  );
564
569
  }
565
570
  }
566
- function renderEnrichDescriptionsReport(report) {
571
+ function renderEnrichDescriptionsReport(report, dt) {
567
572
  const header = `${symbol.ok} ${paint.ai("fab doctor --enrich-descriptions")} mode=${report.mode}${report.dryRun ? " (dry-run)" : ""} scanned=${report.scanned} modified=${report.modified} skipped=${report.skipped}`;
568
573
  writeStdout(header);
569
574
  if (report.candidates.length === 0) {
570
- writeStdout(t("doctor.enrich.allComplete"));
575
+ writeStdout(dt("doctor.enrich.allComplete"));
571
576
  return;
572
577
  }
573
578
  writeStdout("");
@@ -611,14 +616,14 @@ function parseSinceDuration(input) {
611
616
  }
612
617
  throw new Error(`invalid --since value: ${input}`);
613
618
  }
614
- function renderArchiveHistoryReport(report, sinceLabel) {
619
+ function renderArchiveHistoryReport(report, sinceLabel, dt) {
615
620
  if (report.entries.length === 0) {
616
- writeStdout(t("doctor.archive-history.empty", { sinceLabel }));
621
+ writeStdout(dt("doctor.archive-history.empty", { sinceLabel }));
617
622
  return;
618
623
  }
619
624
  const lines = [];
620
625
  lines.push(
621
- t("doctor.archive-history.header", {
626
+ dt("doctor.archive-history.header", {
622
627
  sinceLabel,
623
628
  count: String(report.total),
624
629
  plural: report.total === 1 ? "" : "s"
@@ -626,11 +631,11 @@ function renderArchiveHistoryReport(report, sinceLabel) {
626
631
  );
627
632
  lines.push("");
628
633
  lines.push(
629
- `| ${t("doctor.archive-history.table.session")} | ${t(
634
+ `| ${dt("doctor.archive-history.table.session")} | ${dt(
630
635
  "doctor.archive-history.table.lastAttempt"
631
- )} | ${t("doctor.archive-history.table.outcome")} | ${t(
636
+ )} | ${dt("doctor.archive-history.table.outcome")} | ${dt(
632
637
  "doctor.archive-history.table.candidates"
633
- )} | ${t("doctor.archive-history.table.coveredGap")} |`
638
+ )} | ${dt("doctor.archive-history.table.coveredGap")} |`
634
639
  );
635
640
  lines.push("| ------- | ---------------- | -------- | ---------- | ----------- |");
636
641
  for (const entry of report.entries) {
package/dist/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  t
4
- } from "./chunk-6ICJICVU.js";
4
+ } from "./chunk-PWLW3B57.js";
5
5
 
6
6
  // src/index.ts
7
7
  import { realpathSync } from "fs";
@@ -11,12 +11,12 @@ import { defineCommand, runMain } from "citty";
11
11
 
12
12
  // src/commands/index.ts
13
13
  var allCommands = {
14
- install: () => import("./install-S2J76N2B.js").then((module) => module.default),
15
- doctor: () => import("./doctor-DXKPYPRC.js").then((module) => module.default),
16
- serve: () => import("./serve-NPCI342P.js").then((module) => module.default),
17
- uninstall: () => import("./uninstall-MQM6NUFM.js").then((module) => module.default),
18
- config: () => import("./config-XGUUAYX6.js").then((module) => module.default),
19
- "plan-context-hint": () => import("./plan-context-hint-KPGOW3QC.js").then((module) => module.default),
14
+ install: () => import("./install-UJOFZUYF.js").then((module) => module.default),
15
+ doctor: () => import("./doctor-ZIQXN2T2.js").then((module) => module.default),
16
+ serve: () => import("./serve-U3TPWDOB.js").then((module) => module.default),
17
+ uninstall: () => import("./uninstall-O3PXESM2.js").then((module) => module.default),
18
+ config: () => import("./config-5CH4EJQ2.js").then((module) => module.default),
19
+ "plan-context-hint": () => import("./plan-context-hint-CXTLNVSV.js").then((module) => module.default),
20
20
  // v2.0.0-rc.23 TASK-014 (F8c): S5 onboard-slot coverage. Used by the
21
21
  // fabric-archive Skill's first-run phase to detect unclaimed slots.
22
22
  "onboard-coverage": () => import("./onboard-coverage-JJ5NGU7I.js").then((module) => module.default)
@@ -26,7 +26,7 @@ var allCommands = {
26
26
  var main = defineCommand({
27
27
  meta: {
28
28
  name: "fabric",
29
- version: "2.0.0-rc.25",
29
+ version: "2.0.0-rc.27",
30
30
  description: t("cli.main.description")
31
31
  },
32
32
  subCommands: allCommands
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
  import {
3
3
  installMcpClients
4
- } from "./chunk-STLR2GHP.js";
4
+ } from "./chunk-SRX7WZUG.js";
5
5
  import {
6
6
  installArchiveHintHook,
7
7
  installFabricArchiveSkill,
@@ -18,7 +18,7 @@ import {
18
18
  writeCodexBootstrapManagedBlock,
19
19
  writeCursorBootstrapManagedBlock,
20
20
  writeFabricAgentsSnapshot
21
- } from "./chunk-AXKII55Y.js";
21
+ } from "./chunk-XEGXQOOJ.js";
22
22
  import {
23
23
  detectClientSupports
24
24
  } from "./chunk-MF3OTILQ.js";
@@ -31,7 +31,7 @@ import {
31
31
  } from "./chunk-G2CIOLD4.js";
32
32
  import {
33
33
  t
34
- } from "./chunk-6ICJICVU.js";
34
+ } from "./chunk-PWLW3B57.js";
35
35
  import {
36
36
  createDebugLogger,
37
37
  resolveDevMode
@@ -1348,7 +1348,7 @@ function readProjectName(target) {
1348
1348
  return basename(target);
1349
1349
  }
1350
1350
  function getCliVersion() {
1351
- return true ? "2.0.0-rc.25" : "unknown";
1351
+ return true ? "2.0.0-rc.27" : "unknown";
1352
1352
  }
1353
1353
  function sortRecord(record) {
1354
1354
  return Object.fromEntries(Object.entries(record).sort(([left], [right]) => left.localeCompare(right)));
@@ -70,14 +70,29 @@ async function runPlanContextHint(opts) {
70
70
  id: item.stable_id,
71
71
  type: item.type ?? item.description.knowledge_type ?? "",
72
72
  maturity: item.maturity ?? item.description.maturity ?? "",
73
- summary: item.description.summary
73
+ summary: item.description.summary,
74
+ // v2.0.0-rc.27 TASK-002 (§2.5/§2.7): forward the server-side scope.
75
+ // RuleDescriptionIndexItem already carries this field — knowledge-meta-
76
+ // builder defaults to "broad" for entries without an explicit
77
+ // relevance_scope frontmatter, so this read is total and never undefined.
78
+ relevance_scope: item.relevance_scope ?? "broad"
74
79
  }));
80
+ let narrow_count = 0;
81
+ let broad_only_count = 0;
82
+ for (const e of entries) {
83
+ if (e.relevance_scope === "narrow") narrow_count += 1;
84
+ else broad_only_count += 1;
85
+ }
75
86
  const output = {
76
87
  version: 2,
77
88
  revision_hash: result.revision_hash,
78
89
  target_paths: targetPaths,
79
90
  entries,
80
- broad_count: sharedIndex.length
91
+ // Legacy field — preserved for v2 consumers that haven't migrated. Value
92
+ // semantics unchanged from rc.18 (sharedIndex total).
93
+ broad_count: sharedIndex.length,
94
+ narrow_count,
95
+ broad_only_count
81
96
  };
82
97
  if (result.auto_healed === true) {
83
98
  output.auto_healed = true;
@@ -7,7 +7,7 @@ import {
7
7
  } from "./chunk-G2CIOLD4.js";
8
8
  import {
9
9
  t
10
- } from "./chunk-6ICJICVU.js";
10
+ } from "./chunk-PWLW3B57.js";
11
11
  import {
12
12
  createDebugLogger,
13
13
  resolveDevMode
@@ -7,7 +7,7 @@ import {
7
7
  HOOK_SCRIPT_DESTINATIONS,
8
8
  SKILL_DESTINATIONS,
9
9
  fabricAgentsSnapshotPath
10
- } from "./chunk-AXKII55Y.js";
10
+ } from "./chunk-XEGXQOOJ.js";
11
11
  import {
12
12
  detectClientSupports,
13
13
  resolveClients
@@ -19,7 +19,7 @@ import {
19
19
  } from "./chunk-G2CIOLD4.js";
20
20
  import {
21
21
  t
22
- } from "./chunk-6ICJICVU.js";
22
+ } from "./chunk-PWLW3B57.js";
23
23
  import {
24
24
  createDebugLogger,
25
25
  resolveDevMode
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fenglimg/fabric-cli",
3
- "version": "2.0.0-rc.25",
3
+ "version": "2.0.0-rc.27",
4
4
  "type": "module",
5
5
  "bin": {
6
6
  "fab": "dist/index.js",
@@ -20,8 +20,8 @@
20
20
  "tree-sitter-javascript": "^0.25.0",
21
21
  "tree-sitter-typescript": "^0.23.2",
22
22
  "web-tree-sitter": "^0.26.8",
23
- "@fenglimg/fabric-server": "2.0.0-rc.25",
24
- "@fenglimg/fabric-shared": "2.0.0-rc.25"
23
+ "@fenglimg/fabric-server": "2.0.0-rc.27",
24
+ "@fenglimg/fabric-shared": "2.0.0-rc.27"
25
25
  },
26
26
  "devDependencies": {
27
27
  "@types/node": "^22.15.0",
@@ -477,14 +477,49 @@ function getTopEditedDirectories(projectRoot, topN, anchorTs) {
477
477
  // any leading "./". POSIX-style only — the hook ships under POSIX
478
478
  // path conventions even on Windows (the project doesn't currently
479
479
  // ship a CRLF/backslash test matrix for the sidecar).
480
- const norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
480
+ //
481
+ // v2.0.0-rc.27 TASK-005 (audit §2.8 leak surface): absolute paths
482
+ // already accumulated in legacy sidecars start with `/`. We strip
483
+ // the leading slash and also reject buckets that resolve to user-home
484
+ // segments (`Users/<name>/...`, `home/<name>/...`) so historical
485
+ // pollution from absolute-path writes doesn't surface the user's
486
+ // $HOME in the archive banner. The rc.27 appendEditCounter no longer
487
+ // writes such paths, but the sidecar is append-only so old lines
488
+ // persist until rotation.
489
+ let norm = p.replace(/\\/g, "/").replace(/^\.\//, "");
490
+ // Strip leading `/` so a stale absolute entry doesn't generate a leak.
491
+ while (norm.startsWith("/")) norm = norm.slice(1);
481
492
  const segs = norm.split("/").filter((s) => s.length > 0);
493
+ // Reject any bucket whose top segments look like a host-system home
494
+ // prefix. The pattern is `<top>/<user>/...` where top ∈ Users|home|root.
495
+ // This silently drops legacy absolute-path entries from $HOME without
496
+ // mangling the buckets for legitimate project-relative `Users/...`
497
+ // (unlikely but possible) — the heuristic favours $HOME leak prevention
498
+ // over false-positive bucketing of project paths named after Unix
499
+ // conventions.
500
+ if (segs.length >= 2 && (segs[0] === "Users" || segs[0] === "home" || segs[0] === "root")) {
501
+ continue;
502
+ }
503
+ // v2.0.0-rc.27 TASK-005 (audit §2.8 file-as-dir): when segs[1] looks
504
+ // like a file (contains a dot-extension at the end), surface segs[0]
505
+ // alone instead of `segs[0]/segs[1]/` — a 2-seg path of the form
506
+ // `assets/foo.ts` would otherwise render as "assets/foo.ts/" which
507
+ // misleads the operator about whether they're seeing a file or a
508
+ // directory. The extension regex is permissive: any `.X` where X is
509
+ // 1-8 alphanumerics counts. README.md / package.json / foo.ts all
510
+ // match; "v1.2" or "dotted.module" do too — acceptable false-positive
511
+ // rate, since the worst outcome is over-aggregation to the parent.
512
+ const looksLikeFile = (segment) => /\.[A-Za-z0-9]{1,8}$/u.test(segment);
482
513
  let bucket;
483
514
  if (segs.length >= 2) {
484
- // Leading 2 segments: "packages/cli", "docs/decisions", etc. We
485
- // trail with "/" so the banner reads "packages/cli/" — clearly a
486
- // directory rather than a file basename.
487
- bucket = `${segs[0]}/${segs[1]}/`;
515
+ if (looksLikeFile(segs[1])) {
516
+ bucket = `${segs[0]}/`;
517
+ } else {
518
+ // Leading 2 segments: "packages/cli", "docs/decisions", etc. We
519
+ // trail with "/" so the banner reads "packages/cli/" — clearly a
520
+ // directory rather than a file basename.
521
+ bucket = `${segs[0]}/${segs[1]}/`;
522
+ }
488
523
  } else if (segs.length === 1) {
489
524
  // Single segment — treat the basename as its own bucket. Bare
490
525
  // root-level files (README.md, package.json) get some signal too.
@@ -653,13 +688,25 @@ function decide(events, now, pendingStats, underseedStats, editCounterStats, thr
653
688
  // - "<editCount> 次编辑"
654
689
  // - "阈值 <N>"
655
690
  // - "fabric-archive"
691
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): parts now assembled per-variant
692
+ // via banner-i18n's archivePartsHours / archivePartsEdits so en mode
693
+ // gets fully-English fragments instead of mixed-language output. zh-CN
694
+ // / zh-CN-hybrid still render the original substring contract verbatim.
656
695
  const parts = [];
657
696
  if (triggerByHours) {
658
- parts.push(`已过 ${hoursElapsed.toFixed(1)}h(阈值 ${archiveHintHours}h)`);
697
+ parts.push(
698
+ renderBanner("archivePartsHours", variant, {
699
+ hoursFixed: hoursElapsed.toFixed(1),
700
+ threshold: archiveHintHours,
701
+ }),
702
+ );
659
703
  }
660
704
  if (triggerByEdits) {
661
705
  parts.push(
662
- `累计 ${editStats.editsSinceLastProposed} 次编辑(阈值 ${editStats.threshold})`,
706
+ renderBanner("archivePartsEdits", variant, {
707
+ count: editStats.editsSinceLastProposed,
708
+ threshold: editStats.threshold,
709
+ }),
663
710
  );
664
711
  }
665
712
  // rc.16 TASK-002: 5-banner i18n via lib/banner-i18n.cjs. Substring
@@ -1236,11 +1283,21 @@ function summarizeTranscript(transcriptPath) {
1236
1283
  if (envelope === null || typeof envelope !== "object") continue;
1237
1284
  envelopeIndex += 1;
1238
1285
 
1239
- // User text message — Claude Code shape: { role: "user", content: [...] }
1240
- // OR nested under `message.role`. Be generous.
1241
- const role = envelope.role || (envelope.message && envelope.message.role);
1286
+ // v2.0.0-rc.27 TASK-009 (audit §2.16): Codex CLI uses a different
1287
+ // envelope shape { type:"response_item", payload:{ type:"message",
1288
+ // role, content:[{type:"input_text"|"output_text", text}] } } vs Claude
1289
+ // Code's { type:"user", message:{ role, content } }. Resolve role +
1290
+ // content from whichever shape is present; without this, every Codex
1291
+ // session's digest came out empty (audit §2.16 — fixed here).
1292
+ const role =
1293
+ envelope.role ||
1294
+ (envelope.message && envelope.message.role) ||
1295
+ (envelope.payload && envelope.payload.role);
1242
1296
  if (role === "user") {
1243
- const content = envelope.content || (envelope.message && envelope.message.content);
1297
+ const content =
1298
+ envelope.content ||
1299
+ (envelope.message && envelope.message.content) ||
1300
+ (envelope.payload && envelope.payload.content);
1244
1301
  if (typeof content === "string") {
1245
1302
  out.user_messages.push(content);
1246
1303
  } else if (Array.isArray(content)) {
@@ -1257,7 +1314,10 @@ function summarizeTranscript(transcriptPath) {
1257
1314
  // entry per assistant envelope (even when no KB: line) so downstream can
1258
1315
  // distinguish "turn observed, no KB" (kb_line_raw=null) from "no turn".
1259
1316
  if (role === "assistant") {
1260
- const content = envelope.content || (envelope.message && envelope.message.content);
1317
+ const content =
1318
+ envelope.content ||
1319
+ (envelope.message && envelope.message.content) ||
1320
+ (envelope.payload && envelope.payload.content);
1261
1321
  let firstText = null;
1262
1322
  if (typeof content === "string") {
1263
1323
  firstText = content;
@@ -1277,6 +1337,17 @@ function summarizeTranscript(transcriptPath) {
1277
1337
  // with cite_ids). Sentinel `KB: none` contributes a `cite_tags=["none"]`
1278
1338
  // entry but no commitment — matches the parseCiteLine index contract.
1279
1339
  let citeCommitments = [];
1340
+ // v2.0.0-rc.27 TASK-009: Codex assistant blocks carry text under
1341
+ // `type:"output_text"` (not `type:"text"`). Fall back when no text-typed
1342
+ // block matched but a typed output_text block exists.
1343
+ if (firstText === null && Array.isArray(content)) {
1344
+ for (const block of content) {
1345
+ if (block && typeof block === "object" && block.type === "output_text" && typeof block.text === "string") {
1346
+ firstText = block.text;
1347
+ break;
1348
+ }
1349
+ }
1350
+ }
1280
1351
  if (typeof firstText === "string" && firstText.length > 0) {
1281
1352
  // First non-empty line.
1282
1353
  const linesOfText = firstText.split(/\r?\n/);
@@ -1342,6 +1413,27 @@ function summarizeTranscript(transcriptPath) {
1342
1413
  }
1343
1414
  }
1344
1415
  }
1416
+
1417
+ // v2.0.0-rc.27 TASK-009 (audit §2.16): Codex apply_patch path. Codex
1418
+ // emits one response_item envelope per file-edit invocation with payload
1419
+ // shape { type:"custom_tool_call", name:"apply_patch", input:<patch
1420
+ // string> }. The patch body lists target files via `*** Update File:`,
1421
+ // `*** Add File:`, `*** Delete File:` directives — harvest those.
1422
+ if (
1423
+ envelope.type === "response_item" &&
1424
+ envelope.payload &&
1425
+ envelope.payload.type === "custom_tool_call" &&
1426
+ envelope.payload.name === "apply_patch" &&
1427
+ typeof envelope.payload.input === "string"
1428
+ ) {
1429
+ const patchInput = envelope.payload.input;
1430
+ const fileDirectiveRe = /^\*\*\*\s+(?:Update|Add|Delete)\s+File:\s+(.+?)\s*$/gm;
1431
+ let m;
1432
+ while ((m = fileDirectiveRe.exec(patchInput)) !== null) {
1433
+ const fp = m[1].trim();
1434
+ if (fp.length > 0) out.edit_paths.push(fp);
1435
+ }
1436
+ }
1345
1437
  }
1346
1438
  // 1-line title = first non-empty user message (trimmed). Falls back to "".
1347
1439
  if (out.user_messages.length > 0) {
@@ -267,8 +267,34 @@ function appendEditCounter(projectRoot, now, paths) {
267
267
  mkdirSync(dir, { recursive: true });
268
268
  }
269
269
  const iso = now instanceof Date ? now.toISOString() : new Date(now).toISOString();
270
+ // v2.0.0-rc.27 TASK-005 (audit §2.8): normalize every path to a
271
+ // project-relative form BEFORE persistence. rc.26 wrote whatever the
272
+ // tool_input handed in — frequently absolute paths like
273
+ // `/Users/wepie/.../foo.ts` — which then leaked into the archive banner's
274
+ // "recent activity centered on: Users/wepie/" prose (the dirname pass
275
+ // stripped the leading `/` but produced a $HOME-prefix surface). The
276
+ // normalize-on-write keeps the sidecar containing only project-internal
277
+ // paths so downstream banner rendering can't accidentally surface
278
+ // host-system paths.
279
+ //
280
+ // Strategy: for each path, attempt path.relative(projectRoot, abs). When
281
+ // the result starts with `..` (path is outside the project tree) we
282
+ // silently drop the entry — out-of-tree edits are not meaningful
283
+ // activity for THIS project's banner. Bare relative paths (already in
284
+ // canonical form) round-trip through relative() unchanged.
285
+ const { isAbsolute: pathIsAbsolute, relative: pathRelative } = require("node:path");
270
286
  const pathList = Array.isArray(paths)
271
- ? paths.filter((p) => typeof p === "string" && p.length > 0)
287
+ ? paths
288
+ .filter((p) => typeof p === "string" && p.length > 0)
289
+ .map((p) => {
290
+ if (pathIsAbsolute(p)) {
291
+ const rel = pathRelative(projectRoot, p);
292
+ // path.relative returns `..` segments when p escapes projectRoot.
293
+ return rel.startsWith("..") ? null : rel;
294
+ }
295
+ return p;
296
+ })
297
+ .filter((p) => typeof p === "string" && p.length > 0)
272
298
  : [];
273
299
  const line = JSON.stringify({ ts: iso, paths: pathList });
274
300
  appendFileSync(file, `${line}\n`, "utf8");
@@ -723,7 +749,20 @@ function main(env, stdio) {
723
749
  if (cliPayload === null || cliPayload === undefined) return;
724
750
 
725
751
  // Protocol v2 (rc.18 TASK-005): wire field is `entries`, no v1 shim.
726
- const narrow = Array.isArray(cliPayload.entries) ? cliPayload.entries : [];
752
+ //
753
+ // v2.0.0-rc.27 TASK-005 (audit §2.5/§2.7): filter to entries whose
754
+ // `relevance_scope === "narrow"` so broad cross-cutting entries do NOT
755
+ // pollute the PreToolUse banner. rc.26 emitted broad + narrow as a
756
+ // single list — every Edit fired a hint even for paths the entry never
757
+ // anchored against (audit §2.5 reproduction). Broad entries are already
758
+ // surfaced once per session by the SessionStart hook so the PreToolUse
759
+ // surface should be narrow-only by design.
760
+ //
761
+ // Defensive default: when the CLI omits `relevance_scope` (older server
762
+ // / malformed item) we treat it as broad and skip — pre-rc.27 entries
763
+ // without the field are exactly the broad-leak surface §2.5 calls out.
764
+ const allEntries = Array.isArray(cliPayload.entries) ? cliPayload.entries : [];
765
+ const narrow = allEntries.filter((entry) => entry && entry.relevance_scope === "narrow");
727
766
  if (narrow.length === 0) {
728
767
  // rc.6 TASK-023 (E6): silence-counter — matched-narrow == 0. The CLI
729
768
  // had a chance to match against the extracted paths but came back
@@ -116,12 +116,39 @@ const STRINGS = {
116
116
  // ---- Signal A: archive ----------------------------------------------------
117
117
  // Source (zh-CN): fabric-hint.cjs:614 `📋 Fabric: 距上次归档 ${parts}。`
118
118
  // params: { parts } where parts is pre-joined `已过 25.0h(阈值 24h)` etc.
119
+ //
120
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): `parts` is now constructed by the
121
+ // sibling archivePartsHours / archivePartsEdits keys (also per-variant) so
122
+ // the caller never hardcodes Chinese into the en banner. The substring
123
+ // contract on "25.0h" / "阈值 N" / "次编辑" is preserved per-variant but
124
+ // each variant gets a coherent monolingual rendering — pre-rc.27 produced
125
+ // mixed-language output like `📋 Fabric: 已过 25.0h since last archive.`
126
+ // (audit §2.17 reproduction).
119
127
  archiveLine1: {
120
128
  "zh-CN": (p) => `📋 Fabric: 距上次归档 ${p.parts}。`,
121
129
  en: (p) => `📋 Fabric: ${p.parts} since last archive.`,
122
130
  "zh-CN-hybrid": (p) => `📋 Fabric: 距上次归档 ${p.parts}。`,
123
131
  },
124
132
 
133
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): per-variant assembly of the
134
+ // hours-trigger fragment. zh-CN tightens to the original substring
135
+ // contract (`已过 25.0h(阈值 24h)`); en variant translates the prose
136
+ // while preserving the numeric tokens; hybrid mirrors zh-CN.
137
+ // params: { hoursFixed: string (already toFixed(1)), threshold: number }
138
+ archivePartsHours: {
139
+ "zh-CN": (p) => `已过 ${p.hoursFixed}h(阈值 ${p.threshold}h)`,
140
+ en: (p) => `${p.hoursFixed}h elapsed (threshold ${p.threshold}h)`,
141
+ "zh-CN-hybrid": (p) => `已过 ${p.hoursFixed}h(阈值 ${p.threshold}h)`,
142
+ },
143
+
144
+ // v2.0.0-rc.27 TASK-005 (audit §2.17): edits-trigger fragment.
145
+ // params: { count: number, threshold: number }
146
+ archivePartsEdits: {
147
+ "zh-CN": (p) => `累计 ${p.count} 次编辑(阈值 ${p.threshold})`,
148
+ en: (p) => `${p.count} edits since last archive (threshold ${p.threshold})`,
149
+ "zh-CN-hybrid": (p) => `累计 ${p.count} 次编辑(阈值 ${p.threshold})`,
150
+ },
151
+
125
152
  // Source (zh-CN): fabric-hint.cjs:619 ` 最近活动集中在: ${activity}。`
126
153
  // params: { activity }
127
154
  archiveActivity: {
@@ -28,8 +28,11 @@
28
28
 
29
29
  const ID_RE = /^K[TP]-[A-Z]+-\d+$/;
30
30
  const SENTINEL_RE = /^KB:\s*none\b\s*(?:\[[^\]]*\])?\s*$/i;
31
+ // v2.0.0-rc.27 TASK-003 (audit §2.18): multi-id citations supported via
32
+ // comma-separated ID group. Mirrors packages/shared/src/cite-line-parser.ts.
31
33
  const FULL_RE =
32
- /^KB:\s+(K[TP]-[A-Z]+-\d+)(?:\s+\(([^)]*)\))?(?:\s+\[([^\]]+)\])?(?:\s+→\s*(.+))?\s*$/;
34
+ /^KB:\s+(K[TP]-[A-Z]+-\d+(?:\s*,\s*K[TP]-[A-Z]+-\d+)*)(?:\s+\(([^)]*)\))?(?:\s+\[([^\]]+)\])?(?:\s+→\s*(.+))?\s*$/;
35
+ const CHAINED_FROM_ID_RE = /chained-from\s+(K[TP]-[A-Z]+-\d+)/i;
33
36
 
34
37
  const ALLOWED_TAGS = new Set([
35
38
  "planned",
@@ -80,15 +83,32 @@ function parseLine(line) {
80
83
  const trimmed = line.trim();
81
84
  if (trimmed.length === 0) return null;
82
85
  if (SENTINEL_RE.test(trimmed)) {
83
- return { id: null, tag: "none", commitment: null };
86
+ return { ids: [], tag: "none", commitment: null };
84
87
  }
85
88
  const fullMatch = trimmed.match(FULL_RE);
86
89
  if (fullMatch) {
87
- const id = fullMatch[1];
88
- if (!ID_RE.test(id)) return null;
90
+ // v2.0.0-rc.27 TASK-003 (audit §2.18): split + revalidate each id;
91
+ // capture chained-from tail id when present.
92
+ const primaryIds = fullMatch[1]
93
+ .split(",")
94
+ .map((part) => part.trim())
95
+ .filter((part) => part.length > 0);
96
+ if (primaryIds.some((id) => !ID_RE.test(id))) return null;
97
+
98
+ const rawTag = fullMatch[3];
99
+ const tag = parseTag(rawTag);
100
+
101
+ const chainedIds = [];
102
+ if (rawTag) {
103
+ const chained = CHAINED_FROM_ID_RE.exec(rawTag);
104
+ if (chained && ID_RE.test(chained[1])) {
105
+ chainedIds.push(chained[1]);
106
+ }
107
+ }
108
+
89
109
  return {
90
- id,
91
- tag: parseTag(fullMatch[3]),
110
+ ids: primaryIds.concat(chainedIds),
111
+ tag,
92
112
  commitment: parseContractTail(fullMatch[4]),
93
113
  };
94
114
  }
@@ -99,6 +119,10 @@ function parseLine(line) {
99
119
  * Parse one or more newline-separated `KB:` cite lines into structured arrays
100
120
  * matching the assistant_turn_observed event-ledger fields. Tolerates
101
121
  * whitespace, CR/LF, blank lines, interleaved prose. Never throws.
122
+ *
123
+ * v2.0.0-rc.27 TASK-003 (audit §2.18): supports multi-id citations
124
+ * (`KB: KT-DEC-0001, KT-PIT-0005 ...`) and surfaces `chained-from <id>`'s
125
+ * embedded id as an additional cite_id. cite_tags carries one tag per LINE.
102
126
  */
103
127
  function parseCiteLine(raw) {
104
128
  const result = { cite_ids: [], cite_tags: [], cite_commitments: [] };
@@ -107,9 +131,19 @@ function parseCiteLine(raw) {
107
131
  const parsed = parseLine(line);
108
132
  if (!parsed) continue;
109
133
  result.cite_tags.push(parsed.tag);
110
- if (parsed.id !== null) result.cite_ids.push(parsed.id);
134
+ for (const id of parsed.ids) {
135
+ result.cite_ids.push(id);
136
+ }
111
137
  if (parsed.commitment !== null) {
112
- result.cite_commitments.push(parsed.commitment);
138
+ // v2.0.0-rc.27.1 (Codex review fix): cite_commitments MUST be index-
139
+ // aligned with cite_ids per the schema doc on event-ledger.ts:428.
140
+ // Multi-id citations share ONE parsed contract — propagate it across
141
+ // every id slot so downstream consumers (`doctor.ts` per-cite walk +
142
+ // `cite-contract-reminder.cjs`) can look up `commitments[i]` for any
143
+ // valid `i < cite_ids.length` without falling into an undefined slot.
144
+ for (let i = 0; i < parsed.ids.length; i += 1) {
145
+ result.cite_commitments.push(parsed.commitment);
146
+ }
113
147
  }
114
148
  }
115
149
  return result;
@@ -269,10 +269,11 @@ following 5 categories MUST be rendered in the resolved language:
269
269
  3. **Confirmation prompts** — the per-candidate `Confirm? (Y to accept,
270
270
  edit … inline, N to skip)` line in the batch review template. zh-CN
271
271
  ↔ en mirror.
272
- 4. **Dry-run table headers** — fabric-archive does not currently expose
273
- a dry-run mode; this slot is reserved for parity with fabric-import.
274
- IF a future revision adds dry-run, the table header MUST be
275
- bilingualized per this policy. zh-CN en mirror.
272
+ 4. **Dry-run table headers** — v2.0.0-rc.27 TASK-007 added a dry-run
273
+ override path (see Phase 2.5 "dry-run") so users can preview the
274
+ archive proposal without writing pending entries. The dry-run summary
275
+ header and per-candidate preview labels MUST be bilingualized per
276
+ this policy. zh-CN ↔ en mirror.
276
277
  5. **AskUserQuestion** — `header` + `question` fields (NOT `options[]`).
277
278
  zh-CN ↔ en mirror. fabric-archive itself does not surface
278
279
  AskUserQuestion in the current contract (Phase 1 batch review is a
@@ -1095,6 +1096,20 @@ If the skill needs to record a genuinely separate observation in the same sessio
1095
1096
 
1096
1097
  MANDATORY closing step on every skill invocation — runs AFTER Phase 2 (success path) AND on every early-exit path (Phase 0.0 dropped-all, Phase 0.5 gate-FAIL silent-skip or user-active, Phase 1 batch user-dismissed). Drives the Q3.4 outcome state machine + cross-session digest rescan filter.
1097
1098
 
1099
+ #### Dry-run override (v2.0.0-rc.27 TASK-007 / audit §2.25)
1100
+
1101
+ When the user's invocation explicitly carries a dry-run intent — the prompt or `/fabric-archive` invocation contains a literal `--dry-run`, `dry-run`, `dry_run`, or `预览` token — the skill MUST skip Phase 2.5's ledger write. The mandatory contract above is suspended only in this single case; every other early-exit path still emits the event.
1102
+
1103
+ Rationale: pre-rc.27 the spec read as "MANDATORY on every invocation" which created an irreconcilable conflict when the user explicitly requested a no-mutation preview (audit §2.25). The dry-run override resolves the deadlock by treating dry-run as an entry-context override that disables the ledger side-effect while preserving the rest of the skill's read-side machinery (Phase 0.0 digest collection, Phase 0.5 viability gate, Phase 1 candidate preview render). The user sees what WOULD have happened without the audit trail recording an attempt that never produced a pending entry.
1104
+
1105
+ Detection rule (substring match, case-insensitive): if the originating prompt contains `--dry-run` | `dry-run` | `dry_run` | `预览` as a standalone token, set `dry_run = true` for the entire skill run and skip the Phase 2.5 event emission. All other phases run normally; their user-facing output should prefix `[DRY-RUN]` to make the mode visible.
1106
+
1107
+ When `dry_run = true`:
1108
+ - Phase 1 batch review header MUST include `[DRY-RUN — no writes will occur]`
1109
+ - Phase 2 candidate emission is REPLACED with a "would write N pending entries" preview rendered as a numbered table (`would-write` shape — same columns as the real Phase 1 review)
1110
+ - Phase 2.5 event emission is SKIPPED entirely (the rationale above)
1111
+ - No `fab_extract_knowledge` MCP call is issued (dry-run is purely read-side)
1112
+
1098
1113
  #### What to emit
1099
1114
 
1100
1115
  For EACH `session_id` in the run's scope (multi-session E4 runs emit MULTIPLE events — one per session_id; single-session E1/E2/E3/E5 runs emit ONE event), append ONE `session_archive_attempted` line to `.fabric/events.jsonl`:
@@ -1,10 +0,0 @@
1
- #!/usr/bin/env node
2
-
3
- // src/i18n.ts
4
- import { createTranslator, detectNodeLocale } from "@fenglimg/fabric-shared";
5
- var locale = detectNodeLocale();
6
- var t = createTranslator(locale);
7
-
8
- export {
9
- t
10
- };