@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.
- package/CHANGELOG.md +38 -0
- package/README.md +1 -0
- package/SKILL.md +6 -2
- package/bin/install.mjs +15 -0
- package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
- package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
- package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
- package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
- package/bridges/antigravity-cli-bridge/capability.json +17 -1
- package/bridges/codex-cli-bridge/SKILL.md +15 -1
- package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
- package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
- package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
- package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
- package/bridges/codex-cli-bridge/capability.json +29 -1
- package/capability.json +1 -1
- package/package.json +1 -1
- package/references/modes/bridge-settings.md +29 -0
- package/references/modes/upgrade.md +8 -2
- package/tools/atomic-write.mjs +59 -21
- package/tools/bridge-settings-read.mjs +221 -0
- package/tools/bridge-settings.mjs +288 -0
- package/tools/commands.mjs +7 -0
- package/tools/family-registry.mjs +12 -1
- package/tools/manifest/schema.md +31 -0
- package/tools/manifest/validate.mjs +85 -0
- package/tools/procedures.mjs +26 -4
- package/tools/recipes.mjs +16 -6
- package/tools/renderers.mjs +7 -0
- package/tools/setup-backends.mjs +107 -9
- package/tools/view-model.mjs +7 -0
|
@@ -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})`);
|
package/tools/procedures.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
};
|
package/tools/renderers.mjs
CHANGED
|
@@ -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
|
};
|
package/tools/setup-backends.mjs
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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 };
|
package/tools/view-model.mjs
CHANGED
|
@@ -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) => {
|