@webpresso/agent-kit 3.3.3 → 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.
Files changed (136) 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/hook-surface.js +5 -1
  10. package/dist/esm/audit/repo-guardrails.d.ts +8 -0
  11. package/dist/esm/audit/repo-guardrails.js +71 -1
  12. package/dist/esm/blueprint/core/parser.js +38 -12
  13. package/dist/esm/blueprint/execution/artifacts.js +8 -4
  14. package/dist/esm/blueprint/execution/frontmatter-keys.d.ts +17 -0
  15. package/dist/esm/blueprint/execution/frontmatter-keys.js +24 -0
  16. package/dist/esm/blueprint/execution/metadata.js +8 -4
  17. package/dist/esm/blueprint/lifecycle/audit.d.ts +21 -0
  18. package/dist/esm/blueprint/lifecycle/audit.js +89 -3
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +63 -10
  20. package/dist/esm/blueprint/lifecycle/rollout-anchor.d.ts +22 -0
  21. package/dist/esm/blueprint/lifecycle/rollout-anchor.js +37 -1
  22. package/dist/esm/blueprint/markdown/blocked-line.d.ts +49 -0
  23. package/dist/esm/blueprint/markdown/blocked-line.js +63 -0
  24. package/dist/esm/blueprint/markdown/helpers.js +10 -4
  25. package/dist/esm/blueprint/tracked-document/parser.js +3 -3
  26. package/dist/esm/blueprint/trust/gate-row.d.ts +55 -0
  27. package/dist/esm/blueprint/trust/gate-row.js +113 -0
  28. package/dist/esm/blueprint/trust/promotion.d.ts +17 -0
  29. package/dist/esm/blueprint/trust/promotion.js +19 -19
  30. package/dist/esm/build/atomic-file-copy.d.ts +32 -0
  31. package/dist/esm/build/atomic-file-copy.js +44 -0
  32. package/dist/esm/build/vendor-oxlint-plugins.d.ts +8 -0
  33. package/dist/esm/build/vendor-oxlint-plugins.js +53 -0
  34. package/dist/esm/cli/commands/blueprint/mutations.d.ts +15 -0
  35. package/dist/esm/cli/commands/blueprint/mutations.js +8 -2
  36. package/dist/esm/cli/commands/compile.d.ts +13 -0
  37. package/dist/esm/cli/commands/compile.js +182 -62
  38. package/dist/esm/cli/commands/format.js +17 -1
  39. package/dist/esm/cli/commands/gain/index.js +10 -3
  40. package/dist/esm/cli/commands/init/gitignore-patcher.js +6 -0
  41. package/dist/esm/cli/commands/init/index.js +29 -48
  42. package/dist/esm/cli/commands/init/mcp-spec.d.ts +48 -13
  43. package/dist/esm/cli/commands/init/mcp-spec.js +86 -30
  44. package/dist/esm/cli/commands/init/merge.js +30 -10
  45. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.d.ts +56 -31
  46. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +180 -43
  47. package/dist/esm/cli/commands/init/scaffolders/rtk/index.d.ts +17 -5
  48. package/dist/esm/cli/commands/init/scaffolders/rtk/index.js +56 -75
  49. package/dist/esm/cli/commands/init/scaffolders/runtime-check/index.js +7 -6
  50. package/dist/esm/cli/commands/init/untracked-collision.d.ts +12 -1
  51. package/dist/esm/cli/commands/init/untracked-collision.js +23 -1
  52. package/dist/esm/cli/commands/pr-help.js +1 -1
  53. package/dist/esm/cli/commands/pr.js +18 -4
  54. package/dist/esm/cli/commands/review.js +60 -6
  55. package/dist/esm/cli/commands/worktree/router-dispatch.d.ts +24 -2
  56. package/dist/esm/cli/commands/worktree/router-dispatch.js +80 -48
  57. package/dist/esm/cli/commands/worktree/router.js +5 -2
  58. package/dist/esm/cli/direct-provider-launch.js +12 -4
  59. package/dist/esm/compiler/flatten.d.ts +10 -1
  60. package/dist/esm/compiler/flatten.js +14 -4
  61. package/dist/esm/compose/admin-blocks-registry-integrity.d.ts +22 -0
  62. package/dist/esm/compose/admin-blocks-registry-integrity.js +90 -0
  63. package/dist/esm/compose/compose.d.ts +8 -0
  64. package/dist/esm/compose/compose.js +115 -8
  65. package/dist/esm/compose/ops-report-schema.js +17 -3
  66. package/dist/esm/compose/registry-resolve.d.ts +17 -0
  67. package/dist/esm/compose/registry-resolve.js +78 -11
  68. package/dist/esm/config/oxlint/graphql-conventions.js +2 -5
  69. package/dist/esm/config/oxlint/import-hygiene.js +21 -8
  70. package/dist/esm/config/oxlint/oxlint-config.fixture.d.ts +1 -1
  71. package/dist/esm/config/oxlint/oxlint-config.fixture.js +2 -3
  72. package/dist/esm/config/oxlint/oxlintrc.d.ts +5 -7
  73. package/dist/esm/config/oxlint/oxlintrc.js +18 -21
  74. package/dist/esm/config/oxlint/path-roles.js +53 -0
  75. package/dist/esm/config/oxlint/query-patterns.js +2 -5
  76. package/dist/esm/daemon/domains/blueprint-exec-ledger.d.ts +86 -0
  77. package/dist/esm/daemon/domains/blueprint-exec-ledger.js +134 -0
  78. package/dist/esm/daemon/domains/blueprint-exec.d.ts +106 -0
  79. package/dist/esm/daemon/domains/blueprint-exec.js +161 -0
  80. package/dist/esm/daemon/domains/launch.d.ts +25 -0
  81. package/dist/esm/daemon/domains/launch.js +30 -1
  82. package/dist/esm/daemon/handlers/blueprints-handlers.d.ts +12 -1
  83. package/dist/esm/daemon/handlers/blueprints-handlers.js +148 -6
  84. package/dist/esm/daemon/handlers/product.d.ts +10 -0
  85. package/dist/esm/daemon/handlers/product.js +10 -1
  86. package/dist/esm/daemon/methods.d.ts +21 -1
  87. package/dist/esm/daemon/methods.js +20 -0
  88. package/dist/esm/daemon/protocol/contract.d.ts +58 -0
  89. package/dist/esm/daemon/protocol/contract.js +38 -0
  90. package/dist/esm/daemon/server.js +7 -0
  91. package/dist/esm/docs-linter/blueprint-plan.js +2 -2
  92. package/dist/esm/errors/wp-error.js +21 -2
  93. package/dist/esm/hooks/doctor.d.ts +11 -0
  94. package/dist/esm/hooks/doctor.js +114 -45
  95. package/dist/esm/hooks/pretool-guard/dev-routing.js +1 -1
  96. package/dist/esm/hooks/pretool-guard/rtk-rewrite.d.ts +24 -0
  97. package/dist/esm/hooks/pretool-guard/rtk-rewrite.js +50 -0
  98. package/dist/esm/hooks/pretool-guard/runner.js +6 -0
  99. package/dist/esm/hooks/status/index.js +1 -1
  100. package/dist/esm/mcp/blueprint/_shared/lifecycle.js +39 -11
  101. package/dist/esm/mcp/blueprint/handlers/review-log.d.ts +14 -7
  102. package/dist/esm/mcp/blueprint/handlers/review-log.js +46 -22
  103. package/dist/esm/mcp/blueprint/registration.js +1 -1
  104. package/dist/esm/mcp/tools/_shared/redact.d.ts +22 -0
  105. package/dist/esm/mcp/tools/_shared/redact.js +114 -8
  106. package/dist/esm/mcp/tools/gain.js +28 -2
  107. package/dist/esm/mcp/tools/worktree.js +65 -3
  108. package/dist/esm/mcp/tools/wp-ui-compose.js +38 -4
  109. package/dist/esm/mcp/tools/wp-ui-preview.js +14 -7
  110. package/dist/esm/review/lifecycle-writers.d.ts +76 -0
  111. package/dist/esm/review/lifecycle-writers.js +330 -0
  112. package/dist/esm/review/subject.js +66 -32
  113. package/dist/esm/status/snapshot.d.ts +5 -0
  114. package/dist/esm/status/snapshot.js +27 -2
  115. package/dist/esm/test/shard-durations.json +0 -1
  116. package/dist/esm/test-helpers/global-setup.js +38 -3
  117. package/dist/esm/tool-runtime/managed-rtk.d.ts +44 -0
  118. package/dist/esm/tool-runtime/managed-rtk.js +275 -0
  119. package/dist/esm/tool-runtime/resolve-runner.js +18 -194
  120. package/dist/esm/utils/package-root.d.ts +32 -0
  121. package/dist/esm/utils/package-root.js +82 -0
  122. package/dist/esm/worktrees/git-metadata.d.ts +41 -0
  123. package/dist/esm/worktrees/git-metadata.js +48 -0
  124. package/package.json +16 -21
  125. package/dist/esm/config/oxlint/code-safety.d.ts +0 -18
  126. package/dist/esm/config/oxlint/foundation-purity.d.ts +0 -21
  127. package/dist/esm/config/oxlint/graphql-conventions.d.ts +0 -23
  128. package/dist/esm/config/oxlint/import-hygiene.d.ts +0 -34
  129. package/dist/esm/config/oxlint/index.d.ts +0 -22
  130. package/dist/esm/config/oxlint/index.js +0 -28
  131. package/dist/esm/config/oxlint/monorepo-paths.d.ts +0 -22
  132. package/dist/esm/config/oxlint/query-patterns.d.ts +0 -24
  133. package/dist/esm/config/oxlint/testing-quality.d.ts +0 -35
  134. package/dist/esm/config/oxlint/tier-boundaries.d.ts +0 -35
  135. package/dist/esm/output-transforms/rulesync.d.ts +0 -2
  136. package/dist/esm/output-transforms/rulesync.js +0 -79
@@ -0,0 +1,63 @@
1
+ /**
2
+ * Single owner of the task-body `**Blocked:** <reason>` line grammar.
3
+ *
4
+ * Both sides of the plan-approval contract read this file: `updateBlockedReason`
5
+ * (`#markdown/helpers.js`) writes the line, and `neutralizeTaskBlockedReason`
6
+ * (`#review/subject.js`) removes it before the plan-subject digest is taken. That
7
+ * pairing is exactly the arrangement the Promotion Gates row grammar already uses
8
+ * (`#trust/gate-row.js`), and for the same reason: when a writer and the
9
+ * normalizer spell the same grammar twice, they drift, the lifecycle stamp
10
+ * survives into the digest, and the plan approval the promotion gates on is
11
+ * silently dismissed.
12
+ *
13
+ * Deliberately dependency-free so any module on either side can import it
14
+ * without risking a cycle.
15
+ */
16
+ /**
17
+ * Regex source for a whole `**Blocked:** …` line, anchored to line bounds.
18
+ *
19
+ * Anchoring is load-bearing in both directions. Blueprint prose legitimately
20
+ * mentions the marker mid-sentence ("add a non-empty **Blocked:** line with the
21
+ * reason") — those mentions are plan substance, and an unanchored pattern would
22
+ * have the writer overwrite them and the normalizer strip them.
23
+ */
24
+ export const BLOCKED_LINE_SOURCE = String.raw `^\*\*Blocked:\*\*.*$`;
25
+ /**
26
+ * Build a matcher for the blocked line. Mirrors the `buildTaskHeadingRegex`
27
+ * convention in `./task-heading.js`: callers pick the flags they need, but the
28
+ * pattern itself has exactly one definition.
29
+ */
30
+ export function buildBlockedLineRegex(flags = "imu") {
31
+ return new RegExp(BLOCKED_LINE_SOURCE, flags);
32
+ }
33
+ const SINGLE_LINE_MATCHER = buildBlockedLineRegex("iu");
34
+ /** True when `line` IS a blocked-reason line, not merely a line that mentions one. */
35
+ export function isBlockedLine(line) {
36
+ return SINGLE_LINE_MATCHER.test(line);
37
+ }
38
+ const MARKER_LENGTH = "**Blocked:**".length;
39
+ /**
40
+ * Serialize the blocked line. The writer-side counterpart to
41
+ * {@link isBlockedLine}: every emitter goes through here so the written form and
42
+ * the parsed form cannot diverge.
43
+ */
44
+ export function formatBlockedLine(reason) {
45
+ return `**Blocked:** ${reason}`;
46
+ }
47
+ /**
48
+ * Read the reason off the first blocked line in a task section, or `undefined`
49
+ * when the section has none.
50
+ *
51
+ * The reader counterpart to {@link formatBlockedLine}. Three parsers used to
52
+ * carry their own unanchored copy of this pattern (`src/blueprint/core/parser.ts`,
53
+ * `src/blueprint/tracked-document/parser.ts`, `src/docs-linter/blueprint-plan.ts`),
54
+ * so a task whose prose merely mentioned the marker was read as genuinely
55
+ * blocked and the lifecycle audit failed the document.
56
+ */
57
+ export function extractBlockedReason(section) {
58
+ const match = buildBlockedLineRegex().exec(section);
59
+ if (!match)
60
+ return undefined;
61
+ const reason = match[0].slice(MARKER_LENGTH).trim();
62
+ return reason === "" ? undefined : reason;
63
+ }
@@ -8,6 +8,7 @@
8
8
  * produces identical output (for the same inputs).
9
9
  */
10
10
  import { escapeRegex } from "#utils/string";
11
+ import { BLOCKED_LINE_SOURCE, buildBlockedLineRegex, formatBlockedLine } from "./blocked-line.js";
11
12
  import { buildTaskHeaderRegexForId, buildTaskSectionBoundaryRegex, TASK_HEADING_PREFIX_PATTERN, } from "./task-heading.js";
12
13
  function buildTaskPattern(taskId) {
13
14
  return new RegExp(`(####\\s+${TASK_HEADING_PREFIX_PATTERN}Task\\s+${escapeRegex(taskId)}[:\\s].*?)(?=\\n####|\\n###|$)`, "s");
@@ -65,7 +66,11 @@ export function updateBlockedReason(content, taskId, reason) {
65
66
  const taskPattern = buildTaskPattern(taskId);
66
67
  return content.replace(taskPattern, (section) => {
67
68
  const trimmedReason = reason.trim();
68
- const blockedPattern = /\n?\*\*Blocked:\*\*\s*.*(?:\n+)?/i;
69
+ // Grammar comes from ./blocked-line.js, which the plan-subject normalizer
70
+ // consumes too. Line-anchored on purpose: a task whose prose mentions
71
+ // `**Blocked:**` mid-sentence is plan substance, and the previous
72
+ // unanchored pattern would have overwritten it.
73
+ const blockedPattern = new RegExp(String.raw `\n?${BLOCKED_LINE_SOURCE}\n*`, "imu");
69
74
  if (!trimmedReason) {
70
75
  // Preserve the section's trailing whitespace: trimEnd on the whole
71
76
  // section would also delete the blank line separating this task from the
@@ -76,9 +81,10 @@ export function updateBlockedReason(content, taskId, reason) {
76
81
  .replace(/\n{3,}/g, "\n\n")
77
82
  .trimEnd() + trailingWhitespace);
78
83
  }
79
- const blockedLine = `**Blocked:** ${trimmedReason}`;
80
- if (/\*\*Blocked:\*\*/i.test(section)) {
81
- return section.replace(/\*\*Blocked:\*\*\s*.+/i, blockedLine);
84
+ const blockedLine = formatBlockedLine(trimmedReason);
85
+ const existingLine = buildBlockedLineRegex();
86
+ if (existingLine.test(section)) {
87
+ return section.replace(existingLine, blockedLine);
82
88
  }
83
89
  return insertAfterStatusLine(section, blockedLine);
84
90
  });
@@ -6,6 +6,7 @@
6
6
  *
7
7
  * Git-Native SSoT: Checkbox state derives status, no external state.
8
8
  */
9
+ import { extractBlockedReason } from "#markdown/blocked-line";
9
10
  import { isTaskHeaderLine } from "#markdown/task-heading";
10
11
  /**
11
12
  * Extract checkbox status from a markdown section and derive task state.
@@ -100,10 +101,9 @@ export function extractDepends(section) {
100
101
  * ```
101
102
  */
102
103
  export function extractBlocked(section) {
103
- const blockedMatch = section.match(/\*\*Blocked:\*\*\s*(.+)/i);
104
- if (!blockedMatch?.[1])
104
+ const blockedText = extractBlockedReason(section);
105
+ if (blockedText === undefined)
105
106
  return undefined;
106
- const blockedText = blockedMatch[1].trim();
107
107
  // Handle missing/empty reason gracefully
108
108
  if (blockedText === "" || blockedText.toLowerCase() === "none") {
109
109
  return undefined;
@@ -0,0 +1,55 @@
1
+ /**
2
+ * Single owner of the Trust Dossier "Promotion Gates" table row grammar.
3
+ *
4
+ * Two callers must agree on this grammar byte-for-byte:
5
+ *
6
+ * - the lifecycle WRITER `updateGateLastResult` (`./promotion.ts`), which stamps
7
+ * the `Last result` cell during `wp blueprint promote`; and
8
+ * - the plan-subject digest NORMALIZER `neutralizePromotionGateResults`
9
+ * (`#review/subject.js`), which replaces that same cell with a placeholder so
10
+ * the stamp is never hashed into a plan approval's subject.
11
+ *
12
+ * They each used to implement the grammar independently, and diverged on four
13
+ * axes at once — cell count (writer 4 or 5, normalizer 4 only), section scope
14
+ * (writer any table row in the document, normalizer inside `### Promotion Gates`
15
+ * only), trailing pipe (writer `trim().startsWith("|")` alone, normalizer both
16
+ * pipes), and trailing whitespace after the closing pipe (writer sliced the
17
+ * UNTRIMMED line, so it mis-counted a 4-cell row as 5 and appended a spurious
18
+ * empty cell). Every divergence made `digest(doc) !== digest(writer(doc))`,
19
+ * which destroys the plan approval the promotion is gating on. One owner,
20
+ * consumed from both sides, is the only structural fix; anything else lets them
21
+ * drift again.
22
+ *
23
+ * The grammar deliberately matches what `parsePromotionGatesTable` in
24
+ * `./dossier.ts` already enforces on committed documents: a row is the trimmed
25
+ * line starting AND ending with a pipe, cells are split on `|` and trimmed, and
26
+ * the accepted widths are 4 (legacy) and 5 (opt-in `Defer` column). A row this
27
+ * module rejects is a row the dossier parser reports as `malformed table row`,
28
+ * so it can never be a live gate — skipping it is the fail-safe direction.
29
+ */
30
+ /** Zero-based index of the lifecycle-owned `Last result` cell. */
31
+ export declare const LAST_RESULT_CELL_INDEX = 3;
32
+ /**
33
+ * Cells of one Promotion Gates table row, trimmed, or null when the line is not
34
+ * a row this grammar owns (no pipes, a single pipe, or an unsupported width).
35
+ */
36
+ export declare function parsePromotionGateCells(line: string): readonly string[] | null;
37
+ /**
38
+ * Canonical row bytes. Whitespace around a row (leading indent, padding inside
39
+ * cells, whitespace after the closing pipe) is not plan substance, so both
40
+ * callers re-emit this single canonical form. That is what keeps a stamped row
41
+ * and its normalized counterpart byte-identical, and it is exactly the form the
42
+ * normalizer has always emitted for a 4-cell row, so committed plan digests are
43
+ * unaffected.
44
+ */
45
+ export declare function formatPromotionGateRow(cells: readonly string[]): string;
46
+ /** Copy `cells` with the lifecycle-owned `Last result` cell set to `value`. */
47
+ export declare function setLastResultCell(cells: readonly string[], value: string): readonly string[];
48
+ /**
49
+ * Rewrite every Promotion Gates table row (header, separator, and data rows
50
+ * alike) inside the `### Promotion Gates` subsection through `transform`.
51
+ * Returning the received `cells` reference unchanged leaves the line
52
+ * byte-identical; returning a new array re-emits the canonical row. Every line
53
+ * outside the section is always byte-identical.
54
+ */
55
+ export declare function mapPromotionGateRows(text: string, transform: (cells: readonly string[]) => readonly string[]): string;
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Single owner of the Trust Dossier "Promotion Gates" table row grammar.
3
+ *
4
+ * Two callers must agree on this grammar byte-for-byte:
5
+ *
6
+ * - the lifecycle WRITER `updateGateLastResult` (`./promotion.ts`), which stamps
7
+ * the `Last result` cell during `wp blueprint promote`; and
8
+ * - the plan-subject digest NORMALIZER `neutralizePromotionGateResults`
9
+ * (`#review/subject.js`), which replaces that same cell with a placeholder so
10
+ * the stamp is never hashed into a plan approval's subject.
11
+ *
12
+ * They each used to implement the grammar independently, and diverged on four
13
+ * axes at once — cell count (writer 4 or 5, normalizer 4 only), section scope
14
+ * (writer any table row in the document, normalizer inside `### Promotion Gates`
15
+ * only), trailing pipe (writer `trim().startsWith("|")` alone, normalizer both
16
+ * pipes), and trailing whitespace after the closing pipe (writer sliced the
17
+ * UNTRIMMED line, so it mis-counted a 4-cell row as 5 and appended a spurious
18
+ * empty cell). Every divergence made `digest(doc) !== digest(writer(doc))`,
19
+ * which destroys the plan approval the promotion is gating on. One owner,
20
+ * consumed from both sides, is the only structural fix; anything else lets them
21
+ * drift again.
22
+ *
23
+ * The grammar deliberately matches what `parsePromotionGatesTable` in
24
+ * `./dossier.ts` already enforces on committed documents: a row is the trimmed
25
+ * line starting AND ending with a pipe, cells are split on `|` and trimmed, and
26
+ * the accepted widths are 4 (legacy) and 5 (opt-in `Defer` column). A row this
27
+ * module rejects is a row the dossier parser reports as `malformed table row`,
28
+ * so it can never be a live gate — skipping it is the fail-safe direction.
29
+ */
30
+ /** Zero-based index of the lifecycle-owned `Last result` cell. */
31
+ export const LAST_RESULT_CELL_INDEX = 3;
32
+ /**
33
+ * Exact end-anchored heading match, mirroring `sectionBlock(markdown, 3, ...)`
34
+ * in `./dossier.ts` — `### Promotion Gates Notes` is NOT the owned section and
35
+ * its contents stay plan substance.
36
+ */
37
+ const PROMOTION_GATES_HEADING = /^###\s+Promotion Gates\s*$/iu;
38
+ /**
39
+ * The section ends at the next heading of ANY level. `sectionBlock` itself ends
40
+ * at the next heading of level <= 3, so a table placed after an intervening
41
+ * `####` heading would be a gate to the parser but not to this walk. The
42
+ * narrower scope is deliberate: it is the scope every committed plan digest was
43
+ * computed against (measured: zero tracked blueprint documents put gate rows in
44
+ * that gap), and the consequence of the mismatch is fail-closed — the writer
45
+ * leaves such a row unstamped, so `validateBlueprintTrust` rejects the promotion
46
+ * with a non-passing gate instead of silently leaking the stamp into the digest.
47
+ */
48
+ const ANY_HEADING = /^#{1,6}\s/u;
49
+ const ACCEPTED_ROW_WIDTHS = [4, 5];
50
+ /**
51
+ * Cells of one Promotion Gates table row, trimmed, or null when the line is not
52
+ * a row this grammar owns (no pipes, a single pipe, or an unsupported width).
53
+ */
54
+ export function parsePromotionGateCells(line) {
55
+ const body = line.trim();
56
+ if (body.length < 2 || !body.startsWith("|") || !body.endsWith("|"))
57
+ return null;
58
+ const cells = body
59
+ .slice(1, -1)
60
+ .split("|")
61
+ .map((cell) => cell.trim());
62
+ if (!ACCEPTED_ROW_WIDTHS.includes(cells.length))
63
+ return null;
64
+ return cells;
65
+ }
66
+ /**
67
+ * Canonical row bytes. Whitespace around a row (leading indent, padding inside
68
+ * cells, whitespace after the closing pipe) is not plan substance, so both
69
+ * callers re-emit this single canonical form. That is what keeps a stamped row
70
+ * and its normalized counterpart byte-identical, and it is exactly the form the
71
+ * normalizer has always emitted for a 4-cell row, so committed plan digests are
72
+ * unaffected.
73
+ */
74
+ export function formatPromotionGateRow(cells) {
75
+ return `| ${cells.join(" | ")} |`;
76
+ }
77
+ /** Copy `cells` with the lifecycle-owned `Last result` cell set to `value`. */
78
+ export function setLastResultCell(cells, value) {
79
+ const next = [...cells];
80
+ next[LAST_RESULT_CELL_INDEX] = value;
81
+ return next;
82
+ }
83
+ /**
84
+ * Rewrite every Promotion Gates table row (header, separator, and data rows
85
+ * alike) inside the `### Promotion Gates` subsection through `transform`.
86
+ * Returning the received `cells` reference unchanged leaves the line
87
+ * byte-identical; returning a new array re-emits the canonical row. Every line
88
+ * outside the section is always byte-identical.
89
+ */
90
+ export function mapPromotionGateRows(text, transform) {
91
+ const lines = text.split("\n");
92
+ let inGatesSection = false;
93
+ for (let index = 0; index < lines.length; index += 1) {
94
+ const line = lines[index] ?? "";
95
+ if (PROMOTION_GATES_HEADING.test(line)) {
96
+ inGatesSection = true;
97
+ continue;
98
+ }
99
+ if (inGatesSection && ANY_HEADING.test(line)) {
100
+ inGatesSection = false;
101
+ continue;
102
+ }
103
+ if (!inGatesSection)
104
+ continue;
105
+ const cells = parsePromotionGateCells(line);
106
+ if (!cells)
107
+ continue;
108
+ const next = transform(cells);
109
+ if (next !== cells)
110
+ lines[index] = formatPromotionGateRow(next);
111
+ }
112
+ return lines.join("\n");
113
+ }
@@ -19,3 +19,20 @@ export declare function assertPromotionTrustProofCurrent(input: Pick<PromotionTr
19
19
  export declare function runPromotionCommand(repoRoot: string, command: string, deps?: {
20
20
  spawn?: typeof spawnSync;
21
21
  }): void;
22
+ /**
23
+ * Exported for the plan-subject digest invariance suite
24
+ * (`src/review/lifecycle-writers.ts`, `src/review/subject-normalize.test.ts`).
25
+ * Those tests MUST call the production writer, never a hand-written mirror: a
26
+ * mirror can drift with the very bug under test and stay self-consistent, which
27
+ * is exactly how the 5-cell Promotion Gates leak survived the suite.
28
+ */
29
+ export declare function upsertReadinessValue(markdown: string, key: string, value: string): string;
30
+ /**
31
+ * Stamp the `Last result` cell of the gate named `gate`.
32
+ *
33
+ * Exported for the same reason as `upsertReadinessValue` above. The row grammar
34
+ * and the section scope both come from `./gate-row.js`, which the plan-subject
35
+ * digest normalizer consumes too — hand-rolling either here is what let the two
36
+ * sides diverge on four axes and destroy plan approvals on promotion.
37
+ */
38
+ export declare function updateGateLastResult(markdown: string, gate: string, result: string): string;
@@ -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,8 @@
1
+ #!/usr/bin/env bun
2
+ export declare const AGENT_CONFIG_OXLINT_DIST: string;
3
+ export declare const AGENT_KIT_OXLINT_DIST: string;
4
+ /** Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles). */
5
+ export declare const VENDOR_PLUGIN_BASENAMES: readonly ["code-safety", "foundation-purity", "graphql-conventions", "import-hygiene", "monorepo-paths", "query-patterns", "testing-quality", "tier-boundaries"];
6
+ export declare function vendorOxlintPlugins(sourceDir?: string, destDir?: string): {
7
+ copied: string[];
8
+ };
@@ -0,0 +1,53 @@
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
+ export const AGENT_CONFIG_OXLINT_DIST = join(process.cwd(), "packages/agent-config/dist/esm/oxlint");
13
+ export const AGENT_KIT_OXLINT_DIST = join(process.cwd(), "dist/esm/config/oxlint");
14
+ /** Plugin basenames that ship as sibling .js next to oxlintrc.json (not path-roles). */
15
+ export const VENDOR_PLUGIN_BASENAMES = [
16
+ "code-safety",
17
+ "foundation-purity",
18
+ "graphql-conventions",
19
+ "import-hygiene",
20
+ "monorepo-paths",
21
+ "query-patterns",
22
+ "testing-quality",
23
+ "tier-boundaries",
24
+ ];
25
+ export function vendorOxlintPlugins(sourceDir = AGENT_CONFIG_OXLINT_DIST, destDir = AGENT_KIT_OXLINT_DIST) {
26
+ if (!existsSync(sourceDir)) {
27
+ throw new Error(`vendor-oxlint-plugins: missing agent-config oxlint dist at ${sourceDir}. Build @webpresso/agent-config first.`);
28
+ }
29
+ mkdirSync(destDir, { recursive: true });
30
+ const copied = [];
31
+ for (const name of VENDOR_PLUGIN_BASENAMES) {
32
+ const src = join(sourceDir, `${name}.js`);
33
+ if (!existsSync(src)) {
34
+ throw new Error(`vendor-oxlint-plugins: missing compiled plugin ${src}`);
35
+ }
36
+ const dest = join(destDir, `${name}.js`);
37
+ cpSync(src, dest);
38
+ copied.push(name);
39
+ // path-roles is imported by several plugins as a sibling — vendor it too.
40
+ }
41
+ // Always vendor path-roles (shared classifier used via relative imports inside plugins).
42
+ const pathRoles = join(sourceDir, "path-roles.js");
43
+ if (!existsSync(pathRoles)) {
44
+ throw new Error(`vendor-oxlint-plugins: missing ${pathRoles}`);
45
+ }
46
+ cpSync(pathRoles, join(destDir, "path-roles.js"));
47
+ copied.push("path-roles");
48
+ return { copied };
49
+ }
50
+ if (import.meta.main) {
51
+ const { copied } = vendorOxlintPlugins();
52
+ process.stdout.write(`vendored oxlint plugins: ${copied.join(", ")}\n`);
53
+ }
@@ -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[];