@sabaiway/agent-workflow-kit 1.34.0 → 1.35.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.
@@ -36,6 +36,30 @@ export const VALID = 'valid';
36
36
  export const UNSUPPORTED = 'unsupported';
37
37
  export const INVALID = 'invalid';
38
38
 
39
+ // Typed settings-value grammar (D6, bridges 2.3.0) — the ONE predicate the manifest validator (for a
40
+ // declared `default`), the kit-side bridge-settings writer (for a user-supplied value), and — mirrored
41
+ // in shell as aw_settings_valid — the wrappers all use, so a value the writer accepts is exactly a
42
+ // value a wrapper honors. A value is always compared as a STRING (the settings-file wire format).
43
+ export const SETTING_KINDS = new Set(['enum', 'integer', 'duration', 'boolean']);
44
+ // The wrappers' shell duration grammar: a unit suffix is REQUIRED (a bare integer is invalid), and
45
+ // zero durations are rejected — `timeout 0` DISABLES a hard cap, so a persistent settings line could
46
+ // otherwise silently remove the stall guard.
47
+ export const DURATION_RE = /^[0-9]+(\.[0-9]+)?[smhd]$/;
48
+ export const ZERO_DURATION_RE = /^0+(\.0+)?[smhd]$/;
49
+ export const settingValueValid = (entry, value) => {
50
+ switch (entry.kind) {
51
+ case 'enum': return Array.isArray(entry.values) && entry.values.includes(value);
52
+ case 'integer': {
53
+ if (!/^[0-9]+$/.test(value)) return false;
54
+ const n = Number(value);
55
+ return Number.isSafeInteger(n) && n >= entry.min && n <= entry.max;
56
+ }
57
+ case 'duration': return DURATION_RE.test(value) && !ZERO_DURATION_RE.test(value);
58
+ case 'boolean': return value === '0' || value === '1';
59
+ default: return false;
60
+ }
61
+ };
62
+
39
63
  const hasTraversal = (p) => p.split(/[\\/]/).includes('..');
40
64
  const isUnresolved = (s) => /\{\{|\}\}|\$\{/.test(s);
41
65
 
@@ -228,6 +252,67 @@ export const validateManifest = (skillDir) => {
228
252
  if (role.template != null) checkInSkillPath(`role "${key}".template`, role.template, !isStub);
229
253
  }
230
254
 
255
+ // Typed `settings` block (bridges 2.3.0, D6 manifest-as-source): the per-bridge settings-file
256
+ // surface — an ARRAY of typed entries (a JSON object would silently dedupe duplicate keys under
257
+ // JSON.parse). Unlike the AD-033 `contract` block (validator-tolerated, externally
258
+ // drift-guarded), a malformed `settings` entry FAILS validation: the kit writer, the status
259
+ // renderers, and the wrapper shell constants all consume this block, so a bad entry would
260
+ // corrupt a host-level config surface.
261
+ const settings = manifest.settings;
262
+ if (settings != null) {
263
+ if (!Array.isArray(settings)) {
264
+ errors.push('`settings` must be an array of setting entries');
265
+ } else {
266
+ const cmds = new Set(Object.values(roles)
267
+ .map((r) => (r && typeof r === 'object' && !Array.isArray(r) ? r.cmd : null))
268
+ .filter((c) => typeof c === 'string' && c));
269
+ const seenKeys = new Set();
270
+ settings.forEach((entry, i) => {
271
+ const at = `\`settings[${i}]\``;
272
+ if (entry == null || typeof entry !== 'object' || Array.isArray(entry)) {
273
+ errors.push(`${at} must be an object`);
274
+ return;
275
+ }
276
+ if (typeof entry.key !== 'string' || !/^[A-Z][A-Z0-9_]*$/.test(entry.key)) {
277
+ errors.push(`${at}.key must be an UPPER_SNAKE_CASE string`);
278
+ } else if (seenKeys.has(entry.key)) {
279
+ errors.push(`duplicate settings key "${entry.key}" (${at})`);
280
+ } else {
281
+ seenKeys.add(entry.key);
282
+ }
283
+ if (typeof entry.effect !== 'string' || !entry.effect) errors.push(`${at}.effect must be a non-empty string`);
284
+ if (!Array.isArray(entry.appliesTo) || entry.appliesTo.length === 0
285
+ || !entry.appliesTo.every((c) => typeof c === 'string' && c)) {
286
+ errors.push(`${at}.appliesTo must be a non-empty array of wrapper cmd names`);
287
+ } else {
288
+ for (const c of entry.appliesTo) {
289
+ if (!cmds.has(c)) errors.push(`${at}.appliesTo names "${c}" which is no roles.*.cmd of this manifest`);
290
+ }
291
+ }
292
+ if (!SETTING_KINDS.has(entry.kind)) {
293
+ errors.push(`${at}.kind must be one of enum|integer|duration|boolean`);
294
+ return; // the typed checks below are meaningless without a kind
295
+ }
296
+ if (entry.kind === 'enum'
297
+ && (!Array.isArray(entry.values) || entry.values.length === 0
298
+ || !entry.values.every((v) => typeof v === 'string' && v)
299
+ || new Set(entry.values).size !== entry.values.length)) {
300
+ errors.push(`${at}.values must be a non-empty array of unique non-empty strings (enum kind)`);
301
+ }
302
+ if (entry.kind === 'integer'
303
+ && (!Number.isSafeInteger(entry.min) || !Number.isSafeInteger(entry.max) || entry.min > entry.max)) {
304
+ errors.push(`${at}.min/.max must be integers with min <= max (integer kind)`);
305
+ }
306
+ if (!Object.hasOwn(entry, 'default')) {
307
+ errors.push(`${at}.default is required (null = the wrapper built-ins apply)`);
308
+ } else if (entry.default != null
309
+ && (typeof entry.default !== 'string' || !settingValueValid(entry, entry.default))) {
310
+ errors.push(`${at}.default must be null or a string value that passes the ${entry.kind} validation`);
311
+ }
312
+ });
313
+ }
314
+ }
315
+
231
316
  if (!isStub) {
232
317
  const auth = readAuthoritativeVersion(skillDir);
233
318
  if (auth.version == null) errors.push(`could not resolve an authoritative version (${auth.from})`);
@@ -21,6 +21,9 @@ import { homedir } from 'node:os';
21
21
  import { join, dirname } from 'node:path';
22
22
  import { pathToFileURL, fileURLToPath } from 'node:url';
23
23
  import { detectBackends, wrapperCmdFor, wrapperContractFor } from './detect-backends.mjs';
24
+ // The host-level bridge-settings registry (manifest-as-source) + its allowed-value labels. READ-ONLY
25
+ // core only — never the writer — so this read-only advisor never imports the atomic-write core.
26
+ import { loadRegistry, allowedLabel } from './bridge-settings-read.mjs';
24
27
  import { ACTIVITIES, resolveActivityRecipe, planRecipe } from './recipes.mjs';
25
28
  import { resolveEngineDir, readEngineFragment, PROCEDURES_FRAGMENT_REL } from './engine-source.mjs';
26
29
  // The plan-in-flight detector (AD-038) — imported from the READ-ONLY checker (review-state.mjs
@@ -130,8 +133,19 @@ export const extractSection = (text, activity) => {
130
133
 
131
134
  // ── resolution + rendering ─────────────────────────────────────────────────────────
132
135
 
133
- const resolveAllSlots = ({ activity, config, detection, overrides }) =>
134
- Object.keys(ACTIVITIES[activity].slots).map((slot) => {
136
+ const resolveAllSlots = ({ activity, config, detection, overrides }) => {
137
+ // The host-level settings knobs (manifest-as-source), best-effort: a corrupt bundle degrades to none
138
+ // and the advisor never crashes. Attached per wrapper cmd via each knob's `appliesTo`. Fact-only —
139
+ // model/effort are not knobs, so no model claim can ride here.
140
+ const registry = (() => {
141
+ try {
142
+ return loadRegistry({});
143
+ } catch {
144
+ return new Map();
145
+ }
146
+ })();
147
+ const knobsFor = (cmd) => [...registry.values()].filter((k) => (k.appliesTo ?? []).includes(cmd));
148
+ return Object.keys(ACTIVITIES[activity].slots).map((slot) => {
135
149
  const resolved = resolveActivityRecipe({ config: config ?? {}, readiness: detection, activity, slot, override: overrides[slot] });
136
150
  // The concrete wrapper set this slot's EFFECTIVE recipe dispatches (empty for solo). Reuse
137
151
  // planRecipe's drift-guarded dispatch for WHICH backends, then resolve each (backend, role) to its
@@ -144,9 +158,11 @@ const resolveAllSlots = ({ activity, config, detection, overrides }) =>
144
158
  // is NEVER gated by REVIEW_RECIPES (that set gates only the review-loop economics block).
145
159
  const contracts = dispatch
146
160
  .map((d) => ({ backend: d.backend, role: d.role, cmd: wrapperCmdFor(d.backend, d.role), contract: wrapperContractFor(d.backend, d.role) }))
147
- .filter((c) => c.cmd && c.contract);
161
+ .filter((c) => c.cmd && c.contract)
162
+ .map((c) => ({ ...c, settings: knobsFor(c.cmd).map((k) => ({ key: k.key, allowed: allowedLabel(k) })) }));
148
163
  return { slot, ...resolved, backends, contracts };
149
164
  });
165
+ };
150
166
 
151
167
  // An unsatisfiable EXPLICIT override is the only "warning" (loud, flagged for the agent to relay). A
152
168
  // graceful config/default degradation is reported as a per-slot reason, not a warning.
@@ -250,7 +266,7 @@ const costLanesAdvice = () => [
250
266
  // roles[role].contract (drift-guarded), never re-derived or re-worded here. Rendered for EVERY
251
267
  // dispatched backend of EVERY slot (review AND execute=delegated); each wrapper's --help prints the
252
268
  // same contract, so the agent never needs to open the wrapper source.
253
- const contractLines = ({ cmd, contract }) => {
269
+ const contractLines = ({ cmd, contract, settings }) => {
254
270
  const lines = [` ${cmd} — driving contract (as bundled with this kit; copy-paste — \`${cmd} --help\` prints the same):`];
255
271
  for (const inv of contract.invocations) lines.push(` ${inv}`);
256
272
  for (const f of contract.flags ?? []) lines.push(` ${f}`);
@@ -263,6 +279,12 @@ const contractLines = ({ cmd, contract }) => {
263
279
  if (contract.passthrough) {
264
280
  lines.push(` passthrough after '--' is ${contract.passthrough.policy}: blocked always: ${contract.passthrough.blocked.join(' ')}; relaxed only under CODEX_PROBE=1: ${contract.passthrough.probeRelaxed.join(' ')}`);
265
281
  }
282
+ // Host-level settings knobs this wrapper honors (fact-only, from the bundled manifests; explicit
283
+ // branch — contractLines drops any contract key it does not name, so this must be enumerated here).
284
+ if ((settings ?? []).length) {
285
+ lines.push(' host settings (survive kit upgrades — set via /agent-workflow-kit bridge-settings):');
286
+ for (const s of settings) lines.push(` ${s.key} — ${s.allowed}`);
287
+ }
266
288
  return lines;
267
289
  };
268
290
 
package/tools/recipes.mjs CHANGED
@@ -15,6 +15,9 @@
15
15
  // a backend is advisory or delegated, never autonomous. Dependency-free, Node >= 18.
16
16
 
17
17
  import { pathToFileURL } from 'node:url';
18
+ // The host-level bridge-settings snapshot (fact-only, best-effort). READ-ONLY core only — never the
19
+ // writer — so this read-only advisor never pulls in the atomic-write core.
20
+ import { settingsSnapshot } from './bridge-settings-read.mjs';
18
21
  import {
19
22
  detectBackends,
20
23
  wrapperCmdFor,
@@ -293,12 +296,19 @@ export const resolveActivityRecipe = ({ config = {}, readiness = [], activity, s
293
296
  // DISPLAY_ALIASES — the ONE alias table the recommendation clause already uses; ordering is the
294
297
  // deterministic BACKEND_PRIORITY (codex before agy), independent of detection emission order.
295
298
  // Always exactly one line: no part may carry a newline (pinned by tests).
296
- export const composeStatusLine = (detection, recommendation) => {
299
+ export const composeStatusLine = (detection, recommendation, settings = null) => {
297
300
  const backends = [...detection]
298
301
  .sort((a, b) => priorityIndex(a.name) - priorityIndex(b.name))
299
302
  .map((b) => `${DISPLAY_ALIASES[b.name] ?? b.name} ${b.readiness === READY ? '✓' : '✗'} ${b.readiness}`)
300
303
  .join(' · ');
301
- return `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
304
+ const base = `backends: ${backends} — run /agent-workflow-kit backends · recipes: ${recommendation.clause} — see /agent-workflow-kit recipes`;
305
+ // Fact-only suffix, ONLY when a bridge knob is actively set (env/file, non-default). Omitted otherwise,
306
+ // so the default line is byte-identical to before. A raw env value may (D3) carry newlines/control
307
+ // chars — collapse them to a single space so the "exactly one line" backend-status contract holds.
308
+ const oneLine = (s) => String(s).replace(/[\s]+/g, ' ').trim();
309
+ const active = settings?.active ?? [];
310
+ const suffix = active.length ? ` · settings: ${active.map((s) => `${oneLine(s.key)}=${oneLine(s.value)}`).join(' · ')}` : '';
311
+ return base + suffix;
302
312
  };
303
313
 
304
314
  // ── the one-line ACTIVE-recipe line (the discovery line — configured, never recommended) ───────────
@@ -340,7 +350,7 @@ export const composeActiveRecipeLine = ({ config, source } = {}, detection) => {
340
350
 
341
351
  // The structured report behind `--json` — the recipes, the recommendation, a plan per recipe, and
342
352
  // (additive) the pasteable one-line backend status composed from the same detection.
343
- export const buildReport = (detection) => {
353
+ export const buildReport = (detection, settings = null) => {
344
354
  const recommendation = recommendRecipe(detection);
345
355
  return {
346
356
  recipes: RECIPES.map(({ id, title, role, minBackends, degradesTo, summary }) => ({
@@ -353,7 +363,7 @@ export const buildReport = (detection) => {
353
363
  })),
354
364
  recommendation,
355
365
  plans: RECIPES.map((r) => planRecipe(r.id, detection)),
356
- statusLine: composeStatusLine(detection, recommendation),
366
+ statusLine: composeStatusLine(detection, recommendation, settings),
357
367
  };
358
368
  };
359
369
 
@@ -423,8 +433,8 @@ exclusive. Detection only — never writes, never commits, never runs a subscrip
423
433
  console.error(`[agent-workflow-kit] ${err.message}`);
424
434
  return err.exitCode ?? 1;
425
435
  }
426
- } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection)));
427
- else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection), null, 2));
436
+ } else if (argv.includes('--status-line')) console.log(composeStatusLine(detection, recommendRecipe(detection), settingsSnapshot()));
437
+ else if (argv.includes('--json')) console.log(JSON.stringify(buildReport(detection, settingsSnapshot()), null, 2));
428
438
  else console.log(formatRecipes(detection));
429
439
  return 0;
430
440
  };
@@ -56,6 +56,13 @@ const renderBridges = (vm, { glyph, color }) => {
56
56
  for (const b of vm.bridges) {
57
57
  const wrappers = b.wrappers.map((w) => `${w.cmd} ${glyph[w.state] ?? glyph.unknown}`).join(', ') || '—';
58
58
  lines.push(` ${pad(b.display, MEMBER_COL)}${pad(b.readiness, READINESS_COL)}wrappers: ${wrappers}`);
59
+ // Fact-only host-level settings sub-line: the active knobs for this bridge, or a localized error.
60
+ // Absent when no knob is active, so the block stays byte-identical to before when nothing is set.
61
+ if (b.settings?.error) lines.push(` ${pad('', MEMBER_COL)}${glyph.note} couldn't read bridge settings (${b.settings.error})`);
62
+ else if (b.settings?.active?.length) {
63
+ const active = b.settings.active.map((s) => `${s.key}=${s.value} [${s.source}]`).join(' · ');
64
+ lines.push(` ${pad('', MEMBER_COL)}settings: ${active}`);
65
+ }
59
66
  }
60
67
  return lines;
61
68
  };
@@ -99,6 +99,78 @@ const probeMarker = (file, fs) => {
99
99
  }
100
100
  };
101
101
 
102
+ // ── overwrite honesty (D5) — state what a refresh replaced, never a silent wipe ────────────────────
103
+
104
+ // The host-level settings surface a refresh NEVER touches — the one place a bridge tweak survives a
105
+ // kit upgrade (bridge CODE stays kit-owned; only these knobs persist). Named wherever a refresh
106
+ // reports overwriting a local edit, so the recovery is always a copy-paste away.
107
+ const SETTINGS_FILE_HINT = '${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf';
108
+ const SETTINGS_CMD_HINT = '/agent-workflow-kit bridge-settings';
109
+
110
+ // Bundle-owned regular files whose PLACED copy differs from the bundle (a local edit an equal-version
111
+ // refresh would overwrite) or cannot be read (indeterminate — an honest degrade). Mirrors
112
+ // copyTreeRefresh's src dispatch so it flags EXACTLY the files the overwrite touches: a symlink src is
113
+ // additive (copyTreeRefresh skips an existing dest — never overwritten) and a dir recurses; a
114
+ // bundled-only file (no placed copy) is a pure add, no loss. The BUNDLE read is our own shipped
115
+ // artifact — a failure there is a loud corrupt-kit error upstream (never swallowed here); only the
116
+ // PLACED read is caught (EACCES/EIO → 'unreadable', and the refresh still proceeds). Sorted output is
117
+ // deterministic for the stated line. Exported for a direct unit test (the full driver cannot observe a
118
+ // symlinked placed file — copyTreeRefresh refuses to overwrite one, so the refresh fails before any line).
119
+ export const scanBundleOwnedDrift = (bundleDir, skillDir, fs) => {
120
+ const drifted = [];
121
+ const unreadable = [];
122
+ const walk = (rel) => {
123
+ const src = join(bundleDir, rel);
124
+ const dest = join(skillDir, rel);
125
+ const st = fs.lstat(src);
126
+ if (st.isSymbolicLink()) return;
127
+ if (st.isDirectory()) {
128
+ for (const entry of fs.readdir(src)) walk(rel ? join(rel, entry) : entry);
129
+ return;
130
+ }
131
+ // lstat the PLACED path NO-FOLLOW first — never read THROUGH a symlink (copyTreeRefresh's
132
+ // assertContainedRealPath would refuse to overwrite a symlinked dest, so reading its target here
133
+ // would be both unsafe and moot). Absent → a bundled-only addition (no local loss); a symlink /
134
+ // non-regular / unreadable placed node → "could not compare" without a read-through.
135
+ const destStat = (() => {
136
+ try {
137
+ return fs.lstat(dest);
138
+ } catch (err) {
139
+ return err && err.code === 'ENOENT' ? 'absent' : 'error';
140
+ }
141
+ })();
142
+ if (destStat === 'absent') return;
143
+ if (destStat === 'error' || destStat.isSymbolicLink() || !destStat.isFile()) {
144
+ unreadable.push(rel);
145
+ return;
146
+ }
147
+ const srcBytes = fs.readFile(src);
148
+ const destBytes = (() => {
149
+ try {
150
+ return fs.readFile(dest);
151
+ } catch {
152
+ unreadable.push(rel);
153
+ return null;
154
+ }
155
+ })();
156
+ if (destBytes === null) return;
157
+ if (!Buffer.from(srcBytes).equals(Buffer.from(destBytes))) drifted.push(rel);
158
+ };
159
+ walk('');
160
+ return { drifted: drifted.sort(), unreadable: unreadable.sort() };
161
+ };
162
+
163
+ // One user-facing sentence naming what an equal-version re-sync overwrote — or null when nothing local
164
+ // was lost. Callers apply their own indent; the pointer names the settings file that survives a refresh.
165
+ const driftSummary = (drift) => {
166
+ if (!drift) return null;
167
+ const parts = [];
168
+ if (drift.drifted.length) parts.push(`overwrote ${drift.drifted.length} locally-changed file(s): ${drift.drifted.join(', ')}`);
169
+ if (drift.unreadable.length) parts.push(`could not compare ${drift.unreadable.length} file(s) (kept the bundled copy): ${drift.unreadable.join(', ')}`);
170
+ if (!parts.length) return null;
171
+ return `${parts.join('; ')} — bridge code is kit-owned and always refreshed; persist host tweaks in ${SETTINGS_FILE_HINT} (survives every refresh): ${SETTINGS_CMD_HINT}`;
172
+ };
173
+
102
174
  // ── path resolution ──────────────────────────────────────────────────────────
103
175
 
104
176
  const skillDirOf = (entry, deps) =>
@@ -253,14 +325,26 @@ const assertNoDowngrade = (plan, deps = {}) => {
253
325
  if (compareSemver(placed, bundled) === 1) throw stop(downgradeReason(placed, bundled), { skillDir: plan.skillDir, wouldDowngrade: true });
254
326
  };
255
327
 
328
+ // Copy the bundle over the placed skill dir (the refresh overwrite), FIRST scanning bundle-owned files
329
+ // for local drift so the caller can STATE what the overwrite replaced (D5 — overwrite honesty). Scan
330
+ // only on a refresh: a `place` writes into an absent/empty dir, so there is nothing local to lose. The
331
+ // copy proceeds either way — bridge code is kit-owned. Returns the drift report (null for a place).
332
+ const copyBridgeWithHonesty = (action, bundleDir, skillDir, deps) => {
333
+ const fs = fsDeps(deps);
334
+ const drift = action === 'refresh' ? scanBundleOwnedDrift(bundleDir, skillDir, fs) : null;
335
+ copyTreeRefresh(bundleDir, skillDir, skillDir, fs);
336
+ return drift;
337
+ };
338
+
256
339
  // Place/refresh the bundled bridge skill. Re-inspects before writing (never trusts a stale plan).
340
+ // Returns the plan plus `drift`: on a refresh, the bundle-owned files whose local edits it overwrote.
257
341
  export const placeSkill = (name, deps = {}) => {
258
342
  const entry = registryEntry(name);
259
343
  if (!entry) throw stop(`unknown backend: ${name}`);
260
344
  const plan = inspectSkillDir(entry, deps);
261
345
  assertNoDowngrade(plan, deps);
262
- copyTreeRefresh(plan.bundleDir, plan.skillDir, plan.skillDir, fsDeps(deps));
263
- return plan;
346
+ const drift = copyBridgeWithHonesty(plan.action, plan.bundleDir, plan.skillDir, deps);
347
+ return { ...plan, drift };
264
348
  };
265
349
 
266
350
  // Link the wrappers onto `bindir`. PREFLIGHT all (source is a regular non-symlink file inside the
@@ -413,9 +497,12 @@ export const planFor = (backend, deps = {}) => {
413
497
  // in the plan across the read→write gap). Throws on a mid-flight conflict / fs error (honest partial
414
498
  // failure — placeSkill/chmod/symlink all converge on a re-run, so there is nothing to roll back).
415
499
  const applyBackend = (plan, deps) => {
416
- if (plan.place.action === 'place' || plan.place.action === 'refresh') placeSkill(plan.name, deps);
500
+ const placed = (plan.place.action === 'place' || plan.place.action === 'refresh')
501
+ ? placeSkill(plan.name, deps)
502
+ : null;
417
503
  const manifest = readBundledManifest(plan.place.bundleDir, deps);
418
504
  linkWrappers(plan.skillDir, manifest, { ...deps, bindir: plan.bindir, platform: plan.platform });
505
+ return { drift: placed?.drift ?? null };
419
506
  };
420
507
 
421
508
  // ── formatting ─────────────────────────────────────────────────────────────────
@@ -442,6 +529,11 @@ const formatBackend = (plan, applied) => {
442
529
  }
443
530
  const placeVerb = applied ? { place: 'placed', refresh: 'refreshed' } : { place: 'will place', refresh: 'will refresh' };
444
531
  lines.push(` • skill: ${placeVerb[plan.place.action]}${versionLabel(plan)} → ${plan.skillDir}`);
532
+ // Overwrite honesty (D5): on an equal-version re-sync that clobbered a local edit, say so (a version
533
+ // upgrade's diffs are the version delta — versionLabel's arrow already signals that change).
534
+ const equalVersionRefresh = plan.place.action === 'refresh' && (!plan.priorVersion || plan.priorVersion === plan.version);
535
+ const driftLine = equalVersionRefresh ? driftSummary(plan.drift) : null;
536
+ if (driftLine) lines.push(` ↳ ${driftLine}`);
445
537
  for (const l of plan.links) {
446
538
  const verb = l.dstState === 'ours' ? 'already linked' : applied ? 'linked' : 'will link';
447
539
  lines.push(` • wrapper ${l.cmd}: ${verb} → ${l.dst}`);
@@ -463,10 +555,10 @@ const formatBackend = (plan, applied) => {
463
555
  // a per-bridge lock file would be new machinery for a user-driven, self-healing race.
464
556
  const refreshSkillOnly = (entry, deps = {}) => {
465
557
  const fresh = inspectSkillDir(entry, deps);
466
- if (fresh.action !== 'refresh') return false;
558
+ if (fresh.action !== 'refresh') return { refreshed: false, drift: null };
467
559
  assertNoDowngrade(fresh, deps);
468
- copyTreeRefresh(fresh.bundleDir, fresh.skillDir, fresh.skillDir, fsDeps(deps));
469
- return true;
560
+ const drift = copyBridgeWithHonesty('refresh', fresh.bundleDir, fresh.skillDir, deps);
561
+ return { refreshed: true, drift };
470
562
  };
471
563
 
472
564
  const NOT_PLACED_LINE = 'skipped — not placed (placement is opt-in: /agent-workflow-kit setup)';
@@ -499,7 +591,8 @@ export const refreshPlacedBridges = (deps = {}, names = KNOWN_BACKENDS.map((b) =
499
591
  if (plan.outcome === 'stop' || plan.outcome === 'error') {
500
592
  return { name: plan.name, outcome: 'failed', line: ` ${plan.name}: could not refresh — ${stripPrefix(plan.reason)}; recover with /agent-workflow-kit setup` };
501
593
  }
502
- if (!refreshSkillOnly(registryEntry(plan.name), deps)) {
594
+ const refresh = refreshSkillOnly(registryEntry(plan.name), deps);
595
+ if (!refresh.refreshed) {
503
596
  return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
504
597
  }
505
598
  const manifest = readBundledManifest(plan.place.bundleDir, deps);
@@ -507,10 +600,14 @@ export const refreshPlacedBridges = (deps = {}, names = KNOWN_BACKENDS.map((b) =
507
600
  const current = plan.version !== null && plan.priorVersion === plan.version;
508
601
  // The equal-version line still states the copy that ran (repair-on-rerun) — the tool never
509
602
  // reports a mutation-free "already current" while it re-synced files underneath.
603
+ const base = ` ${plan.name}: ${current ? `already current${versionLabel(plan)} — files re-synced from the bundled copy` : `refreshed${versionLabel(plan)}`}`;
604
+ // Overwrite honesty (D5): only an EQUAL-version re-sync can prove a byte diff is a LOCAL edit —
605
+ // a version upgrade's diffs are the version delta, which the (vOld → vNew) arrow already states.
606
+ const summary = current ? driftSummary(refresh.drift) : null;
510
607
  return {
511
608
  name: plan.name,
512
609
  outcome: current ? 'already-current' : 'refreshed',
513
- line: ` ${plan.name}: ${current ? `already current${versionLabel(plan)} — files re-synced from the bundled copy` : `refreshed${versionLabel(plan)}`}`,
610
+ line: summary ? `${base}\n ↳ ${summary}` : base,
514
611
  };
515
612
  } catch (err) {
516
613
  // A downgrade STOP raised at the write boundary (a newer bridge landed between plan and apply)
@@ -647,7 +744,8 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
647
744
  let plan = planFor(name, runDeps);
648
745
  if (!args.dryRun && plan.outcome === 'ok') {
649
746
  try {
650
- applyBackend(plan, runDeps);
747
+ const { drift } = applyBackend(plan, runDeps);
748
+ plan = { ...plan, drift };
651
749
  appliedOk = true;
652
750
  } catch (err) {
653
751
  plan = { ...plan, outcome: err.code === SETUP_STOP ? 'stop' : 'error', reason: err.message };
@@ -32,6 +32,13 @@ const bridgeVm = (b) => ({
32
32
  readiness: b.readiness,
33
33
  // preserve the three-state wrapper status (present | missing | unknown) — the renderer maps to a glyph.
34
34
  wrappers: (b.wrappers ?? []).map((w) => ({ cmd: w.cmd, state: w.state })),
35
+ // fact-only host-level settings: the active knobs for this bridge, or a localized error; null when
36
+ // nothing is set (the renderer then adds no sub-line — the block stays as it was before any knob).
37
+ settings: b.settings?.error
38
+ ? { error: b.settings.error }
39
+ : b.settings?.active?.length
40
+ ? { active: b.settings.active.map((a) => ({ key: a.key, value: a.value, source: a.source })) }
41
+ : null,
35
42
  });
36
43
 
37
44
  const visibilityVm = (v) => {