@webpresso/agent-kit 3.3.3 → 3.3.5

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 (156) hide show
  1. package/THIRD-PARTY-NOTICES.md +12 -10
  2. package/catalog/agent/rules/changeset-release.md +15 -4
  3. package/catalog/agent/rules/managed-tool-binaries.md +67 -0
  4. package/catalog/agent/rules/parallel-orchestration-quota.md +99 -1
  5. package/catalog/agent/rules/pre-implementation.md +36 -20
  6. package/catalog/agent/rules/rtk-routing.md +22 -16
  7. package/catalog/agent/skills/autopilot/SKILL.md +4 -3
  8. package/catalog/agent/skills/ultragoal/SKILL.md +11 -9
  9. package/dist/esm/audit/agents.js +126 -26
  10. package/dist/esm/audit/hook-surface.js +5 -1
  11. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  12. package/dist/esm/audit/repo-guardrails.js +71 -1
  13. package/dist/esm/blueprint/core/parser.js +38 -12
  14. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  15. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  16. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  17. package/dist/esm/blueprint/execution/metadata.js +8 -4
  18. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  19. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  20. package/dist/esm/blueprint/lifecycle/review-provenance.js +63 -10
  21. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  22. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  23. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  24. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  25. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  26. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  27. package/dist/esm/blueprint/trust/dossier.js +41 -1
  28. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  29. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  30. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  31. package/dist/esm/blueprint/trust/promotion.js +19 -19
  32. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  33. package/dist/esm/build/atomic-file-copy.js +44 -0
  34. package/dist/esm/build/vendor-oxlint-plugins.d.ts +11 -0
  35. package/dist/esm/build/vendor-oxlint-plugins.js +48 -0
  36. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  37. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  38. package/dist/esm/cli/commands/compile.d.ts +13 -0
  39. package/dist/esm/cli/commands/compile.js +182 -62
  40. package/dist/esm/cli/commands/format.js +17 -1
  41. package/dist/esm/cli/commands/gain/index.js +10 -3
  42. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  43. package/dist/esm/cli/commands/init/index.js +32 -52
  44. package/dist/esm/cli/commands/init/mcp-spec.d.ts +48 -13
  45. package/dist/esm/cli/commands/init/mcp-spec.js +86 -30
  46. package/dist/esm/cli/commands/init/merge.js +30 -10
  47. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  48. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  49. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  50. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  51. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  52. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  53. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  54. package/dist/esm/cli/commands/pr-help.js +1 -1
  55. package/dist/esm/cli/commands/pr.js +18 -4
  56. package/dist/esm/cli/commands/review.d.ts +1 -0
  57. package/dist/esm/cli/commands/review.js +137 -7
  58. package/dist/esm/cli/commands/sync.d.ts +8 -6
  59. package/dist/esm/cli/commands/sync.js +11 -9
  60. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  61. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  62. package/dist/esm/cli/commands/worktree/router.js +5 -2
  63. package/dist/esm/cli/direct-provider-launch.js +12 -4
  64. package/dist/esm/compiler/flatten.d.ts +10 -1
  65. package/dist/esm/compiler/flatten.js +14 -4
  66. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  67. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  68. package/dist/esm/compose/compose.d.ts +8 -0
  69. package/dist/esm/compose/compose.js +115 -8
  70. package/dist/esm/compose/ops-report-schema.js +17 -3
  71. package/dist/esm/compose/registry-resolve.d.ts +17 -0
  72. package/dist/esm/compose/registry-resolve.js +78 -11
  73. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  74. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  75. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  76. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  77. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  78. package/dist/esm/config/oxlint/oxlintrc.js +19 -31
  79. package/dist/esm/config/oxlint/path-roles.js +53 -0
  80. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  81. package/dist/esm/content/dispatch.js +4 -0
  82. package/dist/esm/content/loader.d.ts +1 -1
  83. package/dist/esm/content/loader.js +10 -6
  84. package/dist/esm/content/skill-policy.d.ts +11 -0
  85. package/dist/esm/content/skill-policy.js +33 -0
  86. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  87. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  88. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  89. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  90. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  91. package/dist/esm/daemon/domains/launch.js +30 -1
  92. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  93. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  94. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  95. package/dist/esm/daemon/handlers/product.js +10 -1
  96. package/dist/esm/daemon/methods.d.ts +21 -1
  97. package/dist/esm/daemon/methods.js +20 -0
  98. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  99. package/dist/esm/daemon/protocol/contract.js +38 -0
  100. package/dist/esm/daemon/server.js +7 -0
  101. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  102. package/dist/esm/errors/wp-error.js +21 -2
  103. package/dist/esm/hooks/doctor.d.ts +11 -0
  104. package/dist/esm/hooks/doctor.js +114 -45
  105. package/dist/esm/hooks/pretool-guard/dev-routing.js +68 -5
  106. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  107. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  108. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  109. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +62 -3
  110. package/dist/esm/hooks/status/index.js +1 -1
  111. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  112. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  113. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  114. package/dist/esm/mcp/blueprint/registration.js +1 -1
  115. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  116. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  117. package/dist/esm/mcp/tools/gain.js +28 -2
  118. package/dist/esm/mcp/tools/worktree.js +65 -3
  119. package/dist/esm/mcp/tools/wp-ui-compose.js +38 -4
  120. package/dist/esm/mcp/tools/wp-ui-preview.js +14 -7
  121. package/dist/esm/review/authority.js +56 -1
  122. package/dist/esm/review/delivery-verifier.d.ts +4 -0
  123. package/dist/esm/review/delivery-verifier.js +9 -1
  124. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  125. package/dist/esm/review/lifecycle-writers.js +330 -0
  126. package/dist/esm/review/subject.js +66 -32
  127. package/dist/esm/status/snapshot.d.ts +5 -0
  128. package/dist/esm/status/snapshot.js +27 -2
  129. package/dist/esm/symlinker/consumers.d.ts +3 -1
  130. package/dist/esm/symlinker/consumers.js +7 -1
  131. package/dist/esm/symlinker/unified-sync.d.ts +3 -3
  132. package/dist/esm/symlinker/unified-sync.js +88 -11
  133. package/dist/esm/test/shard-durations.json +0 -2
  134. package/dist/esm/test-helpers/global-setup.js +38 -3
  135. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  136. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  137. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  138. package/dist/esm/utils/package-root.d.ts +32 -0
  139. package/dist/esm/utils/package-root.js +82 -0
  140. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  141. package/dist/esm/worktrees/git-metadata.js +48 -0
  142. package/package.json +16 -21
  143. package/dist/esm/cli/commands/init/scaffolders/subagents/index.d.ts +0 -6
  144. package/dist/esm/cli/commands/init/scaffolders/subagents/index.js +0 -77
  145. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  146. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  147. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  148. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  149. package/dist/esm/config/oxlint/index.d.ts +0 -22
  150. package/dist/esm/config/oxlint/index.js +0 -28
  151. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  152. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  153. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  154. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  155. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  156. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -3,6 +3,7 @@ import { existsSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { validateBlueprintTrust } from "./validator.js";
5
5
  import { parseTrustDossier } from "./dossier.js";
6
+ import { mapPromotionGateRows, setLastResultCell } from "./gate-row.js";
6
7
  import { parseAllowedWpCommand, wpTestFileTargets } from "./gates.js";
7
8
  import { ALLOWED_WP_COMMAND_TIMEOUT_MS, executeAllowedWpCommand, tailBounded, } from "./command-runner.js";
8
9
  export { parseAllowedWpCommand };
@@ -138,7 +139,14 @@ function formatPromotionGateFailure(result) {
138
139
  details.push(`log=${result.logPath}`);
139
140
  return `Promotion gate failed (${result.command}): ${details.join("; ")}`;
140
141
  }
141
- function upsertReadinessValue(markdown, key, value) {
142
+ /**
143
+ * Exported for the plan-subject digest invariance suite
144
+ * (`src/review/lifecycle-writers.ts`, `src/review/subject-normalize.test.ts`).
145
+ * Those tests MUST call the production writer, never a hand-written mirror: a
146
+ * mirror can drift with the very bug under test and stay self-consistent, which
147
+ * is exactly how the 5-cell Promotion Gates leak survived the suite.
148
+ */
149
+ export function upsertReadinessValue(markdown, key, value) {
142
150
  const re = new RegExp(`^- ${key}: .*$`, "mu");
143
151
  if (re.test(markdown))
144
152
  return markdown.replace(re, `- ${key}: ${value}`);
@@ -152,22 +160,14 @@ function upsertReadinessValue(markdown, key, value) {
152
160
  lines.splice(insertIndex, 0, `- ${key}: ${value}`);
153
161
  return lines.join("\n");
154
162
  }
155
- function updateGateLastResult(markdown, gate, result) {
156
- const lines = markdown.split("\n");
157
- for (let i = 0; i < lines.length; i++) {
158
- const line = lines[i] ?? "";
159
- const cells = line.trim().startsWith("|")
160
- ? line
161
- .slice(1, -1)
162
- .split("|")
163
- .map((c) => c.trim())
164
- : [];
165
- if (cells[0] === gate && cells.length === 4) {
166
- lines[i] = `| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${result} |`;
167
- }
168
- else if (cells[0] === gate && cells.length === 5) {
169
- lines[i] = `| ${cells[0]} | ${cells[1]} | ${cells[2]} | ${result} | ${cells[4]} |`;
170
- }
171
- }
172
- return lines.join("\n");
163
+ /**
164
+ * Stamp the `Last result` cell of the gate named `gate`.
165
+ *
166
+ * Exported for the same reason as `upsertReadinessValue` above. The row grammar
167
+ * and the section scope both come from `./gate-row.js`, which the plan-subject
168
+ * digest normalizer consumes too — hand-rolling either here is what let the two
169
+ * sides diverge on four axes and destroy plan approvals on promotion.
170
+ */
171
+ export function updateGateLastResult(markdown, gate, result) {
172
+ return mapPromotionGateRows(markdown, (cells) => cells[0] === gate ? setLastResultCell(cells, result) : cells);
173
173
  }
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Copy `source` to `destination` via copy-to-temp + atomic rename.
3
+ *
4
+ * A plain `copyFileSync(source, destination)` opens `destination` directly
5
+ * for writing. If another process currently has `destination` open for
6
+ * execution (a running compiled binary, a loaded native addon), POSIX
7
+ * returns `ETXTBSY` ("text file is busy"). This is not a flake: it is a
8
+ * deterministic race whenever a staging step runs concurrently with a
9
+ * process that execs the file being replaced (for example a vitest shard
10
+ * running the freshly staged `wp` runtime binary while another shard
11
+ * restages it).
12
+ *
13
+ * `rename(2)` is atomic within a filesystem and is legal even onto a file
14
+ * another process currently has open for execution: the running process
15
+ * keeps its already-open inode (and keeps running unaffected), while any
16
+ * new open/exec of `destination` resolves to the new file. Copying to a
17
+ * unique temp file beside `destination` (same directory) keeps the rename
18
+ * on the same filesystem so it stays atomic, then renaming over the
19
+ * destination replaces it without ever truncating a file that may be
20
+ * actively executing. `copyFileSync` preserves the source file's mode on
21
+ * the temp file, so there is no non-executable window between the rename
22
+ * and any later `chmodSync` a caller performs.
23
+ *
24
+ * On any failure (source read, temp write, or rename) the temp file is
25
+ * removed via `rmSync(..., { force: true })` (which tolerates the file not
26
+ * existing) before the error is rethrown.
27
+ *
28
+ * This guarantee is POSIX-only: on Windows, `renameSync` onto a file that
29
+ * is currently open for execution fails with `EPERM` for the same reason
30
+ * `copyFileSync` would, so this helper does not remove the race there.
31
+ */
32
+ export declare function copyFileAtomic(source: string, destination: string): void;
@@ -0,0 +1,44 @@
1
+ import { copyFileSync, renameSync, rmSync } from "node:fs";
2
+ import { randomUUID } from "node:crypto";
3
+ /**
4
+ * Copy `source` to `destination` via copy-to-temp + atomic rename.
5
+ *
6
+ * A plain `copyFileSync(source, destination)` opens `destination` directly
7
+ * for writing. If another process currently has `destination` open for
8
+ * execution (a running compiled binary, a loaded native addon), POSIX
9
+ * returns `ETXTBSY` ("text file is busy"). This is not a flake: it is a
10
+ * deterministic race whenever a staging step runs concurrently with a
11
+ * process that execs the file being replaced (for example a vitest shard
12
+ * running the freshly staged `wp` runtime binary while another shard
13
+ * restages it).
14
+ *
15
+ * `rename(2)` is atomic within a filesystem and is legal even onto a file
16
+ * another process currently has open for execution: the running process
17
+ * keeps its already-open inode (and keeps running unaffected), while any
18
+ * new open/exec of `destination` resolves to the new file. Copying to a
19
+ * unique temp file beside `destination` (same directory) keeps the rename
20
+ * on the same filesystem so it stays atomic, then renaming over the
21
+ * destination replaces it without ever truncating a file that may be
22
+ * actively executing. `copyFileSync` preserves the source file's mode on
23
+ * the temp file, so there is no non-executable window between the rename
24
+ * and any later `chmodSync` a caller performs.
25
+ *
26
+ * On any failure (source read, temp write, or rename) the temp file is
27
+ * removed via `rmSync(..., { force: true })` (which tolerates the file not
28
+ * existing) before the error is rethrown.
29
+ *
30
+ * This guarantee is POSIX-only: on Windows, `renameSync` onto a file that
31
+ * is currently open for execution fails with `EPERM` for the same reason
32
+ * `copyFileSync` would, so this helper does not remove the race there.
33
+ */
34
+ export function copyFileAtomic(source, destination) {
35
+ const tmpDestination = `${destination}.tmp-${process.pid}-${randomUUID()}`;
36
+ try {
37
+ copyFileSync(source, tmpDestination);
38
+ renameSync(tmpDestination, destination);
39
+ }
40
+ catch (error) {
41
+ rmSync(tmpDestination, { force: true });
42
+ throw error;
43
+ }
44
+ }
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env bun
2
+ export declare const AGENT_CONFIG_OXLINT_DIST: string;
3
+ export declare const AGENT_KIT_OXLINT_DIST: string;
4
+ /**
5
+ * Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles).
6
+ * SSOT: `@webpresso/agent-config/oxlint/basenames`.
7
+ */
8
+ export declare const VENDOR_PLUGIN_BASENAMES: readonly ["code-safety", "foundation-purity", "graphql-conventions", "import-hygiene", "monorepo-paths", "query-patterns", "testing-quality", "tier-boundaries"];
9
+ export declare function vendorOxlintPlugins(sourceDir?: string, destDir?: string): {
10
+ copied: string[];
11
+ };
@@ -0,0 +1,48 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * Vendor compiled oxlint policy plugins from agent-config into agent-kit dist.
4
+ *
5
+ * Consumer SSOT remains @webpresso/agent-config/oxlint/*. agent-kit's inject
6
+ * oxlintrc uses sibling-relative jsPlugins; we copy the built plugin files so
7
+ * the published kit has zero runtime dependency on agent-config (no re-export
8
+ * stubs, no pack chicken-egg).
9
+ */
10
+ import { cpSync, existsSync, mkdirSync } from "node:fs";
11
+ import { join } from "node:path";
12
+ import { OXLINT_PATH_ROLES_BASENAME, OXLINT_POLICY_PLUGIN_BASENAMES, } from "@webpresso/agent-config/oxlint/basenames";
13
+ export const AGENT_CONFIG_OXLINT_DIST = join(process.cwd(), "packages/agent-config/dist/esm/oxlint");
14
+ export const AGENT_KIT_OXLINT_DIST = join(process.cwd(), "dist/esm/config/oxlint");
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;
20
+ export function vendorOxlintPlugins(sourceDir = AGENT_CONFIG_OXLINT_DIST, destDir = AGENT_KIT_OXLINT_DIST) {
21
+ if (!existsSync(sourceDir)) {
22
+ throw new Error(`vendor-oxlint-plugins: missing agent-config oxlint dist at ${sourceDir}. Build @webpresso/agent-config first.`);
23
+ }
24
+ mkdirSync(destDir, { recursive: true });
25
+ const copied = [];
26
+ for (const name of VENDOR_PLUGIN_BASENAMES) {
27
+ const src = join(sourceDir, `${name}.js`);
28
+ if (!existsSync(src)) {
29
+ throw new Error(`vendor-oxlint-plugins: missing compiled plugin ${src}`);
30
+ }
31
+ const dest = join(destDir, `${name}.js`);
32
+ cpSync(src, dest);
33
+ copied.push(name);
34
+ // path-roles is imported by several plugins as a sibling — vendor it too.
35
+ }
36
+ // Always vendor path-roles (shared classifier used via relative imports inside plugins).
37
+ const pathRoles = join(sourceDir, `${OXLINT_PATH_ROLES_BASENAME}.js`);
38
+ if (!existsSync(pathRoles)) {
39
+ throw new Error(`vendor-oxlint-plugins: missing ${pathRoles}`);
40
+ }
41
+ cpSync(pathRoles, join(destDir, `${OXLINT_PATH_ROLES_BASENAME}.js`));
42
+ copied.push(OXLINT_PATH_ROLES_BASENAME);
43
+ return { copied };
44
+ }
45
+ if (import.meta.main) {
46
+ const { copied } = vendorOxlintPlugins();
47
+ process.stdout.write(`vendored oxlint plugins: ${copied.join(", ")}\n`);
48
+ }
@@ -85,6 +85,21 @@ export interface PromoteBlueprintResult {
85
85
  readonly newPath: string;
86
86
  readonly message: string;
87
87
  }
88
+ /**
89
+ * Update `status:` in YAML frontmatter. Preserves everything else verbatim.
90
+ *
91
+ * Exported for the plan-subject digest invariance suite
92
+ * (`src/review/lifecycle-writers.ts`, `src/review/subject-normalize.test.ts`),
93
+ * which must exercise the production writer rather than a hand-written mirror.
94
+ */
95
+ export declare function updateFrontmatterStatus(content: string, newStatus: string): string;
96
+ /**
97
+ * Add or update `completed_at:` in YAML frontmatter.
98
+ * Inserts after the `status:` line if not already present.
99
+ *
100
+ * Exported for the same reason as `updateFrontmatterStatus` above.
101
+ */
102
+ export declare function upsertCompletedAt(content: string, isoDate: string): string;
88
103
  /**
89
104
  * Advance a task's status in its blueprint markdown document, then re-ingest.
90
105
  *
@@ -188,15 +188,21 @@ async function forceReIngestBlueprint(cwd, slug, documentPath) {
188
188
  }
189
189
  /**
190
190
  * Update `status:` in YAML frontmatter. Preserves everything else verbatim.
191
+ *
192
+ * Exported for the plan-subject digest invariance suite
193
+ * (`src/review/lifecycle-writers.ts`, `src/review/subject-normalize.test.ts`),
194
+ * which must exercise the production writer rather than a hand-written mirror.
191
195
  */
192
- function updateFrontmatterStatus(content, newStatus) {
196
+ export function updateFrontmatterStatus(content, newStatus) {
193
197
  return content.replace(/^(status:\s*)(['"]?)[^'"\r\n]+?(['"]?)(\s*)$/m, `$1${newStatus}$4`);
194
198
  }
195
199
  /**
196
200
  * Add or update `completed_at:` in YAML frontmatter.
197
201
  * Inserts after the `status:` line if not already present.
202
+ *
203
+ * Exported for the same reason as `updateFrontmatterStatus` above.
198
204
  */
199
- function upsertCompletedAt(content, isoDate) {
205
+ export function upsertCompletedAt(content, isoDate) {
200
206
  // If already present, update it
201
207
  if (/^completed_at:/m.test(content)) {
202
208
  return content.replace(/^(completed_at:\s*).*$/m, `$1'${isoDate}'`);
@@ -1,4 +1,17 @@
1
1
  import type { CAC } from "cac";
2
+ import { type ModuleResolver } from "#shared-utils/package-root.js";
3
+ export declare function resolveRulesyncBinFromAgentKit(resolver?: ModuleResolver): string | null;
4
+ /**
5
+ * The rulesync version agent-kit itself resolves — the only honest baseline to
6
+ * compare a consumer's install against.
7
+ *
8
+ * This used to be a hand-maintained `PINNED_RULESYNC_VERSION` literal, which
9
+ * drifted five majors behind the declared dependency (9.4.0 vs 14.0.1). The
10
+ * mismatch warning then fired on every single run, including inside agent-kit
11
+ * itself, which trains readers to ignore the one warning meant to catch a real
12
+ * mismatch. Deriving it from the resolved package makes that drift impossible.
13
+ */
14
+ export declare function resolveBundledRulesyncVersion(resolver?: ModuleResolver): string | null;
2
15
  export interface CompileResult {
3
16
  readonly ok: boolean;
4
17
  readonly targets: readonly string[];
@@ -1,35 +1,125 @@
1
1
  import { spawnSync } from "node:child_process";
2
2
  import { createHash } from "node:crypto";
3
3
  import { existsSync, mkdtempSync, readFileSync, readdirSync, renameSync, rmSync, unlinkSync, writeFileSync, } from "node:fs";
4
- import { tmpdir } from "node:os";
5
4
  import { dirname, join, resolve } from "node:path";
6
5
  import { createRequire } from "node:module";
7
6
  import { z } from "zod";
8
7
  import { flattenAgentDir, writeFlattenedAssets } from "#compiler/flatten";
9
- const PINNED_RULESYNC_VERSION = "9.4.0";
8
+ import { findPackageAsset } from "#utils/package-assets.js";
9
+ import { readPackageManifest, resolvePackageRoot, resolvePackageVersion, } from "#shared-utils/package-root.js";
10
10
  const SUPPORTED_TARGETS = ["claude", "codex", "opencode"];
11
11
  const DEFAULT_TARGETS = SUPPORTED_TARGETS.join(",");
12
12
  const COMPILE_MANIFEST_VERSION = 1;
13
+ /**
14
+ * wp's target names are the stable CLI contract; rulesync's own ids differ for
15
+ * two of the three. rulesync does not reject an unknown target — it logs a skip
16
+ * and exits 0 — so passing wp's names straight through generated nothing for
17
+ * `claude` and `codex` while still reporting success.
18
+ */
19
+ const RULESYNC_TARGET_IDS = {
20
+ claude: "claudecode",
21
+ codex: "codexcli",
22
+ opencode: "opencode",
23
+ };
24
+ /**
25
+ * rulesync defaults `--features` to `rules`, which agent-kit never emits — so
26
+ * omitting the flag guaranteed an empty generate. This is the set agent-kit
27
+ * actually flattens.
28
+ *
29
+ * Deliberately not `*`: that also enables `rules`, whose output writes
30
+ * `AGENTS.md`/`CLAUDE.md` at the repo root and collides with the mergeAgentsMd
31
+ * step below, plus `ignore`/`mcp`/`hooks`/`permissions`, whose loaders print
32
+ * ENOENT lines straight to the user's terminal through the inherited stdio.
33
+ */
34
+ const RULESYNC_FEATURES = "skills,commands,subagents";
35
+ const ASSET_KINDS = ["skills", "commands", "agents"];
36
+ /**
37
+ * Where rulesync writes each asset kind, per wp target.
38
+ *
39
+ * A kind omitted from a target's entry is one rulesync does not emit in project
40
+ * scope — notably codexcli commands, which it supports only under `--global`
41
+ * and otherwise skips with a warning. Omitting it keeps the check below honest
42
+ * rather than demanding a file rulesync was never going to write.
43
+ */
44
+ const RULESYNC_OUTPUT_PATHS = {
45
+ claude: {
46
+ skills: (name) => join(".claude", "skills", name, "SKILL.md"),
47
+ commands: (name) => join(".claude", "commands", `${name}.md`),
48
+ agents: (name) => join(".claude", "agents", `${name}.md`),
49
+ },
50
+ codex: {
51
+ skills: (name) => join(".agents", "skills", name, "SKILL.md"),
52
+ // Codex subagents are TOML, not markdown — every other target emits .md.
53
+ agents: (name) => join(".codex", "agents", `${name}.toml`),
54
+ },
55
+ opencode: {
56
+ skills: (name) => join(".opencode", "skills", name, "SKILL.md"),
57
+ commands: (name) => join(".opencode", "commands", `${name}.md`),
58
+ agents: (name) => join(".opencode", "agents", `${name}.md`),
59
+ },
60
+ };
61
+ /** Every output path a target is expected to produce for the given assets. */
62
+ function expectedOutputsForTarget(target, assets) {
63
+ const paths = RULESYNC_OUTPUT_PATHS[target];
64
+ const expected = [];
65
+ for (const kind of ASSET_KINDS) {
66
+ const toPath = paths[kind];
67
+ if (!toPath)
68
+ continue;
69
+ for (const name of Object.keys(assets[kind]))
70
+ expected.push(toPath(name));
71
+ }
72
+ return expected;
73
+ }
74
+ function expectedOutputs(targets, assets) {
75
+ return targets.flatMap((target) => expectedOutputsForTarget(target, assets));
76
+ }
13
77
  // Resolve rulesync bin — checks consumer's node_modules first,
14
78
  // then falls back to webpresso's own bundled rulesync.
79
+ // rulesync's `exports` map declares only ".", so its package.json is not
80
+ // resolvable by subpath — see #shared-utils/package-root for the full trap.
15
81
  const _require = createRequire(import.meta.url);
16
- function resolveRulesyncBinFromAgentKit() {
17
- try {
18
- const pkgPath = _require.resolve("rulesync/package.json");
19
- const pkg = JSON.parse(readFileSync(pkgPath, "utf-8"));
20
- const binField = pkg.bin;
21
- const rel = typeof binField === "string"
22
- ? binField
23
- : typeof binField === "object" && binField !== null && "rulesync" in binField
24
- ? String(binField["rulesync"])
25
- : null;
26
- if (!rel)
27
- return null;
28
- return join(dirname(pkgPath), rel);
29
- }
30
- catch {
82
+ /**
83
+ * Resolve rulesync relative to agent-kit's installed location rather than to
84
+ * this module's URL.
85
+ *
86
+ * In a bundled-Bun single-file binary `import.meta.url` is a `/$bunfs/root/...`
87
+ * virtual path, so a require anchored on it resolves nothing — and that is
88
+ * exactly the globally-installed case where the consumer has no local rulesync
89
+ * and this fallback is the only copy available.
90
+ */
91
+ function agentKitAnchoredRequire() {
92
+ const manifestPath = findPackageAsset("package.json");
93
+ if (manifestPath === null)
94
+ return _require;
95
+ return createRequire(join(dirname(manifestPath), "noop.cjs"));
96
+ }
97
+ export function resolveRulesyncBinFromAgentKit(resolver = agentKitAnchoredRequire()) {
98
+ const root = resolvePackageRoot("rulesync", resolver);
99
+ if (!root)
31
100
  return null;
32
- }
101
+ const binField = readPackageManifest(root)?.bin;
102
+ const rel = typeof binField === "string"
103
+ ? binField
104
+ : typeof binField === "object" && binField !== null && "rulesync" in binField
105
+ ? String(binField["rulesync"])
106
+ : null;
107
+ if (!rel)
108
+ return null;
109
+ return join(root, rel);
110
+ }
111
+ /**
112
+ * The rulesync version agent-kit itself resolves — the only honest baseline to
113
+ * compare a consumer's install against.
114
+ *
115
+ * This used to be a hand-maintained `PINNED_RULESYNC_VERSION` literal, which
116
+ * drifted five majors behind the declared dependency (9.4.0 vs 14.0.1). The
117
+ * mismatch warning then fired on every single run, including inside agent-kit
118
+ * itself, which trains readers to ignore the one warning meant to catch a real
119
+ * mismatch. Deriving it from the resolved package makes that drift impossible.
120
+ */
121
+ export function resolveBundledRulesyncVersion(resolver = agentKitAnchoredRequire()) {
122
+ return resolvePackageVersion("rulesync", resolver);
33
123
  }
34
124
  const CompileManifestSchema = z.object({
35
125
  version: z.number(),
@@ -56,25 +146,6 @@ function readRulesyncVersion(cwd) {
56
146
  return null;
57
147
  }
58
148
  }
59
- function contentHash(assets) {
60
- const entries = [
61
- ...Object.entries(assets.skills).map(([k, v]) => `s:${k}:${v}`),
62
- ...Object.entries(assets.commands).map(([k, v]) => `c:${k}:${v}`),
63
- ...Object.entries(assets.agents).map(([k, v]) => `a:${k}:${v}`),
64
- ];
65
- entries.sort();
66
- return entries.join("\0");
67
- }
68
- function readHashFile(p) {
69
- if (!existsSync(p))
70
- return null;
71
- try {
72
- return readFileSync(p, "utf-8").trim();
73
- }
74
- catch {
75
- return null;
76
- }
77
- }
78
149
  /** SHA-256 hash of all .md files under agentDir, recursively (content only). */
79
150
  export function hashAgentDir(agentDir) {
80
151
  const h = createHash("sha256");
@@ -144,20 +215,26 @@ export async function runCompile(options) {
144
215
  }
145
216
  const agentDir = join(cwd, ".agent");
146
217
  const lockPath = join(agentDir, ".compile.lock");
147
- const hashPath = join(agentDir, ".compile.hash");
148
218
  const manifestPath = join(agentDir, ".compile-manifest.json");
149
219
  const rulesyncBin = resolveRulesyncBin(cwd);
220
+ const expectedVersion = resolveBundledRulesyncVersion();
150
221
  if (!rulesyncBin || !existsSync(rulesyncBin)) {
222
+ const installHint = expectedVersion ? `rulesync@${expectedVersion}` : "rulesync";
151
223
  return {
152
224
  ok: false,
153
225
  targets: targetList,
154
226
  noOp: false,
155
- message: "rulesync is not installed — run `vp add -w rulesync@9.4.0`",
227
+ message: `rulesync is not installed — run \`vp add -w ${installHint}\``,
156
228
  };
157
229
  }
158
230
  const installedVersion = readRulesyncVersion(cwd);
159
- if (installedVersion !== null && installedVersion !== PINNED_RULESYNC_VERSION) {
160
- process.stderr.write(`wp compile: warning installed rulesync@${installedVersion} does not match pinned @${PINNED_RULESYNC_VERSION}\n`);
231
+ // Only compare against a version we actually resolved: with no baseline there
232
+ // is nothing to assert, and a fabricated one is what produced the old
233
+ // permanently-firing warning.
234
+ if (expectedVersion !== null &&
235
+ installedVersion !== null &&
236
+ installedVersion !== expectedVersion) {
237
+ process.stderr.write(`wp compile: warning — installed rulesync@${installedVersion} does not match the rulesync@${expectedVersion} agent-kit resolves\n`);
161
238
  }
162
239
  // Atomic lock via O_EXCL — fails if another compile is running
163
240
  try {
@@ -171,31 +248,48 @@ export async function runCompile(options) {
171
248
  message: `wp compile: lock file exists at ${lockPath} — another compile is running`,
172
249
  };
173
250
  }
251
+ // Staging dir lives in cwd (see the rename below), so an interrupted compile
252
+ // would strand a `.rulesync.tmp-*` directory inside the user's repo that no
253
+ // later run reaps. Tracked here so the signal path clears it too.
254
+ let stagingDir = null;
174
255
  const cleanup = () => {
175
256
  try {
176
257
  if (existsSync(lockPath))
177
258
  unlinkSync(lockPath);
259
+ if (stagingDir !== null)
260
+ rmSync(stagingDir, { recursive: true, force: true });
178
261
  }
179
262
  catch {
180
263
  /* best-effort */
181
264
  }
182
265
  };
183
- process.on("exit", cleanup);
184
- process.on("SIGINT", () => {
266
+ // Held as named refs so `finally` can detach them: runCompile is called
267
+ // repeatedly within one process (the test suites do exactly this), and
268
+ // anonymous handlers would stack up a MaxListenersExceededWarning plus N
269
+ // SIGINT handlers each racing to call process.exit.
270
+ const onSigint = () => {
185
271
  cleanup();
186
272
  process.exit(130);
187
- });
188
- process.on("SIGTERM", () => {
273
+ };
274
+ const onSigterm = () => {
189
275
  cleanup();
190
276
  process.exit(143);
191
- });
277
+ };
278
+ process.on("exit", cleanup);
279
+ process.on("SIGINT", onSigint);
280
+ process.on("SIGTERM", onSigterm);
192
281
  try {
193
282
  const assets = flattenAgentDir(agentDir);
194
- const hash = contentHash(assets);
195
283
  const sourceHash = hashAgentDir(agentDir);
196
- // Check compile manifest for idempotent no-op
284
+ // An unchanged source hash only proves the *input* is the same; it says
285
+ // nothing about whether the generated surfaces still exist. Deleting
286
+ // .claude/skills/ would otherwise leave compile reporting "no changes"
287
+ // forever while the files it exists to produce stay missing — the very
288
+ // silent-zero-output failure this command was repaired to eliminate.
289
+ const expected = expectedOutputs(targetList, assets);
290
+ const outputsPresent = expected.every((rel) => existsSync(join(cwd, rel)));
197
291
  const existingManifest = readCompileManifest(manifestPath);
198
- if (existingManifest !== null && existingManifest.sourceHash === sourceHash) {
292
+ if (outputsPresent && existingManifest !== null && existingManifest.sourceHash === sourceHash) {
199
293
  return {
200
294
  ok: true,
201
295
  targets: targetList,
@@ -203,30 +297,35 @@ export async function runCompile(options) {
203
297
  message: "No changes (manifest matches)",
204
298
  };
205
299
  }
206
- // Legacy hash file check (pre-manifest path)
207
- if (existingManifest === null && readHashFile(hashPath) === hash) {
208
- return {
209
- ok: true,
210
- targets: targetList,
211
- noOp: true,
212
- message: "wp compile: no-op (content unchanged)",
213
- };
214
- }
215
- // Step 1+2: Write flattened assets to tmpdir then atomically rename to .rulesync/
216
- const tmpOut = mkdtempSync(join(tmpdir(), "wp-compile-"));
300
+ // Step 1+2: Stage the flattened assets, then atomically rename to .rulesync/.
301
+ //
302
+ // The staging dir must live inside `cwd`, not in os.tmpdir(): renameSync
303
+ // cannot cross filesystems, and on any host where /tmp is its own mount
304
+ // (tmpfs on most systemd Linux distros, Docker --tmpfs, many CI runners)
305
+ // the swap below would throw EXDEV and take the whole command down. The
306
+ // test suites cannot catch that on their own, because their cwd is itself
307
+ // under tmpdir() and so always lands on the same filesystem.
308
+ const tmpOut = mkdtempSync(join(cwd, ".rulesync.tmp-"));
309
+ stagingDir = tmpOut;
217
310
  try {
218
311
  await writeFlattenedAssets(assets, tmpOut);
219
312
  const rulesyncInputDir = join(cwd, ".rulesync");
220
313
  if (existsSync(rulesyncInputDir))
221
314
  rmSync(rulesyncInputDir, { recursive: true, force: true });
222
315
  renameSync(tmpOut, rulesyncInputDir);
316
+ // Renamed away: nothing left at the staging path for cleanup to remove.
317
+ stagingDir = null;
223
318
  }
224
319
  catch (err) {
225
320
  rmSync(tmpOut, { recursive: true, force: true });
321
+ stagingDir = null;
226
322
  throw err;
227
323
  }
228
324
  // Step 3: Run rulesync generate
229
- const result = spawnSync(rulesyncBin, ["generate", "--targets", targets], {
325
+ const rulesyncTargets = targetList
326
+ .map((target) => RULESYNC_TARGET_IDS[target])
327
+ .join(",");
328
+ const result = spawnSync(rulesyncBin, ["generate", "--targets", rulesyncTargets, "--features", RULESYNC_FEATURES], {
230
329
  cwd,
231
330
  stdio: "inherit",
232
331
  });
@@ -247,6 +346,21 @@ export async function runCompile(options) {
247
346
  message: `wp compile: rulesync exited with code ${exitCode}`,
248
347
  };
249
348
  }
349
+ // rulesync exits 0 when it generates nothing at all ("All files are up to
350
+ // date"), so the exit code above cannot distinguish a real generate from a
351
+ // silent no-op caused by a target it skipped or input it could not see.
352
+ // The files consumers need are the contract, so assert they exist.
353
+ const missing = expected.filter((rel) => !existsSync(join(cwd, rel)));
354
+ if (missing.length > 0) {
355
+ const shown = missing.slice(0, 5).join(", ");
356
+ const rest = missing.length > 5 ? ` (and ${missing.length - 5} more)` : "";
357
+ return {
358
+ ok: false,
359
+ targets: targetList,
360
+ noOp: false,
361
+ message: `wp compile: rulesync exited 0 but ${missing.length} expected output(s) were not generated: ${shown}${rest}`,
362
+ };
363
+ }
250
364
  // Step 4: Run mergeAgentsMd to produce AGENTS.md at repo root
251
365
  // (Plugin manifests are hand-maintained SSOT under packages/*-plugin/;
252
366
  // wp compile no longer emits them — golden tests + version stamping own
@@ -270,6 +384,10 @@ export async function runCompile(options) {
270
384
  const outputHashes = {
271
385
  "AGENTS.md": hashFile(join(cwd, "AGENTS.md")),
272
386
  };
387
+ // Every generated surface, not just AGENTS.md — this is what gives
388
+ // `wp audit compile-drift` something real to compare against.
389
+ for (const rel of expected)
390
+ outputHashes[rel] = hashFile(join(cwd, rel));
273
391
  const compileManifest = {
274
392
  version: COMPILE_MANIFEST_VERSION,
275
393
  timestamp: new Date().toISOString(),
@@ -277,7 +395,6 @@ export async function runCompile(options) {
277
395
  outputHashes,
278
396
  };
279
397
  writeCompileManifest(manifestPath, compileManifest);
280
- writeFileSync(hashPath, hash);
281
398
  return {
282
399
  ok: true,
283
400
  targets: targetList,
@@ -287,6 +404,9 @@ export async function runCompile(options) {
287
404
  }
288
405
  finally {
289
406
  cleanup();
407
+ process.removeListener("exit", cleanup);
408
+ process.removeListener("SIGINT", onSigint);
409
+ process.removeListener("SIGTERM", onSigterm);
290
410
  }
291
411
  }
292
412
  function collectAgentsLayers(agentDir) {
@@ -178,11 +178,27 @@ function formatterTargetFromCwd(file, cwd) {
178
178
  return file;
179
179
  return relativeTarget;
180
180
  }
181
+ // The blueprint root is not always the top-level `blueprints/` directory: a
182
+ // consumer repo can resolve to `webpresso/blueprints/` (the webpresso-legacy
183
+ // fallback layout — see #utils/blueprint-root.js) or a `.webpressorc.json`
184
+ // override. A prefix anchored to one specific root silently fails to exclude
185
+ // review-artifact markdown under any other layout, so `wp format` reformats
186
+ // it. That mutates the artifact's bytes between when `wp review gate`
187
+ // records its sha256 into review-events.jsonl and when the file is
188
+ // committed, producing a false "Review artifact digest mismatch" at
189
+ // `wp_blueprint_finalize` time (src/review/authority.ts correctly detects
190
+ // the drift and fails closed). Match on the directory name alone — not
191
+ // anchored to any specific root prefix — so this stays correct across every
192
+ // current and future blueprint root layout without tracking
193
+ // resolveBlueprintRoot's precedence rules in a second place. This also keeps
194
+ // parity with the whole-repo formatter's own ignore layer
195
+ // (vite.config.ts#fmt.ignorePatterns), which uses the same unanchored
196
+ // `**/review-artifacts/**` glob for the same reason.
181
197
  function filterFormatableFiles(files, cwd) {
182
198
  return files.filter((file) => {
183
199
  const extension = path.extname(file).toLowerCase();
184
200
  const normalized = file.replaceAll("\\", "/");
185
- const isReviewArtifact = normalized.startsWith("blueprints/") && normalized.includes("/review-artifacts/");
201
+ const isReviewArtifact = normalized.includes("/review-artifacts/");
186
202
  return OXFMT_EXTENSIONS.has(extension) && !isReviewArtifact && existsSync(path.join(cwd, file));
187
203
  });
188
204
  }