@webpresso/agent-kit 3.3.4 → 3.3.6
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/dist/esm/audit/agents.js +126 -26
- package/dist/esm/blueprint/trust/dossier.js +41 -1
- package/dist/esm/blueprint/verification.d.ts +12 -1
- package/dist/esm/blueprint/verification.js +26 -3
- package/dist/esm/build/vendor-oxlint-plugins.d.ts +4 -1
- package/dist/esm/build/vendor-oxlint-plugins.js +9 -14
- package/dist/esm/cli/commands/init/index.js +3 -4
- package/dist/esm/cli/commands/init/scaffold-base-kit.js +5 -1
- package/dist/esm/cli/commands/mcp.js +2 -0
- package/dist/esm/cli/commands/review.d.ts +1 -0
- package/dist/esm/cli/commands/review.js +77 -1
- package/dist/esm/cli/commands/sync.d.ts +8 -6
- package/dist/esm/cli/commands/sync.js +19 -9
- package/dist/esm/config/oxlint/oxlintrc.d.ts +1 -1
- package/dist/esm/config/oxlint/oxlintrc.js +3 -12
- package/dist/esm/content/dispatch.js +4 -0
- package/dist/esm/content/loader.d.ts +1 -1
- package/dist/esm/content/loader.js +10 -6
- package/dist/esm/content/skill-policy.d.ts +11 -0
- package/dist/esm/content/skill-policy.js +33 -0
- package/dist/esm/hooks/doctor.js +114 -10
- package/dist/esm/hooks/pretool-guard/dev-routing.js +67 -4
- package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
- package/dist/esm/mcp/blueprint/_shared/lifecycle.d.ts +13 -0
- package/dist/esm/mcp/blueprint/_shared/lifecycle.js +40 -8
- package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +97 -0
- package/dist/esm/mcp/blueprint/handlers/document-mutations.js +23 -3
- package/dist/esm/mcp/blueprint/handlers/finalize.js +18 -7
- package/dist/esm/mcp/blueprint/registration.js +31 -2
- package/dist/esm/review/authority.js +56 -1
- package/dist/esm/review/delivery-verifier.d.ts +4 -0
- package/dist/esm/review/delivery-verifier.js +9 -1
- package/dist/esm/symlinker/consumers.d.ts +20 -1
- package/dist/esm/symlinker/consumers.js +15 -1
- package/dist/esm/symlinker/ownership.d.ts +48 -0
- package/dist/esm/symlinker/ownership.js +83 -0
- package/dist/esm/symlinker/unified-sync.d.ts +35 -3
- package/dist/esm/symlinker/unified-sync.js +112 -14
- package/dist/esm/test/shard-durations.json +0 -1
- package/package.json +12 -12
- package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
- package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
package/dist/esm/audit/agents.js
CHANGED
|
@@ -1,20 +1,13 @@
|
|
|
1
1
|
import { existsSync, lstatSync, readFileSync, readdirSync, readlinkSync } from "node:fs";
|
|
2
2
|
import { basename, dirname, join, relative, resolve } from "node:path";
|
|
3
3
|
import { readConfig } from "#cli/commands/init/config";
|
|
4
|
-
import { DEFAULT_PACKAGED_SKILL_SLUG_SET } from "#content/skill-policy";
|
|
4
|
+
import { AGENT_KIT_INTERNAL_SKILL_SLUGS, DEFAULT_PACKAGED_SKILL_SLUG_SET, DEFAULT_PACKAGED_SKILL_SLUGS, MERGED_DELETED_SKILL_SLUGS, UNPACKAGED_SKILL_SLUGS, } from "#content/skill-policy";
|
|
5
5
|
import { readTrustedJsonFile } from "#shared-utils/read-json-file.js";
|
|
6
6
|
import matter from "gray-matter";
|
|
7
7
|
import { WP_HOOK_SPECS } from "#cli/commands/init/scaffolders/agent-hooks/ir.js";
|
|
8
8
|
const REQUIRED_HOOKS = WP_HOOK_SPECS.map(({ event, bin }) => ({ event, bin }));
|
|
9
9
|
const CLAUDE_SETTINGS_PATH = ".claude/settings.json";
|
|
10
10
|
const CODEX_HOOKS_PATH = ".codex/hooks.json";
|
|
11
|
-
const REQUIRED_SUBAGENTS = [
|
|
12
|
-
"code-reviewer.md",
|
|
13
|
-
"security-auditor.md",
|
|
14
|
-
"doc-writer.md",
|
|
15
|
-
"explorer.md",
|
|
16
|
-
"implementer.md",
|
|
17
|
-
];
|
|
18
11
|
const EXPLORER_CONTRACT_MARKERS = [
|
|
19
12
|
"3–5 tool calls",
|
|
20
13
|
"Defs:",
|
|
@@ -54,6 +47,10 @@ export function auditAgents(rootDirectory = process.cwd()) {
|
|
|
54
47
|
checkCatalogAgents(root, violations);
|
|
55
48
|
checked += 1;
|
|
56
49
|
checkCatalogReportOnlySkills(root, violations);
|
|
50
|
+
checked += 1;
|
|
51
|
+
checkSelfHostClaudeAgents(root, violations);
|
|
52
|
+
checked += 1;
|
|
53
|
+
checkCatalogSkillClassification(root, violations);
|
|
57
54
|
}
|
|
58
55
|
else {
|
|
59
56
|
checked += 1;
|
|
@@ -117,6 +114,19 @@ function checkCatalogSkillHooks(root, violations) {
|
|
|
117
114
|
}
|
|
118
115
|
}
|
|
119
116
|
}
|
|
117
|
+
/**
|
|
118
|
+
* List the `.md` filenames (e.g. "code-reviewer.md") directly under a
|
|
119
|
+
* catalog/agent/agents-shaped directory, excluding README.md. Returns an
|
|
120
|
+
* empty array when the directory does not exist. This is the single source
|
|
121
|
+
* of truth for "which subagents are required" — no hardcoded list.
|
|
122
|
+
*/
|
|
123
|
+
function listCatalogAgentFiles(agentsSourceDir) {
|
|
124
|
+
if (!existsSync(agentsSourceDir))
|
|
125
|
+
return [];
|
|
126
|
+
return readdirSync(agentsSourceDir)
|
|
127
|
+
.filter((file) => file.endsWith(".md") && file !== "README.md")
|
|
128
|
+
.sort();
|
|
129
|
+
}
|
|
120
130
|
function checkCatalogAgents(root, violations) {
|
|
121
131
|
const agentsSource = join(root, "catalog", "agent", "agents");
|
|
122
132
|
if (!existsSync(agentsSource)) {
|
|
@@ -126,28 +136,44 @@ function checkCatalogAgents(root, violations) {
|
|
|
126
136
|
});
|
|
127
137
|
return;
|
|
128
138
|
}
|
|
129
|
-
|
|
139
|
+
const requiredFiles = listCatalogAgentFiles(agentsSource);
|
|
140
|
+
if (requiredFiles.length === 0) {
|
|
141
|
+
violations.push({
|
|
142
|
+
file: "catalog/agent/agents",
|
|
143
|
+
message: "catalog/agent/agents must contain canonical subagent markdown files.",
|
|
144
|
+
});
|
|
145
|
+
return;
|
|
146
|
+
}
|
|
147
|
+
for (const file of requiredFiles) {
|
|
148
|
+
if (file !== "explorer.md")
|
|
149
|
+
continue;
|
|
130
150
|
const sourcePath = join(agentsSource, file);
|
|
131
|
-
|
|
151
|
+
const content = readFileSync(sourcePath, "utf8");
|
|
152
|
+
for (const marker of EXPLORER_CONTRACT_MARKERS) {
|
|
153
|
+
if (content.includes(marker))
|
|
154
|
+
continue;
|
|
132
155
|
violations.push({
|
|
133
156
|
file: relative(root, sourcePath),
|
|
134
|
-
message: `
|
|
157
|
+
message: `Explorer subagent output contract is missing marker: ${marker}`,
|
|
135
158
|
});
|
|
136
|
-
continue;
|
|
137
|
-
}
|
|
138
|
-
if (file === "explorer.md") {
|
|
139
|
-
const content = readFileSync(sourcePath, "utf8");
|
|
140
|
-
for (const marker of EXPLORER_CONTRACT_MARKERS) {
|
|
141
|
-
if (content.includes(marker))
|
|
142
|
-
continue;
|
|
143
|
-
violations.push({
|
|
144
|
-
file: relative(root, sourcePath),
|
|
145
|
-
message: `Explorer subagent output contract is missing marker: ${marker}`,
|
|
146
|
-
});
|
|
147
|
-
}
|
|
148
159
|
}
|
|
149
160
|
}
|
|
150
161
|
}
|
|
162
|
+
/**
|
|
163
|
+
* Self-host-only: validate `.claude/agents/` (this repo's own generated
|
|
164
|
+
* projection) the same way a consumer's `.claude/agents/` is validated.
|
|
165
|
+
* Absent is acceptable — the surface is generated + gitignored in a clean
|
|
166
|
+
* checkout — but when present, every catalog-required subagent must resolve
|
|
167
|
+
* to a live symlink.
|
|
168
|
+
*/
|
|
169
|
+
function checkSelfHostClaudeAgents(root, violations) {
|
|
170
|
+
const agentsTarget = join(root, ".claude", "agents");
|
|
171
|
+
if (!existsSync(agentsTarget))
|
|
172
|
+
return;
|
|
173
|
+
const agentsSource = join(root, "catalog", "agent", "agents");
|
|
174
|
+
const requiredFiles = listCatalogAgentFiles(agentsSource);
|
|
175
|
+
validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations);
|
|
176
|
+
}
|
|
151
177
|
function checkCatalogReportOnlySkills(root, violations) {
|
|
152
178
|
const skillsSource = join(root, "catalog", "agent", "skills");
|
|
153
179
|
if (!existsSync(skillsSource)) {
|
|
@@ -193,6 +219,63 @@ function checkCatalogReportOnlySkills(root, violations) {
|
|
|
193
219
|
}
|
|
194
220
|
}
|
|
195
221
|
}
|
|
222
|
+
/**
|
|
223
|
+
* Resolve the required subagent file list for a consumer repo. Preferred
|
|
224
|
+
* source: the installed catalog under node_modules (the real consumer-mode
|
|
225
|
+
* source of truth that `.claude/agents/*.md` symlinks point at). When
|
|
226
|
+
* node_modules is not installed (e.g. a partial checkout), fall back to
|
|
227
|
+
* whatever `.claude/agents/*.md` already exists so symlink-health checks
|
|
228
|
+
* still run without false-positiving "missing" for agents we have no
|
|
229
|
+
* catalog reference for.
|
|
230
|
+
*/
|
|
231
|
+
function resolveConsumerRequiredAgentFiles(root) {
|
|
232
|
+
const installedAgentsSource = join(root, "node_modules", "@webpresso", "agent-kit", "catalog", "agent", "agents");
|
|
233
|
+
const fromInstalled = listCatalogAgentFiles(installedAgentsSource);
|
|
234
|
+
if (fromInstalled.length > 0)
|
|
235
|
+
return fromInstalled;
|
|
236
|
+
const agentsTarget = join(root, ".claude", "agents");
|
|
237
|
+
if (!existsSync(agentsTarget))
|
|
238
|
+
return [];
|
|
239
|
+
// Restrict to symlinks only — a hand-authored real file (Claude Code's
|
|
240
|
+
// native subagent authoring surface) is never agent-kit-managed and must
|
|
241
|
+
// not be false-flagged as "must remain a symlink" just because node_modules
|
|
242
|
+
// isn't installed to prove otherwise.
|
|
243
|
+
return readdirSync(agentsTarget)
|
|
244
|
+
.filter((file) => file.endsWith(".md") && lstatSync(join(agentsTarget, file)).isSymbolicLink())
|
|
245
|
+
.sort();
|
|
246
|
+
}
|
|
247
|
+
/**
|
|
248
|
+
* Every catalog skill slug must be accounted for as either default-packaged,
|
|
249
|
+
* explicitly unpackaged (with a reason), merged/deleted, or an
|
|
250
|
+
* agent-kit-internal maintainer-only skill. An unclassified slug is a
|
|
251
|
+
* violation — it means a new catalog skill was added without a deliberate
|
|
252
|
+
* packaging decision.
|
|
253
|
+
*/
|
|
254
|
+
function checkCatalogSkillClassification(root, violations) {
|
|
255
|
+
const skillsSource = join(root, "catalog", "agent", "skills");
|
|
256
|
+
if (!existsSync(skillsSource))
|
|
257
|
+
return; // reported by checkCatalogReportOnlySkills
|
|
258
|
+
const knownSlugs = new Set([
|
|
259
|
+
...DEFAULT_PACKAGED_SKILL_SLUGS,
|
|
260
|
+
...Object.keys(UNPACKAGED_SKILL_SLUGS),
|
|
261
|
+
...MERGED_DELETED_SKILL_SLUGS,
|
|
262
|
+
...AGENT_KIT_INTERNAL_SKILL_SLUGS,
|
|
263
|
+
]);
|
|
264
|
+
for (const entry of readdirSync(skillsSource, { withFileTypes: true })) {
|
|
265
|
+
if (!entry.isDirectory())
|
|
266
|
+
continue;
|
|
267
|
+
if (!existsSync(join(skillsSource, entry.name, "SKILL.md")))
|
|
268
|
+
continue;
|
|
269
|
+
if (knownSlugs.has(entry.name))
|
|
270
|
+
continue;
|
|
271
|
+
violations.push({
|
|
272
|
+
file: `catalog/agent/skills/${entry.name}/SKILL.md`,
|
|
273
|
+
message: `Skill "${entry.name}" is not classified as packaged or unpackaged. Add it to ` +
|
|
274
|
+
"DEFAULT_PACKAGED_SKILL_SLUGS or UNPACKAGED_SKILL_SLUGS in src/content/skill-policy.ts " +
|
|
275
|
+
"with a one-line reason.",
|
|
276
|
+
});
|
|
277
|
+
}
|
|
278
|
+
}
|
|
196
279
|
function checkClaudeAgents(root, violations) {
|
|
197
280
|
const agentsTarget = join(root, ".claude", "agents");
|
|
198
281
|
if (!existsSync(agentsTarget)) {
|
|
@@ -202,16 +285,33 @@ function checkClaudeAgents(root, violations) {
|
|
|
202
285
|
});
|
|
203
286
|
return;
|
|
204
287
|
}
|
|
205
|
-
|
|
288
|
+
const requiredFiles = resolveConsumerRequiredAgentFiles(root);
|
|
289
|
+
validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations);
|
|
290
|
+
}
|
|
291
|
+
/**
|
|
292
|
+
* Shared per-file symlink validator for `.claude/agents/<file>`, used by both
|
|
293
|
+
* the self-host and consumer audit paths. Checks: missing entry, not a
|
|
294
|
+
* symlink, wrong/unrecognized target, and dangling target.
|
|
295
|
+
*/
|
|
296
|
+
function validateClaudeAgentSymlinks(root, agentsTarget, requiredFiles, violations) {
|
|
297
|
+
for (const file of requiredFiles) {
|
|
206
298
|
const targetPath = join(agentsTarget, file);
|
|
207
|
-
|
|
299
|
+
// lstatSync (not existsSync) so a dangling symlink — an entry that
|
|
300
|
+
// exists but resolves to nothing — is distinguished from a genuinely
|
|
301
|
+
// missing entry. existsSync follows symlinks, so it would misreport
|
|
302
|
+
// every dangling symlink as "missing" and the dangling check below
|
|
303
|
+
// would never be reachable.
|
|
304
|
+
let stat;
|
|
305
|
+
try {
|
|
306
|
+
stat = lstatSync(targetPath);
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
208
309
|
violations.push({
|
|
209
310
|
file: relative(root, targetPath),
|
|
210
311
|
message: `Missing Claude subagent ${file}. Run \`wp setup\` to re-sync canonical subagents.`,
|
|
211
312
|
});
|
|
212
313
|
continue;
|
|
213
314
|
}
|
|
214
|
-
const stat = lstatSync(targetPath);
|
|
215
315
|
if (!stat.isSymbolicLink()) {
|
|
216
316
|
violations.push({
|
|
217
317
|
file: relative(root, targetPath),
|
|
@@ -39,8 +39,15 @@ export function parseTrustDossier(markdown) {
|
|
|
39
39
|
const gates = parsePromotionGatesTable(requiredBlock(blocks, "Promotion Gates"), violations);
|
|
40
40
|
const residualUnknowns = requiredBlock(blocks, "Residual Unknowns").trim();
|
|
41
41
|
for (const [section, block] of blocks) {
|
|
42
|
-
|
|
42
|
+
const fields = findPlaceholderFields(block);
|
|
43
|
+
if (fields.length > 0) {
|
|
44
|
+
for (const field of fields) {
|
|
45
|
+
violations.push({ section, message: `placeholder value not filled in: ${field}` });
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
else if (containsPlaceholder(block)) {
|
|
43
49
|
violations.push({ section, message: "placeholder values are not allowed" });
|
|
50
|
+
}
|
|
44
51
|
}
|
|
45
52
|
if (violations.length > 0 || readiness === null)
|
|
46
53
|
return { violations };
|
|
@@ -181,6 +188,39 @@ function splitRow(row) {
|
|
|
181
188
|
function containsPlaceholder(value) {
|
|
182
189
|
return /<[^>]+>|\bTBD\b|\bTODO\b/u.test(value);
|
|
183
190
|
}
|
|
191
|
+
// Names the specific field(s) inside a Trust Dossier subsection block that
|
|
192
|
+
// still carry a scaffold placeholder, instead of only flagging the section as
|
|
193
|
+
// a whole. Covers both shapes the dossier uses: `- key: value` list lines
|
|
194
|
+
// (Readiness Verdict) and markdown table rows (Material Claims/Decisions,
|
|
195
|
+
// Promotion Gates). Free-text sections (Residual Unknowns) have no named
|
|
196
|
+
// field to point at, so the caller falls back to the whole-section message
|
|
197
|
+
// when this returns an empty array.
|
|
198
|
+
function findPlaceholderFields(block) {
|
|
199
|
+
const fields = [];
|
|
200
|
+
for (const line of block.split("\n")) {
|
|
201
|
+
const match = /^-\s*([^:]+):\s*(.+?)\s*$/u.exec(line.trim());
|
|
202
|
+
if (match && containsPlaceholder(match[2] ?? ""))
|
|
203
|
+
fields.push(match[1].trim());
|
|
204
|
+
}
|
|
205
|
+
const rows = block
|
|
206
|
+
.split("\n")
|
|
207
|
+
.map((line) => line.trim())
|
|
208
|
+
.filter((line) => line.startsWith("|") && line.endsWith("|"));
|
|
209
|
+
if (rows.length >= 2) {
|
|
210
|
+
const header = splitRow(rows[0] ?? "");
|
|
211
|
+
for (const row of rows.slice(2)) {
|
|
212
|
+
const cells = splitRow(row);
|
|
213
|
+
for (const [index, cell] of cells.entries()) {
|
|
214
|
+
if (!containsPlaceholder(cell))
|
|
215
|
+
continue;
|
|
216
|
+
const columnName = header[index] ?? `column ${index + 1}`;
|
|
217
|
+
const rowId = cells[0] ? ` (row ${cells[0]})` : "";
|
|
218
|
+
fields.push(`${columnName}${rowId}`);
|
|
219
|
+
}
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
return fields;
|
|
223
|
+
}
|
|
184
224
|
function escapeRegExp(value) {
|
|
185
225
|
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
186
226
|
}
|
|
@@ -110,6 +110,17 @@ export declare function readTaskVerification(markdown: string, taskId: string):
|
|
|
110
110
|
*/
|
|
111
111
|
export declare function assertTaskHasCanonicalPassingEvidence(markdown: string, taskId: string): readonly Evidence[];
|
|
112
112
|
/**
|
|
113
|
-
*
|
|
113
|
+
* Collect a failure message for every supplied task id that is missing
|
|
114
|
+
* task-local canonical passing evidence, instead of stopping at the first
|
|
115
|
+
* one. Callers that need to report every blocking condition in a single
|
|
116
|
+
* response (e.g. `wp_blueprint_finalize`) use this directly; callers that
|
|
117
|
+
* only need a single throw use {@link assertAllTasksHaveCanonicalPassingEvidence}.
|
|
118
|
+
*/
|
|
119
|
+
export declare function collectMissingCanonicalPassingEvidence(markdown: string, taskIds: readonly string[]): string[];
|
|
120
|
+
/**
|
|
121
|
+
* Assert that each supplied task id has task-local canonical passing
|
|
122
|
+
* evidence. Throws once with every failing task's message joined, rather
|
|
123
|
+
* than stopping at the first failure — a caller fixing one issue at a time
|
|
124
|
+
* would otherwise need one failed round-trip per missing-evidence task.
|
|
114
125
|
*/
|
|
115
126
|
export declare function assertAllTasksHaveCanonicalPassingEvidence(markdown: string, taskIds: readonly string[]): void;
|
|
@@ -173,11 +173,34 @@ export function assertTaskHasCanonicalPassingEvidence(markdown, taskId) {
|
|
|
173
173
|
return evidence;
|
|
174
174
|
}
|
|
175
175
|
/**
|
|
176
|
-
*
|
|
176
|
+
* Collect a failure message for every supplied task id that is missing
|
|
177
|
+
* task-local canonical passing evidence, instead of stopping at the first
|
|
178
|
+
* one. Callers that need to report every blocking condition in a single
|
|
179
|
+
* response (e.g. `wp_blueprint_finalize`) use this directly; callers that
|
|
180
|
+
* only need a single throw use {@link assertAllTasksHaveCanonicalPassingEvidence}.
|
|
177
181
|
*/
|
|
178
|
-
export function
|
|
182
|
+
export function collectMissingCanonicalPassingEvidence(markdown, taskIds) {
|
|
183
|
+
const failures = [];
|
|
179
184
|
for (const taskId of taskIds) {
|
|
180
|
-
|
|
185
|
+
try {
|
|
186
|
+
assertTaskHasCanonicalPassingEvidence(markdown, taskId);
|
|
187
|
+
}
|
|
188
|
+
catch (error) {
|
|
189
|
+
failures.push(error instanceof Error ? error.message : String(error));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
return failures;
|
|
193
|
+
}
|
|
194
|
+
/**
|
|
195
|
+
* Assert that each supplied task id has task-local canonical passing
|
|
196
|
+
* evidence. Throws once with every failing task's message joined, rather
|
|
197
|
+
* than stopping at the first failure — a caller fixing one issue at a time
|
|
198
|
+
* would otherwise need one failed round-trip per missing-evidence task.
|
|
199
|
+
*/
|
|
200
|
+
export function assertAllTasksHaveCanonicalPassingEvidence(markdown, taskIds) {
|
|
201
|
+
const failures = collectMissingCanonicalPassingEvidence(markdown, taskIds);
|
|
202
|
+
if (failures.length > 0) {
|
|
203
|
+
throw new Error(failures.join("; "));
|
|
181
204
|
}
|
|
182
205
|
}
|
|
183
206
|
// ---------------------------------------------------------------------------
|
|
@@ -1,7 +1,10 @@
|
|
|
1
1
|
#!/usr/bin/env bun
|
|
2
2
|
export declare const AGENT_CONFIG_OXLINT_DIST: string;
|
|
3
3
|
export declare const AGENT_KIT_OXLINT_DIST: string;
|
|
4
|
-
/**
|
|
4
|
+
/**
|
|
5
|
+
* Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles).
|
|
6
|
+
* SSOT: `@webpresso/agent-config/oxlint/basenames`.
|
|
7
|
+
*/
|
|
5
8
|
export declare const VENDOR_PLUGIN_BASENAMES: readonly ["code-safety", "foundation-purity", "graphql-conventions", "import-hygiene", "monorepo-paths", "query-patterns", "testing-quality", "tier-boundaries"];
|
|
6
9
|
export declare function vendorOxlintPlugins(sourceDir?: string, destDir?: string): {
|
|
7
10
|
copied: string[];
|
|
@@ -9,19 +9,14 @@
|
|
|
9
9
|
*/
|
|
10
10
|
import { cpSync, existsSync, mkdirSync } from "node:fs";
|
|
11
11
|
import { join } from "node:path";
|
|
12
|
+
import { OXLINT_PATH_ROLES_BASENAME, OXLINT_POLICY_PLUGIN_BASENAMES, } from "@webpresso/agent-config/oxlint/basenames";
|
|
12
13
|
export const AGENT_CONFIG_OXLINT_DIST = join(process.cwd(), "packages/agent-config/dist/esm/oxlint");
|
|
13
14
|
export const AGENT_KIT_OXLINT_DIST = join(process.cwd(), "dist/esm/config/oxlint");
|
|
14
|
-
/**
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
"import-hygiene",
|
|
20
|
-
"monorepo-paths",
|
|
21
|
-
"query-patterns",
|
|
22
|
-
"testing-quality",
|
|
23
|
-
"tier-boundaries",
|
|
24
|
-
];
|
|
15
|
+
/**
|
|
16
|
+
* Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles).
|
|
17
|
+
* SSOT: `@webpresso/agent-config/oxlint/basenames`.
|
|
18
|
+
*/
|
|
19
|
+
export const VENDOR_PLUGIN_BASENAMES = OXLINT_POLICY_PLUGIN_BASENAMES;
|
|
25
20
|
export function vendorOxlintPlugins(sourceDir = AGENT_CONFIG_OXLINT_DIST, destDir = AGENT_KIT_OXLINT_DIST) {
|
|
26
21
|
if (!existsSync(sourceDir)) {
|
|
27
22
|
throw new Error(`vendor-oxlint-plugins: missing agent-config oxlint dist at ${sourceDir}. Build @webpresso/agent-config first.`);
|
|
@@ -39,12 +34,12 @@ export function vendorOxlintPlugins(sourceDir = AGENT_CONFIG_OXLINT_DIST, destDi
|
|
|
39
34
|
// path-roles is imported by several plugins as a sibling — vendor it too.
|
|
40
35
|
}
|
|
41
36
|
// Always vendor path-roles (shared classifier used via relative imports inside plugins).
|
|
42
|
-
const pathRoles = join(sourceDir,
|
|
37
|
+
const pathRoles = join(sourceDir, `${OXLINT_PATH_ROLES_BASENAME}.js`);
|
|
43
38
|
if (!existsSync(pathRoles)) {
|
|
44
39
|
throw new Error(`vendor-oxlint-plugins: missing ${pathRoles}`);
|
|
45
40
|
}
|
|
46
|
-
cpSync(pathRoles, join(destDir,
|
|
47
|
-
copied.push(
|
|
41
|
+
cpSync(pathRoles, join(destDir, `${OXLINT_PATH_ROLES_BASENAME}.js`));
|
|
42
|
+
copied.push(OXLINT_PATH_ROLES_BASENAME);
|
|
48
43
|
return { copied };
|
|
49
44
|
}
|
|
50
45
|
if (import.meta.main) {
|
|
@@ -61,7 +61,6 @@ import { ensureOpenCodeWebpressoPlugin } from "./scaffolders/opencode-plugin/ind
|
|
|
61
61
|
import { ensureGrokWebpressoMcp } from "./scaffolders/grok-mcp/index.js";
|
|
62
62
|
import { ensureRtk } from "./scaffolders/rtk/index.js";
|
|
63
63
|
import { checkRuntimes } from "./scaffolders/runtime-check/index.js";
|
|
64
|
-
import { scaffoldSubagents } from "./scaffolders/subagents/index.js";
|
|
65
64
|
import { maybeRunVisionInterview } from "./scaffolders/vision/interview.js";
|
|
66
65
|
import { scaffoldVision } from "./scaffolders/vision/index.js";
|
|
67
66
|
import { scaffoldWorkspaceConfig } from "./scaffolders/workspace-config/index.js";
|
|
@@ -1333,7 +1332,7 @@ export async function runInit(flags, deps = {}) {
|
|
|
1333
1332
|
runUnifiedSync({
|
|
1334
1333
|
catalogDir: join(catalogDir, "agent"),
|
|
1335
1334
|
consumerRoot: consumer.repoRoot,
|
|
1336
|
-
kinds: ["rule", "skill"],
|
|
1335
|
+
kinds: ["rule", "skill", "agent"],
|
|
1337
1336
|
check: false,
|
|
1338
1337
|
allowedSkillSlugs,
|
|
1339
1338
|
hosts: selectedHosts,
|
|
@@ -1484,7 +1483,8 @@ export async function runInit(flags, deps = {}) {
|
|
|
1484
1483
|
}
|
|
1485
1484
|
throw error;
|
|
1486
1485
|
}
|
|
1487
|
-
|
|
1486
|
+
// Canonical subagents (.claude/agents/) are projected by the unified sync
|
|
1487
|
+
// call above (kinds includes "agent") — no standalone scaffolder here.
|
|
1488
1488
|
// Generated host instructions (AGENTS.md) are a tier-1 (guarded/full)
|
|
1489
1489
|
// surface; the tier-0 quality profile omits them.
|
|
1490
1490
|
// Base-kit may have created or updated package metadata earlier in this
|
|
@@ -1853,7 +1853,6 @@ export async function runInit(flags, deps = {}) {
|
|
|
1853
1853
|
]
|
|
1854
1854
|
: []),
|
|
1855
1855
|
...claudeRulesResults,
|
|
1856
|
-
...subagentResults,
|
|
1857
1856
|
...presetResults,
|
|
1858
1857
|
];
|
|
1859
1858
|
const summary = summarizeResults(all);
|
|
@@ -209,7 +209,11 @@ export function ensureGuardrailsCiStep(content, repoAlreadyWired = false) {
|
|
|
209
209
|
// Safe: anchorStepStart is an index returned by findLiveTestRunAnchor over
|
|
210
210
|
// this same content.split("\n"), so it is always within lines' bounds here.
|
|
211
211
|
const indent = lines[anchorStepStart].match(/^(\s*)/u)?.[1] ?? "";
|
|
212
|
-
|
|
212
|
+
// continue-on-error: true matches framework quality.yml and keeps
|
|
213
|
+
// ci-guardrails-wiring green without hard-failing thin consumers on
|
|
214
|
+
// agent-kit dogfood audits (command-surface, etc.) or known suite debt.
|
|
215
|
+
// Explicit hard-fail audits remain separate workflow steps.
|
|
216
|
+
lines.splice(anchorStepStart, 0, `${indent}- name: Run wp audit guardrails`, `${indent} continue-on-error: true`, `${indent} run: wp audit guardrails`);
|
|
213
217
|
return lines.join("\n");
|
|
214
218
|
}
|
|
215
219
|
function migrateConsumerWorkflows(repoRoot, actionRef, version, options) {
|
|
@@ -6,6 +6,8 @@ export const MCP_COMMAND_HELP = [
|
|
|
6
6
|
"by a Claude Code plugin manifest entry (`mcpServers.webpresso`) or any MCP",
|
|
7
7
|
"client. Tools are auto-discovered from `dist/esm/mcp/tools/*.js`.",
|
|
8
8
|
"",
|
|
9
|
+
"No subcommands are supported; use `wp review gate` for review workflows.",
|
|
10
|
+
"",
|
|
9
11
|
"Examples:",
|
|
10
12
|
" wp mcp",
|
|
11
13
|
].join("\n");
|
|
@@ -220,6 +220,7 @@ export interface ReviewGateDependencies {
|
|
|
220
220
|
*/
|
|
221
221
|
readonly prepareOpencodeCredential?: (account: OpencodeAccount) => string;
|
|
222
222
|
}
|
|
223
|
+
export declare function describeVerificationFailure(verification: ReviewGateVerification | undefined, gateId: string): string;
|
|
223
224
|
/**
|
|
224
225
|
* Sanctioned fallback when `wp review gate` does not reach "approved" — points
|
|
225
226
|
* at the tracked-provenance path (`wp review log` / `wp_blueprint_review_log`)
|
|
@@ -28,6 +28,7 @@ import { isPolicyApprovalVerdict } from "#review/verdict.js";
|
|
|
28
28
|
import { verificationArtifactSection, verificationPromptSummary, verifyDeliveryPromotionGates, } from "#review/delivery-verifier.js";
|
|
29
29
|
import { createDeliverySubjectAtRef, createPlanSubjectAtRef, findUnreviewedWorkingTreeChanges, } from "#review/subject.js";
|
|
30
30
|
import { runCommand } from "#mcp/tools/_shared/run-command.js";
|
|
31
|
+
import { ALLOWED_WP_COMMAND_TIMEOUT_MS } from "#trust/command-runner.js";
|
|
31
32
|
import { NotInGitRepoError, withLock } from "#paths/state-root.js";
|
|
32
33
|
import { writeFileAtomic, writeJsonFileAtomic } from "#shared-utils/write-json-file.js";
|
|
33
34
|
export const REVIEWER_IDS = [
|
|
@@ -1844,6 +1845,64 @@ function selectGateProviders(input) {
|
|
|
1844
1845
|
const available = ordered.filter((provider) => !isProviderSuppressed(input.availability, provider));
|
|
1845
1846
|
return available.length > 0 ? available : [];
|
|
1846
1847
|
}
|
|
1848
|
+
// Builds the `message` for a "verification-failed" gate result: host-side
|
|
1849
|
+
// verification (delivery Promotion Gates + Trust Dossier parsing) died before
|
|
1850
|
+
// any review provider ran, so `attempts` is empty and the default
|
|
1851
|
+
// approval-gate-template message ("Approval gate not satisfied (...)") reads
|
|
1852
|
+
// like a reviewer rejected the work when no reviewer was ever invoked. Names
|
|
1853
|
+
// the actual cause and the corrective action so a failed gate does not cost
|
|
1854
|
+
// a repeat run just to discover what happened. Exported for tests.
|
|
1855
|
+
export function describeVerificationFailure(verification, gateId) {
|
|
1856
|
+
const resume = `Retry with --resume ${gateId} once fixed.`;
|
|
1857
|
+
const preamble = "Host verification failed before any review provider ran (0 review attempts) -- this is not a provider/quota failure.";
|
|
1858
|
+
if (!verification || verification.status !== "failed") {
|
|
1859
|
+
return `${preamble} ${resume}`;
|
|
1860
|
+
}
|
|
1861
|
+
if (verification.failureCode === "malformed-trust-dossier") {
|
|
1862
|
+
const fields = (verification.violations ?? [])
|
|
1863
|
+
.map((violation) => `${violation.section}: ${violation.message}`)
|
|
1864
|
+
.join("; ");
|
|
1865
|
+
return `${preamble} The Trust Dossier is malformed${fields ? ` (${fields})` : ""}. Fill in the named field(s) via wp_blueprint_put (or wp blueprint promote to see the same detail) before gating again. ${resume}`;
|
|
1866
|
+
}
|
|
1867
|
+
const failedCommand = verification.commands.find((command) => command.outcome !== "passed");
|
|
1868
|
+
if (verification.failureCode === "command-timed-out" && failedCommand) {
|
|
1869
|
+
const budgetSeconds = ALLOWED_WP_COMMAND_TIMEOUT_MS / 1000;
|
|
1870
|
+
return (`${preamble} The Promotion Gate command \`${failedCommand.command}\` exceeded the ` +
|
|
1871
|
+
`${budgetSeconds}s sandboxed gate budget and was killed -- this is a fixed per-command ` +
|
|
1872
|
+
"budget, not a live outage, and raising it is not the fix. Either replace this gate with " +
|
|
1873
|
+
"a faster command (e.g. `wp lint`, or a narrow `wp audit <kind>`), or mark this Promotion " +
|
|
1874
|
+
'Gates row `defer: "pre-implementation"` so it is recorded without being executed at gate ' +
|
|
1875
|
+
`time. ${resume}`);
|
|
1876
|
+
}
|
|
1877
|
+
if (failedCommand) {
|
|
1878
|
+
const exitDetail = failedCommand.exitCode === null ? "no exit code" : `exit ${failedCommand.exitCode}`;
|
|
1879
|
+
const logDetail = verification.logPath ? ` Log: ${verification.logPath}.` : "";
|
|
1880
|
+
return (`${preamble} Gate command \`${failedCommand.command}\` failed (${exitDetail}, ` +
|
|
1881
|
+
`outcome: ${failedCommand.outcome}).${logDetail} This is usually a missing dependency or ` +
|
|
1882
|
+
"stale install in the sandboxed checkout -- run the repo's install command " +
|
|
1883
|
+
`(e.g. \`wp install\`) and retry. ${resume}`);
|
|
1884
|
+
}
|
|
1885
|
+
const codeDetail = verification.failureCode ? ` (failureCode: ${verification.failureCode})` : "";
|
|
1886
|
+
return `${preamble} Verification failed${codeDetail} before any gate command ran. ${resume}`;
|
|
1887
|
+
}
|
|
1888
|
+
// Human-readable summary of why every candidate provider/reviewer is
|
|
1889
|
+
// currently suppressed in the project's availability.json cache, so a
|
|
1890
|
+
// zero-candidate gate result names the file and the reason instead of
|
|
1891
|
+
// looking like a silent no-op.
|
|
1892
|
+
function describeSuppressedCandidates(availability, candidates) {
|
|
1893
|
+
return candidates
|
|
1894
|
+
.map((provider) => {
|
|
1895
|
+
const record = availability.providers[provider];
|
|
1896
|
+
if (!record?.suppressed)
|
|
1897
|
+
return undefined;
|
|
1898
|
+
const reason = record.suppressionReason ?? record.terminalClassification ?? "unknown reason";
|
|
1899
|
+
const since = record.suppressedAt ? ` since ${record.suppressedAt}` : "";
|
|
1900
|
+
const retry = record.retryAfterMs === undefined ? "" : ` (retry window ${record.retryAfterMs}ms)`;
|
|
1901
|
+
return `${provider}: suppressed${since} (${reason}${retry})`;
|
|
1902
|
+
})
|
|
1903
|
+
.filter((value) => value !== undefined)
|
|
1904
|
+
.join("; ");
|
|
1905
|
+
}
|
|
1847
1906
|
function safeArtifactPath(blueprintPath, relativeArtifact) {
|
|
1848
1907
|
if (path.isAbsolute(relativeArtifact) || relativeArtifact.split(/[\\/]/u).includes("..")) {
|
|
1849
1908
|
throw new Error(`Unsafe review artifact path: ${relativeArtifact}`);
|
|
@@ -2225,7 +2284,7 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
|
|
|
2225
2284
|
prompt = renderPrompt(summary);
|
|
2226
2285
|
}
|
|
2227
2286
|
if (verification?.status === "failed") {
|
|
2228
|
-
const result = makeResult(currentApprovalGate(), "verification-failed");
|
|
2287
|
+
const result = makeResult(currentApprovalGate(), "verification-failed", describeVerificationFailure(verification, gateId));
|
|
2229
2288
|
assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
|
|
2230
2289
|
persistGateState(projectRoot, result);
|
|
2231
2290
|
cleanupSharedCheckout();
|
|
@@ -2297,6 +2356,23 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
|
|
|
2297
2356
|
availability,
|
|
2298
2357
|
...(explicitProvider ? { explicitProvider } : {}),
|
|
2299
2358
|
});
|
|
2359
|
+
if (providerQueue.length === 0) {
|
|
2360
|
+
const candidates = hostNeutralProviderOrder(explicitProvider);
|
|
2361
|
+
const detail = describeSuppressedCandidates(availability, candidates);
|
|
2362
|
+
const availabilityFile = path
|
|
2363
|
+
.relative(projectRoot, reviewAvailabilityPath(projectRoot))
|
|
2364
|
+
.replace(/\\/gu, "/");
|
|
2365
|
+
const result = makeResult(approvalGate, "needs-review", `No review provider was invoked: every candidate provider is suppressed by the ` +
|
|
2366
|
+
`cached availability record at ${availabilityFile}${detail ? ` (${detail})` : ""}. ` +
|
|
2367
|
+
"This is a stale-cache condition, not a live provider outage -- if the " +
|
|
2368
|
+
"account/provider has since recovered or been re-authenticated, delete the stale " +
|
|
2369
|
+
`entry (or the whole file) at ${availabilityFile}, or wait for its suppression ` +
|
|
2370
|
+
`window to elapse, then retry with --resume ${gateId}.`);
|
|
2371
|
+
assertReviewRefUnchanged(projectRoot, input.target ?? "HEAD", targetSha);
|
|
2372
|
+
persistGateState(projectRoot, result);
|
|
2373
|
+
cleanupSharedCheckout();
|
|
2374
|
+
return result;
|
|
2375
|
+
}
|
|
2300
2376
|
const usedReviewers = new Set([
|
|
2301
2377
|
...attempts
|
|
2302
2378
|
.filter((attempt) => !isRetryableGateAttempt(attempt))
|
|
@@ -1,16 +1,18 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `wp sync` — projects the canonical webpresso rule/skill catalog into
|
|
3
|
-
* supported host surfaces.
|
|
2
|
+
* `wp sync` — projects the canonical webpresso rule/skill/agent catalog into
|
|
3
|
+
* the supported host surfaces.
|
|
4
4
|
*
|
|
5
|
-
* Projects unified rule + skill content (catalog ∪ consumer) into
|
|
6
|
-
* surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
|
|
5
|
+
* Projects unified rule + skill + agent content (catalog ∪ consumer) into
|
|
6
|
+
* per-IDE surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
|
|
7
7
|
*
|
|
8
8
|
* Flags:
|
|
9
|
-
* --kind rules|skills Filter to a single kind (default:
|
|
10
|
-
* --check
|
|
9
|
+
* --kind rules|skills|agents Filter to a single kind (default: all).
|
|
10
|
+
* --check Dry-run; exit 1 on first drift, no writes.
|
|
11
11
|
*/
|
|
12
12
|
import type { CAC } from "cac";
|
|
13
|
+
import type { ContentKind } from "#content/loader";
|
|
13
14
|
import { type ConsumerContext } from "./init/detect-consumer.js";
|
|
15
|
+
export declare function parseKind(input: string | undefined): readonly ContentKind[] | undefined;
|
|
14
16
|
export declare function computeSyncDriftCounts(input: {
|
|
15
17
|
readonly surfaceFixCount: number;
|
|
16
18
|
readonly agentsChanged: boolean;
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* `wp sync` — projects the canonical webpresso rule/skill catalog into
|
|
3
|
-
* supported host surfaces.
|
|
2
|
+
* `wp sync` — projects the canonical webpresso rule/skill/agent catalog into
|
|
3
|
+
* the supported host surfaces.
|
|
4
4
|
*
|
|
5
|
-
* Projects unified rule + skill content (catalog ∪ consumer) into
|
|
6
|
-
* surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
|
|
5
|
+
* Projects unified rule + skill + agent content (catalog ∪ consumer) into
|
|
6
|
+
* per-IDE surfaces according to `DEFAULT_UNIFIED_CONSUMERS`.
|
|
7
7
|
*
|
|
8
8
|
* Flags:
|
|
9
|
-
* --kind rules|skills Filter to a single kind (default:
|
|
10
|
-
* --check
|
|
9
|
+
* --kind rules|skills|agents Filter to a single kind (default: all).
|
|
10
|
+
* --check Dry-run; exit 1 on first drift, no writes.
|
|
11
11
|
*/
|
|
12
12
|
import { existsSync } from "node:fs";
|
|
13
13
|
import { join } from "node:path";
|
|
@@ -25,14 +25,16 @@ function commandError(message, exitCode = 1) {
|
|
|
25
25
|
err.exitCode = exitCode;
|
|
26
26
|
return err;
|
|
27
27
|
}
|
|
28
|
-
function parseKind(input) {
|
|
28
|
+
export function parseKind(input) {
|
|
29
29
|
if (input === undefined)
|
|
30
30
|
return undefined;
|
|
31
31
|
if (input === "rules" || input === "rule")
|
|
32
32
|
return ["rule"];
|
|
33
33
|
if (input === "skills" || input === "skill")
|
|
34
34
|
return ["skill"];
|
|
35
|
-
|
|
35
|
+
if (input === "agents" || input === "agent")
|
|
36
|
+
return ["agent"];
|
|
37
|
+
throw commandError(`Invalid --kind: ${input}. Must be 'rules', 'skills', or 'agents'.`);
|
|
36
38
|
}
|
|
37
39
|
function formatMismatches(mismatches) {
|
|
38
40
|
return mismatches.map((m) => ` - [${m.consumerId}] ${m.targetPath}: ${m.reason}`).join("\n");
|
|
@@ -90,7 +92,7 @@ export function isUnmaterializedAgentKitSourceWorktree(consumer) {
|
|
|
90
92
|
export function registerSyncCommand(cli) {
|
|
91
93
|
cli
|
|
92
94
|
.command("sync", "Sync agent rules + skills across all supported host surfaces")
|
|
93
|
-
.option("--kind <kind>", "Filter: rules | skills (default:
|
|
95
|
+
.option("--kind <kind>", "Filter: rules | skills | agents (default: all)")
|
|
94
96
|
.option("--check", "Exit 1 on drift; no writes")
|
|
95
97
|
.action(async (options = {}) => {
|
|
96
98
|
const kinds = parseKind(options.kind);
|
|
@@ -140,6 +142,14 @@ export function registerSyncCommand(cli) {
|
|
|
140
142
|
}
|
|
141
143
|
throw error;
|
|
142
144
|
}
|
|
145
|
+
// Operator-owned paths agent-kit refused to replace. A warning, never
|
|
146
|
+
// drift: treating it as a mismatch would red `wp sync --check` on every
|
|
147
|
+
// run for someone who legitimately customized a subagent.
|
|
148
|
+
for (const entry of result.preserved) {
|
|
149
|
+
console.warn(`wp sync: preserved ${entry.targetPath} — it is not an agent-kit projection, ` +
|
|
150
|
+
`so the catalog '${entry.slug}' agent was not written there. ` +
|
|
151
|
+
`Rename yours, or move it to agent-agents/ to have it managed.`);
|
|
152
|
+
}
|
|
143
153
|
const retiredMcp = cleanupRetiredMcpFanouts(repoRoot, { check });
|
|
144
154
|
for (const targetPath of retiredMcp.preserved) {
|
|
145
155
|
console.warn(`wp sync: preserved divergent retired MCP target ${targetPath}; remove it manually if it is no longer needed.`);
|