@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.
- package/CHANGELOG.md +99 -0
- package/README.md +2 -1
- package/SKILL.md +6 -2
- package/bin/install.mjs +37 -1
- 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/launchers/windsurf-workflow.md +3 -1
- package/package.json +1 -1
- package/references/contracts.md +3 -2
- package/references/modes/bootstrap.md +11 -6
- package/references/modes/bridge-settings.md +29 -0
- package/references/modes/gates.md +4 -2
- package/references/modes/review-state.md +1 -1
- package/references/modes/status.md +2 -0
- package/references/modes/upgrade.md +11 -5
- package/references/shared/deploy-tail.md +1 -1
- package/references/shared/report-footer.md +11 -4
- package/tools/atomic-write.mjs +144 -0
- package/tools/bridge-settings-read.mjs +221 -0
- package/tools/bridge-settings.mjs +288 -0
- package/tools/commands.mjs +22 -1
- package/tools/family-registry.mjs +45 -2
- package/tools/manifest/schema.md +31 -0
- package/tools/manifest/validate.mjs +85 -0
- package/tools/orchestration-write.mjs +8 -78
- package/tools/presentation.mjs +1 -0
- package/tools/procedures.mjs +26 -4
- package/tools/recipes.mjs +16 -6
- package/tools/renderers.mjs +17 -2
- package/tools/review-state.mjs +4 -2
- package/tools/seed-gates.mjs +413 -0
- package/tools/setup-backends.mjs +107 -9
- package/tools/velocity-profile.mjs +4 -0
- 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)));
|
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 };
|
|
@@ -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
|
});
|
package/tools/view-model.mjs
CHANGED
|
@@ -32,6 +32,13 @@ const bridgeVm = (b) => ({
|
|
|
32
32
|
readiness: b.readiness,
|
|
33
33
|
// preserve the three-state wrapper status (present | missing | unknown) — the renderer maps to a glyph.
|
|
34
34
|
wrappers: (b.wrappers ?? []).map((w) => ({ cmd: w.cmd, state: w.state })),
|
|
35
|
+
// fact-only host-level settings: the active knobs for this bridge, or a localized error; null when
|
|
36
|
+
// nothing is set (the renderer then adds no sub-line — the block stays as it was before any knob).
|
|
37
|
+
settings: b.settings?.error
|
|
38
|
+
? { error: b.settings.error }
|
|
39
|
+
: b.settings?.active?.length
|
|
40
|
+
? { active: b.settings.active.map((a) => ({ key: a.key, value: a.value, source: a.source })) }
|
|
41
|
+
: null,
|
|
35
42
|
});
|
|
36
43
|
|
|
37
44
|
const visibilityVm = (v) => {
|
|
@@ -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;
|