@sabaiway/agent-workflow-kit 5.6.0 → 5.8.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.
Files changed (53) hide show
  1. package/CHANGELOG.md +92 -0
  2. package/README.md +2 -2
  3. package/SKILL.md +1 -1
  4. package/bridges/antigravity-cli-bridge/SKILL.md +26 -17
  5. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +6 -5
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +13 -4
  7. package/bridges/antigravity-cli-bridge/bin/agy.sh +7 -4
  8. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +24 -0
  9. package/bridges/antigravity-cli-bridge/capability.json +3 -3
  10. package/bridges/antigravity-cli-bridge/references/driving-agy.md +9 -8
  11. package/bridges/antigravity-cli-bridge/references/models-and-flags.md +31 -14
  12. package/bridges/antigravity-cli-bridge/setup/README.md +4 -3
  13. package/capability.json +1 -1
  14. package/package.json +1 -1
  15. package/references/hooks/gate-approve.mjs +7 -1
  16. package/references/modes/doc-parity.md +1 -1
  17. package/references/modes/gates.md +16 -3
  18. package/references/modes/grounding.md +4 -3
  19. package/references/modes/recommendations.md +3 -0
  20. package/references/modes/review-state.md +1 -1
  21. package/references/modes/setup.md +18 -2
  22. package/references/modes/upgrade.md +38 -18
  23. package/references/scripts/migrate-gates-branches.test.mjs +146 -1
  24. package/references/scripts/migrate-gates.mjs +295 -60
  25. package/references/scripts/migrate-gates.test.mjs +206 -14
  26. package/references/shared/deploy-tail.md +1 -1
  27. package/references/templates/gates.json +1 -1
  28. package/tools/ack-write.mjs +20 -11
  29. package/tools/atomic-write.mjs +71 -18
  30. package/tools/checker-claim.mjs +100 -0
  31. package/tools/coverage-producer.mjs +43 -6
  32. package/tools/direct-run.mjs +76 -0
  33. package/tools/doc-parity.mjs +34 -3
  34. package/tools/engine-source.mjs +12 -8
  35. package/tools/ensure-configs.mjs +141 -0
  36. package/tools/ensure-ops.mjs +284 -0
  37. package/tools/ensure-vocabulary.mjs +71 -0
  38. package/tools/gates-declaration.mjs +23 -10
  39. package/tools/gates-init.mjs +6 -3
  40. package/tools/grounding.mjs +105 -16
  41. package/tools/hide-footprint.mjs +21 -3
  42. package/tools/lens-region.mjs +74 -23
  43. package/tools/orchestration-config.mjs +5 -3
  44. package/tools/orchestration-write.mjs +7 -0
  45. package/tools/recommendations.mjs +315 -66
  46. package/tools/refresh-parity.mjs +263 -0
  47. package/tools/run-gates.mjs +8 -5
  48. package/tools/setup-backends.mjs +88 -77
  49. package/tools/source-size-check.mjs +6 -16
  50. package/tools/source-size-core.mjs +7 -1
  51. package/tools/source-size-gate-cmd.mjs +18 -46
  52. package/tools/tracked-tree-census.mjs +102 -0
  53. package/tools/upgrade-runlist.mjs +92 -0
@@ -0,0 +1,263 @@
1
+ // refresh-parity.mjs — the ONE bundle↔placed comparison walk, plus the POST-FAILURE parity reading
2
+ // of its result (feedback-hardening Plan 1 F3 / D3+D4).
3
+ //
4
+ // Two callers read the SAME walk for two different questions, so there is exactly one scanner:
5
+ // • the refresh itself asks "what did my overwrite replace?" — it scans BEFORE copying, and an
6
+ // absent placed file is a pure ADD (nothing local to lose), so it is not reported;
7
+ // • the read-only degrade asks "does the placed tree still MATCH the bundle?" — it scans AFTER the
8
+ // write was refused, and an absent placed file is exactly the drift a writable rerun repairs.
9
+ // The walk therefore reports FOUR buckets (drifted / unreadable / absent / conflicts) and each
10
+ // caller decides what they mean. A second scanner would be a second definition of "the same".
11
+ //
12
+ // Why the honesty bar (D4): the read-only skip line used to claim, unconditionally, that the tree
13
+ // "may be PARTIALLY updated" and that "any remaining drift persists" — two claims about post-state
14
+ // that nothing had checked. Every clause a line here composes binds to a state PROVEN this run: the
15
+ // verdict is computed from a real re-scan, and anything the re-scan could not read WITHHOLDS the calm
16
+ // claim (reported as could-not-verify) instead of being rendered as clean.
17
+ //
18
+ // The wrapper axis is part of the claim on purpose: the degrade returns before the caller's
19
+ // linkWrappers step, so a "nothing to repair" that only looked at files would be an unproven claim
20
+ // about a reconcile that never ran.
21
+ //
22
+ // Pure of process state and of the kit's own writers — a LEAF (fs is injected, nothing is imported
23
+ // from the caller). Read-only: never writes, never spawns. Dependency-free, Node >= 22.
24
+
25
+ import { join, resolve, relative, dirname, isAbsolute, sep } from 'node:path';
26
+
27
+ // The CLOSED parity verdict vocabulary — the three states the read-only skip line may report.
28
+ // doc-parity binds every value into the setup + upgrade mode contracts, so a doc that drops or
29
+ // renames an outcome fails a declared gate instead of describing a verdict the tool never emits.
30
+ export const PARITY = Object.freeze({
31
+ clean: 'clean-parity',
32
+ drifted: 'drifted',
33
+ unverifiable: 'unverifiable',
34
+ });
35
+
36
+ // The recovery every read-only outcome points at: the in-session `setup` would hit the same read-only
37
+ // dir, so the only real repair is a writable session. Shared by the skip line and the (unchanged)
38
+ // version-behind failure line, so the two can never drift apart.
39
+ export const READONLY_RERUN_HINT = 're-run the refresh from a writable session (e.g. outside the read-only sandbox)';
40
+
41
+ // The mode a managed wrapper source carries — the ONE definition, imported by the link step that
42
+ // chmods it and by the parity check that reads it back. "Executable enough" is not parity: a rerun
43
+ // sets exactly this, so any other mode is a state the rerun would change.
44
+ export const WRAPPER_MODE = 0o755;
45
+
46
+ // lstat NO-FOLLOW, classified: the Stats object | 'absent' (ENOENT) | 'error' (any other fs failure).
47
+ // Never reads THROUGH the node — a symlinked placed path is a "could not compare", never a read of
48
+ // whatever it points at.
49
+ const probe = (path, fs) => {
50
+ try {
51
+ return fs.lstat(path);
52
+ } catch (err) {
53
+ return err && err.code === 'ENOENT' ? 'absent' : 'error';
54
+ }
55
+ };
56
+
57
+ // The reconcile-set walk: every bundle-owned node, classified by WHAT THE REFRESH COULD DO TO IT —
58
+ // not by whether this scanner could read it. The distinction is the whole point: "I could not compare
59
+ // this" and "the refresh cannot bring this to the required state" are different facts with different
60
+ // recoveries, and the earlier shape collapsed them.
61
+ //
62
+ // The writer's own policy decides the partition. copyTreeRefresh guards EVERY dest through
63
+ // assertContainedRealPath, whose walk includes the LEAF — so any placed symlink on a node in the
64
+ // reconcile-set is refused before the copy dispatch is even reached. Past that guard: a bundled
65
+ // symlink is ADDITIVE (created when the dest is absent, otherwise left alone — an explicit no-op, not
66
+ // a comparison), a bundled directory is mkdir -p (EEXIST over a file), a bundled file is copyFile
67
+ // (EISDIR over a directory; a device or FIFO may be WRITTEN INTO or block rather than be replaced).
68
+ // The BUNDLE read is our own shipped artifact — a failure there is a loud corrupt-kit error upstream
69
+ // (never swallowed here); only the PLACED read is caught. Buckets, each sorted:
70
+ // drifted — placed bytes differ from the bundle; a rerun overwrites them
71
+ // absent — the bundle ships it and the placed tree does not; a rerun creates it
72
+ // conflicts — a rerun cannot be GUARANTEED to converge this node under the writer's no-follow /
73
+ // ownership policy (any placed symlink; an incompatible shape). Labeled with the cause
74
+ // unreadable — a genuine read/stat error, and nothing else
75
+ // Plus `modes`: the placed mode of every node the walk actually reached as a regular file. It exists
76
+ // so the wrapper axis can judge a source's mode WITHOUT a second lstat of its own — a separate stat
77
+ // could traverse a symlinked ancestor this walk already refused, and produce a contradicting verdict.
78
+ export const scanBundleOwnedDrift = (bundleDir, skillDir, fs) => {
79
+ const drifted = [];
80
+ const unreadable = [];
81
+ const absent = [];
82
+ const conflicts = [];
83
+ const modes = new Map();
84
+ const refuse = (rel, cause) => conflicts.push(`${rel} (${cause})`);
85
+ const walk = (rel) => {
86
+ const src = join(bundleDir, rel);
87
+ const dest = join(skillDir, rel);
88
+ const st = fs.lstat(src);
89
+ const placed = rel === '' ? null : probe(dest, fs);
90
+ // The skill dir itself (rel '') is the caller's own inspected root — it proved that node is a real
91
+ // directory before any of this ran. Every node BELOW it meets the containment guard first, and the
92
+ // guard refuses a symlink at the leaf just as it does at an ancestor.
93
+ if (placed === 'error') {
94
+ unreadable.push(rel);
95
+ return;
96
+ }
97
+ if (placed !== null && placed !== 'absent' && placed.isSymbolicLink()) {
98
+ refuse(rel, 'a symlink is in the way');
99
+ return;
100
+ }
101
+ if (st.isSymbolicLink()) {
102
+ // ADDITIVE by contract: created when absent, otherwise left alone WITHOUT comparison. A node
103
+ // left alone is outside every claim this scan makes — it is neither drift nor a finding.
104
+ if (placed === 'absent') absent.push(rel);
105
+ return;
106
+ }
107
+ if (st.isDirectory()) {
108
+ if (placed !== null && placed !== 'absent' && !placed.isDirectory()) {
109
+ refuse(rel, 'a non-directory is in the way');
110
+ return;
111
+ }
112
+ const entries = fs.readdir(src);
113
+ // An EMPTY bundled dir names nothing below it, so its absence would go unreported — record the
114
+ // dir itself. A NON-empty one is already named by its children; recording it too would
115
+ // double-report one absence.
116
+ if (placed === 'absent' && entries.length === 0) absent.push(rel);
117
+ for (const entry of entries) walk(rel ? join(rel, entry) : entry);
118
+ return;
119
+ }
120
+ if (placed === 'absent') {
121
+ absent.push(rel);
122
+ return;
123
+ }
124
+ if (!placed.isFile()) {
125
+ refuse(rel, 'a node of the wrong kind is in the way');
126
+ return;
127
+ }
128
+ if (typeof placed.mode === 'number') modes.set(rel, placed.mode);
129
+ const srcBytes = fs.readFile(src);
130
+ const destBytes = (() => {
131
+ try {
132
+ return fs.readFile(dest);
133
+ } catch {
134
+ unreadable.push(rel);
135
+ return null;
136
+ }
137
+ })();
138
+ if (destBytes === null) return;
139
+ if (!Buffer.from(srcBytes).equals(Buffer.from(destBytes))) drifted.push(rel);
140
+ };
141
+ walk('');
142
+ return {
143
+ drifted: drifted.sort(), unreadable: unreadable.sort(), absent: absent.sort(), conflicts: conflicts.sort(), modes,
144
+ };
145
+ };
146
+
147
+ // A wrapper has TWO axes the link step touches — the LINK, which lives outside the bundle tree, and
148
+ // the SOURCE, which does not. Each outcome is `null` (nothing to say) or a `[bucketName, cause]`
149
+ // tuple the caller files under that bucket, labeled by axis: a wrapper broken on one axis and
150
+ // unknown on the other rides BOTH lists rather than losing a fact the re-scan proved.
151
+ const WRAPPER_CLEAN = null;
152
+
153
+ // The link: absent is repairable (the link step creates it); anything else standing there is a
154
+ // refusal (linkManaged replaces ONLY a symlink already pointing at our source). Never follows a
155
+ // foreign link — readlink, then a string compare against the SAME physical base the writer uses:
156
+ // linkWrappers canonicalises the bindir through realpath before linking, so a relative target read
157
+ // from a symlinked bindir must resolve against the real directory or the two would disagree. A
158
+ // realpath failure is could-not-verify with NO lexical fallback — falling back could call a foreign
159
+ // link ours.
160
+ const wrapperDstOutcome = (link, fs) => {
161
+ const dstStat = probe(link.dst, fs);
162
+ if (dstStat === 'error') return ['unverifiable', 'its link could not be read'];
163
+ if (dstStat === 'absent') return ['drifted', 'not linked'];
164
+ if (!dstStat.isSymbolicLink()) return ['conflicts', 'a non-symlink is in the way'];
165
+ let target;
166
+ try {
167
+ target = fs.readlink(link.dst);
168
+ } catch {
169
+ return ['unverifiable', 'its link target could not be read'];
170
+ }
171
+ let resolved;
172
+ if (isAbsolute(target)) resolved = target;
173
+ else {
174
+ try {
175
+ resolved = resolve(fs.realpath(dirname(link.dst)), target);
176
+ } catch {
177
+ return ['unverifiable', 'its link directory could not be resolved'];
178
+ }
179
+ }
180
+ return resolved === resolve(link.source) ? WRAPPER_CLEAN : ['conflicts', 'a foreign symlink is in the way'];
181
+ };
182
+
183
+ // The source is a BUNDLE-OWNED file (deriveLinks resolves it inside the skill dir and planFor proves
184
+ // the bundle ships it), so the reconcile-set walk already classified its existence and its shape and
185
+ // already named it if anything was wrong. This axis therefore owns exactly ONE fact the walk does not
186
+ // compare: the mode. A node the walk never reached — absent, refused, or unreadable — yields nothing
187
+ // here, so one broken file is never reported twice under two different names.
188
+ const wrapperSourceOutcome = (link, skillDir, modes) => {
189
+ const rel = relative(skillDir, link.source).split(sep).join('/');
190
+ if (!modes.has(rel)) return WRAPPER_CLEAN;
191
+ return (modes.get(rel) & 0o7777) === WRAPPER_MODE ? WRAPPER_CLEAN : ['drifted', 'its source mode differs'];
192
+ };
193
+
194
+ export const scanWrapperParity = (links, fs, { skillDir, modes }) => {
195
+ const out = { drifted: [], conflicts: [], unverifiable: [] };
196
+ for (const link of links ?? []) {
197
+ for (const outcome of [wrapperDstOutcome(link, fs), wrapperSourceOutcome(link, skillDir, modes)]) {
198
+ if (outcome === WRAPPER_CLEAN) continue;
199
+ const [bucket, cause] = outcome;
200
+ out[bucket].push(`wrapper ${link.cmd} (${cause})`);
201
+ }
202
+ }
203
+ return { drifted: out.drifted.sort(), conflicts: out.conflicts.sort(), unverifiable: out.unverifiable.sort() };
204
+ };
205
+
206
+ // The ONE verdict the line is composed from. `clean-parity` requires EVERY list empty — any node the
207
+ // re-scan could not compare withholds the calm claim rather than being counted as equal. `drifted` is
208
+ // the headline whenever something is provably wrong (repairable or refused): a proven break outranks
209
+ // an unknown, and the line still names every list, so no proven fact is collapsed into another.
210
+ export const parityVerdict = ({ scan, wrappers }) => {
211
+ const drifted = [...scan.drifted, ...scan.absent, ...wrappers.drifted].sort();
212
+ const conflicts = [...scan.conflicts, ...wrappers.conflicts].sort();
213
+ const unverifiable = [...scan.unreadable, ...wrappers.unverifiable].sort();
214
+ const broken = drifted.length > 0 || conflicts.length > 0;
215
+ const state = broken ? PARITY.drifted : unverifiable.length > 0 ? PARITY.unverifiable : PARITY.clean;
216
+ return { state, drifted, conflicts, unverifiable };
217
+ };
218
+
219
+ // The verdict a caller reports when the re-scan itself could not run (a corrupt/unreadable bundle,
220
+ // any fs failure raised out of the walk). Could-not-verify is the honest floor — never a false clean.
221
+ export const unverifiableParity = (item) => ({ state: PARITY.unverifiable, drifted: [], conflicts: [], unverifiable: [item] });
222
+
223
+ const count = (items) => `${items.length} item(s)`;
224
+
225
+ // The read-only skip line (outcome `skipped-readonly`, exit 0 — the stated skip). Two clauses are
226
+ // proven by the caller before it composes: the versions are KNOWN equal, and the failure was tagged
227
+ // at a WRITE boundary. Every remaining clause comes from the verdict — no clause without its check,
228
+ // and every item the verdict carries is NAMED (a count alone would not tell the user what to look at).
229
+ export const readonlySkipLine = (name, version, verdict) => {
230
+ const head = ` ${name}: already current${version ? ` (v${version})` : ''} — the file re-sync could not write: ` +
231
+ 'the skills directory is read-only this session.';
232
+ if (verdict.state === PARITY.clean) {
233
+ // The claim is scoped to what the refresh MANAGES, and it names each checked axis rather than
234
+ // generalising: a rerun would still rewrite byte-equal files (so "nothing would change" would be
235
+ // false), a placed-only extra is preserved and never in scope, and an additive node the writer
236
+ // leaves alone is outside the comparison by contract.
237
+ return `${head} A read-only re-scan found no refresh-managed difference to repair: every file the ` +
238
+ 'refresh would overwrite already matches, every node it would add is present, and every wrapper ' +
239
+ 'link and source mode is in place.';
240
+ }
241
+ // One sentence per non-empty list, each carrying the recovery that actually applies to it: the
242
+ // writable rerun REPAIRS the drifted set, and REFUSES the conflicting set — one blanket "re-run to
243
+ // repair" over both would promise a repair that never happens.
244
+ const said = [];
245
+ if (verdict.drifted.length > 0) {
246
+ said.push(`A read-only re-scan found ${count(verdict.drifted)} still differing from the bundled copy: ` +
247
+ `${verdict.drifted.join(', ')} — ${READONLY_RERUN_HINT} to repair.`);
248
+ }
249
+ if (verdict.conflicts.length > 0) {
250
+ said.push(`${said.length > 0 ? 'It also found' : 'A read-only re-scan found'} ${count(verdict.conflicts)} a ` +
251
+ `writable rerun would REFUSE rather than repair: ${verdict.conflicts.join(', ')} — resolve each by hand, ` +
252
+ 'then re-run the refresh.');
253
+ }
254
+ if (verdict.unverifiable.length > 0) {
255
+ // The unknown-need clause and its recovery ride EVERY unverifiable outcome, not only the one
256
+ // that happens to open the line: a reader who already saw a drift sentence would otherwise be
257
+ // left with a list of names and no statement of what is unknown about them or what to do.
258
+ const lead = said.length > 0 ? 'It could not verify a further' : 'A read-only re-scan could NOT verify';
259
+ said.push(`${lead} ${count(verdict.unverifiable)}: ${verdict.unverifiable.join(', ')} — whether those ` +
260
+ `still need repair is unknown; ${READONLY_RERUN_HINT}.`);
261
+ }
262
+ return `${head} ${said.join(' ')}`;
263
+ };
@@ -140,7 +140,7 @@ const USAGE = [
140
140
  'exported to every gate child, and STRIPPED from the inherited environment first — a host-set',
141
141
  'value never stands in for a computed one. A selected gate referencing a producer variable this',
142
142
  'run will not set is refused BEFORE anything spawns (exit 1), naming the gate, the variable, and',
143
- 'the remedy — never left to expand to empty and fail far from its cause.',
143
+ 'the remedy — never left to run without the runner-produced value.',
144
144
  'Sandbox-safe: the runner itself needs no network and writes only repo-local state — the D4 sandbox',
145
145
  'lane; each DECLARED gate command is the project\'s own, so ITS sandbox-safety is command-shape',
146
146
  'dependent (first try the sandbox-safe shape — cache under $TMPDIR, offline/notifier off).',
@@ -197,9 +197,12 @@ export const spawnGateViaBash = (cmd, cwd, extraEnv = {}) => {
197
197
  return spawnSync('bash', ['-c', cmd], { cwd, env: { ...env, ...extraEnv }, encoding: 'utf8', maxBuffer: MAX_GATE_OUTPUT_BYTES });
198
198
  };
199
199
 
200
- // A `$VAR` / `${VAR}` reference to a producer variable. A `${VAR:-default}` form is deliberately NOT
201
- // matched a cmd carrying its own fallback does not depend on the injection.
202
- const referencesProducer = (cmd, name) => new RegExp(`\\$\\{${name}\\}|\\$${name}(?![A-Za-z0-9_])`).test(cmd);
200
+ // A `$VAR` / `${VAR}` / `${VAR:?word}` reference to a producer variable. The required-parameter form
201
+ // is the canonical producer destination: it makes an uninjected expansion fail BY NAME instead of
202
+ // writing under an empty prefix, and it still depends on the injection — so it must refuse here,
203
+ // pre-spawn, rather than dying inside a gate child far from its cause. A `${VAR:-default}` form
204
+ // stays deliberately unmatched — a cmd carrying its own fallback does not depend on the injection.
205
+ const referencesProducer = (cmd, name) => new RegExp(`\\$\\{${name}(?:\\}|:\\?)|\\$${name}(?![A-Za-z0-9_])`).test(cmd);
203
206
 
204
207
  const PRODUCER_RECOVERY = Object.freeze({
205
208
  AW_GIT_DIR: 'run from inside a git work tree — the runner resolves the git dir there and exports it to every gate.',
@@ -609,7 +612,7 @@ export const runCli = (argv, deps = {}) => {
609
612
  const unmet = findUnmetProducerRefs(selected, injectedEnv);
610
613
  if (unmet.length > 0) {
611
614
  for (const { id, name } of unmet) {
612
- logError(`[run-gates] gate "${id}" references $${name}, which this run will not set — it would expand to empty and fail far from its cause.`);
615
+ logError(`[run-gates] gate "${id}" references $${name}, which this run will not set — the child would run without the runner-produced value.`);
613
616
  logError(` Recovery: ${PRODUCER_RECOVERY[name]}`);
614
617
  }
615
618
  releaseSubsetRunLock();
@@ -32,6 +32,10 @@ import { fileURLToPath, pathToFileURL } from 'node:url';
32
32
  import os from 'node:os';
33
33
  import { KNOWN_BACKENDS, detectBackend, detectBackends, resolveDir, guideFor, READY } from './detect-backends.mjs';
34
34
  import { copyTreeRefresh, linkManaged, isReadonlyWriteBoundary } from './fs-safe.mjs';
35
+ import {
36
+ READONLY_RERUN_HINT, WRAPPER_MODE, scanBundleOwnedDrift, scanWrapperParity, parityVerdict,
37
+ unverifiableParity, readonlySkipLine,
38
+ } from './refresh-parity.mjs';
35
39
  import { validateManifest, readAuthoritativeVersion, UNSUPPORTED, INVALID } from './manifest/validate.mjs';
36
40
  import { compareSemver } from './semver-lite.mjs';
37
41
 
@@ -113,62 +117,16 @@ const probeMarker = (file, fs) => {
113
117
  const SETTINGS_FILE_HINT = '${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf';
114
118
  const SETTINGS_CMD_HINT = '/agent-workflow-kit bridge-settings';
115
119
 
116
- // Bundle-owned regular files whose PLACED copy differs from the bundle (a local edit an equal-version
117
- // refresh would overwrite) or cannot be read (indeterminate an honest degrade). Mirrors
118
- // copyTreeRefresh's src dispatch so it flags EXACTLY the files the overwrite touches: a symlink src is
119
- // additive (copyTreeRefresh skips an existing dest never overwritten) and a dir recurses; a
120
- // bundled-only file (no placed copy) is a pure add, no loss. The BUNDLE read is our own shipped
121
- // artifact a failure there is a loud corrupt-kit error upstream (never swallowed here); only the
122
- // PLACED read is caught (EACCES/EIO → 'unreadable', and the refresh still proceeds). Sorted output is
123
- // deterministic for the stated line. Exported for a direct unit test (the full driver cannot observe a
124
- // symlinked placed file — copyTreeRefresh refuses to overwrite one, so the refresh fails before any line).
125
- export const scanBundleOwnedDrift = (bundleDir, skillDir, fs) => {
126
- const drifted = [];
127
- const unreadable = [];
128
- const walk = (rel) => {
129
- const src = join(bundleDir, rel);
130
- const dest = join(skillDir, rel);
131
- const st = fs.lstat(src);
132
- if (st.isSymbolicLink()) return;
133
- if (st.isDirectory()) {
134
- for (const entry of fs.readdir(src)) walk(rel ? join(rel, entry) : entry);
135
- return;
136
- }
137
- // lstat the PLACED path NO-FOLLOW first — never read THROUGH a symlink (copyTreeRefresh's
138
- // assertContainedRealPath would refuse to overwrite a symlinked dest, so reading its target here
139
- // would be both unsafe and moot). Absent → a bundled-only addition (no local loss); a symlink /
140
- // non-regular / unreadable placed node → "could not compare" without a read-through.
141
- const destStat = (() => {
142
- try {
143
- return fs.lstat(dest);
144
- } catch (err) {
145
- return err && err.code === 'ENOENT' ? 'absent' : 'error';
146
- }
147
- })();
148
- if (destStat === 'absent') return;
149
- if (destStat === 'error' || destStat.isSymbolicLink() || !destStat.isFile()) {
150
- unreadable.push(rel);
151
- return;
152
- }
153
- const srcBytes = fs.readFile(src);
154
- const destBytes = (() => {
155
- try {
156
- return fs.readFile(dest);
157
- } catch {
158
- unreadable.push(rel);
159
- return null;
160
- }
161
- })();
162
- if (destBytes === null) return;
163
- if (!Buffer.from(srcBytes).equals(Buffer.from(destBytes))) drifted.push(rel);
164
- };
165
- walk('');
166
- return { drifted: drifted.sort(), unreadable: unreadable.sort() };
167
- };
120
+ // The bundle↔placed comparison walk lives in refresh-parity.mjs ONE scanner, two readings (this
121
+ // module's refresh-time drift report below, and the read-only degrade's post-failure parity verdict).
122
+ // Re-exported so the scan's own unit tests and any consumer keep importing it from the writer they
123
+ // already know. In THIS reading a bundled-only file (the scan's `absent` bucket) is a pure add — no
124
+ // local loss so `driftSummary` ignores it; the parity reading is the one that calls it drift.
125
+ export { scanBundleOwnedDrift };
168
126
 
169
127
  // One user-facing sentence naming what an equal-version re-sync overwrote — or null when nothing local
170
128
  // was lost. Callers apply their own indent; the pointer names the settings file that survives a refresh.
171
- const driftSummary = (drift) => {
129
+ export const driftSummary = (drift) => {
172
130
  if (!drift) return null;
173
131
  const parts = [];
174
132
  if (drift.drifted.length) parts.push(`overwrote ${drift.drifted.length} locally-changed file(s): ${drift.drifted.join(', ')}`);
@@ -284,6 +242,10 @@ export const deriveLinks = (manifest, skillDir) => {
284
242
  };
285
243
 
286
244
  // Classify a wrapper dst per-bindir (NOT PATH-wide): absent | ours (symlink → our source) | conflict.
245
+ // A RELATIVE target resolves against the dst's PHYSICAL parent, because linkWrappers canonicalises the
246
+ // bindir through realpath before it links: under a symlinked bindir (a common dotfiles setup) a
247
+ // lexical base would answer a different question than the one the writer asks. A realpath failure is
248
+ // a conflict, never a lexical fallback — the fallback could call a foreign link ours.
287
249
  const inspectDst = (dst, source, fs) => {
288
250
  const st = lstatNoFollow(dst, fs.lstat);
289
251
  if (st === null) return { state: 'absent' };
@@ -294,7 +256,15 @@ const inspectDst = (dst, source, fs) => {
294
256
  } catch (err) {
295
257
  return { state: 'conflict', reason: `unreadable symlink (${err.code ?? 'fs error'})` };
296
258
  }
297
- const resolved = isAbsolute(target) ? target : resolve(dirname(dst), target);
259
+ let resolved;
260
+ if (isAbsolute(target)) resolved = target;
261
+ else {
262
+ try {
263
+ resolved = resolve(fs.realpath(dirname(dst)), target);
264
+ } catch (err) {
265
+ return { state: 'conflict', reason: `cannot resolve the link's directory (${err.code ?? 'fs error'})` };
266
+ }
267
+ }
298
268
  return resolved === resolve(source) ? { state: 'ours' } : { state: 'conflict', reason: `foreign symlink → ${target}` };
299
269
  };
300
270
 
@@ -378,7 +348,7 @@ export const linkWrappers = (skillDir, manifest, opts = {}) => {
378
348
  const realBindir = fs.realpath(bindir);
379
349
  const links = [];
380
350
  for (const { cmd, source } of derived) {
381
- fs.chmod(source, 0o755);
351
+ fs.chmod(source, WRAPPER_MODE);
382
352
  const action = linkManaged(source, join(realBindir, cmd), realBindir, fs); // 'linked' | 'noop'
383
353
  links.push({ cmd, source, dst: join(bindir, cmd), action });
384
354
  }
@@ -586,19 +556,56 @@ const refreshSkillOnly = (entry, deps = {}) => {
586
556
  const NOT_PLACED_LINE = 'skipped — not placed (placement is opt-in: /agent-workflow-kit setup)';
587
557
  const stripPrefix = (message) => message.replace('[agent-workflow-kit] ', '');
588
558
 
589
- // The read-only degrade wording (D1). The STATED-skip line: version current + the re-sync
590
- // skipped/incomplete + the read-only cause + the residual it never claims a re-sync RAN (that is
591
- // already-current's line) nor file integrity (a partial copy may have happened before the throw). The
592
- // FAILED line (a version-behind upgrade blocked by the same read-only dir) stays loud, but its
593
- // recovery points at a writable rerun the in-session `setup` would hit the same read-only dir.
594
- const READONLY_RERUN_HINT = 're-run the refresh from a writable session (e.g. outside the read-only sandbox)';
595
- const skippedReadonlyLine = (name, version) =>
596
- ` ${name}: already current${version ? ` (v${version})` : ''} — the re-sync was skipped/incomplete: ` +
597
- `the skills directory is read-only this session (the tree may be PARTIALLY updated). Repair-on-rerun cannot ` +
598
- `run here; any remaining drift persists until you ${READONLY_RERUN_HINT}.`;
559
+ // The read-only degrade wording (D1, honesty-split per D4). The STATED-skip line is composed in
560
+ // refresh-parity.mjs from a POST-FAILURE re-scan: the old line asserted "PARTIALLY updated" and "any
561
+ // remaining drift persists" unconditionally two claims about post-state that nothing had checked.
562
+ // It still never claims a re-sync RAN (that is already-current's line). The FAILED line (a
563
+ // version-behind upgrade blocked by the same read-only dir) stays loud, but its recovery points at a
564
+ // writable rerun the in-session `setup` would hit the same read-only dir.
599
565
  const readonlyRefreshFailedLine = (name, message) =>
600
566
  ` ${name}: could not refresh — ${stripPrefix(message)}; the skills directory is read-only this session — ${READONLY_RERUN_HINT}`;
601
567
 
568
+ // The per-bridge refresh outcome lines, one pure composer per outcome — the composed-lines guard
569
+ // (test/composed-lines-ux.test.mjs) renders every key against the L2 user-grade invariants. `label`
570
+ // is the pre-rendered version suffix (versionLabel); reasons arrive already prefix-stripped.
571
+ export const REFRESH_LINES = Object.freeze({
572
+ unsupported: (name, reason) => ` ${name}: skipped — ${reason}`,
573
+ 'kept-newer': (name, reason) => ` ${name}: skipped — ${reason}`,
574
+ 'not-placed': (name) => ` ${name}: ${NOT_PLACED_LINE}`,
575
+ failed: (name, reason) => ` ${name}: could not refresh — ${reason}; recover with /agent-workflow-kit setup`,
576
+ 'failed-readonly': readonlyRefreshFailedLine,
577
+ [SKIPPED_READONLY]: readonlySkipLine,
578
+ 'already-current': (name, label, summary) => {
579
+ const base = ` ${name}: already current${label} — files re-synced from the bundled copy`;
580
+ return summary ? `${base}\n ↳ ${summary}` : base;
581
+ },
582
+ refreshed: (name, label) => ` ${name}: refreshed${label}`,
583
+ });
584
+
585
+ // The post-failure parity verdict behind that line (D3): a read-only re-scan of the bundle-owned
586
+ // files AND the wrapper links, run AFTER the write refusal, so every clause binds to state proven
587
+ // THIS run. The degrade returns before linkWrappers, so the wrapper axis is genuinely unreconciled —
588
+ // a calm claim that skipped it would be unproven. Any failure raised out of the walk itself (the
589
+ // bundle read is deliberately uncaught in there) collapses to could-not-verify: the calm claim is
590
+ // withheld, never inverted into a false clean, and a re-scan problem never turns the stated skip
591
+ // into a reported failure.
592
+ const readonlyParity = (plan, deps) => {
593
+ const fs = fsDeps(deps);
594
+ try {
595
+ // ONE walk feeds both halves: the wrapper axis judges a source's mode from what the walk already
596
+ // reached, never from a second stat that could traverse an ancestor the walk refused.
597
+ const scan = scanBundleOwnedDrift(plan.place.bundleDir, plan.skillDir, fs);
598
+ return parityVerdict({
599
+ scan,
600
+ wrappers: scanWrapperParity(plan.links, fs, { skillDir: plan.skillDir, modes: scan.modes }),
601
+ });
602
+ } catch (err) {
603
+ // The catch cannot know WHICH side raised (the walk reads the bundle and the placed tree), so it
604
+ // names the comparison, never a side — attributing it would be the same unproven claim one layer down.
605
+ return unverifiableParity(`the bundle/placed comparison (${err.code ?? 'fs error'})`);
606
+ }
607
+ };
608
+
602
609
  // Refresh every ALREADY-PLACED bridge from the kit's bundled copies and re-link its wrappers (a newer
603
610
  // bridge can add one). One reported outcome per backend — never a crash; a per-backend STOP/error
604
611
  // becomes a `failed` result. `line` is the tool-composed sentence callers print VERBATIM (the agent
@@ -612,55 +619,59 @@ export const refreshPlacedBridges = (deps = {}, names = KNOWN_BACKENDS.map((b) =
612
619
  try {
613
620
  const plan = planFor(name, deps);
614
621
  if (plan.outcome === 'unsupported') {
615
- return { name: plan.name, outcome: 'unsupported', line: ` ${plan.name}: skipped — ${plan.reason}` };
622
+ return { name: plan.name, outcome: 'unsupported', line: REFRESH_LINES.unsupported(plan.name, plan.reason) };
616
623
  }
617
624
  if (plan.wouldDowngrade) {
618
- return { name: plan.name, outcome: 'kept-newer', line: ` ${plan.name}: skipped — ${stripPrefix(plan.reason)}` };
625
+ return { name: plan.name, outcome: 'kept-newer', line: REFRESH_LINES['kept-newer'](plan.name, stripPrefix(plan.reason)) };
619
626
  }
620
627
  // An absent/empty skill dir is `not placed` REGARDLESS of any later plan trouble (a foreign
621
628
  // wrapper conflict, a bundle-source problem): the refresh-only driver skips an unplaced bridge
622
629
  // before those axes matter, so it must never claim "could not refresh" what it would not touch.
623
630
  if (plan.place && plan.place.action !== 'refresh') {
624
- return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
631
+ return { name: plan.name, outcome: 'not-placed', line: REFRESH_LINES['not-placed'](plan.name) };
625
632
  }
626
633
  if (plan.outcome === 'stop' || plan.outcome === 'error') {
627
- return { name: plan.name, outcome: 'failed', line: ` ${plan.name}: could not refresh — ${stripPrefix(plan.reason)}; recover with /agent-workflow-kit setup` };
634
+ return { name: plan.name, outcome: 'failed', line: REFRESH_LINES.failed(plan.name, stripPrefix(plan.reason)) };
628
635
  }
629
636
  const refresh = refreshSkillOnly(registryEntry(plan.name), deps);
630
637
  // A read-only skills dir at an EQUAL version is a STATED skip (repair-on-rerun cannot run here) —
631
638
  // never a false "could not refresh" and never a failure exit (D1/AD-056).
632
639
  if (refresh.skippedReadonly) {
633
- return { name: plan.name, outcome: SKIPPED_READONLY, line: skippedReadonlyLine(plan.name, refresh.version) };
640
+ return {
641
+ name: plan.name,
642
+ outcome: SKIPPED_READONLY,
643
+ line: REFRESH_LINES[SKIPPED_READONLY](plan.name, refresh.version, readonlyParity(plan, deps)),
644
+ };
634
645
  }
635
646
  if (!refresh.refreshed) {
636
- return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
647
+ return { name: plan.name, outcome: 'not-placed', line: REFRESH_LINES['not-placed'](plan.name) };
637
648
  }
638
649
  const manifest = readBundledManifest(plan.place.bundleDir, deps);
639
650
  linkWrappers(plan.skillDir, manifest, { ...deps, bindir: plan.bindir, platform: plan.platform });
640
651
  const current = plan.version !== null && plan.priorVersion === plan.version;
641
652
  // The equal-version line still states the copy that ran (repair-on-rerun) — the tool never
642
653
  // reports a mutation-free "already current" while it re-synced files underneath.
643
- const base = ` ${plan.name}: ${current ? `already current${versionLabel(plan)} — files re-synced from the bundled copy` : `refreshed${versionLabel(plan)}`}`;
644
654
  // Overwrite honesty (D5): only an EQUAL-version re-sync can prove a byte diff is a LOCAL edit —
645
655
  // a version upgrade's diffs are the version delta, which the (vOld → vNew) arrow already states.
646
- const summary = current ? driftSummary(refresh.drift) : null;
647
656
  return {
648
657
  name: plan.name,
649
658
  outcome: current ? 'already-current' : 'refreshed',
650
- line: summary ? `${base}\n ↳ ${summary}` : base,
659
+ line: current
660
+ ? REFRESH_LINES['already-current'](plan.name, versionLabel(plan), driftSummary(refresh.drift))
661
+ : REFRESH_LINES.refreshed(plan.name, versionLabel(plan)),
651
662
  };
652
663
  } catch (err) {
653
664
  // A downgrade STOP raised at the write boundary (a newer bridge landed between plan and apply)
654
665
  // is the same stated skip as the planned one — classified structurally via the typed field.
655
666
  if (err.wouldDowngrade) {
656
- return { name: canonical, outcome: 'kept-newer', line: ` ${canonical}: skipped — ${stripPrefix(err.message)}` };
667
+ return { name: canonical, outcome: 'kept-newer', line: REFRESH_LINES['kept-newer'](canonical, stripPrefix(err.message)) };
657
668
  }
658
669
  // A read-only WRITE failure on a version-BEHIND (upgrade) refresh stays a loud failure — but the
659
670
  // in-session "recover with setup" would hit the same read-only dir, so point at a writable rerun.
660
671
  if (isReadonlyWriteBoundary(err)) {
661
- return { name: canonical, outcome: 'failed', line: readonlyRefreshFailedLine(canonical, err.message) };
672
+ return { name: canonical, outcome: 'failed', line: REFRESH_LINES['failed-readonly'](canonical, err.message) };
662
673
  }
663
- return { name: canonical, outcome: 'failed', line: ` ${canonical}: could not refresh — ${stripPrefix(err.message)}; recover with /agent-workflow-kit setup` };
674
+ return { name: canonical, outcome: 'failed', line: REFRESH_LINES.failed(canonical, stripPrefix(err.message)) };
664
675
  }
665
676
  });
666
677
 
@@ -26,10 +26,9 @@
26
26
  // Exit codes: 0 green / 1 violation or refusal / 2 usage, config or enumeration error.
27
27
  // Dependency-free, Node >= 22. No side effects on import.
28
28
 
29
- import { fileURLToPath } from 'node:url';
30
- import { realpathSync } from 'node:fs';
31
29
  import { resolve } from 'node:path';
32
30
  import { assertDocsAiDeployment, writeDocsAiFileAtomic } from './atomic-write.mjs';
31
+ import { isDirectRun, sameFile } from './direct-run.mjs';
33
32
  import {
34
33
  AUTHORED_KEYS,
35
34
  SOURCE_SIZE_CONFIG_REL,
@@ -299,20 +298,11 @@ export const main = (argv, ctx = {}) => {
299
298
  }
300
299
  };
301
300
 
302
- // Compared by REAL path, not lexically: ESM resolves a symlinked entry point to its target, so a
303
- // lexical comparison is false whenever the tool is invoked through a link — and a gate whose cmd
304
- // names a link would then exit 0 having run nothing, which reads as PASS.
305
- // Exported as a test seam (the coverage-check keyFor idiom): the unresolvable arm cannot be reached
306
- // through the CLI, where an existing entry point is a precondition of getting this far.
307
- export const sameFile = (a, b) => {
308
- try {
309
- return realpathSync(a) === realpathSync(b);
310
- } catch {
311
- return false;
312
- }
313
- };
314
- const isDirectRun = Boolean(process.argv[1]) && sameFile(fileURLToPath(import.meta.url), process.argv[1]);
315
- if (isDirectRun) {
301
+ // The realpath-compare direct-run predicate now lives in tools/direct-run.mjs (the fix this file
302
+ // carried first, extracted so every module shares one implementation); `sameFile` stays exported here
303
+ // because this module's tests bind it as a seam.
304
+ export { sameFile };
305
+ if (isDirectRun(import.meta.url)) {
316
306
  const result = main(process.argv.slice(2));
317
307
  if (result.stdout) process.stdout.write(result.stdout.endsWith('\n') ? result.stdout : `${result.stdout}\n`);
318
308
  if (result.stderr) process.stderr.write(result.stderr.endsWith('\n') ? result.stderr : `${result.stderr}\n`);
@@ -7,7 +7,8 @@
7
7
  // • source-size-refusal.mjs — the two exit classes, and the absolute config path every refusal names
8
8
  // • source-size-config.mjs — the config file: its grammar, its four states, its reader
9
9
  // • source-size-scope.mjs — which files are judged (D-6) and how big each one is (D-7)
10
- // • source-size-gate-cmd.mjs — whether a declared gate cmd IS this checker (the canonical matcher)
10
+ // • source-size-gate-cmd.mjs — whether a declared gate cmd IS this checker (the canonical matcher),
11
+ // and which of the three tool claims it makes when it is not
11
12
  //
12
13
  // Re-export only: a consumer imports the practice, never a particular half, so a later split moves
13
14
  // code without touching a single call site.
@@ -48,6 +49,11 @@ export {
48
49
  export {
49
50
  SOURCE_SIZE_GATE_ID,
50
51
  SOURCE_SIZE_TOOL_PATH,
52
+ classifySourceSizeGate,
51
53
  dqUnsafePath,
52
54
  matchesSourceSizeGate,
53
55
  } from './source-size-gate-cmd.mjs';
56
+
57
+ // The claim vocabulary itself — a consumer naming an outcome imports the name, never a string
58
+ // literal it could misspell into a silently-never-true comparison.
59
+ export { CHECKER_CLAIM } from './checker-claim.mjs';