@webpresso/agent-kit 3.3.2 → 3.3.4
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/THIRD-PARTY-NOTICES.md +12 -10
- package/catalog/AGENTS.md.tpl +3 -5
- package/catalog/agent/rules/changeset-release.md +15 -4
- package/catalog/agent/rules/managed-tool-binaries.md +67 -0
- package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
- package/catalog/agent/rules/pre-implementation.md +36 -20
- package/catalog/agent/rules/rtk-routing.md +22 -16
- package/catalog/agent/skills/autopilot/SKILL.md +4 -3
- package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
- package/catalog/agent/skills/verify/SKILL.md +12 -12
- package/catalog/compose/admin-blocks-registry.json +659 -0
- package/dist/esm/audit/hook-surface.js +5 -1
- package/dist/esm/audit/repo-guardrails.d.ts +8 -0
- package/dist/esm/audit/repo-guardrails.js +71 -1
- package/dist/esm/blueprint/core/parser.js +40 -16
- package/dist/esm/blueprint/core/validation/criteria.js +2 -1
- package/dist/esm/blueprint/execution/artifacts.js +8 -4
- package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
- package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
- package/dist/esm/blueprint/execution/metadata.js +8 -4
- package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
- package/dist/esm/blueprint/lifecycle/audit.js +89 -3
- package/dist/esm/blueprint/lifecycle/review-provenance.js +148 -40
- package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
- package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
- package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
- package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
- package/dist/esm/blueprint/markdown/helpers.js +10 -4
- package/dist/esm/blueprint/tracked-document/parser.js +3 -3
- package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
- package/dist/esm/blueprint/trust/gate-row.js +113 -0
- package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
- package/dist/esm/blueprint/trust/promotion.js +19 -19
- package/dist/esm/build/atomic-file-copy.d.ts +32 -0
- package/dist/esm/build/atomic-file-copy.js +44 -0
- package/dist/esm/build/cli-mcp-parity.js +3 -2
- package/dist/esm/build/package-manifest.js +7 -2
- package/dist/esm/build/vendor-oxlint-plugins.d.ts +8 -0
- package/dist/esm/build/vendor-oxlint-plugins.js +53 -0
- package/dist/esm/cli/commands/audit-core.js +9 -1
- package/dist/esm/cli/commands/audit.js +16 -5
- package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
- package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
- package/dist/esm/cli/commands/compile.d.ts +13 -0
- package/dist/esm/cli/commands/compile.js +182 -62
- package/dist/esm/cli/commands/format.js +17 -1
- package/dist/esm/cli/commands/gain/index.js +10 -3
- package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
- package/dist/esm/cli/commands/init/index.js +29 -48
- package/dist/esm/cli/commands/init/mcp-spec.d.ts +54 -4
- package/dist/esm/cli/commands/init/mcp-spec.js +126 -22
- package/dist/esm/cli/commands/init/merge.js +30 -10
- package/dist/esm/cli/commands/init/scaffold-agents-md.js +5 -7
- package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
- package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
- package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
- package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
- package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
- package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
- package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
- package/dist/esm/cli/commands/package-manager.js +8 -2
- package/dist/esm/cli/commands/pr-help.js +1 -1
- package/dist/esm/cli/commands/pr.js +18 -4
- package/dist/esm/cli/commands/review.js +60 -6
- package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
- package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
- package/dist/esm/cli/commands/worktree/router.js +5 -2
- package/dist/esm/cli/direct-provider-launch.js +12 -4
- package/dist/esm/compiler/flatten.d.ts +10 -1
- package/dist/esm/compiler/flatten.js +14 -4
- package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
- package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
- package/dist/esm/compose/compose.d.ts +46 -0
- package/dist/esm/compose/compose.js +250 -0
- package/dist/esm/compose/escape-html.d.ts +5 -0
- package/dist/esm/compose/escape-html.js +12 -0
- package/dist/esm/compose/ops-report-render.d.ts +5 -0
- package/dist/esm/compose/ops-report-render.js +71 -0
- package/dist/esm/compose/ops-report-schema.d.ts +42 -0
- package/dist/esm/compose/ops-report-schema.js +60 -0
- package/dist/esm/compose/preview.d.ts +14 -0
- package/dist/esm/compose/preview.js +32 -0
- package/dist/esm/compose/registry-resolve.d.ts +64 -0
- package/dist/esm/compose/registry-resolve.js +200 -0
- package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
- package/dist/esm/config/oxlint/import-hygiene.js +21 -8
- package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
- package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
- package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
- package/dist/esm/config/oxlint/oxlintrc.js +18 -21
- package/dist/esm/config/oxlint/path-roles.js +53 -0
- package/dist/esm/config/oxlint/query-patterns.js +2 -5
- package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
- package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
- package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
- package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
- package/dist/esm/daemon/domains/launch.d.ts +25 -0
- package/dist/esm/daemon/domains/launch.js +30 -1
- package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
- package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
- package/dist/esm/daemon/handlers/product.d.ts +10 -0
- package/dist/esm/daemon/handlers/product.js +10 -1
- package/dist/esm/daemon/methods.d.ts +21 -1
- package/dist/esm/daemon/methods.js +20 -0
- package/dist/esm/daemon/protocol/contract.d.ts +58 -0
- package/dist/esm/daemon/protocol/contract.js +38 -0
- package/dist/esm/daemon/server.js +7 -0
- package/dist/esm/docs-linter/blueprint-plan.js +2 -2
- package/dist/esm/errors/wp-error.js +21 -2
- package/dist/esm/git/changed-files.d.ts +20 -0
- package/dist/esm/git/changed-files.js +63 -0
- package/dist/esm/hooks/doctor.d.ts +11 -0
- package/dist/esm/hooks/doctor.js +114 -45
- package/dist/esm/hooks/pretool-guard/dev-routing.js +1 -1
- package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
- package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
- package/dist/esm/hooks/pretool-guard/runner.js +6 -0
- package/dist/esm/hooks/status/index.js +1 -1
- package/dist/esm/hooks/stop/qa-changed-files.js +6 -1
- package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
- package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
- package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
- package/dist/esm/mcp/blueprint/handlers/validate.js +6 -0
- package/dist/esm/mcp/blueprint/registration.js +1 -1
- package/dist/esm/mcp/tools/_names.d.ts +1 -1
- package/dist/esm/mcp/tools/_names.js +4 -0
- package/dist/esm/mcp/tools/_registry.js +8 -0
- package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
- package/dist/esm/mcp/tools/_shared/redact.js +114 -8
- package/dist/esm/mcp/tools/gain.js +28 -2
- package/dist/esm/mcp/tools/release-progress.d.ts +9 -0
- package/dist/esm/mcp/tools/release-progress.js +110 -0
- package/dist/esm/mcp/tools/worktree.js +65 -3
- package/dist/esm/mcp/tools/wp-ui-blocks-list.d.ts +3 -0
- package/dist/esm/mcp/tools/wp-ui-blocks-list.js +43 -0
- package/dist/esm/mcp/tools/wp-ui-compose.d.ts +3 -0
- package/dist/esm/mcp/tools/wp-ui-compose.js +96 -0
- package/dist/esm/mcp/tools/wp-ui-preview.d.ts +3 -0
- package/dist/esm/mcp/tools/wp-ui-preview.js +59 -0
- package/dist/esm/package.json +2 -0
- package/dist/esm/review/lifecycle-writers.d.ts +76 -0
- package/dist/esm/review/lifecycle-writers.js +330 -0
- package/dist/esm/review/subject.js +66 -32
- package/dist/esm/status/snapshot.d.ts +5 -0
- package/dist/esm/status/snapshot.js +27 -2
- package/dist/esm/test/shard-durations.json +0 -1
- package/dist/esm/test-helpers/global-setup.js +38 -3
- package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
- package/dist/esm/tool-runtime/managed-rtk.js +275 -0
- package/dist/esm/tool-runtime/resolve-runner.js +18 -194
- package/dist/esm/utils/package-root.d.ts +32 -0
- package/dist/esm/utils/package-root.js +82 -0
- package/dist/esm/worktrees/git-metadata.d.ts +41 -0
- package/dist/esm/worktrees/git-metadata.js +48 -0
- package/package.json +27 -21
- package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
- package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
- package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
- package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
- package/dist/esm/config/oxlint/index.d.ts +0 -22
- package/dist/esm/config/oxlint/index.js +0 -28
- package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
- package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
- package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
- package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
- package/dist/esm/output-transforms/rulesync.d.ts +0 -2
- package/dist/esm/output-transforms/rulesync.js +0 -79
|
@@ -2,15 +2,29 @@ import { PR_WAIT_UNTIL_VALUES, runPrWait } from "#mcp/tools/pr-wait.js";
|
|
|
2
2
|
import { buildStatusSnapshot, formatStatusSnapshot } from "#status/snapshot.js";
|
|
3
3
|
// PR help text lives in ./pr-help.ts (dependency-free) so the `wp pr --help`
|
|
4
4
|
// fast path can render it without importing this heavy command module.
|
|
5
|
-
|
|
5
|
+
// Mirrors the hard cap `readonlyOpsBaseSchema` enforces downstream for `pr
|
|
6
|
+
// wait` (src/mcp/tools/_readonly-ops.ts). `pr status` shares the same
|
|
7
|
+
// `--timeout-ms` flag but reaches `buildStatusSnapshot` directly, which has
|
|
8
|
+
// no schema of its own, so an over-cap value was silently accepted with no
|
|
9
|
+
// error and no clamp. Validating here makes both subcommands fail the same
|
|
10
|
+
// way, with a clear message, before any network probe starts, instead of
|
|
11
|
+
// relying on a downstream schema that not every code path shares.
|
|
12
|
+
const MAX_TIMEOUT_MS = 300_000;
|
|
13
|
+
function parsePositiveInteger(value, name, max) {
|
|
6
14
|
if (value === undefined)
|
|
7
15
|
return undefined;
|
|
8
16
|
const parsed = typeof value === "number" ? value : Number.parseInt(String(value), 10);
|
|
9
17
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
|
10
18
|
throw new Error(`${name} must be a positive integer`);
|
|
11
19
|
}
|
|
20
|
+
if (max !== undefined && parsed > max) {
|
|
21
|
+
throw new Error(`${name} must not exceed ${max}`);
|
|
22
|
+
}
|
|
12
23
|
return parsed;
|
|
13
24
|
}
|
|
25
|
+
function parseTimeoutMs(value) {
|
|
26
|
+
return parsePositiveInteger(value, "--timeout-ms", MAX_TIMEOUT_MS);
|
|
27
|
+
}
|
|
14
28
|
function normalizeUntil(value) {
|
|
15
29
|
if (value === undefined)
|
|
16
30
|
return undefined;
|
|
@@ -26,7 +40,7 @@ export function registerPrCommand(cli) {
|
|
|
26
40
|
.option("--branch <name>", "Branch selector")
|
|
27
41
|
.option("--pr <number-or-url>", "Explicit PR selector")
|
|
28
42
|
.option("--until <target>", "ready | checks-terminal | checks-green | merged")
|
|
29
|
-
.option("--timeout-ms <ms>", "Overall deadline; for ready, per-probe timeout only")
|
|
43
|
+
.option("--timeout-ms <ms>", "Overall deadline (max 300000ms / 5min); for ready, per-probe timeout only")
|
|
30
44
|
.option("--poll-interval-ms <ms>", "Poll interval")
|
|
31
45
|
.option("--include-checks", "Include final check snapshot", { default: true })
|
|
32
46
|
.option("--max-output-bytes <n>", "Per-command output budget")
|
|
@@ -41,7 +55,7 @@ export function registerPrCommand(cli) {
|
|
|
41
55
|
includePr: true,
|
|
42
56
|
maxOutputBytes: parsePositiveInteger(options.maxOutputBytes, "--max-output-bytes"),
|
|
43
57
|
pr: options.pr === undefined ? undefined : String(options.pr),
|
|
44
|
-
timeoutMs:
|
|
58
|
+
timeoutMs: parseTimeoutMs(options.timeoutMs),
|
|
45
59
|
});
|
|
46
60
|
console.log(options.json ? JSON.stringify(snapshot, null, 2) : formatStatusSnapshot(snapshot));
|
|
47
61
|
return options.requireReady && snapshot.status !== "ready" ? 1 : 0;
|
|
@@ -57,7 +71,7 @@ export function registerPrCommand(cli) {
|
|
|
57
71
|
maxOutputBytes: parsePositiveInteger(options.maxOutputBytes, "--max-output-bytes"),
|
|
58
72
|
pollIntervalMs: parsePositiveInteger(options.pollIntervalMs, "--poll-interval-ms"),
|
|
59
73
|
pr: options.pr === undefined ? undefined : String(options.pr),
|
|
60
|
-
timeoutMs:
|
|
74
|
+
timeoutMs: parseTimeoutMs(options.timeoutMs),
|
|
61
75
|
until,
|
|
62
76
|
});
|
|
63
77
|
console.log(JSON.stringify(payload, null, 2));
|
|
@@ -1463,6 +1463,35 @@ function gateFailureFromSummary(summary) {
|
|
|
1463
1463
|
...(summary.retryAfterMs === undefined ? {} : { retryAfterMs: summary.retryAfterMs }),
|
|
1464
1464
|
};
|
|
1465
1465
|
}
|
|
1466
|
+
// `providerErrorDetail`'s doc comment promises "sanitized, length-bounded"
|
|
1467
|
+
// text; a checkout-creation error's message/cause chain is target/environment
|
|
1468
|
+
// text (pnpm/git stderr), not attacker-shaped, but is still capped here so
|
|
1469
|
+
// the invariant holds regardless.
|
|
1470
|
+
const MAX_PROVIDER_ERROR_DETAIL_LENGTH = 2000;
|
|
1471
|
+
// Walks an `Error.cause` chain into a single human-readable line. Used to
|
|
1472
|
+
// surface the real reason a review checkout could not be created instead of
|
|
1473
|
+
// discarding it behind a fixed string (see the bare `catch` this replaces in
|
|
1474
|
+
// `invokeProvider`). Bounded depth + a `seen` set guard against a pathological
|
|
1475
|
+
// or cyclic cause chain.
|
|
1476
|
+
function formatErrorWithCauseChain(error, maxDepth = 5) {
|
|
1477
|
+
const parts = [];
|
|
1478
|
+
const seen = new Set();
|
|
1479
|
+
let current = error;
|
|
1480
|
+
for (let depth = 0; depth < maxDepth; depth += 1) {
|
|
1481
|
+
if (current === undefined || current === null || seen.has(current))
|
|
1482
|
+
break;
|
|
1483
|
+
seen.add(current);
|
|
1484
|
+
if (current instanceof Error) {
|
|
1485
|
+
parts.push(current.message);
|
|
1486
|
+
current = current.cause;
|
|
1487
|
+
}
|
|
1488
|
+
else {
|
|
1489
|
+
parts.push(String(current));
|
|
1490
|
+
break;
|
|
1491
|
+
}
|
|
1492
|
+
}
|
|
1493
|
+
return parts.join(" -> caused by: ");
|
|
1494
|
+
}
|
|
1466
1495
|
export async function invokeProvider(input, executeReview = runReviewExecution, runCatalogCommand = runCommand, createTargetCheckout = createReviewTargetCheckout,
|
|
1467
1496
|
// A thunk (not a checkout instance) so callers that never reach a real
|
|
1468
1497
|
// provider attempt — injected fakes included — never pay the checkout's
|
|
@@ -1475,19 +1504,34 @@ getSharedCheckout) {
|
|
|
1475
1504
|
checkout = existingCheckout ?? createTargetCheckout(input.projectRoot, input.targetSha);
|
|
1476
1505
|
checkout.beginAttempt();
|
|
1477
1506
|
}
|
|
1478
|
-
catch {
|
|
1507
|
+
catch (error) {
|
|
1508
|
+
// Bind + chain via Error.cause (ES2022) rather than discarding the real
|
|
1509
|
+
// failure — this exact swallow previously hid a genuine supply-chain
|
|
1510
|
+
// signal (`ERR_PNPM_TRUST_DOWNGRADE`) behind a fixed string for a full
|
|
1511
|
+
// day. See catalog/agent/rules/ts-coding-conventions.md.
|
|
1512
|
+
const isolationFailure = new Error("Unable to create the disposable review checkout; review was not started", { cause: error });
|
|
1513
|
+
let cleanupError;
|
|
1479
1514
|
if (ownsCheckout) {
|
|
1480
1515
|
try {
|
|
1481
1516
|
checkout?.cleanup();
|
|
1482
1517
|
}
|
|
1483
|
-
catch {
|
|
1484
|
-
//
|
|
1518
|
+
catch (cleanupFailure) {
|
|
1519
|
+
// Cleanup failing must not mask the primary isolation failure above;
|
|
1520
|
+
// capture it as secondary diagnostic context instead of discarding it.
|
|
1521
|
+
cleanupError = cleanupFailure;
|
|
1485
1522
|
}
|
|
1486
1523
|
}
|
|
1487
1524
|
const requestedModel = input.model ?? defaultReviewModel(input.provider) ?? "unresolved";
|
|
1488
1525
|
const model = resolveReviewModel(input.provider, requestedModel);
|
|
1526
|
+
// Chain from the original `error`, not `isolationFailure` — the latter's
|
|
1527
|
+
// own message is already the fixed prefix below, so chaining from it
|
|
1528
|
+
// would print that fixed sentence twice.
|
|
1529
|
+
const providerErrorDetail = (formatErrorWithCauseChain(error) +
|
|
1530
|
+
(cleanupError === undefined
|
|
1531
|
+
? ""
|
|
1532
|
+
: ` | cleanup after isolation failure also failed: ${formatErrorWithCauseChain(cleanupError)}`)).slice(0, MAX_PROVIDER_ERROR_DETAIL_LENGTH);
|
|
1489
1533
|
return {
|
|
1490
|
-
output:
|
|
1534
|
+
output: `${isolationFailure.message}: ${providerErrorDetail}`,
|
|
1491
1535
|
model,
|
|
1492
1536
|
reviewer: input.provider === "opencode" && model !== "unresolved"
|
|
1493
1537
|
? reviewerFamily(model)
|
|
@@ -1497,6 +1541,7 @@ getSharedCheckout) {
|
|
|
1497
1541
|
verdict: "no-verdict",
|
|
1498
1542
|
terminalClassification: "review-isolation-failure",
|
|
1499
1543
|
failureCode: "review-isolation-failure",
|
|
1544
|
+
providerErrorDetail,
|
|
1500
1545
|
};
|
|
1501
1546
|
}
|
|
1502
1547
|
try {
|
|
@@ -2127,21 +2172,30 @@ export async function runReviewGate(projectRoot, slug, input, dependencies = { i
|
|
|
2127
2172
|
// never need one (plan-purpose reuse of an existing approval, terminal
|
|
2128
2173
|
// rejections, exhausted scope). A setup failure is remembered so provider
|
|
2129
2174
|
// attempts still surface the same isolation-failure evidence.
|
|
2175
|
+
// Captures the real failure the first time checkoutFactory is
|
|
2176
|
+
// attempted this run, so the re-throw below (reached whenever a later
|
|
2177
|
+
// caller reuses the memoized failure) can chain to it via Error.cause
|
|
2178
|
+
// instead of discarding it — see ts-coding-conventions.md "never
|
|
2179
|
+
// swallow an error".
|
|
2180
|
+
let checkoutSetupError;
|
|
2130
2181
|
const ensureSharedCheckout = () => {
|
|
2131
2182
|
if (sharedCheckout !== undefined || checkoutSetupFailed)
|
|
2132
2183
|
return sharedCheckout;
|
|
2133
2184
|
try {
|
|
2134
2185
|
sharedCheckout = checkoutFactory(projectRoot, targetSha);
|
|
2135
2186
|
}
|
|
2136
|
-
catch {
|
|
2187
|
+
catch (error) {
|
|
2137
2188
|
checkoutSetupFailed = true;
|
|
2189
|
+
checkoutSetupError = error;
|
|
2138
2190
|
}
|
|
2139
2191
|
return sharedCheckout;
|
|
2140
2192
|
};
|
|
2141
2193
|
const createCheckoutForAttempt = () => {
|
|
2142
2194
|
// Only reachable when ensureSharedCheckout() already failed — a live
|
|
2143
2195
|
// shared checkout is always passed to the attempt as existingCheckout.
|
|
2144
|
-
throw new Error("review checkout setup failed"
|
|
2196
|
+
throw new Error("review checkout setup failed", {
|
|
2197
|
+
cause: checkoutSetupError,
|
|
2198
|
+
});
|
|
2145
2199
|
};
|
|
2146
2200
|
let sharedCheckoutCleaned = false;
|
|
2147
2201
|
const cleanupSharedCheckout = () => {
|
|
@@ -8,6 +8,18 @@ export interface WorktreeCommandOptions {
|
|
|
8
8
|
prefix?: string;
|
|
9
9
|
dryRun?: boolean;
|
|
10
10
|
force?: boolean;
|
|
11
|
+
/**
|
|
12
|
+
* Bypass the "primary must be on the expected branch" merge-cleanup
|
|
13
|
+
* refusal by switching a CLEAN primary checkout to that branch. Never
|
|
14
|
+
* bypasses the "primary is dirty" refusal — a dirty primary always
|
|
15
|
+
* refuses, regardless of this flag (see `decideMergeCleanup`).
|
|
16
|
+
*/
|
|
17
|
+
switchPrimary?: boolean;
|
|
18
|
+
/**
|
|
19
|
+
* @deprecated Renamed to `switchPrimary`. Kept as an accepted alias only
|
|
20
|
+
* (mapped from the CLI's legacy `--stash-primary` flag) — it no longer
|
|
21
|
+
* stashes anything; a dirty primary always refuses regardless of this flag.
|
|
22
|
+
*/
|
|
11
23
|
stashPrimary?: boolean;
|
|
12
24
|
noSetup?: boolean;
|
|
13
25
|
/** Dependency hydration for `new` only: install-locked (default) or reconcile. */
|
|
@@ -41,7 +53,6 @@ export interface MergeCleanupPlan {
|
|
|
41
53
|
readonly removeArgs: readonly string[];
|
|
42
54
|
readonly fetchArgs: readonly string[];
|
|
43
55
|
readonly mergeArgs: readonly string[];
|
|
44
|
-
readonly stashArgs?: readonly string[];
|
|
45
56
|
readonly switchArgs?: readonly string[];
|
|
46
57
|
readonly preservedPrimaryBranch?: string;
|
|
47
58
|
}
|
|
@@ -54,7 +65,18 @@ export interface MergeCleanupDecisionInput {
|
|
|
54
65
|
readonly currentBranch: string;
|
|
55
66
|
readonly repoDirty: boolean;
|
|
56
67
|
readonly targetDirty: boolean;
|
|
57
|
-
|
|
68
|
+
/**
|
|
69
|
+
* Bypasses the wrong-branch refusal for a CLEAN primary only. A dirty
|
|
70
|
+
* primary always refuses — see `decideMergeCleanup` — this never bypasses
|
|
71
|
+
* that refusal, and merge-cleanup never stashes primary state.
|
|
72
|
+
*/
|
|
73
|
+
readonly switchPrimary?: boolean;
|
|
74
|
+
/**
|
|
75
|
+
* When the primary is dirty, optional porcelain -z lists the offending
|
|
76
|
+
* paths in the refusal message. Omitted messages stay generic (e.g. for
|
|
77
|
+
* unit tests that don't care about message content).
|
|
78
|
+
*/
|
|
79
|
+
readonly repoPorcelainZ?: string;
|
|
58
80
|
/**
|
|
59
81
|
* When target is dirty, optional porcelain -z + slug enrich the refusal message
|
|
60
82
|
* (scaffold-only vs other dirt). Omitted messages stay generic for unit tests.
|
|
@@ -15,9 +15,10 @@ import { runWorktreeSetup, runWorktreeTeardown } from "#worktrees/lifecycle-scri
|
|
|
15
15
|
import { hydrateWorktreeDependencies, reconcileWorktreeDependencies, } from "#worktrees/dependencies.js";
|
|
16
16
|
import { projectWorktreeAgentSurfaces } from "#worktrees/project-agent-surfaces.js";
|
|
17
17
|
import { assertNotLocalMainBranch } from "#worktrees/main-ownership.js";
|
|
18
|
-
import { formatTargetDirtyRefusal } from "#worktrees/owner-dirt.js";
|
|
18
|
+
import { formatBoundedPaths, formatTargetDirtyRefusal, parsePorcelainV1Z, } from "#worktrees/owner-dirt.js";
|
|
19
19
|
import { buildWorktreeInventory, canonicalizeWorktreePath, classifyMembership, isManagedWorktreePath, resolveRepoIdentity, } from "#worktrees/identity.js";
|
|
20
20
|
import { findRegistryCandidatesByPath, planStaleWorktreeRegistryPrune, readWorktreeRegistry, removeWorktreeRegistryEntries, repoScopedPathPredicate, upsertWorktreeRegistryEntry, } from "#worktrees/registry.js";
|
|
21
|
+
import { isStaleWorktreeRegistration, pruneStaleGitWorktreeMetadata, } from "#worktrees/git-metadata.js";
|
|
21
22
|
import { buildRegistryRepoRootMap, classifyOrphanCandidate, discoverOrphanScanCandidates, resolveRepoRootFromGitPointer, } from "#worktrees/orphan-scan.js";
|
|
22
23
|
function parseDepsOption(value) {
|
|
23
24
|
if (value === undefined || value === null || value === "")
|
|
@@ -165,9 +166,24 @@ export function planMergeCleanup(targetPath, baseRef) {
|
|
|
165
166
|
mergeArgs: ["merge", "--ff-only", baseRef],
|
|
166
167
|
};
|
|
167
168
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
169
|
+
/**
|
|
170
|
+
* Refusal for a DIRTY primary checkout. Reuses the same low-level porcelain
|
|
171
|
+
* parser and bounded-list formatter `formatTargetDirtyRefusal` uses for the
|
|
172
|
+
* target worktree (`#worktrees/owner-dirt.js`) — this is NOT a classifier:
|
|
173
|
+
* it never decides whose dirt it is or whether it's "safe" to move. A dirty
|
|
174
|
+
* primary always refuses; this only lists the paths so the human knows what
|
|
175
|
+
* blocked the command.
|
|
176
|
+
*/
|
|
177
|
+
function formatPrimaryDirtyRefusal(repoRoot, porcelainZ) {
|
|
178
|
+
const paths = [
|
|
179
|
+
...new Set(parsePorcelainV1Z(porcelainZ).flatMap((record) => record.paths.filter((path) => path.length > 0))),
|
|
180
|
+
].toSorted();
|
|
181
|
+
return [
|
|
182
|
+
`wp worktree merge-cleanup refused: primary checkout ${repoRoot} has uncommitted changes`,
|
|
183
|
+
"Paths:",
|
|
184
|
+
formatBoundedPaths(paths),
|
|
185
|
+
"fix: commit or `git stash` the listed paths yourself (or `wp blueprint abandon <slug> --confirm` if this is leftover blueprint draft scaffold), then re-run merge-cleanup",
|
|
186
|
+
].join("\n");
|
|
171
187
|
}
|
|
172
188
|
export function decideMergeCleanup(input) {
|
|
173
189
|
const entry = input.entries.find((candidate) => candidate.path === input.targetPath);
|
|
@@ -193,34 +209,18 @@ export function decideMergeCleanup(input) {
|
|
|
193
209
|
throw new Error(`wp worktree merge-cleanup refused: ${input.targetPath} has uncommitted changes`);
|
|
194
210
|
}
|
|
195
211
|
if (input.repoDirty) {
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
"--include-untracked",
|
|
203
|
-
"--message",
|
|
204
|
-
primaryStashMessage({
|
|
205
|
-
currentBranch: input.currentBranch,
|
|
206
|
-
expectedPrimaryBranch: plan.expectedPrimaryBranch,
|
|
207
|
-
targetPath: input.targetPath,
|
|
208
|
-
}),
|
|
209
|
-
],
|
|
210
|
-
...(input.currentBranch !== plan.expectedPrimaryBranch
|
|
211
|
-
? {
|
|
212
|
-
switchArgs: ["switch", plan.expectedPrimaryBranch],
|
|
213
|
-
preservedPrimaryBranch: input.currentBranch || "(detached)",
|
|
214
|
-
}
|
|
215
|
-
: {
|
|
216
|
-
preservedPrimaryBranch: input.currentBranch || "(detached)",
|
|
217
|
-
}),
|
|
218
|
-
};
|
|
219
|
-
}
|
|
220
|
-
throw new Error(`wp worktree merge-cleanup refused: primary checkout ${input.repoRoot} has uncommitted changes`);
|
|
212
|
+
// A dirty primary ALWAYS refuses -- unconditionally, regardless of
|
|
213
|
+
// --switch-primary/--stash-primary. merge-cleanup never stashes primary
|
|
214
|
+
// state; it never decides whose dirt it is or whether moving it is safe.
|
|
215
|
+
throw new Error(input.repoPorcelainZ !== undefined
|
|
216
|
+
? formatPrimaryDirtyRefusal(input.repoRoot, input.repoPorcelainZ)
|
|
217
|
+
: `wp worktree merge-cleanup refused: primary checkout ${input.repoRoot} has uncommitted changes`);
|
|
221
218
|
}
|
|
222
219
|
if (input.currentBranch !== plan.expectedPrimaryBranch) {
|
|
223
|
-
|
|
220
|
+
// --switch-primary bypasses ONLY this refusal, and only for a CLEAN
|
|
221
|
+
// primary: switching a clean checkout to the expected branch is
|
|
222
|
+
// harmless (nothing to lose), unlike stashing dirty state.
|
|
223
|
+
if (input.switchPrimary === true) {
|
|
224
224
|
return {
|
|
225
225
|
...plan,
|
|
226
226
|
switchArgs: ["switch", plan.expectedPrimaryBranch],
|
|
@@ -456,10 +456,14 @@ function handleRefresh(opts) {
|
|
|
456
456
|
async function handlePrune(opts) {
|
|
457
457
|
if (!opts.all)
|
|
458
458
|
throw new Error("Usage: wp worktree prune --all");
|
|
459
|
+
const repoRoot = resolveWorktreeRepoRoot(opts.repo ?? opts.cwd ?? process.cwd());
|
|
460
|
+
// Reconcile git's own admin records FIRST: a hand-deleted worktree directory
|
|
461
|
+
// leaves a record that still claims its branch, and it would otherwise report
|
|
462
|
+
// as live below and pin the matching registry entry in place too.
|
|
463
|
+
const gitPruned = pruneStaleGitWorktreeMetadata(repoRoot, listEntries(repoRoot));
|
|
459
464
|
// Repository-scoped, three-state prune: only THIS repo's entries that are
|
|
460
465
|
// proven stale against a COMPLETE git-live inventory are dropped; unknown,
|
|
461
466
|
// other-repo, and live-legacy entries are retained.
|
|
462
|
-
const repoRoot = resolveWorktreeRepoRoot(opts.repo ?? opts.cwd ?? process.cwd());
|
|
463
467
|
const inventory = buildWorktreeInventory(repoRoot);
|
|
464
468
|
const result = planStaleWorktreeRegistryPrune({
|
|
465
469
|
inventory,
|
|
@@ -468,6 +472,13 @@ async function handlePrune(opts) {
|
|
|
468
472
|
await removeCodexProjectTrusts(result.removed.map((entry) => entry.path));
|
|
469
473
|
result.commit();
|
|
470
474
|
console.log(`Pruned ${result.removed.length} stale managed registry entr${result.removed.length === 1 ? "y" : "ies"}.`);
|
|
475
|
+
if (gitPruned.error)
|
|
476
|
+
console.log(`warning: git worktree prune failed: ${gitPruned.error}`);
|
|
477
|
+
else {
|
|
478
|
+
console.log(`Cleared ${gitPruned.prunedPaths.length} stale git worktree registration${gitPruned.prunedPaths.length === 1 ? "" : "s"}.`);
|
|
479
|
+
for (const path of gitPruned.prunedPaths)
|
|
480
|
+
console.log(` ${path}`);
|
|
481
|
+
}
|
|
471
482
|
for (const warning of result.warnings)
|
|
472
483
|
console.log(`warning: ${warning}`);
|
|
473
484
|
}
|
|
@@ -546,6 +557,23 @@ async function handleRemove(nameOrPath, opts) {
|
|
|
546
557
|
const repoRoot = resolvePrimaryRoot(callerRoot, entries);
|
|
547
558
|
const resolved = resolveWorktreePath(nameOrPath, entries);
|
|
548
559
|
const entry = entries.find((candidate) => candidate.path === resolved);
|
|
560
|
+
// The directory is already gone (hand-deleted, or a failed earlier removal):
|
|
561
|
+
// `git worktree remove` cannot act on it and teardown scripts have no cwd, so
|
|
562
|
+
// reconcile git's leftover admin record instead of failing.
|
|
563
|
+
if (isStaleWorktreeRegistration(entry, resolved, existsSync)) {
|
|
564
|
+
// Staleness is not a bypass for the managed-root boundary, which is the
|
|
565
|
+
// same protection the MCP surface applies to registry-gone stale targets.
|
|
566
|
+
if (!isManagedWorktreePath(canonicalizeWorktreePath(resolved), resolveKnownManagedWorktreeRoots())) {
|
|
567
|
+
throw new Error(`Refusing to reconcile ${resolved}: its directory is gone but it is not under a known managed worktree root.`);
|
|
568
|
+
}
|
|
569
|
+
const gitPruned = pruneStaleGitWorktreeMetadata(repoRoot, entries);
|
|
570
|
+
if (gitPruned.error)
|
|
571
|
+
throw new Error(`git worktree prune failed: ${gitPruned.error}`);
|
|
572
|
+
await removeCodexProjectTrust(resolved);
|
|
573
|
+
removeWorktreeRegistryEntries(repoScopedPathPredicate(resolved, { authoritativeRepoKey: resolveRepoIdentity(repoRoot) }));
|
|
574
|
+
console.log(`Cleared stale worktree registration for ${resolved} (directory already gone).`);
|
|
575
|
+
return;
|
|
576
|
+
}
|
|
549
577
|
if (!opts.force) {
|
|
550
578
|
runWorktreeTeardown(readConfig(resolved) ?? readConfig(repoRoot), {
|
|
551
579
|
worktreePath: resolved,
|
|
@@ -713,6 +741,22 @@ async function handleMergeCleanup(nameOrPath, opts) {
|
|
|
713
741
|
})()
|
|
714
742
|
: undefined;
|
|
715
743
|
const targetBlueprintSlug = inferBlueprintSlugFromOwnerPath(resolved);
|
|
744
|
+
// `--switch-primary`/legacy `--stash-primary` only ever bypasses the
|
|
745
|
+
// clean-primary wrong-branch refusal; a dirty primary always refuses (see
|
|
746
|
+
// `decideMergeCleanup`), so merge-cleanup never stashes primary state.
|
|
747
|
+
const switchPrimary = opts.switchPrimary === true || opts.stashPrimary === true;
|
|
748
|
+
const repoDirty = isDirty(repoRoot);
|
|
749
|
+
const repoPorcelainZ = repoDirty
|
|
750
|
+
? (() => {
|
|
751
|
+
const status = spawnSync("git", ["status", "--porcelain=v1", "--untracked-files=all", "--ignored=no", "-z"], { cwd: repoRoot, encoding: "utf8" });
|
|
752
|
+
// Unlike the target-worktree probe, do NOT substitute a sentinel path
|
|
753
|
+
// on failure: a fake "(git-status-failed)" path in the refusal message
|
|
754
|
+
// would misrepresent what's actually dirty. Leaving this undefined
|
|
755
|
+
// falls back to the generic (path-free) refusal in decideMergeCleanup
|
|
756
|
+
// -- the refusal itself is unaffected either way.
|
|
757
|
+
return status.status === 0 ? String(status.stdout ?? "") : undefined;
|
|
758
|
+
})()
|
|
759
|
+
: undefined;
|
|
716
760
|
const plan = decideMergeCleanup({
|
|
717
761
|
repoRoot,
|
|
718
762
|
targetPath: resolved,
|
|
@@ -720,16 +764,15 @@ async function handleMergeCleanup(nameOrPath, opts) {
|
|
|
720
764
|
entries,
|
|
721
765
|
registryEntries: scopedRegistryEntries,
|
|
722
766
|
currentBranch: currentBranch(repoRoot),
|
|
723
|
-
repoDirty
|
|
767
|
+
repoDirty,
|
|
724
768
|
targetDirty,
|
|
725
|
-
|
|
769
|
+
switchPrimary,
|
|
726
770
|
...(targetPorcelainZ !== undefined ? { targetPorcelainZ } : {}),
|
|
727
771
|
...(targetBlueprintSlug !== undefined ? { targetBlueprintSlug } : {}),
|
|
772
|
+
...(repoPorcelainZ !== undefined ? { repoPorcelainZ } : {}),
|
|
728
773
|
});
|
|
729
774
|
if (opts.dryRun) {
|
|
730
775
|
console.log("[dry-run] Would run merge cleanup:");
|
|
731
|
-
if (plan.stashArgs)
|
|
732
|
-
console.log(` stash: git ${plan.stashArgs.join(" ")}`);
|
|
733
776
|
if (plan.switchArgs)
|
|
734
777
|
console.log(` switch: git ${plan.switchArgs.join(" ")}`);
|
|
735
778
|
console.log(` remove: git ${plan.removeArgs.join(" ")}`);
|
|
@@ -737,12 +780,6 @@ async function handleMergeCleanup(nameOrPath, opts) {
|
|
|
737
780
|
console.log(` sync: git ${plan.mergeArgs.join(" ")}`);
|
|
738
781
|
return;
|
|
739
782
|
}
|
|
740
|
-
if (plan.stashArgs) {
|
|
741
|
-
const stashResult = spawnSync("git", [...plan.stashArgs], { cwd: repoRoot, stdio: "inherit" });
|
|
742
|
-
if (stashResult.status !== 0) {
|
|
743
|
-
throw new Error("git stash push failed");
|
|
744
|
-
}
|
|
745
|
-
}
|
|
746
783
|
if (plan.switchArgs) {
|
|
747
784
|
const switchResult = spawnSync("git", [...plan.switchArgs], {
|
|
748
785
|
cwd: repoRoot,
|
|
@@ -775,13 +812,8 @@ async function handleMergeCleanup(nameOrPath, opts) {
|
|
|
775
812
|
throw new Error(`git merge --ff-only ${baseRef} failed`);
|
|
776
813
|
}
|
|
777
814
|
console.log(`Removed worktree ${resolved} and fast-forwarded ${repoRoot} to ${baseRef}.`);
|
|
778
|
-
if (plan.
|
|
779
|
-
|
|
780
|
-
cwd: repoRoot,
|
|
781
|
-
encoding: "utf8",
|
|
782
|
-
});
|
|
783
|
-
const summary = String(stashRef.stdout ?? "").trim();
|
|
784
|
-
console.log(`Preserved prior primary checkout state${plan.preservedPrimaryBranch ? ` from ${plan.preservedPrimaryBranch}` : ""} in ${summary || "git stash list"}.`);
|
|
815
|
+
if (plan.switchArgs && plan.preservedPrimaryBranch) {
|
|
816
|
+
console.log(`Switched primary checkout from ${plan.preservedPrimaryBranch} to ${plan.expectedPrimaryBranch}.`);
|
|
785
817
|
}
|
|
786
818
|
}
|
|
787
819
|
export async function executeWorktreeSubcommand(subcommand, args, opts) {
|
|
@@ -27,7 +27,9 @@ const HELP_TEXT = [
|
|
|
27
27
|
" --dry-run Print the resolved worktree target without writing",
|
|
28
28
|
" --no-setup Skip configured worktree.setup scripts (new only)",
|
|
29
29
|
" --force Skip configured teardown scripts and force removal",
|
|
30
|
-
" --
|
|
30
|
+
" --switch-primary Switch a CLEAN primary checkout to the expected branch before merge-cleanup",
|
|
31
|
+
" (a DIRTY primary always refuses, with or without this flag; it is never stashed)",
|
|
32
|
+
" --stash-primary Deprecated alias for --switch-primary. No longer stashes anything.",
|
|
31
33
|
" --orphans Filesystem-level orphan sweep for prune (see prune --orphans above)",
|
|
32
34
|
" --repo-id <id> Scope prune --orphans to one repo namespace under repos/<id>",
|
|
33
35
|
" --yes Alias for --force (prune --orphans only)",
|
|
@@ -44,7 +46,8 @@ export function registerWorktreeRouter(cli) {
|
|
|
44
46
|
.option("--no-setup", "Skip configured worktree.setup scripts (new only)")
|
|
45
47
|
.option("--deps <mode>", "Dependency hydration for new: install-locked (default, pnpm GVS path) or reconcile")
|
|
46
48
|
.option("--force", "Skip configured teardown scripts and force removal (remove/merge-cleanup only)")
|
|
47
|
-
.option("--
|
|
49
|
+
.option("--switch-primary", "Switch a CLEAN primary checkout to the expected branch before merge-cleanup (a dirty primary always refuses; never stashed)")
|
|
50
|
+
.option("--stash-primary", "Deprecated alias for --switch-primary. No longer stashes anything.")
|
|
48
51
|
.option("--all", "Use global managed inventory where supported")
|
|
49
52
|
.option("--repo <dir>", "Repo root for refresh/rebind (default: cwd)")
|
|
50
53
|
.option("--orphans", "Filesystem-level orphan sweep for prune (dry-run unless --force/--yes)")
|
|
@@ -11,7 +11,7 @@ import { canonicalizeWorktreePath } from "#worktrees/identity.js";
|
|
|
11
11
|
import { dashProviderOwnershipName, parseDashPermissionMode, } from "#cli/commands/dash/provider-capabilities.js";
|
|
12
12
|
import { optionalToolCanonicalCommand } from "#cli/optional-tools.js";
|
|
13
13
|
import { commandExists } from "#runtime/command-exists.js";
|
|
14
|
-
import { ensureCodexProjectMcpServers,
|
|
14
|
+
import { ensureCodexProjectMcpServers, findInstalledCodexPluginMcpManifests, pruneLegacyCodexWebpressoMcp, } from "#cli/commands/init/scaffolders/codex-mcp/index.js";
|
|
15
15
|
export class DirectProviderLaunchPreparationError extends Error {
|
|
16
16
|
action;
|
|
17
17
|
constructor(message, action) {
|
|
@@ -91,11 +91,19 @@ function validateDirectProviderLaunch(provider, cwd) {
|
|
|
91
91
|
vpCommand,
|
|
92
92
|
};
|
|
93
93
|
}
|
|
94
|
+
/**
|
|
95
|
+
* `wp codex` is the only path that reaches machines which never re-run setup,
|
|
96
|
+
* so it carries the legacy-channel retirement too. The surviving channel is
|
|
97
|
+
* the bundled Codex plugin: probe its cache directly and WARN when it is
|
|
98
|
+
* absent. A `codex plugin marketplace` subprocess here would add latency to
|
|
99
|
+
* every launch and could hard-fail a machine where the plugin already works —
|
|
100
|
+
* a degraded session beats a blocked launch.
|
|
101
|
+
*/
|
|
94
102
|
function ensureCodexSessionTooling(repoRoot) {
|
|
95
103
|
const options = { dryRun: false, overwrite: false };
|
|
96
|
-
|
|
97
|
-
if (
|
|
98
|
-
|
|
104
|
+
pruneLegacyCodexWebpressoMcp({ options });
|
|
105
|
+
if (findInstalledCodexPluginMcpManifests().length === 0) {
|
|
106
|
+
console.warn("⚠ The webpresso Codex plugin is not installed, so the webpresso MCP server will be unavailable in this session. Run `wp setup` to install it.");
|
|
99
107
|
}
|
|
100
108
|
const project = ensureCodexProjectMcpServers({ repoRoot, options });
|
|
101
109
|
if (project.kind === "codex-project-mcp-invalid") {
|
|
@@ -5,5 +5,14 @@ export interface FlattenedAssets {
|
|
|
5
5
|
}
|
|
6
6
|
/** Reads `.agent/{skills,commands,agents}/` and returns an in-memory flattened structure. */
|
|
7
7
|
export declare function flattenAgentDir(agentDir: string): FlattenedAssets;
|
|
8
|
-
/**
|
|
8
|
+
/**
|
|
9
|
+
* Writes flattened assets to the layout rulesync reads.
|
|
10
|
+
*
|
|
11
|
+
* The three shapes are not interchangeable, and getting one wrong is silent:
|
|
12
|
+
* rulesync discovers skills by globbing `skills/` for *directories* and reading
|
|
13
|
+
* `SKILL.md` inside each, reads subagents from `subagents/` (not `agents/`),
|
|
14
|
+
* and reads commands as flat `commands/*.md`. An asset written in the wrong
|
|
15
|
+
* shape is simply never seen — rulesync still exits 0, having generated
|
|
16
|
+
* nothing for it.
|
|
17
|
+
*/
|
|
9
18
|
export declare function writeFlattenedAssets(assets: FlattenedAssets, outDir: string): Promise<void>;
|
|
@@ -60,19 +60,29 @@ export function flattenAgentDir(agentDir) {
|
|
|
60
60
|
}
|
|
61
61
|
return { skills, commands, agents };
|
|
62
62
|
}
|
|
63
|
-
/**
|
|
63
|
+
/**
|
|
64
|
+
* Writes flattened assets to the layout rulesync reads.
|
|
65
|
+
*
|
|
66
|
+
* The three shapes are not interchangeable, and getting one wrong is silent:
|
|
67
|
+
* rulesync discovers skills by globbing `skills/` for *directories* and reading
|
|
68
|
+
* `SKILL.md` inside each, reads subagents from `subagents/` (not `agents/`),
|
|
69
|
+
* and reads commands as flat `commands/*.md`. An asset written in the wrong
|
|
70
|
+
* shape is simply never seen — rulesync still exits 0, having generated
|
|
71
|
+
* nothing for it.
|
|
72
|
+
*/
|
|
64
73
|
export async function writeFlattenedAssets(assets, outDir) {
|
|
65
|
-
const subdirs = ["skills", "commands", "
|
|
74
|
+
const subdirs = ["skills", "commands", "subagents"];
|
|
66
75
|
for (const sub of subdirs) {
|
|
67
76
|
mkdirSync(join(outDir, sub), { recursive: true });
|
|
68
77
|
}
|
|
69
78
|
for (const [name, content] of Object.entries(assets.skills)) {
|
|
70
|
-
|
|
79
|
+
mkdirSync(join(outDir, "skills", name), { recursive: true });
|
|
80
|
+
writeFileSync(join(outDir, "skills", name, "SKILL.md"), content);
|
|
71
81
|
}
|
|
72
82
|
for (const [name, content] of Object.entries(assets.commands)) {
|
|
73
83
|
writeFileSync(join(outDir, "commands", `${name}.md`), content);
|
|
74
84
|
}
|
|
75
85
|
for (const [name, content] of Object.entries(assets.agents)) {
|
|
76
|
-
writeFileSync(join(outDir, "
|
|
86
|
+
writeFileSync(join(outDir, "subagents", `${name}.md`), content);
|
|
77
87
|
}
|
|
78
88
|
}
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import { type BlockRegistry } from "./registry-resolve.js";
|
|
2
|
+
export interface SnapshotIntegrityResult {
|
|
3
|
+
readonly ok: boolean;
|
|
4
|
+
readonly path: string;
|
|
5
|
+
readonly registry?: BlockRegistry;
|
|
6
|
+
readonly diagnostics: readonly string[];
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Fail-on-drift **equivalent** for agent-kit CI without a framework dependency:
|
|
10
|
+
* load the committed catalog snapshot and enforce generator-contract invariants
|
|
11
|
+
* (schema parse, version shape, unique name+view, well-formed slots).
|
|
12
|
+
*
|
|
13
|
+
* Full regenerate-and-diff against `@webpresso/codegen` remains a cross-repo
|
|
14
|
+
* operator step; this gate prevents a rotten or hand-broken snapshot from landing.
|
|
15
|
+
*/
|
|
16
|
+
/**
|
|
17
|
+
* Check (does not throw). Callers must inspect `.ok` — this is a gate helper,
|
|
18
|
+
* not a Node assertion that aborts the process.
|
|
19
|
+
*/
|
|
20
|
+
export declare function checkAdminBlocksSnapshotIntegrity(options?: {
|
|
21
|
+
readonly snapshotPath?: string;
|
|
22
|
+
}): SnapshotIntegrityResult;
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
import { blockRegistrySchema, defaultSnapshotPath, } from "./registry-resolve.js";
|
|
3
|
+
const SEMVER_LIKE = /^\d+\.\d+\.\d+/;
|
|
4
|
+
const slotSchemaLoose = {
|
|
5
|
+
isSlot(value) {
|
|
6
|
+
if (value === null || typeof value !== "object" || Array.isArray(value))
|
|
7
|
+
return false;
|
|
8
|
+
const rec = value;
|
|
9
|
+
return typeof rec["name"] === "string" && rec["name"].length > 0;
|
|
10
|
+
},
|
|
11
|
+
};
|
|
12
|
+
/**
|
|
13
|
+
* Fail-on-drift **equivalent** for agent-kit CI without a framework dependency:
|
|
14
|
+
* load the committed catalog snapshot and enforce generator-contract invariants
|
|
15
|
+
* (schema parse, version shape, unique name+view, well-formed slots).
|
|
16
|
+
*
|
|
17
|
+
* Full regenerate-and-diff against `@webpresso/codegen` remains a cross-repo
|
|
18
|
+
* operator step; this gate prevents a rotten or hand-broken snapshot from landing.
|
|
19
|
+
*/
|
|
20
|
+
/**
|
|
21
|
+
* Check (does not throw). Callers must inspect `.ok` — this is a gate helper,
|
|
22
|
+
* not a Node assertion that aborts the process.
|
|
23
|
+
*/
|
|
24
|
+
export function checkAdminBlocksSnapshotIntegrity(options = {}) {
|
|
25
|
+
const path = options.snapshotPath ?? defaultSnapshotPath();
|
|
26
|
+
const diagnostics = [];
|
|
27
|
+
let raw;
|
|
28
|
+
try {
|
|
29
|
+
raw = readFileSync(path, "utf8");
|
|
30
|
+
}
|
|
31
|
+
catch (error) {
|
|
32
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
33
|
+
return { ok: false, path, diagnostics: [`cannot read snapshot: ${detail}`] };
|
|
34
|
+
}
|
|
35
|
+
let parsed;
|
|
36
|
+
try {
|
|
37
|
+
parsed = JSON.parse(raw);
|
|
38
|
+
}
|
|
39
|
+
catch (error) {
|
|
40
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
41
|
+
return { ok: false, path, diagnostics: [`snapshot is not valid JSON: ${detail}`] };
|
|
42
|
+
}
|
|
43
|
+
const result = blockRegistrySchema.safeParse(parsed);
|
|
44
|
+
if (!result.success) {
|
|
45
|
+
return {
|
|
46
|
+
ok: false,
|
|
47
|
+
path,
|
|
48
|
+
diagnostics: result.error.issues.map((i) => `${i.path.join(".") || "(root)"}: ${i.message}`),
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
const registry = result.data;
|
|
52
|
+
if (!SEMVER_LIKE.test(registry.version)) {
|
|
53
|
+
diagnostics.push(`version "${registry.version}" is not semver-like`);
|
|
54
|
+
}
|
|
55
|
+
if (registry.minVersion !== undefined && !SEMVER_LIKE.test(registry.minVersion)) {
|
|
56
|
+
diagnostics.push(`minVersion "${registry.minVersion}" is not semver-like`);
|
|
57
|
+
}
|
|
58
|
+
const seen = new Set();
|
|
59
|
+
for (const [index, block] of registry.blocks.entries()) {
|
|
60
|
+
const key = `${block.view}::${block.name}`;
|
|
61
|
+
if (seen.has(key)) {
|
|
62
|
+
diagnostics.push(`duplicate block ${key} at index ${index}`);
|
|
63
|
+
}
|
|
64
|
+
seen.add(key);
|
|
65
|
+
if (block.slots !== undefined) {
|
|
66
|
+
if (!Array.isArray(block.slots)) {
|
|
67
|
+
diagnostics.push(`blocks[${index}].slots must be an array`);
|
|
68
|
+
continue;
|
|
69
|
+
}
|
|
70
|
+
for (const [slotIndex, slot] of block.slots.entries()) {
|
|
71
|
+
if (!slotSchemaLoose.isSlot(slot)) {
|
|
72
|
+
diagnostics.push(`blocks[${index}].slots[${slotIndex}] requires string name`);
|
|
73
|
+
continue;
|
|
74
|
+
}
|
|
75
|
+
const accepts = slot.accepts;
|
|
76
|
+
if (accepts !== undefined) {
|
|
77
|
+
if (!Array.isArray(accepts) || accepts.some((a) => typeof a !== "string")) {
|
|
78
|
+
diagnostics.push(`blocks[${index}].slots[${slotIndex}].accepts must be string[] when present`);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return {
|
|
85
|
+
ok: diagnostics.length === 0,
|
|
86
|
+
path,
|
|
87
|
+
registry,
|
|
88
|
+
diagnostics,
|
|
89
|
+
};
|
|
90
|
+
}
|