@sabaiway/agent-workflow-kit 1.34.0 → 1.36.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 +71 -0
- package/README.md +1 -0
- package/SKILL.md +23 -13
- 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/review-ledger.md +28 -0
- package/references/modes/set-autonomy.md +29 -0
- package/references/modes/upgrade.md +8 -2
- package/references/modes/velocity.md +13 -0
- package/tools/atomic-write.mjs +59 -21
- package/tools/autonomy-config.mjs +306 -0
- package/tools/autonomy-write.mjs +27 -0
- package/tools/bridge-settings-read.mjs +221 -0
- package/tools/bridge-settings.mjs +288 -0
- package/tools/commands.mjs +21 -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/review-ledger-write.mjs +257 -0
- package/tools/review-ledger.mjs +508 -0
- package/tools/seed-gates.mjs +45 -4
- package/tools/set-autonomy.mjs +195 -0
- package/tools/setup-backends.mjs +107 -9
- package/tools/velocity-profile.mjs +468 -5
- package/tools/view-model.mjs +7 -0
|
@@ -0,0 +1,195 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
// set-autonomy.mjs — the WRITER for docs/ai/autonomy.json (the per-project autonomy policy). Mirrors
|
|
3
|
+
// set-recipe.mjs (AD-044): the AGENT turns plain language into explicit `--set <section>.<key>=<value>`
|
|
4
|
+
// / `--unset <section>.<key>` ops; the KIT does the deterministic validate → merge → preview → write.
|
|
5
|
+
// No NL parser (stays dependency-free + deterministic), no `all`-magic — the agent expands plain
|
|
6
|
+
// language into explicit per-key ops (asking if scope is unclear). Ops route through the SAME
|
|
7
|
+
// accept/reject grammar as validateAutonomy (autonomy-config.mjs), so the writer and the config
|
|
8
|
+
// validator can never disagree.
|
|
9
|
+
//
|
|
10
|
+
// Posture: PREVIEW BY DEFAULT (dry-run); `--write` applies. It NEVER commits, NEVER runs a backend, and
|
|
11
|
+
// NEVER renders enforcement — it writes ONLY docs/ai/autonomy.json (the render into .claude/settings.json
|
|
12
|
+
// is the separate velocity autonomy mode). It writes only via the hardened writeAutonomy (deployment
|
|
13
|
+
// gate; exclusive-create tmp + rename; symlink/TOCTOU-safe; last-writer-wins). A no-op set never writes
|
|
14
|
+
// and never spuriously seeds the _README. `--unset` returns a key to its computed default, so reverting
|
|
15
|
+
// needs no hand-edit either. Hand-edit stays first-class — this is an OFFERED convenience, never a lock.
|
|
16
|
+
//
|
|
17
|
+
// Output is ENGLISH/structured (repo-artifact Hard Constraint); the agent localizes when narrating.
|
|
18
|
+
// Exit codes: 0 success; 2 usage (bad/duplicate op, --write with zero ops); 1 config error
|
|
19
|
+
// (malformed/unreadable policy) or a write STOP (no deployment / symlinked leaf). main(argv, ctx) →
|
|
20
|
+
// { code, stdout, stderr }; cwd / fs are injectable for host-independent tests.
|
|
21
|
+
//
|
|
22
|
+
// Dependency-free, Node >= 18. No side effects on import (the isDirectRun idiom).
|
|
23
|
+
|
|
24
|
+
import { readFileSync, lstatSync } from 'node:fs';
|
|
25
|
+
import { pathToFileURL } from 'node:url';
|
|
26
|
+
import {
|
|
27
|
+
AUTONOMY_REL,
|
|
28
|
+
fail,
|
|
29
|
+
loadAutonomy,
|
|
30
|
+
validateAutonomy,
|
|
31
|
+
parseAutonomyOp,
|
|
32
|
+
applyAutonomyOps,
|
|
33
|
+
serializeAutonomy,
|
|
34
|
+
resolveAutonomy,
|
|
35
|
+
AUTONOMY_README,
|
|
36
|
+
} from './autonomy-config.mjs';
|
|
37
|
+
import { writeAutonomy as writeAutonomyFs } from './autonomy-write.mjs';
|
|
38
|
+
|
|
39
|
+
// ── argument parsing (usage errors → exit 2) ────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
// Parse argv → { ops, write, json }. `--set`/`--unset` take a fully-qualified token (parseAutonomyOp
|
|
42
|
+
// validates it). A duplicate op for the same section.key, a `--write` with zero ops, an unknown flag, or
|
|
43
|
+
// a bad token → exit 2. `--set=<tok>` / `--unset=<tok>` inline forms are accepted too.
|
|
44
|
+
const parseArgs = (argv) => {
|
|
45
|
+
const ops = [];
|
|
46
|
+
const seen = new Set();
|
|
47
|
+
let write = false;
|
|
48
|
+
let json = false;
|
|
49
|
+
const takeOp = (kind, tok) => {
|
|
50
|
+
if (tok === undefined || tok.startsWith('--')) throw fail(2, `--${kind} requires <section>.<key>${kind === 'set' ? '=<value>' : ''}`);
|
|
51
|
+
const op = parseAutonomyOp(kind, tok);
|
|
52
|
+
const key = `${op.section}.${op.key}`;
|
|
53
|
+
if (seen.has(key)) throw fail(2, `duplicate op for "${key}" — name each section.key at most once`);
|
|
54
|
+
seen.add(key);
|
|
55
|
+
ops.push(op);
|
|
56
|
+
};
|
|
57
|
+
for (let i = 0; i < argv.length; i += 1) {
|
|
58
|
+
const a = argv[i];
|
|
59
|
+
if (a === '--json') json = true;
|
|
60
|
+
else if (a === '--write') write = true;
|
|
61
|
+
else if (a === '--set') { takeOp('set', argv[i + 1]); i += 1; }
|
|
62
|
+
else if (a === '--unset') { takeOp('unset', argv[i + 1]); i += 1; }
|
|
63
|
+
else if (a.startsWith('--set=')) takeOp('set', a.slice('--set='.length));
|
|
64
|
+
else if (a.startsWith('--unset=')) takeOp('unset', a.slice('--unset='.length));
|
|
65
|
+
else if (a.startsWith('-')) throw fail(2, `unknown flag: ${a}`);
|
|
66
|
+
else throw fail(2, `unexpected argument: ${a}`);
|
|
67
|
+
}
|
|
68
|
+
if (write && ops.length === 0) throw fail(2, 'nothing to write — pass at least one --set/--unset (a bare --write is a no-op)');
|
|
69
|
+
return { ops, write, json };
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
// ── per-op before/after + resolved effective value ──────────────────────────────────
|
|
73
|
+
|
|
74
|
+
// The resolved effective value for a section.key against a resolved policy (computed defaults filled).
|
|
75
|
+
const resolvedValueFor = (resolved, section, key) =>
|
|
76
|
+
section === 'redlines' ? resolved.redlines[key] : resolved.activities[section]?.[key];
|
|
77
|
+
|
|
78
|
+
// A single op's before/after value + the effective value it resolves to (the computed default shows for
|
|
79
|
+
// an unset). `to` is null for an unset (falls to the computed default).
|
|
80
|
+
const resolveOp = (op, current, after) => {
|
|
81
|
+
const from = current?.[op.section]?.[op.key] ?? null;
|
|
82
|
+
const to = after?.[op.section]?.[op.key] ?? null;
|
|
83
|
+
const effective = resolvedValueFor(resolveAutonomy(after), op.section, op.key);
|
|
84
|
+
return { section: op.section, key: op.key, from, to, effective };
|
|
85
|
+
};
|
|
86
|
+
|
|
87
|
+
// ── rendering (ENGLISH; the agent localizes) ────────────────────────────────────────
|
|
88
|
+
|
|
89
|
+
const valueLabel = (v) => (v == null ? '(computed default)' : v);
|
|
90
|
+
|
|
91
|
+
const APPLY_HINT =
|
|
92
|
+
'Next: render this policy into .claude/settings.json with the velocity autonomy mode (previews first) — the writer changes only the policy file, never the settings.';
|
|
93
|
+
|
|
94
|
+
const formatHuman = ({ changed, unchanged, wrote, fileBody }) => {
|
|
95
|
+
const lines = [];
|
|
96
|
+
if (wrote) lines.push(`wrote ${AUTONOMY_REL}`);
|
|
97
|
+
else if (changed.length) lines.push('set-autonomy — preview (nothing written; re-run with --write to apply)');
|
|
98
|
+
for (const e of changed) {
|
|
99
|
+
lines.push(` ${e.section}.${e.key}: ${valueLabel(e.from)} → ${valueLabel(e.to)}`);
|
|
100
|
+
lines.push(` ↳ effective: ${e.effective}`);
|
|
101
|
+
}
|
|
102
|
+
for (const e of unchanged) lines.push(` ${e.section}.${e.key}: already ${valueLabel(e.from)} (no change)`);
|
|
103
|
+
if (wrote && fileBody) lines.push('', `${AUTONOMY_REL} now reads:`, fileBody.replace(/\n$/, ''), '', APPLY_HINT);
|
|
104
|
+
if (!wrote) {
|
|
105
|
+
if (!changed.length) lines.push(' no changes — nothing to write.');
|
|
106
|
+
else lines.push('', `would write ${AUTONOMY_REL} — re-run with --write to apply.`);
|
|
107
|
+
}
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
};
|
|
110
|
+
|
|
111
|
+
const buildJson = ({ changed, unchanged, writtenPath, noop }) => ({
|
|
112
|
+
changed: changed.map((e) => ({ section: e.section, key: e.key, from: e.from, to: e.to, effective: e.effective })),
|
|
113
|
+
unchanged: unchanged.map((e) => ({ section: e.section, key: e.key, value: e.from })),
|
|
114
|
+
writtenPath: writtenPath ?? null,
|
|
115
|
+
noop,
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const HELP = `set-autonomy — write the per-project autonomy policy (docs/ai/autonomy.json).
|
|
119
|
+
|
|
120
|
+
Usage:
|
|
121
|
+
node set-autonomy.mjs [--set <section>.<key>=<value>]... [--unset <section>.<key>]... [--write] [--json]
|
|
122
|
+
|
|
123
|
+
--set <section>.<key>=<value> pin a policy value (fully-qualified; e.g. plan-execution.autonomy=sandbox)
|
|
124
|
+
--unset <section>.<key> return a key to its computed default
|
|
125
|
+
--write apply the change (default: preview only — writes nothing)
|
|
126
|
+
--json machine-readable output
|
|
127
|
+
--help, -h this help
|
|
128
|
+
|
|
129
|
+
Sections/keys: redlines → commit|push|publish|network|credentials|fs_outside_repo (each ask|deny);
|
|
130
|
+
plan-authoring.autonomy, plan-execution.autonomy (each sandbox|prompt)
|
|
131
|
+
|
|
132
|
+
Previews by default; --write applies via an atomic, symlink/TOCTOU-safe write behind a deployment gate.
|
|
133
|
+
Policy writer only: it NEVER renders enforcement (that is the velocity autonomy mode), NEVER runs a
|
|
134
|
+
backend, and NEVER commits. Hand-editing the file stays fully supported.
|
|
135
|
+
|
|
136
|
+
Exit codes: 0 success; 2 usage (bad/duplicate op, or --write with no ops);
|
|
137
|
+
1 config error (malformed/unreadable policy) or a write STOP (no deployment / symlinked leaf).`;
|
|
138
|
+
|
|
139
|
+
// ── main ────────────────────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
export const main = (argv, ctx = {}) => {
|
|
142
|
+
const cwd = ctx.cwd ?? process.cwd();
|
|
143
|
+
const readFile = ctx.readFileSync ?? readFileSync;
|
|
144
|
+
const lstat = ctx.lstatSync ?? lstatSync;
|
|
145
|
+
const writeAutonomy = ctx.writeAutonomy ?? writeAutonomyFs;
|
|
146
|
+
try {
|
|
147
|
+
if (argv.includes('--help') || argv.includes('-h')) return { code: 0, stdout: HELP, stderr: '' };
|
|
148
|
+
const { ops, write, json } = parseArgs(argv);
|
|
149
|
+
|
|
150
|
+
// Load the current policy first (loadAutonomy throws fail(1) loud on malformed/unreadable — a write
|
|
151
|
+
// never clobbers an unparseable file; the message points the agent at the parse error to help fix it).
|
|
152
|
+
const { config: current, source } = loadAutonomy(cwd, readFile, lstat);
|
|
153
|
+
|
|
154
|
+
// No ops + no --write → show the current policy + a hint (read-only; nothing changes).
|
|
155
|
+
if (ops.length === 0) {
|
|
156
|
+
if (json) {
|
|
157
|
+
return { code: 0, stdout: JSON.stringify(buildJson({ changed: [], unchanged: [], writtenPath: null, noop: true }), null, 2), stderr: '' };
|
|
158
|
+
}
|
|
159
|
+
const shown = current == null ? `(no ${AUTONOMY_REL} yet — computed defaults apply)` : serializeAutonomy(current).replace(/\n$/, '');
|
|
160
|
+
const hint = `\nPass --set <section>.<key>=<value> (preview) then --write to apply. Sections: redlines, plan-authoring.autonomy, plan-execution.autonomy.`;
|
|
161
|
+
return { code: 0, stdout: `${source === 'none' ? '' : `${AUTONOMY_REL}:\n`}${shown}${hint}`, stderr: '' };
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const after = applyAutonomyOps(current, ops, { seedReadme: AUTONOMY_README });
|
|
165
|
+
const resolved = ops.map((op) => resolveOp(op, current, after));
|
|
166
|
+
const changed = resolved.filter((e) => e.from !== e.to);
|
|
167
|
+
const unchanged = resolved.filter((e) => e.from === e.to);
|
|
168
|
+
const noop = changed.length === 0;
|
|
169
|
+
|
|
170
|
+
if (!write || noop) {
|
|
171
|
+
const stdout = json
|
|
172
|
+
? JSON.stringify(buildJson({ changed, unchanged, writtenPath: null, noop }), null, 2)
|
|
173
|
+
: formatHuman({ changed, unchanged, wrote: false });
|
|
174
|
+
return { code: 0, stdout, stderr: '' };
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
validateAutonomy(after); // defensive re-validate immediately before the write
|
|
178
|
+
const { writtenPath } = writeAutonomy(cwd, after, ctx);
|
|
179
|
+
const fileBody = serializeAutonomy(after);
|
|
180
|
+
const stdout = json
|
|
181
|
+
? JSON.stringify(buildJson({ changed, unchanged, writtenPath, noop: false }), null, 2)
|
|
182
|
+
: formatHuman({ changed, unchanged, wrote: true, fileBody });
|
|
183
|
+
return { code: 0, stdout, stderr: '' };
|
|
184
|
+
} catch (err) {
|
|
185
|
+
return { code: err.exitCode ?? 1, stdout: '', stderr: `set-autonomy: ${err.message}` };
|
|
186
|
+
}
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
|
|
190
|
+
if (isDirectRun) {
|
|
191
|
+
const r = main(process.argv.slice(2));
|
|
192
|
+
if (r.stdout) console.log(r.stdout);
|
|
193
|
+
if (r.stderr) console.error(r.stderr);
|
|
194
|
+
process.exit(r.code);
|
|
195
|
+
}
|
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 };
|