@sabaiway/agent-workflow-kit 1.33.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.
Files changed (45) hide show
  1. package/CHANGELOG.md +99 -0
  2. package/README.md +2 -1
  3. package/SKILL.md +6 -2
  4. package/bin/install.mjs +37 -1
  5. package/bridges/antigravity-cli-bridge/SKILL.md +13 -1
  6. package/bridges/antigravity-cli-bridge/bin/agy-review.sh +96 -1
  7. package/bridges/antigravity-cli-bridge/bin/agy-review.test.mjs +204 -1
  8. package/bridges/antigravity-cli-bridge/bin/agy.sh +94 -0
  9. package/bridges/antigravity-cli-bridge/bin/agy.test.mjs +245 -1
  10. package/bridges/antigravity-cli-bridge/capability.json +17 -1
  11. package/bridges/codex-cli-bridge/SKILL.md +15 -1
  12. package/bridges/codex-cli-bridge/bin/codex-exec.sh +113 -0
  13. package/bridges/codex-cli-bridge/bin/codex-exec.test.mjs +288 -0
  14. package/bridges/codex-cli-bridge/bin/codex-review.sh +114 -1
  15. package/bridges/codex-cli-bridge/bin/codex-review.test.mjs +229 -1
  16. package/bridges/codex-cli-bridge/capability.json +29 -1
  17. package/capability.json +1 -1
  18. package/launchers/windsurf-workflow.md +3 -1
  19. package/package.json +1 -1
  20. package/references/contracts.md +3 -2
  21. package/references/modes/bootstrap.md +11 -6
  22. package/references/modes/bridge-settings.md +29 -0
  23. package/references/modes/gates.md +4 -2
  24. package/references/modes/review-state.md +1 -1
  25. package/references/modes/status.md +2 -0
  26. package/references/modes/upgrade.md +11 -5
  27. package/references/shared/deploy-tail.md +1 -1
  28. package/references/shared/report-footer.md +11 -4
  29. package/tools/atomic-write.mjs +144 -0
  30. package/tools/bridge-settings-read.mjs +221 -0
  31. package/tools/bridge-settings.mjs +288 -0
  32. package/tools/commands.mjs +22 -1
  33. package/tools/family-registry.mjs +45 -2
  34. package/tools/manifest/schema.md +31 -0
  35. package/tools/manifest/validate.mjs +85 -0
  36. package/tools/orchestration-write.mjs +8 -78
  37. package/tools/presentation.mjs +1 -0
  38. package/tools/procedures.mjs +26 -4
  39. package/tools/recipes.mjs +16 -6
  40. package/tools/renderers.mjs +17 -2
  41. package/tools/review-state.mjs +4 -2
  42. package/tools/seed-gates.mjs +413 -0
  43. package/tools/setup-backends.mjs +107 -9
  44. package/tools/velocity-profile.mjs +4 -0
  45. package/tools/view-model.mjs +20 -0
@@ -0,0 +1,413 @@
1
+ #!/usr/bin/env node
2
+ // seed-gates.mjs — the consent-gated docs/ai/gates.json seeder (AD-042). Reached ONLY through
3
+ // explicitly-consenting prose (the bootstrap accelerators block and the gates.md consent-seed
4
+ // section) — it is NOT a routable mode token, it sits OUTSIDE every velocity allowlist tier
5
+ // (a consent-per-run writer is never pre-approved), and the shipped gates.json TEMPLATE stays
6
+ // EMPTY (AD-021/AD-038): a populated declaration is per-entry maintainer consent recorded through
7
+ // this preview, never auto-seeding.
8
+ //
9
+ // What it offers (the derivation invariants, test-pinned):
10
+ // • sources: discoverGateCandidates over package.json scripts (velocity-profile.mjs stays
11
+ // read-only; THIS module owns the offer mapping) + the conditional review-state candidate;
12
+ // • warn-flagged candidates (release/publish/deploy/push/version/commit/tag, pre*/post*) NEVER
13
+ // enter the offer — excluded, not offered-with-a-warning;
14
+ // • only TERMINATING verification classes are offered (test / lint / type-check / build) —
15
+ // never dev/start/watch/serve/preview, never a formatter write-mode;
16
+ // • commands are package-manager-aware (packageManager field, else lockfile probe, else npm);
17
+ // • ids derive kebab-case from script names (build:prod → build-prod) and every offered entry
18
+ // passes the runner's validateDeclaration (the seeder imports the validator — NEVER the
19
+ // reverse: run-gates.mjs stays a runner that writes nothing);
20
+ // • the review-state candidate appears ONLY when docs/ai/orchestration.json DECLARES
21
+ // reviewed/council on plan-execution.review — the slot the checker enforces — with the
22
+ // resolved, QUOTED tool path (spaces survive; executes from the project root).
23
+ //
24
+ // Write discipline: preview (dry-run) is the DEFAULT and writes NOTHING — a declined offer leaves
25
+ // the file byte-identical. `--apply` appends EXACTLY the consented entries (`--only <id>`
26
+ // repeatable) through the shared atomic-write core (tools/atomic-write.mjs — exclusive-create
27
+ // tmp+rename, TOCTOU re-check, symlink STOPs): append-only, never modifies or removes an existing
28
+ // entry, refuses id collisions, refuses a malformed declaration (never writes over what it cannot
29
+ // parse). Deployment-gated: docs/ai presence (lstat, no-follow) on EVERY run; the
30
+ // .workflow-version == lineage-head stamp gate on --apply only (the velocity/gate-hook precedent).
31
+ //
32
+ // Exit codes: 0 done / dry-run; 1 precondition STOP (no deployment, stamp, symlink, malformed
33
+ // declaration, id collision); 2 usage. Dependency-free, Node >= 18. No side effects on import.
34
+
35
+ import { existsSync, lstatSync, readFileSync } from 'node:fs';
36
+ import { join, resolve, dirname } from 'node:path';
37
+ import { fileURLToPath, pathToFileURL } from 'node:url';
38
+ import { discoverGateCandidates, EXPECTED_WORKFLOW_VERSION } from './velocity-profile.mjs';
39
+ import { GATES_REL, validateDeclaration } from './run-gates.mjs';
40
+ import { loadConfig } from './orchestration-config.mjs';
41
+ import { assertDocsAiDeployment, writeDocsAiFileAtomic, lstatNoFollow } from './atomic-write.mjs';
42
+
43
+ const HERE = dirname(fileURLToPath(import.meta.url));
44
+ const KIT_ROOT = resolve(HERE, '..');
45
+ const TEMPLATE_PATH = join(KIT_ROOT, 'references', 'templates', 'gates.json');
46
+ const REVIEW_STATE_TOOL = join(KIT_ROOT, 'tools', 'review-state.mjs');
47
+ const STAMP_REL = join('docs', 'ai', '.workflow-version');
48
+
49
+ const EXIT_OK = 0;
50
+ const EXIT_PRECONDITION = 1;
51
+ const EXIT_USAGE = 2;
52
+
53
+ export const SEED_GATES_STOP = 'SEED_GATES_STOP';
54
+ const stop = (message) =>
55
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { name: 'SeedGatesStop', code: SEED_GATES_STOP, exitCode: EXIT_PRECONDITION });
56
+ const usageFail = (message) =>
57
+ Object.assign(new Error(`[agent-workflow-kit] ${message}`), { exitCode: EXIT_USAGE });
58
+
59
+ // The trust-chain disclosure (AD-042) — printed with EVERY preview, token-pinned by the tests: a
60
+ // consent-seeded command becomes auto-approvable only after TWO explicit consents.
61
+ export const TRUST_CHAIN_DISCLOSURE =
62
+ 'Disclosure: once the optional approval hook is wired (/agent-workflow-kit hook), it ' +
63
+ 'auto-approves byte-exact declared gate commands from the project root — seeding (this consent) ' +
64
+ 'and hook wiring (its own consent) are two separate yeses.';
65
+
66
+ const USAGE = `usage: seed-gates [--dry-run | --apply] [--only <id>]... [--cwd <dir>] [--help]
67
+
68
+ Consent-gated seeder for the project's own docs/ai/gates.json. Default is --dry-run: prints the
69
+ derived { id, title, cmd } entries and writes NOTHING. --apply APPENDS exactly the consented
70
+ entries (--only <id> selects a subset; append-only — existing entries are never modified or
71
+ removed, an id collision is refused). Only terminating verification commands are offered
72
+ (test / lint / type-check / build); release/publish/deploy scripts and watch/serve modes never
73
+ enter the offer. ${TRUST_CHAIN_DISCLOSURE}`;
74
+
75
+ // ── candidate classification (the LOCKED derivation invariants) ────────────────────────
76
+ // The derived cmd (`<pm> run <name>`) is bash-interpolated by the gate runner and can become
77
+ // hook-auto-approvable, so only shell-SAFE script names ever enter the offer: a name carrying
78
+ // whitespace or any shell metacharacter (`test:ci && echo pwn`) is screened out entirely.
79
+ const SAFE_SCRIPT_NAME_PATTERN = /^[A-Za-z0-9:_.-]+$/;
80
+ const TERMINATING_CLASS_PATTERN = /^(test|lint|type-?check|types|tsc|build)([:._-]|$)/i;
81
+ // Non-terminating screening is TOKEN-set based, position-independent (never an anchored regex —
82
+ // the anchored form missed `build:preview` and a bare `vite preview` body): a non-terminating
83
+ // token in ANY name segment, or as ANY bare/dashed body word, disqualifies; `watch` disqualifies
84
+ // as a substring anywhere (watchAll, --watchAll). Conservative on purpose — a screened-out script
85
+ // can still be declared by hand; a wrongly-included one would become hook-auto-approvable.
86
+ const NON_TERMINATING_TOKENS = new Set(['dev', 'start', 'serve', 'watch', 'preview']);
87
+ // A terminating-looking NAME can still hide release work in its BODY (`"test": "npm publish"`):
88
+ // the same token classes the warn-name screen rejects are rejected as bare body words too — an
89
+ // offered cmd is hook-auto-approvable, so a dangerous body must never ride a clean name.
90
+ const DANGEROUS_BODY_TOKENS = new Set(['release', 'publish', 'deploy', 'push', 'version', 'commit', 'tag']);
91
+ const WATCH_ANYWHERE_PATTERN = /watch/i;
92
+ const wordOf = (raw) => raw.toLowerCase().replace(/^-+/, '').split('=')[0];
93
+ const hasTokenIn = (text, splitter, tokens) =>
94
+ String(text)
95
+ .split(splitter)
96
+ .some((part) => tokens.has(wordOf(part)));
97
+ const isNonTerminatingName = (name) =>
98
+ hasTokenIn(name, /[:._-]/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(name);
99
+ const isNonTerminatingBody = (body) =>
100
+ hasTokenIn(body, /\s+/, NON_TERMINATING_TOKENS) || WATCH_ANYWHERE_PATTERN.test(body);
101
+ const isDangerousBody = (body) => hasTokenIn(body, /\s+/, DANGEROUS_BODY_TOKENS);
102
+ // A MUTATING VARIANT of a terminating class never enters the offer — a hook-auto-approvable gate
103
+ // must never be a writer. Screened on BOTH axes: the script NAME (lint:fix, test:update,
104
+ // build:write, test:snapshot) and the script BODY's write-mode flags (eslint --fix,
105
+ // prettier --write / -w, jest -u / --updateSnapshot, tsc -w). Conservative by design: an excluded
106
+ // candidate can still be declared by hand — a wrongly-included one would be silently auto-approved.
107
+ const MUTATING_VARIANT_NAME_PATTERN = /(^|[:._-])(fix|write|update|snapshot)([:._-]|$)/i;
108
+ const MUTATING_BODY_FLAG_PATTERN = /(^|\s)(--fix|--write|--update(?:-snapshot)?|--updateSnapshot|-w|-u)(=|\s|$)/;
109
+
110
+ export const kebabIdOf = (name) =>
111
+ String(name)
112
+ .toLowerCase()
113
+ .replace(/[^a-z0-9]+/g, '-')
114
+ .replace(/^-+|-+$/g, '');
115
+
116
+ // package manager: the package.json `packageManager` field wins, else the lockfile probe, else npm.
117
+ export const detectPackageManager = (cwd, deps = {}) => {
118
+ const read = deps.readFile ?? readFileSync;
119
+ const exists = deps.exists ?? existsSync;
120
+ const fromField = (() => {
121
+ try {
122
+ const pkg = JSON.parse(String(read(join(cwd, 'package.json'), 'utf8')));
123
+ const pm = typeof pkg.packageManager === 'string' ? pkg.packageManager.split('@')[0] : null;
124
+ return pm === 'pnpm' || pm === 'yarn' || pm === 'npm' ? pm : null;
125
+ } catch {
126
+ return null;
127
+ }
128
+ })();
129
+ if (fromField) return fromField;
130
+ if (exists(join(cwd, 'pnpm-lock.yaml'))) return 'pnpm';
131
+ if (exists(join(cwd, 'yarn.lock'))) return 'yarn';
132
+ return 'npm';
133
+ };
134
+
135
+ // The script-derived offer entries. Order = package.json scripts order (the offer the user reads).
136
+ export const deriveScriptEntries = (cwd, deps = {}) => {
137
+ const read = deps.readFile ?? readFileSync;
138
+ const pkg = (() => {
139
+ try {
140
+ return JSON.parse(String(read(join(cwd, 'package.json'), 'utf8')));
141
+ } catch {
142
+ return null; // no/unreadable package.json → no script candidates (an honest empty offer)
143
+ }
144
+ })();
145
+ const pm = detectPackageManager(cwd, deps);
146
+ const seen = new Set();
147
+ const bodyOf = (name) => String(pkg?.scripts?.[name] ?? '');
148
+ return discoverGateCandidates(pkg ?? {})
149
+ .filter((c) => !c.warn) // warn-flagged NEVER enter the offer
150
+ .filter((c) => SAFE_SCRIPT_NAME_PATTERN.test(c.scriptName)) // shell-safe names only, FIRST
151
+ .filter((c) => TERMINATING_CLASS_PATTERN.test(c.scriptName))
152
+ .filter((c) => !isNonTerminatingName(c.scriptName))
153
+ .filter((c) => !MUTATING_VARIANT_NAME_PATTERN.test(c.scriptName) && !MUTATING_BODY_FLAG_PATTERN.test(bodyOf(c.scriptName)))
154
+ .filter((c) => !isNonTerminatingBody(bodyOf(c.scriptName)))
155
+ .filter((c) => !isDangerousBody(bodyOf(c.scriptName)))
156
+ .map((c) => ({
157
+ id: kebabIdOf(c.scriptName),
158
+ title: `Project script: ${pm} run ${c.scriptName}`,
159
+ cmd: `${pm} run ${c.scriptName}`,
160
+ }))
161
+ .filter((e) => {
162
+ if (!e.id || seen.has(e.id)) return false; // an empty or duplicate derived id never enters the offer
163
+ seen.add(e.id);
164
+ return true;
165
+ });
166
+ };
167
+
168
+ // The conditional review-state candidate — keyed on the SLOT the checker enforces
169
+ // (plan-execution.review, tools/review-state.mjs), read via the shared config reader. Offered only
170
+ // when the config DECLARES reviewed/council there; solo configs and a council-on-plan-authoring-only
171
+ // config never see it. The cmd carries the resolved, QUOTED tool path and passes the validator.
172
+ // Double-quote-unsafe shell metacharacters: inside `"…"` bash still expands `$`, backticks and
173
+ // backslashes, and a `"` breaks the quoting entirely. A candidate cmd is hook-auto-approvable, so a
174
+ // path that cannot be safely double-quoted is WITHHELD with a loud note — never offered wrongly.
175
+ const DQ_UNSAFE_PATH_PATTERN = /["$`\\\r\n]/;
176
+
177
+ export const reviewStateCandidate = (cwd, deps = {}) => {
178
+ const toolPath = deps.reviewStateTool ?? REVIEW_STATE_TOOL;
179
+ try {
180
+ const { config } = loadConfig(resolve(cwd), deps.readFile ?? readFileSync, deps.lstat ?? lstatSync);
181
+ const declared = config?.['plan-execution']?.review;
182
+ if (declared !== 'reviewed' && declared !== 'council') return { candidate: null, note: null };
183
+ if (DQ_UNSAFE_PATH_PATTERN.test(toolPath)) {
184
+ return {
185
+ candidate: null,
186
+ note:
187
+ `the review-state candidate was withheld: the resolved kit path contains shell ` +
188
+ `metacharacters that do not survive double-quoting (${toolPath}) — declare the gate ` +
189
+ `by hand per references/modes/review-state.md step 3`,
190
+ };
191
+ }
192
+ return {
193
+ candidate: {
194
+ id: 'review-state',
195
+ title: 'Review receipts current for the uncommitted tree',
196
+ cmd: `node "${toolPath}" --check`,
197
+ },
198
+ note: null,
199
+ };
200
+ } catch (err) {
201
+ return {
202
+ candidate: null,
203
+ note: `orchestration config unreadable (${err.message}) — the review-state candidate was not evaluated`,
204
+ };
205
+ }
206
+ };
207
+
208
+ // Every --only id must name an OFFERED entry — enforced in BOTH paths (dry-run and apply), before
209
+ // any empty-offer shortcut, so a typo is a loud usage error, never a silent filter or a silent
210
+ // "nothing to offer" success.
211
+ const assertOnlyIdsOffered = (offer, onlyIds = []) => {
212
+ const offered = new Set(offer.entries.map((e) => e.id));
213
+ const unknown = onlyIds.filter((id) => !offered.has(id));
214
+ if (unknown.length) {
215
+ throw usageFail(`--only names ids not in the offer: ${unknown.join(', ')} (offered: ${[...offered].join(', ') || 'none'})`);
216
+ }
217
+ };
218
+
219
+ // The full offer: script entries + the conditional review-state candidate (last), plus loud notes.
220
+ export const buildOffer = (cwd, deps = {}) => {
221
+ const entries = deriveScriptEntries(cwd, deps);
222
+ const { candidate, note } = reviewStateCandidate(cwd, deps);
223
+ return {
224
+ entries: candidate ? [...entries, candidate] : entries,
225
+ notes: note ? [note] : [],
226
+ };
227
+ };
228
+
229
+ // The RUNNABLE apply invocation for a given project — this tool has no bin and no mode token, so
230
+ // the consent step must print the real command, never a bare `seed-gates`. Consent integrity: a
231
+ // previewed --only subset is carried into the hint VERBATIM (ids are shell-safe by construction —
232
+ // kebabIdOf output / the fixed `review-state`), so following the hint can never widen the consent.
233
+ // Paths that do not survive double-quoting (the reviewStateCandidate screen, same pattern) make
234
+ // this return null — the preview then falls back to a generic, unquoted instruction.
235
+ export const applyInvocationFor = (cwd, onlyIds = []) => {
236
+ const ownPath = fileURLToPath(import.meta.url);
237
+ if (DQ_UNSAFE_PATH_PATTERN.test(ownPath) || DQ_UNSAFE_PATH_PATTERN.test(cwd)) return null;
238
+ const only = onlyIds.map((id) => ` --only ${id}`).join('');
239
+ return `node "${ownPath}" --cwd "${cwd}" --apply${only}`;
240
+ };
241
+
242
+ const GENERIC_APPLY_HINT = 're-run this same command with --apply [--only <id>]...';
243
+
244
+ export const formatPreview = (offer, applyInvocation = null, { explicitOnly = false } = {}) => {
245
+ const lines = ['[agent-workflow-kit] gates seeding preview (dry-run — nothing was written):'];
246
+ if (!offer.entries.length) {
247
+ lines.push(' nothing to offer — no seedable terminating verification scripts were found.');
248
+ }
249
+ for (const e of offer.entries) {
250
+ lines.push(` ${e.id}: ${e.cmd} (${e.title})`);
251
+ }
252
+ for (const note of offer.notes) lines.push(` note: ${note}`);
253
+ if (offer.entries.length) {
254
+ const suffix = explicitOnly ? '' : ' [--only <id>]...';
255
+ lines.push(` apply exactly the entries you consent to: ${applyInvocation ?? GENERIC_APPLY_HINT}${applyInvocation ? suffix : ''}`);
256
+ }
257
+ lines.push(` ${TRUST_CHAIN_DISCLOSURE}`);
258
+ return lines.join('\n');
259
+ };
260
+
261
+ // ── the existing declaration (append-only source) ──────────────────────────────────────
262
+ const loadExistingDeclaration = (cwd, deps = {}) => {
263
+ const read = deps.readFile ?? readFileSync;
264
+ const lstat = deps.lstat ?? lstatSync;
265
+ const full = join(cwd, GATES_REL);
266
+ const leaf = lstatNoFollow(full, lstat);
267
+ if (leaf === null) return { outcome: 'missing' };
268
+ // Refuse a symlinked leaf HERE, before any read/parse — the atomic core would refuse it at write
269
+ // time anyway, but the honest STOP names the symlink, not a misleading parse error on its target.
270
+ if (leaf.isSymbolicLink()) {
271
+ throw stop(`${GATES_REL} is a symlink — refusing to touch it (a write would clobber the link target)`);
272
+ }
273
+ const parsed = (() => {
274
+ try {
275
+ return JSON.parse(String(read(full, 'utf8')));
276
+ } catch (err) {
277
+ throw stop(`${GATES_REL}: malformed JSON (${err.message}) — fix it by hand; the seeder never writes over a declaration it cannot parse`);
278
+ }
279
+ })();
280
+ const gates = (() => {
281
+ try {
282
+ return validateDeclaration(parsed);
283
+ } catch (err) {
284
+ throw stop(`${err.message} — fix it by hand; the seeder never writes over an invalid declaration`);
285
+ }
286
+ })();
287
+ return { outcome: 'loaded', readme: typeof parsed._README === 'string' ? parsed._README : undefined, gates };
288
+ };
289
+
290
+ const templateReadme = (deps = {}) => {
291
+ const read = deps.readTemplate ?? readFileSync;
292
+ try {
293
+ const parsed = JSON.parse(String(read(TEMPLATE_PATH, 'utf8')));
294
+ if (typeof parsed._README !== 'string') throw new Error('template has no _README');
295
+ return parsed._README;
296
+ } catch (err) {
297
+ throw stop(`the bundled gates.json template is unreadable (${err.message}) — the kit install is incomplete`);
298
+ }
299
+ };
300
+
301
+ const readStampValue = (cwd, deps = {}) => {
302
+ const read = deps.readFile ?? readFileSync;
303
+ try {
304
+ const v = String(read(join(cwd, STAMP_REL), 'utf8')).trim();
305
+ return v.length ? v : null;
306
+ } catch {
307
+ return null;
308
+ }
309
+ };
310
+
311
+ // ── apply (append exactly the consented entries) ───────────────────────────────────────
312
+ export const applySeed = ({ cwd, onlyIds = [] }, deps = {}) => {
313
+ assertDocsAiDeployment(cwd, deps, { stop, noun: 'a gate declaration', rel: GATES_REL });
314
+ const stampValue = readStampValue(cwd, deps);
315
+ if (stampValue !== EXPECTED_WORKFLOW_VERSION) {
316
+ throw stop(
317
+ `--apply is deployment-gated: ${STAMP_REL} is ${stampValue ?? 'absent'} but this kit expects ` +
318
+ `${EXPECTED_WORKFLOW_VERSION} (the preview works on any deployment; run upgrade first)`,
319
+ );
320
+ }
321
+ const offer = buildOffer(cwd, deps);
322
+ assertOnlyIdsOffered(offer, onlyIds); // BEFORE the empty-offer return — a typo is never masked
323
+ if (!offer.entries.length) return { outcome: 'nothing' };
324
+ const selected = onlyIds.length ? offer.entries.filter((e) => onlyIds.includes(e.id)) : offer.entries;
325
+
326
+ const existing = loadExistingDeclaration(cwd, deps);
327
+ const existingGates = existing.outcome === 'loaded' ? existing.gates : [];
328
+ const existingIds = new Set(existingGates.map((g) => g.id));
329
+ const collisions = selected.filter((e) => existingIds.has(e.id)).map((e) => e.id);
330
+ if (collisions.length) {
331
+ throw stop(
332
+ `id collision — already declared in ${GATES_REL}: ${collisions.join(', ')} (append-only: the ` +
333
+ `seeder never modifies or removes an existing entry; pick the others with --only, or edit by hand)`,
334
+ );
335
+ }
336
+
337
+ const merged = {
338
+ _README: existing.outcome === 'loaded' && existing.readme !== undefined ? existing.readme : templateReadme(deps),
339
+ gates: [...existingGates, ...selected],
340
+ };
341
+ validateDeclaration(merged); // every written declaration passes the runner's validator, always
342
+ const body = `${JSON.stringify(merged, null, 2)}\n`;
343
+ const { writtenPath } = writeDocsAiFileAtomic(cwd, GATES_REL, body, deps, { stop, noun: 'a gate declaration' });
344
+ return { outcome: 'written', writtenPath, appended: selected.map((e) => e.id) };
345
+ };
346
+
347
+ // ── CLI ────────────────────────────────────────────────────────────────────────────────
348
+ export const parseArgs = (argv) => {
349
+ const parsed = argv.reduce(
350
+ (acc, a, i) => {
351
+ if (acc.skip) return { ...acc, skip: false };
352
+ if (a === '--help' || a === '-h') return { ...acc, help: true };
353
+ // A consent-gated writer never lets a later flag silently decide whether it mutates:
354
+ // mixed --dry-run/--apply is a usage error, whichever order they arrive in.
355
+ if (a === '--dry-run') {
356
+ if (acc.apply === true) throw usageFail('--dry-run and --apply are mutually exclusive — pick one');
357
+ return { ...acc, apply: false, dryRunExplicit: true };
358
+ }
359
+ if (a === '--apply') {
360
+ if (acc.dryRunExplicit) throw usageFail('--dry-run and --apply are mutually exclusive — pick one');
361
+ return { ...acc, apply: true };
362
+ }
363
+ if (a === '--cwd') {
364
+ const value = argv[i + 1];
365
+ if (value === undefined || value.startsWith('-')) throw usageFail('--cwd needs a value: --cwd <dir>');
366
+ return { ...acc, cwd: value, skip: true };
367
+ }
368
+ if (a === '--only') {
369
+ const value = argv[i + 1];
370
+ if (value === undefined || value.startsWith('-')) throw usageFail('--only needs a gate id');
371
+ return { ...acc, only: [...acc.only, value], skip: true };
372
+ }
373
+ throw usageFail(`unknown argument: ${a}`);
374
+ },
375
+ { help: false, apply: false, dryRunExplicit: false, cwd: undefined, only: [], skip: false },
376
+ );
377
+ return parsed;
378
+ };
379
+
380
+ export const main = (argv = process.argv.slice(2), deps = {}) => {
381
+ const log = deps.log ?? console.log;
382
+ const error = deps.error ?? console.error;
383
+ try {
384
+ const args = parseArgs(argv);
385
+ if (args.help) {
386
+ log(USAGE);
387
+ return EXIT_OK;
388
+ }
389
+ const cwd = resolve(args.cwd ?? process.cwd());
390
+ assertDocsAiDeployment(cwd, deps, { stop, noun: 'a gate declaration', rel: GATES_REL });
391
+ if (!args.apply) {
392
+ const offer = buildOffer(cwd, deps);
393
+ assertOnlyIdsOffered(offer, args.only); // a dry-run --only typo is loud too
394
+ const filtered = args.only.length ? { ...offer, entries: offer.entries.filter((e) => args.only.includes(e.id)) } : offer;
395
+ log(formatPreview(filtered, applyInvocationFor(cwd, args.only), { explicitOnly: args.only.length > 0 }));
396
+ return EXIT_OK;
397
+ }
398
+ const result = applySeed({ cwd, onlyIds: args.only }, deps);
399
+ if (result.outcome === 'nothing') {
400
+ log('[agent-workflow-kit] nothing to offer — no seedable terminating verification scripts were found; wrote nothing.');
401
+ return EXIT_OK;
402
+ }
403
+ log(`[agent-workflow-kit] appended ${result.appended.length} consented gate(s) to ${GATES_REL}: ${result.appended.join(', ')}`);
404
+ log(`[agent-workflow-kit] ${TRUST_CHAIN_DISCLOSURE}`);
405
+ return EXIT_OK;
406
+ } catch (err) {
407
+ error(err?.message ?? String(err));
408
+ return err?.exitCode ?? EXIT_PRECONDITION;
409
+ }
410
+ };
411
+
412
+ const isDirectRun = process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href;
413
+ if (isDirectRun) process.exit(main(process.argv.slice(2)));
@@ -99,6 +99,78 @@ const probeMarker = (file, fs) => {
99
99
  }
100
100
  };
101
101
 
102
+ // ── overwrite honesty (D5) — state what a refresh replaced, never a silent wipe ────────────────────
103
+
104
+ // The host-level settings surface a refresh NEVER touches — the one place a bridge tweak survives a
105
+ // kit upgrade (bridge CODE stays kit-owned; only these knobs persist). Named wherever a refresh
106
+ // reports overwriting a local edit, so the recovery is always a copy-paste away.
107
+ const SETTINGS_FILE_HINT = '${XDG_CONFIG_HOME:-~/.config}/agent-workflow/bridge-settings.conf';
108
+ const SETTINGS_CMD_HINT = '/agent-workflow-kit bridge-settings';
109
+
110
+ // Bundle-owned regular files whose PLACED copy differs from the bundle (a local edit an equal-version
111
+ // refresh would overwrite) or cannot be read (indeterminate — an honest degrade). Mirrors
112
+ // copyTreeRefresh's src dispatch so it flags EXACTLY the files the overwrite touches: a symlink src is
113
+ // additive (copyTreeRefresh skips an existing dest — never overwritten) and a dir recurses; a
114
+ // bundled-only file (no placed copy) is a pure add, no loss. The BUNDLE read is our own shipped
115
+ // artifact — a failure there is a loud corrupt-kit error upstream (never swallowed here); only the
116
+ // PLACED read is caught (EACCES/EIO → 'unreadable', and the refresh still proceeds). Sorted output is
117
+ // deterministic for the stated line. Exported for a direct unit test (the full driver cannot observe a
118
+ // symlinked placed file — copyTreeRefresh refuses to overwrite one, so the refresh fails before any line).
119
+ export const scanBundleOwnedDrift = (bundleDir, skillDir, fs) => {
120
+ const drifted = [];
121
+ const unreadable = [];
122
+ const walk = (rel) => {
123
+ const src = join(bundleDir, rel);
124
+ const dest = join(skillDir, rel);
125
+ const st = fs.lstat(src);
126
+ if (st.isSymbolicLink()) return;
127
+ if (st.isDirectory()) {
128
+ for (const entry of fs.readdir(src)) walk(rel ? join(rel, entry) : entry);
129
+ return;
130
+ }
131
+ // lstat the PLACED path NO-FOLLOW first — never read THROUGH a symlink (copyTreeRefresh's
132
+ // assertContainedRealPath would refuse to overwrite a symlinked dest, so reading its target here
133
+ // would be both unsafe and moot). Absent → a bundled-only addition (no local loss); a symlink /
134
+ // non-regular / unreadable placed node → "could not compare" without a read-through.
135
+ const destStat = (() => {
136
+ try {
137
+ return fs.lstat(dest);
138
+ } catch (err) {
139
+ return err && err.code === 'ENOENT' ? 'absent' : 'error';
140
+ }
141
+ })();
142
+ if (destStat === 'absent') return;
143
+ if (destStat === 'error' || destStat.isSymbolicLink() || !destStat.isFile()) {
144
+ unreadable.push(rel);
145
+ return;
146
+ }
147
+ const srcBytes = fs.readFile(src);
148
+ const destBytes = (() => {
149
+ try {
150
+ return fs.readFile(dest);
151
+ } catch {
152
+ unreadable.push(rel);
153
+ return null;
154
+ }
155
+ })();
156
+ if (destBytes === null) return;
157
+ if (!Buffer.from(srcBytes).equals(Buffer.from(destBytes))) drifted.push(rel);
158
+ };
159
+ walk('');
160
+ return { drifted: drifted.sort(), unreadable: unreadable.sort() };
161
+ };
162
+
163
+ // One user-facing sentence naming what an equal-version re-sync overwrote — or null when nothing local
164
+ // was lost. Callers apply their own indent; the pointer names the settings file that survives a refresh.
165
+ const driftSummary = (drift) => {
166
+ if (!drift) return null;
167
+ const parts = [];
168
+ if (drift.drifted.length) parts.push(`overwrote ${drift.drifted.length} locally-changed file(s): ${drift.drifted.join(', ')}`);
169
+ if (drift.unreadable.length) parts.push(`could not compare ${drift.unreadable.length} file(s) (kept the bundled copy): ${drift.unreadable.join(', ')}`);
170
+ if (!parts.length) return null;
171
+ return `${parts.join('; ')} — bridge code is kit-owned and always refreshed; persist host tweaks in ${SETTINGS_FILE_HINT} (survives every refresh): ${SETTINGS_CMD_HINT}`;
172
+ };
173
+
102
174
  // ── path resolution ──────────────────────────────────────────────────────────
103
175
 
104
176
  const skillDirOf = (entry, deps) =>
@@ -253,14 +325,26 @@ const assertNoDowngrade = (plan, deps = {}) => {
253
325
  if (compareSemver(placed, bundled) === 1) throw stop(downgradeReason(placed, bundled), { skillDir: plan.skillDir, wouldDowngrade: true });
254
326
  };
255
327
 
328
+ // Copy the bundle over the placed skill dir (the refresh overwrite), FIRST scanning bundle-owned files
329
+ // for local drift so the caller can STATE what the overwrite replaced (D5 — overwrite honesty). Scan
330
+ // only on a refresh: a `place` writes into an absent/empty dir, so there is nothing local to lose. The
331
+ // copy proceeds either way — bridge code is kit-owned. Returns the drift report (null for a place).
332
+ const copyBridgeWithHonesty = (action, bundleDir, skillDir, deps) => {
333
+ const fs = fsDeps(deps);
334
+ const drift = action === 'refresh' ? scanBundleOwnedDrift(bundleDir, skillDir, fs) : null;
335
+ copyTreeRefresh(bundleDir, skillDir, skillDir, fs);
336
+ return drift;
337
+ };
338
+
256
339
  // Place/refresh the bundled bridge skill. Re-inspects before writing (never trusts a stale plan).
340
+ // Returns the plan plus `drift`: on a refresh, the bundle-owned files whose local edits it overwrote.
257
341
  export const placeSkill = (name, deps = {}) => {
258
342
  const entry = registryEntry(name);
259
343
  if (!entry) throw stop(`unknown backend: ${name}`);
260
344
  const plan = inspectSkillDir(entry, deps);
261
345
  assertNoDowngrade(plan, deps);
262
- copyTreeRefresh(plan.bundleDir, plan.skillDir, plan.skillDir, fsDeps(deps));
263
- return plan;
346
+ const drift = copyBridgeWithHonesty(plan.action, plan.bundleDir, plan.skillDir, deps);
347
+ return { ...plan, drift };
264
348
  };
265
349
 
266
350
  // Link the wrappers onto `bindir`. PREFLIGHT all (source is a regular non-symlink file inside the
@@ -413,9 +497,12 @@ export const planFor = (backend, deps = {}) => {
413
497
  // in the plan across the read→write gap). Throws on a mid-flight conflict / fs error (honest partial
414
498
  // failure — placeSkill/chmod/symlink all converge on a re-run, so there is nothing to roll back).
415
499
  const applyBackend = (plan, deps) => {
416
- if (plan.place.action === 'place' || plan.place.action === 'refresh') placeSkill(plan.name, deps);
500
+ const placed = (plan.place.action === 'place' || plan.place.action === 'refresh')
501
+ ? placeSkill(plan.name, deps)
502
+ : null;
417
503
  const manifest = readBundledManifest(plan.place.bundleDir, deps);
418
504
  linkWrappers(plan.skillDir, manifest, { ...deps, bindir: plan.bindir, platform: plan.platform });
505
+ return { drift: placed?.drift ?? null };
419
506
  };
420
507
 
421
508
  // ── formatting ─────────────────────────────────────────────────────────────────
@@ -442,6 +529,11 @@ const formatBackend = (plan, applied) => {
442
529
  }
443
530
  const placeVerb = applied ? { place: 'placed', refresh: 'refreshed' } : { place: 'will place', refresh: 'will refresh' };
444
531
  lines.push(` • skill: ${placeVerb[plan.place.action]}${versionLabel(plan)} → ${plan.skillDir}`);
532
+ // Overwrite honesty (D5): on an equal-version re-sync that clobbered a local edit, say so (a version
533
+ // upgrade's diffs are the version delta — versionLabel's arrow already signals that change).
534
+ const equalVersionRefresh = plan.place.action === 'refresh' && (!plan.priorVersion || plan.priorVersion === plan.version);
535
+ const driftLine = equalVersionRefresh ? driftSummary(plan.drift) : null;
536
+ if (driftLine) lines.push(` ↳ ${driftLine}`);
445
537
  for (const l of plan.links) {
446
538
  const verb = l.dstState === 'ours' ? 'already linked' : applied ? 'linked' : 'will link';
447
539
  lines.push(` • wrapper ${l.cmd}: ${verb} → ${l.dst}`);
@@ -463,10 +555,10 @@ const formatBackend = (plan, applied) => {
463
555
  // a per-bridge lock file would be new machinery for a user-driven, self-healing race.
464
556
  const refreshSkillOnly = (entry, deps = {}) => {
465
557
  const fresh = inspectSkillDir(entry, deps);
466
- if (fresh.action !== 'refresh') return false;
558
+ if (fresh.action !== 'refresh') return { refreshed: false, drift: null };
467
559
  assertNoDowngrade(fresh, deps);
468
- copyTreeRefresh(fresh.bundleDir, fresh.skillDir, fresh.skillDir, fsDeps(deps));
469
- return true;
560
+ const drift = copyBridgeWithHonesty('refresh', fresh.bundleDir, fresh.skillDir, deps);
561
+ return { refreshed: true, drift };
470
562
  };
471
563
 
472
564
  const NOT_PLACED_LINE = 'skipped — not placed (placement is opt-in: /agent-workflow-kit setup)';
@@ -499,7 +591,8 @@ export const refreshPlacedBridges = (deps = {}, names = KNOWN_BACKENDS.map((b) =
499
591
  if (plan.outcome === 'stop' || plan.outcome === 'error') {
500
592
  return { name: plan.name, outcome: 'failed', line: ` ${plan.name}: could not refresh — ${stripPrefix(plan.reason)}; recover with /agent-workflow-kit setup` };
501
593
  }
502
- if (!refreshSkillOnly(registryEntry(plan.name), deps)) {
594
+ const refresh = refreshSkillOnly(registryEntry(plan.name), deps);
595
+ if (!refresh.refreshed) {
503
596
  return { name: plan.name, outcome: 'not-placed', line: ` ${plan.name}: ${NOT_PLACED_LINE}` };
504
597
  }
505
598
  const manifest = readBundledManifest(plan.place.bundleDir, deps);
@@ -507,10 +600,14 @@ export const refreshPlacedBridges = (deps = {}, names = KNOWN_BACKENDS.map((b) =
507
600
  const current = plan.version !== null && plan.priorVersion === plan.version;
508
601
  // The equal-version line still states the copy that ran (repair-on-rerun) — the tool never
509
602
  // reports a mutation-free "already current" while it re-synced files underneath.
603
+ const base = ` ${plan.name}: ${current ? `already current${versionLabel(plan)} — files re-synced from the bundled copy` : `refreshed${versionLabel(plan)}`}`;
604
+ // Overwrite honesty (D5): only an EQUAL-version re-sync can prove a byte diff is a LOCAL edit —
605
+ // a version upgrade's diffs are the version delta, which the (vOld → vNew) arrow already states.
606
+ const summary = current ? driftSummary(refresh.drift) : null;
510
607
  return {
511
608
  name: plan.name,
512
609
  outcome: current ? 'already-current' : 'refreshed',
513
- line: ` ${plan.name}: ${current ? `already current${versionLabel(plan)} — files re-synced from the bundled copy` : `refreshed${versionLabel(plan)}`}`,
610
+ line: summary ? `${base}\n ↳ ${summary}` : base,
514
611
  };
515
612
  } catch (err) {
516
613
  // A downgrade STOP raised at the write boundary (a newer bridge landed between plan and apply)
@@ -647,7 +744,8 @@ export const main = (argv = process.argv.slice(2), deps = {}) => {
647
744
  let plan = planFor(name, runDeps);
648
745
  if (!args.dryRun && plan.outcome === 'ok') {
649
746
  try {
650
- applyBackend(plan, runDeps);
747
+ const { drift } = applyBackend(plan, runDeps);
748
+ plan = { ...plan, drift };
651
749
  appliedOk = true;
652
750
  } catch (err) {
653
751
  plan = { ...plan, outcome: err.code === SETUP_STOP ? 'stop' : 'error', reason: err.message };
@@ -364,8 +364,12 @@ const isScriptMap = (scripts) => Boolean(scripts) && typeof scripts === 'object'
364
364
  const isMutatingScriptName = (name) =>
365
365
  MUTATING_SCRIPT_NAME_PATTERN.test(name) || MUTATING_SCRIPT_HOOK_PATTERN.test(name);
366
366
 
367
+ // `scriptName` is ADDITIVE (AD-042): the seed-gates offer layer maps a candidate to a
368
+ // package-manager-aware `{ id, title, cmd }` and needs the raw script name for that derivation;
369
+ // `command` stays the advisory's own npm-run spelling (this fn is otherwise unchanged).
367
370
  const makeGateCandidate = (name) => ({
368
371
  command: `${NPM_RUN_COMMAND} ${name}`,
372
+ scriptName: name,
369
373
  addByHand: ADD_BY_HAND,
370
374
  ...(isMutatingScriptName(name) ? { warn: DO_NOT_ADD_WARNING } : {}),
371
375
  });
@@ -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) => {
@@ -65,6 +72,12 @@ const velocityVm = (v) => {
65
72
  return { defaultMode: v.defaultMode ?? null, allow: { project: v.allowEntries?.project ?? 0, local: v.allowEntries?.local ?? 0 } };
66
73
  };
67
74
 
75
+ const agentsVm = (a) => {
76
+ if (!a) return null;
77
+ if (a.error) return { error: a.error };
78
+ return { bundled: a.bundled ?? 0, placed: a.placed ?? 0 };
79
+ };
80
+
68
81
  const hookVm = (h) => {
69
82
  if (!h) return null;
70
83
  if (h.error) return { error: h.error };
@@ -72,6 +85,12 @@ const hookVm = (h) => {
72
85
  wired: Boolean(h.wired),
73
86
  filePlaced: Boolean(h.filePlaced),
74
87
  declarationPresent: Boolean(h.declarationPresent),
88
+ // 0 = absent/empty declaration; null = unreadable/malformed OR an envelope predating the
89
+ // field — unknown reads as unknown, never as a count (the welcome-mat hook rung keys on > 0).
90
+ declaredGates: h.declaredGates ?? null,
91
+ // The preserved validation reason for a null count (null when absent) — rendered beside the
92
+ // unknown marker so a malformed declaration is never a silent bare "?".
93
+ declarationError: h.declarationError ?? null,
75
94
  };
76
95
  };
77
96
 
@@ -81,6 +100,7 @@ const settingsVm = (s) =>
81
100
  recipes: recipesVm(s.recipes),
82
101
  attribution: attributionVm(s.attribution),
83
102
  velocity: velocityVm(s.velocity),
103
+ agents: agentsVm(s.agents),
84
104
  hook: hookVm(s.hook),
85
105
  }
86
106
  : null;