@sabaiway/agent-workflow-kit 1.32.0 → 1.34.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 +100 -0
- package/README.md +1 -1
- package/SKILL.md +2 -2
- package/bin/install.mjs +22 -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/gates.md +4 -2
- package/references/modes/review-state.md +1 -1
- package/references/modes/status.md +2 -0
- package/references/modes/upgrade.md +19 -9
- package/references/shared/composition-handoff.md +12 -0
- package/references/shared/deploy-tail.md +1 -1
- package/references/shared/report-footer.md +11 -4
- package/references/templates/agent_rules.md +1 -1
- package/tools/atomic-write.mjs +106 -0
- package/tools/commands.mjs +15 -1
- package/tools/delegation.mjs +6 -3
- package/tools/engine-source.mjs +6 -0
- package/tools/family-registry.mjs +41 -5
- package/tools/lens-region.mjs +251 -0
- package/tools/orchestration-write.mjs +8 -78
- package/tools/presentation.mjs +1 -0
- package/tools/renderers.mjs +10 -2
- package/tools/review-state.mjs +4 -2
- package/tools/seed-gates.mjs +413 -0
- package/tools/velocity-profile.mjs +4 -0
- package/tools/view-model.mjs +13 -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)));
|
|
@@ -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
|
@@ -65,6 +65,12 @@ const velocityVm = (v) => {
|
|
|
65
65
|
return { defaultMode: v.defaultMode ?? null, allow: { project: v.allowEntries?.project ?? 0, local: v.allowEntries?.local ?? 0 } };
|
|
66
66
|
};
|
|
67
67
|
|
|
68
|
+
const agentsVm = (a) => {
|
|
69
|
+
if (!a) return null;
|
|
70
|
+
if (a.error) return { error: a.error };
|
|
71
|
+
return { bundled: a.bundled ?? 0, placed: a.placed ?? 0 };
|
|
72
|
+
};
|
|
73
|
+
|
|
68
74
|
const hookVm = (h) => {
|
|
69
75
|
if (!h) return null;
|
|
70
76
|
if (h.error) return { error: h.error };
|
|
@@ -72,6 +78,12 @@ const hookVm = (h) => {
|
|
|
72
78
|
wired: Boolean(h.wired),
|
|
73
79
|
filePlaced: Boolean(h.filePlaced),
|
|
74
80
|
declarationPresent: Boolean(h.declarationPresent),
|
|
81
|
+
// 0 = absent/empty declaration; null = unreadable/malformed OR an envelope predating the
|
|
82
|
+
// field — unknown reads as unknown, never as a count (the welcome-mat hook rung keys on > 0).
|
|
83
|
+
declaredGates: h.declaredGates ?? null,
|
|
84
|
+
// The preserved validation reason for a null count (null when absent) — rendered beside the
|
|
85
|
+
// unknown marker so a malformed declaration is never a silent bare "?".
|
|
86
|
+
declarationError: h.declarationError ?? null,
|
|
75
87
|
};
|
|
76
88
|
};
|
|
77
89
|
|
|
@@ -81,6 +93,7 @@ const settingsVm = (s) =>
|
|
|
81
93
|
recipes: recipesVm(s.recipes),
|
|
82
94
|
attribution: attributionVm(s.attribution),
|
|
83
95
|
velocity: velocityVm(s.velocity),
|
|
96
|
+
agents: agentsVm(s.agents),
|
|
84
97
|
hook: hookVm(s.hook),
|
|
85
98
|
}
|
|
86
99
|
: null;
|