@webpresso/agent-kit 3.2.0 → 3.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (93) hide show
  1. package/bin/_run.js +4 -1
  2. package/dist/esm/audit/blueprint-trust.js +14 -10
  3. package/dist/esm/audit/changeset-evidence.d.ts +23 -0
  4. package/dist/esm/audit/changeset-evidence.js +30 -0
  5. package/dist/esm/audit/changeset-required.d.ts +10 -0
  6. package/dist/esm/audit/changeset-required.js +73 -0
  7. package/dist/esm/audit/ci-guardrails-detection.d.ts +27 -0
  8. package/dist/esm/audit/ci-guardrails-detection.js +68 -0
  9. package/dist/esm/audit/ci-guardrails-wiring.js +3 -13
  10. package/dist/esm/audit/registry.d.ts +4 -4
  11. package/dist/esm/audit/registry.js +6 -1
  12. package/dist/esm/blueprint/core/parser.js +61 -4
  13. package/dist/esm/blueprint/core/schema.d.ts +1 -0
  14. package/dist/esm/blueprint/core/schema.js +26 -0
  15. package/dist/esm/blueprint/lifecycle/audit.js +22 -10
  16. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  17. package/dist/esm/blueprint/lifecycle/review-provenance.js +39 -8
  18. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +97 -0
  19. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +161 -0
  20. package/dist/esm/blueprint/sync/client.d.ts +2 -1
  21. package/dist/esm/blueprint/sync/client.js +1 -1
  22. package/dist/esm/blueprint/trust/dossier.d.ts +1 -0
  23. package/dist/esm/blueprint/trust/dossier.js +40 -6
  24. package/dist/esm/blueprint/trust/promotion.js +12 -0
  25. package/dist/esm/build/cli-mcp-parity.js +5 -0
  26. package/dist/esm/cli/cli.d.ts +1 -1
  27. package/dist/esm/cli/cli.js +9 -0
  28. package/dist/esm/cli/commands/agent-launch.d.ts +2 -0
  29. package/dist/esm/cli/commands/agent-launch.js +31 -20
  30. package/dist/esm/cli/commands/audit.js +4 -0
  31. package/dist/esm/cli/commands/ci-preflight.js +24 -3
  32. package/dist/esm/cli/commands/init/convergence-apply.d.ts +64 -0
  33. package/dist/esm/cli/commands/init/convergence-apply.js +127 -23
  34. package/dist/esm/cli/commands/init/index.d.ts +2 -0
  35. package/dist/esm/cli/commands/init/index.js +2 -2
  36. package/dist/esm/cli/commands/init/package-root.d.ts +7 -0
  37. package/dist/esm/cli/commands/init/package-root.js +10 -2
  38. package/dist/esm/cli/commands/init/scaffold-base-kit.d.ts +12 -3
  39. package/dist/esm/cli/commands/init/scaffold-base-kit.js +31 -30
  40. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/index.js +3 -1
  41. package/dist/esm/cli/commands/init/scaffolders/file-transaction.d.ts +17 -2
  42. package/dist/esm/cli/commands/init/scaffolders/file-transaction.js +189 -17
  43. package/dist/esm/cli/commands/opencode-probe.d.ts +107 -0
  44. package/dist/esm/cli/commands/opencode-probe.js +323 -0
  45. package/dist/esm/cli/commands/opencode-rotate.d.ts +23 -0
  46. package/dist/esm/cli/commands/opencode-rotate.js +23 -11
  47. package/dist/esm/cli/commands/quality-runner.js +3 -3
  48. package/dist/esm/cli/commands/review.js +27 -5
  49. package/dist/esm/cli/commands/rust-check.d.ts +24 -0
  50. package/dist/esm/cli/commands/rust-check.js +110 -0
  51. package/dist/esm/cli/commands/typecheck.d.ts +1 -0
  52. package/dist/esm/cli/commands/typecheck.js +30 -1
  53. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +6 -0
  54. package/dist/esm/cli/commands/worktree/router-dispatch.js +113 -1
  55. package/dist/esm/cli/commands/worktree/router.js +10 -0
  56. package/dist/esm/hooks/doctor.d.ts +2 -0
  57. package/dist/esm/hooks/doctor.js +37 -1
  58. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +6 -1
  59. package/dist/esm/hooks/shared/types.js +27 -1
  60. package/dist/esm/hooks/stop/qa-changed-files.js +18 -0
  61. package/dist/esm/mcp/blueprint/handlers/document-mutations.d.ts +5 -0
  62. package/dist/esm/mcp/blueprint/handlers/document-mutations.js +31 -7
  63. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  64. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  65. package/dist/esm/mcp/tools/audit.js +12 -0
  66. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  67. package/dist/esm/mcp/tools/session-fetch-and-index.d.ts +1 -1
  68. package/dist/esm/package.json +2 -0
  69. package/dist/esm/platform/client.d.ts +7 -1
  70. package/dist/esm/platform/github-contract-source.d.ts +2 -1
  71. package/dist/esm/pr-description/contract.js +24 -4
  72. package/dist/esm/review/authority.js +14 -9
  73. package/dist/esm/review/availability.d.ts +11 -0
  74. package/dist/esm/review/availability.js +18 -3
  75. package/dist/esm/review/execution/adapters.d.ts +11 -1
  76. package/dist/esm/review/execution/adapters.js +10 -1
  77. package/dist/esm/review/execution/artifacts.d.ts +1 -1
  78. package/dist/esm/review/execution/review-checkout.js +35 -16
  79. package/dist/esm/review/execution/supervisor.js +31 -4
  80. package/dist/esm/review/execution/types.d.ts +1 -0
  81. package/dist/esm/review/subject.d.ts +13 -0
  82. package/dist/esm/review/subject.js +42 -4
  83. package/dist/esm/rust/affected-manifests.d.ts +38 -0
  84. package/dist/esm/rust/affected-manifests.js +69 -0
  85. package/dist/esm/session-memory/fetch-index.d.ts +1 -1
  86. package/dist/esm/status/snapshot.js +2 -6
  87. package/dist/esm/test-helpers/global-setup.d.ts +0 -1
  88. package/dist/esm/test-helpers/global-setup.js +18 -15
  89. package/dist/esm/typecheck/planner.d.ts +9 -1
  90. package/dist/esm/typecheck/planner.js +31 -0
  91. package/dist/esm/worktrees/orphan-scan.d.ts +50 -0
  92. package/dist/esm/worktrees/orphan-scan.js +211 -0
  93. package/package.json +14 -12
@@ -3,7 +3,7 @@ import { existsSync, readFileSync } from "node:fs";
3
3
  import path from "node:path";
4
4
  import { readReviewAuthorityAtRef, readWorkingReviewEvents } from "#review/authority.js";
5
5
  import { evaluateReviewEvents, parseReviewEventLog } from "#review/events.js";
6
- import { createDeliverySubjectAtRef, createPlanSubjectDigestForContent, normalizePlanSubjectContent, } from "#review/subject.js";
6
+ import { createDeliverySubjectAtRef, createLegacyPlanSubjectDigestForContent, createPlanSubjectDigestForContent, createPreTagsStripPlanSubjectDigestForContent, normalizePlanSubjectContent, } from "#review/subject.js";
7
7
  import { isPolicyApprovalVerdict } from "#review/verdict.js";
8
8
  const REVIEW_ENTRY_MARKER = "<!-- wp:review-entry ";
9
9
  function normalizePath(file) {
@@ -44,7 +44,12 @@ function findGitRoot(file) {
44
44
  current = parent;
45
45
  }
46
46
  }
47
- function stableSlugForBlueprintFile(file) {
47
+ /**
48
+ * Slug for a blueprint file, invariant across lifecycle moves
49
+ * (`draft/ → planned/ → completed/`) and across both the folder
50
+ * (`<slug>/_overview.md`) and legacy flat (`<slug>.md`) shapes.
51
+ */
52
+ export function stableSlugForBlueprintFile(file) {
48
53
  const segments = normalizePath(file).split("/");
49
54
  const lifecycleIndex = segments.findIndex((segment) => new Set(["draft", "planned", "parked", "in-progress", "completed", "archived"]).has(segment));
50
55
  if (lifecycleIndex >= 0 && segments.at(-1) === "_overview.md") {
@@ -149,18 +154,42 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
149
154
  if (working.events.length === 0)
150
155
  return [];
151
156
  issues?.push(...working.issues);
157
+ // Events whose recorded `reviewedCommit` no longer resolves (typically
158
+ // orphaned by a rebase). Collected here rather than mutating
159
+ // `working.compromisedIds`, which is a ReadonlySet by contract, then merged
160
+ // into the set handed to evaluateReviewEvents below.
161
+ const unresolvableEventIds = new Set();
152
162
  const subjects = purpose === "plan"
153
163
  ? [
154
- {
155
- scheme: "wp-blueprint-v1",
156
- digest: createPlanSubjectDigestForContent(slug, readFileSync(file)),
157
- },
164
+ ...new Map([
165
+ createPlanSubjectDigestForContent(slug, readFileSync(file)),
166
+ createPreTagsStripPlanSubjectDigestForContent(slug, readFileSync(file)),
167
+ createLegacyPlanSubjectDigestForContent(slug, readFileSync(file)),
168
+ ].map((digest) => [digest, { scheme: "wp-blueprint-v1", digest }])).values(),
158
169
  ]
159
170
  : [
160
171
  ...new Map(working.events
161
172
  .filter((event) => event.purpose === "delivery")
162
173
  .flatMap((event) => {
163
- const subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, normalizePath(path.relative(repoRoot, file)));
174
+ // Scope subject-construction failure to THIS event. A rebase
175
+ // rewrites commit SHAs, so a previously-recorded
176
+ // `reviewedCommit` can become unresolvable; letting that throw
177
+ // would escape to the outer catch and discard every approval,
178
+ // including valid ones for commits that still resolve. Mirror
179
+ // the ref-path's markCompromisedArtifactEvents: record the
180
+ // issue, mark the event compromised, and drop only it.
181
+ let subject;
182
+ try {
183
+ subject = createDeliverySubjectAtRef(repoRoot, event.reviewedCommit, slug, normalizePath(path.relative(repoRoot, file)));
184
+ }
185
+ catch (error) {
186
+ issues?.push(`Working delivery review subject unavailable for ${event.id} at ${event.reviewedCommit}: ${error instanceof Error ? error.message : String(error)}`);
187
+ // Compromised (not merely skipped) so the event can never
188
+ // grant approval while still participating in reviewer
189
+ // supersession — see evaluateReviewEvents.
190
+ unresolvableEventIds.add(event.id);
191
+ return [];
192
+ }
164
193
  if (subject.digest !== event.subjectDigest) {
165
194
  issues?.push(`Working delivery review subject mismatch for ${event.id}: expected ${subject.digest}, found ${event.subjectDigest}.`);
166
195
  return [];
@@ -174,7 +203,9 @@ function collectWorkingV2ReviewProvenance(file, purpose, issues) {
174
203
  blueprintSlug: slug,
175
204
  subjectScheme: subject.scheme,
176
205
  subjectDigest: subject.digest,
177
- }, working.compromisedIds).approvals)
206
+ }, unresolvableEventIds.size === 0
207
+ ? working.compromisedIds
208
+ : new Set([...working.compromisedIds, ...unresolvableEventIds])).approvals)
178
209
  .filter((event) => event.artifactPath)
179
210
  .map((event) => ({
180
211
  reviewer: event.reviewer,
@@ -0,0 +1,97 @@
1
+ /**
2
+ * Rollout grandfathering, decided from git history instead of frontmatter.
3
+ *
4
+ * Two gates used to grant themselves an exemption by reading a blueprint's own
5
+ * `created:` frontmatter and comparing it to a hardcoded rollout date. That
6
+ * value is written by the same author the gate is meant to constrain, so
7
+ * `created: "2020-01-01"` disabled the check outright — on the CI audit path and
8
+ * (for the approval gate) on the `assertCompletedApprovalGate` write path, which
9
+ * would let a blueprint reach `completed` carrying no approval at all.
10
+ *
11
+ * Presence in a fixed commit's tree is instead a structural fact about history:
12
+ * no file content can add a slug to a tree that was written months ago.
13
+ *
14
+ * Matching on **slug** rather than path is deliberate. Blueprints legitimately
15
+ * move `draft/ → planned/ → completed/`, so a path-based check would mistake a
16
+ * lifecycle move for a new blueprint, while `git log --follow` would introduce a
17
+ * rename-similarity heuristic. The slug is invariant across those moves — the
18
+ * repo already enforces one blueprint = one slug = one `bp/<slug>` branch.
19
+ */
20
+ /**
21
+ * The commit that introduced both the completed-approval gate and the Trust
22
+ * Dossier requirement ("fix: preserve pre-contract blueprint rollouts",
23
+ * 2026-07-12). Blueprints already tracked at this commit predate both contracts.
24
+ */
25
+ export declare const ROLLOUT_ANCHOR_COMMIT = "c6ccb5f31cbecde3be4dbd6d3b7e67918006d22e";
26
+ /** The date that anchor landed, used only by the legacy fallback below. */
27
+ export declare const ROLLOUT_DATE = "2026-07-12";
28
+ /**
29
+ * Outcome of consulting the anchor, cached per (repoRoot, anchorCommit).
30
+ *
31
+ * - `not-applicable`: the anchor commit is not in this repository at all. The
32
+ * rollout it marks is an agent-kit history event, so in a consumer repo (which
33
+ * has its own unrelated history) the "predates the rollout" question is
34
+ * vacuous. Those repos keep their prior behaviour: nothing is retroactively
35
+ * gated. The security property is unaffected where it matters, because in
36
+ * agent-kit the anchor is an ancestor of `main`. Note a shallow clone can
37
+ * also lack it; that case is detected separately and fails closed rather
38
+ * than falling back, so truncated history cannot reopen the bypass.
39
+ * - `slugs`: the anchor resolved; membership decides the exemption.
40
+ * - `unreadable`: the anchor exists but could not be listed, OR it is absent
41
+ * from a shallow clone where truncated history could be hiding it. Both are
42
+ * inconclusive rather than different-repo signals, so both fail **closed**.
43
+ * Memoised for the process lifetime, so a transient git failure stays
44
+ * fail-closed for the rest of the run -- acceptable for short-lived CLI and
45
+ * audit processes, and the safe direction if it ever is not.
46
+ */
47
+ type AnchorLookup = {
48
+ readonly kind: "not-applicable";
49
+ } | {
50
+ readonly kind: "slugs";
51
+ readonly slugs: ReadonlySet<string>;
52
+ } | {
53
+ readonly kind: "unreadable";
54
+ };
55
+ /**
56
+ * Slugs tracked under `blueprints/` at `anchorCommit`, or `null` when the anchor
57
+ * cannot be read. Memoised per (repoRoot, anchorCommit).
58
+ *
59
+ * Callers must treat `null` as "grandfather nobody" — see
60
+ * {@link isBlueprintExemptFromRollout}, which fails closed.
61
+ */
62
+ export declare function blueprintSlugsAtRolloutAnchor(repoRoot: string, anchorCommit?: string): AnchorLookup;
63
+ /**
64
+ * True when this blueprint is exempt from a rollout-gated contract.
65
+ *
66
+ * Resolution order, deliberately:
67
+ *
68
+ * 1. **Anchor is present in this repository** (the agent-kit repo, where these
69
+ * contracts actually rolled out and where CI and the promote/finalize write
70
+ * path enforce them): membership in the anchor tree decides, and the
71
+ * author-supplied `created` is ignored entirely. This is what closes the
72
+ * forgery — a blueprint cannot add its own slug to a months-old tree.
73
+ * 2. **Anchor absent from a full (non-shallow) clone, or no git repository at
74
+ * all**: the rollout is an agent-kit history event that never happened here,
75
+ * so the anchor cannot answer the question. Fall back to the previous
76
+ * `created` compare. This is unchanged prior behaviour for consumer repos and
77
+ * non-git contexts, which is deliberate: those contexts have no such
78
+ * protection today, so tightening them is a separate decision, and gating
79
+ * them here would be unsatisfiable anyway because every accepted form of
80
+ * approval provenance is git-tracked.
81
+ * 3. **Inconclusive**: the anchor exists but cannot be listed, or it is absent
82
+ * from a *shallow* clone where truncated history could be hiding it. Fails
83
+ * **closed** — the gate applies. Without the shallow check, `git clone
84
+ * --depth 1` of this very repository would drop to the forgeable date
85
+ * fallback and reopen the bypass on the local promote/finalize write path.
86
+ *
87
+ * Net effect: strictly hardens the gate where it is enforced, and regresses
88
+ * nothing anywhere else.
89
+ */
90
+ export declare function isBlueprintExemptFromRollout(input: {
91
+ readonly repoRoot: string | null | undefined;
92
+ readonly slug: string;
93
+ readonly created: unknown;
94
+ readonly rolloutDate: string;
95
+ readonly anchorCommit?: string;
96
+ }): boolean;
97
+ export {};
@@ -0,0 +1,161 @@
1
+ import { execFileSync } from "node:child_process";
2
+ /**
3
+ * Rollout grandfathering, decided from git history instead of frontmatter.
4
+ *
5
+ * Two gates used to grant themselves an exemption by reading a blueprint's own
6
+ * `created:` frontmatter and comparing it to a hardcoded rollout date. That
7
+ * value is written by the same author the gate is meant to constrain, so
8
+ * `created: "2020-01-01"` disabled the check outright — on the CI audit path and
9
+ * (for the approval gate) on the `assertCompletedApprovalGate` write path, which
10
+ * would let a blueprint reach `completed` carrying no approval at all.
11
+ *
12
+ * Presence in a fixed commit's tree is instead a structural fact about history:
13
+ * no file content can add a slug to a tree that was written months ago.
14
+ *
15
+ * Matching on **slug** rather than path is deliberate. Blueprints legitimately
16
+ * move `draft/ → planned/ → completed/`, so a path-based check would mistake a
17
+ * lifecycle move for a new blueprint, while `git log --follow` would introduce a
18
+ * rename-similarity heuristic. The slug is invariant across those moves — the
19
+ * repo already enforces one blueprint = one slug = one `bp/<slug>` branch.
20
+ */
21
+ /**
22
+ * The commit that introduced both the completed-approval gate and the Trust
23
+ * Dossier requirement ("fix: preserve pre-contract blueprint rollouts",
24
+ * 2026-07-12). Blueprints already tracked at this commit predate both contracts.
25
+ */
26
+ export const ROLLOUT_ANCHOR_COMMIT = "c6ccb5f31cbecde3be4dbd6d3b7e67918006d22e";
27
+ /** The date that anchor landed, used only by the legacy fallback below. */
28
+ export const ROLLOUT_DATE = "2026-07-12";
29
+ const anchorSlugCache = new Map();
30
+ function anchorCommitExists(repoRoot, anchorCommit) {
31
+ try {
32
+ execFileSync("git", ["cat-file", "-e", `${anchorCommit}^{commit}`], {
33
+ cwd: repoRoot,
34
+ stdio: ["ignore", "ignore", "ignore"],
35
+ });
36
+ return true;
37
+ }
38
+ catch {
39
+ return false;
40
+ }
41
+ }
42
+ /**
43
+ * A shallow clone (`git clone --depth 1`) can be missing the anchor purely
44
+ * because history was truncated, which is indistinguishable from "different
45
+ * repository" by anchor lookup alone. Detecting shallowness lets us tell the two
46
+ * apart instead of assuming the benign one.
47
+ */
48
+ function isShallowRepository(repoRoot) {
49
+ try {
50
+ const out = execFileSync("git", ["rev-parse", "--is-shallow-repository"], {
51
+ cwd: repoRoot,
52
+ encoding: "utf8",
53
+ stdio: ["ignore", "pipe", "ignore"],
54
+ });
55
+ // A path that is not a git repository at all answers with a non-zero exit,
56
+ // handled below. Anything else that is not a clean "false" is treated as
57
+ // truncated, so an odd repository shape fails closed rather than falling
58
+ // back to the forgeable date.
59
+ return out.trim() !== "false";
60
+ }
61
+ catch {
62
+ // Not a git repository (or git is unusable here). This is the "no history to
63
+ // consult" case, not a truncated-history case: the caller falls back, which
64
+ // preserves prior behaviour for non-git contexts where the gate would be
65
+ // unsatisfiable anyway.
66
+ return false;
67
+ }
68
+ }
69
+ /**
70
+ * Slugs tracked under `blueprints/` at `anchorCommit`, or `null` when the anchor
71
+ * cannot be read. Memoised per (repoRoot, anchorCommit).
72
+ *
73
+ * Callers must treat `null` as "grandfather nobody" — see
74
+ * {@link isBlueprintExemptFromRollout}, which fails closed.
75
+ */
76
+ export function blueprintSlugsAtRolloutAnchor(repoRoot, anchorCommit = ROLLOUT_ANCHOR_COMMIT) {
77
+ const cacheKey = `${anchorCommit}:${repoRoot}`;
78
+ const cached = anchorSlugCache.get(cacheKey);
79
+ if (cached !== undefined)
80
+ return cached;
81
+ const result = (() => {
82
+ if (!anchorCommitExists(repoRoot, anchorCommit)) {
83
+ // Absent because this is a different repository (fine -- the rollout never
84
+ // happened here) or because history was truncated (NOT fine -- that would
85
+ // silently reopen the forged-date bypass). Only the former may fall back.
86
+ return isShallowRepository(repoRoot) ? { kind: "unreadable" } : { kind: "not-applicable" };
87
+ }
88
+ try {
89
+ const stdout = execFileSync("git", ["ls-tree", "-r", "--name-only", anchorCommit, "blueprints/"], { cwd: repoRoot, encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] });
90
+ const found = new Set();
91
+ for (const line of stdout.split("\n")) {
92
+ const match = /^blueprints\/[^/]+\/(.+)$/u.exec(line.trim());
93
+ if (!match)
94
+ continue;
95
+ const rest = match[1] ?? "";
96
+ // Folder shape `<slug>/_overview.md`, or legacy flat `<slug>.md`.
97
+ const slug = rest.includes("/")
98
+ ? rest.slice(0, rest.indexOf("/"))
99
+ : rest.endsWith(".md")
100
+ ? rest.slice(0, -3)
101
+ : "";
102
+ if (slug)
103
+ found.add(slug);
104
+ }
105
+ return { kind: "slugs", slugs: found };
106
+ }
107
+ catch {
108
+ return { kind: "unreadable" };
109
+ }
110
+ })();
111
+ anchorSlugCache.set(cacheKey, result);
112
+ return result;
113
+ }
114
+ /** Legacy fallback: the author-supplied `created` date compare. */
115
+ function createdPredatesRollout(created, rolloutDate) {
116
+ const date = created instanceof Date
117
+ ? created.toISOString().slice(0, 10)
118
+ : typeof created === "string" && /^\d{4}-\d{2}-\d{2}$/u.test(created)
119
+ ? created
120
+ : null;
121
+ return date !== null && date < rolloutDate;
122
+ }
123
+ /**
124
+ * True when this blueprint is exempt from a rollout-gated contract.
125
+ *
126
+ * Resolution order, deliberately:
127
+ *
128
+ * 1. **Anchor is present in this repository** (the agent-kit repo, where these
129
+ * contracts actually rolled out and where CI and the promote/finalize write
130
+ * path enforce them): membership in the anchor tree decides, and the
131
+ * author-supplied `created` is ignored entirely. This is what closes the
132
+ * forgery — a blueprint cannot add its own slug to a months-old tree.
133
+ * 2. **Anchor absent from a full (non-shallow) clone, or no git repository at
134
+ * all**: the rollout is an agent-kit history event that never happened here,
135
+ * so the anchor cannot answer the question. Fall back to the previous
136
+ * `created` compare. This is unchanged prior behaviour for consumer repos and
137
+ * non-git contexts, which is deliberate: those contexts have no such
138
+ * protection today, so tightening them is a separate decision, and gating
139
+ * them here would be unsatisfiable anyway because every accepted form of
140
+ * approval provenance is git-tracked.
141
+ * 3. **Inconclusive**: the anchor exists but cannot be listed, or it is absent
142
+ * from a *shallow* clone where truncated history could be hiding it. Fails
143
+ * **closed** — the gate applies. Without the shallow check, `git clone
144
+ * --depth 1` of this very repository would drop to the forgeable date
145
+ * fallback and reopen the bypass on the local promote/finalize write path.
146
+ *
147
+ * Net effect: strictly hardens the gate where it is enforced, and regresses
148
+ * nothing anywhere else.
149
+ */
150
+ export function isBlueprintExemptFromRollout(input) {
151
+ const { repoRoot, slug, created, rolloutDate } = input;
152
+ const anchorCommit = input.anchorCommit ?? ROLLOUT_ANCHOR_COMMIT;
153
+ if (repoRoot) {
154
+ const lookup = blueprintSlugsAtRolloutAnchor(repoRoot, anchorCommit);
155
+ if (lookup.kind === "slugs")
156
+ return slug.length > 0 && lookup.slugs.has(slug);
157
+ if (lookup.kind === "unreadable")
158
+ return false;
159
+ }
160
+ return createdPredatesRollout(created, rolloutDate);
161
+ }
@@ -5,6 +5,7 @@
5
5
  * platform requests share the strict origin, redirect, body, redaction, abort,
6
6
  * and retry boundary in src/platform/client.ts.
7
7
  */
8
+ import { type FetchLike } from "#platform/client.js";
8
9
  import type { SyncCredentials } from "./auth.js";
9
10
  import type { BlueprintPlatformClient, BlueprintPlatformEvent, BlueprintSnapshot, BlueprintTemplateEntry } from "./types.js";
10
11
  export declare class AuthError extends Error {
@@ -22,7 +23,7 @@ export declare class BlueprintSyncClient implements BlueprintPlatformClient {
22
23
  private readonly fetchFn;
23
24
  private platformClient;
24
25
  private readonly retry;
25
- constructor(creds: SyncCredentials, fetchFn?: typeof globalThis.fetch, retryOptions?: RetryOptions);
26
+ constructor(creds: SyncCredentials, fetchFn?: FetchLike, retryOptions?: RetryOptions);
26
27
  pushEvent(payload: BlueprintPlatformEvent): Promise<void>;
27
28
  getSnapshot(opts?: {
28
29
  readonly slug?: string;
@@ -6,7 +6,7 @@
6
6
  * and retry boundary in src/platform/client.ts.
7
7
  */
8
8
  import { randomUUID } from "node:crypto";
9
- import { PlatformClient, PlatformHttpError } from "#platform/client.js";
9
+ import { PlatformClient, PlatformHttpError, } from "#platform/client.js";
10
10
  export class AuthError extends Error {
11
11
  constructor(message) {
12
12
  super(message);
@@ -22,6 +22,7 @@ export type TrustPromotionGate = {
22
22
  command: string;
23
23
  expectedOutcome: string;
24
24
  lastResult: string;
25
+ defer: string;
25
26
  };
26
27
  export type TrustDossier = {
27
28
  readiness: TrustReadinessVerdict;
@@ -36,12 +36,7 @@ export function parseTrustDossier(markdown) {
36
36
  rejectedAlternatives,
37
37
  rationale,
38
38
  }), violations);
39
- const gates = parseTable(requiredBlock(blocks, "Promotion Gates"), "Promotion Gates", ["Gate", "Command", "Expected outcome", "Last result"], ([gate = "", command = "", expectedOutcome = "", lastResult = ""]) => ({
40
- gate,
41
- command,
42
- expectedOutcome,
43
- lastResult,
44
- }), violations);
39
+ const gates = parsePromotionGatesTable(requiredBlock(blocks, "Promotion Gates"), violations);
45
40
  const residualUnknowns = requiredBlock(blocks, "Residual Unknowns").trim();
46
41
  for (const [section, block] of blocks) {
47
42
  if (containsPlaceholder(block))
@@ -126,6 +121,45 @@ function parseTable(block, section, expectedHeader, build, violations) {
126
121
  }
127
122
  return result;
128
123
  }
124
+ const PROMOTION_GATES_HEADER_LEGACY = ["Gate", "Command", "Expected outcome", "Last result"];
125
+ const PROMOTION_GATES_HEADER_WITH_DEFER = [...PROMOTION_GATES_HEADER_LEGACY, "Defer"];
126
+ // Bespoke (not parseTable()) so the header/row width can be EITHER the
127
+ // legacy 4-column shape (every Promotion Gates table committed before this
128
+ // fix, and the common case going forward) OR the 5-column shape with a
129
+ // trailing Defer column (opt-in, see TrustPromotionGate.defer). A committed
130
+ // blueprint's table must have every row match its own header's width -- a
131
+ // row can't silently be short one cell.
132
+ function parsePromotionGatesTable(block, violations) {
133
+ const section = "Promotion Gates";
134
+ const rows = block
135
+ .split("\n")
136
+ .map((line) => line.trim())
137
+ .filter((line) => line.startsWith("|") && line.endsWith("|"));
138
+ if (rows.length < 2) {
139
+ violations.push({ section, message: "missing markdown table" });
140
+ return [];
141
+ }
142
+ const header = splitRow(rows[0] ?? "").map((cell) => cell.toLowerCase());
143
+ const legacyHeader = PROMOTION_GATES_HEADER_LEGACY.map((cell) => cell.toLowerCase());
144
+ const deferHeader = PROMOTION_GATES_HEADER_WITH_DEFER.map((cell) => cell.toLowerCase());
145
+ const hasDeferColumn = header.join("|") === deferHeader.join("|");
146
+ if (!hasDeferColumn && header.join("|") !== legacyHeader.join("|")) {
147
+ violations.push({ section, message: `malformed table header for ${section}` });
148
+ return [];
149
+ }
150
+ const expectedWidth = hasDeferColumn ? 5 : 4;
151
+ const result = [];
152
+ for (const row of rows.slice(2)) {
153
+ const cells = splitRow(row);
154
+ if (cells.length !== expectedWidth) {
155
+ violations.push({ section, message: `malformed table row in ${section}` });
156
+ continue;
157
+ }
158
+ const [gate = "", command = "", expectedOutcome = "", lastResult = "", defer = ""] = cells;
159
+ result.push({ gate, command, expectedOutcome, lastResult, defer });
160
+ }
161
+ return result;
162
+ }
129
163
  function requiredBlock(blocks, key) {
130
164
  const value = blocks.get(key);
131
165
  if (value === undefined)
@@ -28,6 +28,15 @@ export function preparePromotionTrustGate(input) {
28
28
  throw new Error(`Blueprint trust gate failed: ${preflight.violations.map((v) => `${v.section}: ${v.message}`).join("; ")}`);
29
29
  const parsed = parseTrustDossier(markdown);
30
30
  for (const gate of parsed.dossier?.gates ?? []) {
31
+ if (gate.defer === "pre-implementation") {
32
+ // Explicit opt-in deferral (Trust Dossier "Defer" column): the gate's
33
+ // command is never executed nor existence-checked at promote time. This
34
+ // is deliberately narrower than the automatic `wp test --file` deferral
35
+ // below -- it only applies when a human/agent explicitly asked for it,
36
+ // so a genuinely broken/mistyped gate command still fails closed.
37
+ markdown = updateGateLastResult(markdown, gate.gate, "deferred: pre-implementation (explicit defer marker)");
38
+ continue;
39
+ }
31
40
  const deferredTarget = firstMissingTestTarget(input.repoRoot, gate.command);
32
41
  if (deferredTarget !== undefined) {
33
42
  // A `wp test --file <path>` gate whose target file does not exist yet is
@@ -156,6 +165,9 @@ function updateGateLastResult(markdown, gate, result) {
156
165
  if (cells[0] === gate && cells.length === 4) {
157
166
  lines[i] = `| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${result} |`;
158
167
  }
168
+ else if (cells[0] === gate && cells.length === 5) {
169
+ lines[i] = `| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${result} | ${cells[4]} |`;
170
+ }
159
171
  }
160
172
  return lines.join("\n");
161
173
  }
@@ -208,6 +208,11 @@ export const CLI_MCP_PARITY_CLASSIFICATION = {
208
208
  tools: ["wp_lint"],
209
209
  reason: "lint MCP tool exists",
210
210
  },
211
+ "rust-check": {
212
+ command: "rust-check",
213
+ classification: "follow-up-candidate",
214
+ reason: "cargo check/test verb for the ci-preflight Rust lane; CLI-only dev gate with no dedicated MCP tool",
215
+ },
211
216
  logs: {
212
217
  command: "logs",
213
218
  classification: "follow-up-candidate",
@@ -5,7 +5,7 @@
5
5
  * Lazy-loads subcommand modules based on the first argv to keep startup
6
6
  * cheap. Modeled on apps/cli-wp/src/cli.ts.
7
7
  */
8
- declare const SUPPORTED_COMMANDS: readonly ["blueprint", "browser", "dash", "dash-helper-start", "dash-helper-complete", "claude", "codex", "grok", "opencode", "yolo", "config", "secrets", "roadmap", "status", "review", "sync", "audit", "qa", "ultragoal", "compile", "rule", "skill", "skills", "docs", "setup", "init", "dev", "deploy", "preview", "cleanup", "migrate", "doctor", "err", "test", "e2e", "ci", "ci-preflight", "public:readiness", "typecheck", "lint", "format", "logs", "session-id", "session-info", "session", "tech-debt", "pr", "fleet", "worktree", "mcp", "hook", "hooks", "self-install-guard", "optional-tool-refresh", "gain", "bench", "install", "add", "remove", "update", "exec", "run"];
8
+ declare const SUPPORTED_COMMANDS: readonly ["blueprint", "browser", "dash", "dash-helper-start", "dash-helper-complete", "claude", "codex", "grok", "opencode", "yolo", "config", "secrets", "roadmap", "status", "review", "sync", "audit", "qa", "ultragoal", "compile", "rule", "skill", "skills", "docs", "setup", "init", "dev", "deploy", "preview", "cleanup", "migrate", "doctor", "err", "test", "e2e", "ci", "ci-preflight", "public:readiness", "typecheck", "lint", "format", "rust-check", "logs", "session-id", "session-info", "session", "tech-debt", "pr", "fleet", "worktree", "mcp", "hook", "hooks", "self-install-guard", "optional-tool-refresh", "gain", "bench", "install", "add", "remove", "update", "exec", "run"];
9
9
  export type SupportedCommand = (typeof SUPPORTED_COMMANDS)[number];
10
10
  type RootHelpEntry = {
11
11
  readonly command: string;
@@ -59,6 +59,7 @@ const SUPPORTED_COMMANDS = [
59
59
  "typecheck",
60
60
  "lint",
61
61
  "format",
62
+ "rust-check",
62
63
  "logs",
63
64
  "session-id",
64
65
  "session-info",
@@ -103,6 +104,10 @@ const ROOT_HELP_CORE_ENTRIES = [
103
104
  command: "format",
104
105
  description: "Format through the portable wp surface (--check for CI/husky)",
105
106
  },
107
+ {
108
+ command: "rust-check",
109
+ description: "Prove affected tracked Rust crates compile and pass tests (cargo check + test)",
110
+ },
106
111
  {
107
112
  command: "e2e",
108
113
  description: "Build and run E2E commands through the portable webpresso surface",
@@ -386,6 +391,10 @@ const COMMAND_REGISTRARS = {
386
391
  const { registerFormatCommand } = await import("./commands/format.js");
387
392
  registerFormatCommand(cli);
388
393
  },
394
+ "rust-check": async (cli) => {
395
+ const { registerRustCheckCommand } = await import("./commands/rust-check.js");
396
+ registerRustCheckCommand(cli);
397
+ },
389
398
  logs: async (cli) => {
390
399
  const { registerLogsCommand } = await import("./commands/logs.js");
391
400
  registerLogsCommand(cli);
@@ -3,6 +3,7 @@ import { type DashPermissionMode, type DashProvider } from "#cli/commands/dash/p
3
3
  import { type RuntimeCommandOptions } from "#runtime/executor.js";
4
4
  import { type SecretsConfig } from "#runtime/secrets-config.js";
5
5
  import { resolveOpencodeAccountsGracefully } from "#runtime/opencode-account-materializer.js";
6
+ import { resolveHealthyOpencodeAccount } from "#cli/commands/opencode-probe.js";
6
7
  export declare const AGENT_LAUNCH_COMMANDS: readonly ["claude", "codex", "grok", "opencode"];
7
8
  export type AgentLaunchCommand = DashProvider;
8
9
  export declare const DEFAULT_AGENT_SECRET_PROFILE = "preview";
@@ -23,6 +24,7 @@ export interface AgentLaunchDeps {
23
24
  readonly materializeGlobalOpencodeAuth?: (key: string) => void;
24
25
  readonly readActiveKey?: () => string | undefined;
25
26
  readonly recordCooldown?: (canonicalId: string) => Promise<unknown>;
27
+ readonly resolveHealthyOpencodeAccount?: typeof resolveHealthyOpencodeAccount;
26
28
  }
27
29
  export declare function buildDefaultAgentArgs(command: AgentLaunchCommand, args?: readonly string[], permissionMode?: DashPermissionMode): readonly string[];
28
30
  export declare function isAgentLaunchCommand(command: string | undefined): command is AgentLaunchCommand;
@@ -12,6 +12,7 @@ import { loadOpencodeAccountAvailability, pruneExpiredOpencodeAccountSuppression
12
12
  import { selectActiveOpencodeAccount } from "#review/opencode-account.js";
13
13
  import { materializeOpencodeGoAuth } from "#review/opencode-auth.js";
14
14
  import { describeOpencodeRotate, OPENCODE_ROTATE_FLAG, rotateActiveOpencodeAccount, } from "#cli/commands/opencode-rotate.js";
15
+ import { OPENCODE_SKIP_PROBE_ENV, resolveHealthyOpencodeAccount, } from "#cli/commands/opencode-probe.js";
15
16
  export const AGENT_LAUNCH_COMMANDS = DASH_PROVIDERS;
16
17
  export const DEFAULT_AGENT_SECRET_PROFILE = "preview";
17
18
  export const AGENT_SECRET_PROFILE_ENV = "WP_AGENT_SECRET_PROFILE";
@@ -89,21 +90,26 @@ function ensureCodexSkillSurface(cwd, env = process.env) {
89
90
  }
90
91
  }
91
92
  /**
92
- * Choose the active opencode-go account and materialize its key into the
93
- * global opencode `auth.json` before the TUI launches.
93
+ * Choose the active opencode-go account, verify it against the real provider,
94
+ * and materialize its key into the global opencode `auth.json` before the TUI
95
+ * launches.
96
+ *
97
+ * A launch-time health probe (`resolveHealthyOpencodeAccount`) spawns a bounded
98
+ * `opencode run` against the candidate's isolated credential store; a detected
99
+ * usage limit cools that account with the provider's real reset window and the
100
+ * next account is probed instead. So a plain relaunch now *does* rotate past an
101
+ * exhausted account, and `wp opencode --wp-rotate` is only needed to force a
102
+ * swap off an account that is still answering.
94
103
  *
95
104
  * Honest limitation: interactive opencode is a long-lived TUI — the account is
96
- * chosen once at launch and a mid-session dollar-cap is NOT auto-swapped. A
97
- * plain relaunch does NOT rotate either: nothing on this path records a
98
- * cooldown, so an exhausted account stays "first non-suppressed" and gets
99
- * re-picked every time. Run `wp opencode --wp-rotate` to cool the current
100
- * account and launch on the next one. Only the review gate does automatic
101
- * in-flight failover, because only it can observe the usage-limit response.
105
+ * chosen once at launch, so a cap hit *mid-session* is still NOT auto-swapped.
106
+ * Only the review gate does in-flight failover, because only it observes every
107
+ * provider response for the life of the run.
102
108
  *
103
- * Never throws: a Doppler outage or materialize failure must not block the
104
- * TUI from launching, so every failure is logged and swallowed.
109
+ * Never throws: a Doppler outage, a failed probe, or a materialize failure must
110
+ * not block the TUI from launching, so every failure is logged and swallowed.
105
111
  */
106
- function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile) {
112
+ async function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile, env) {
107
113
  try {
108
114
  const resolution = (deps.resolveOpencodeAccounts ?? resolveOpencodeAccountsGracefully)({
109
115
  cwd,
@@ -112,11 +118,15 @@ function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile) {
112
118
  if (!resolution.ok)
113
119
  return;
114
120
  const pruned = pruneExpiredOpencodeAccountSuppressions(loadOpencodeAccountAvailability());
115
- const selection = selectActiveOpencodeAccount(resolution.accounts, pruned.state);
116
- if (!selection)
121
+ const initial = selectActiveOpencodeAccount(resolution.accounts, pruned.state);
122
+ if (!initial)
117
123
  return;
124
+ const probed = await (deps.resolveHealthyOpencodeAccount ?? resolveHealthyOpencodeAccount)(resolution.accounts, initial, { env });
125
+ for (const line of probed.lines)
126
+ console.error(line);
127
+ const selection = probed.selection;
118
128
  (deps.materializeGlobalOpencodeAuth ?? ((key) => materializeOpencodeGoAuth(key)))(selection.account.key);
119
- if (selection.cooled) {
129
+ if (selection.cooled && probed.lines.length === 0) {
120
130
  console.error(`wp opencode: all OpenCode Go accounts are within their usage cooldown; launching on the soonest-expiring account (${selection.account.id}).`);
121
131
  }
122
132
  }
@@ -130,10 +140,11 @@ function selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile) {
130
140
  * forward to the binary. `--wp-rotate` is consumed here and must never reach
131
141
  * opencode, which would treat it as a prompt.
132
142
  */
133
- async function prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile) {
143
+ async function prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile, env) {
134
144
  if (!args.includes(OPENCODE_ROTATE_FLAG)) {
135
- if (secretsConfig !== null)
136
- selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile);
145
+ if (secretsConfig !== null) {
146
+ await selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile, env);
147
+ }
137
148
  return args;
138
149
  }
139
150
  const outcome = await rotateActiveOpencodeAccount(cwd, secretProfile, deps);
@@ -144,7 +155,7 @@ async function prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfi
144
155
  // rotation could not run, fall through to that selection so the launch still
145
156
  // gets the best account it can rather than whatever the store happens to hold.
146
157
  if (outcome.kind === "unavailable" && secretsConfig !== null) {
147
- selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile);
158
+ await selectAndMaterializeOpencodeAccount(cwd, deps, secretProfile, env);
148
159
  }
149
160
  return args.filter((arg) => arg !== OPENCODE_ROTATE_FLAG);
150
161
  }
@@ -166,7 +177,7 @@ export async function runAgentLaunchCommand(command, args = [], options = {}, de
166
177
  // worktree, CI clone, and fresh checkout.
167
178
  const secretProfile = resolveAgentSecretProfile(options.env ?? process.env);
168
179
  const launchArgs = command === "opencode"
169
- ? await prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile)
180
+ ? await prepareOpencodeLaunch(cwd, args, secretsConfig, deps, secretProfile, options.env ?? process.env)
170
181
  : args;
171
182
  const runtimeOptions = buildAgentRuntimeCommandOptions({
172
183
  command,
@@ -184,7 +195,7 @@ export async function runAgentLaunchCommand(command, args = [], options = {}, de
184
195
  }
185
196
  export function registerAgentLaunchCommand(cli, command) {
186
197
  const rotateHint = command === "opencode"
187
- ? ` (pass \`${OPENCODE_ROTATE_FLAG}\` to cool the current OpenCode Go account and launch on the next one)`
198
+ ? ` (the selected OpenCode Go account is health-probed first and a capped one is rotated past automatically; pass \`${OPENCODE_ROTATE_FLAG}\` to force a rotation, or set \`${OPENCODE_SKIP_PROBE_ENV}=1\` to skip the probe)`
188
199
  : "";
189
200
  cli
190
201
  .command(`${command} [...args]`, `Launch ${command} with provider-backed agent secrets and safe-by-default permissions (run \`wp yolo on\` for persistent full-auto)${rotateHint}`)