@webpresso/agent-kit 3.1.28 → 3.1.30

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 (98) hide show
  1. package/catalog/AGENTS.md.tpl +4 -4
  2. package/catalog/agent/rules/ci-cost-local-first.md +139 -0
  3. package/catalog/agent/rules/ci-test-perf.md +5 -1
  4. package/catalog/agent/rules/cmd-execution.md +9 -0
  5. package/catalog/agent/rules/pre-implementation.md +2 -1
  6. package/catalog/agent/rules/test-scan-perf.md +80 -0
  7. package/catalog/agent/rules/workflow-skills-routing.md +4 -2
  8. package/catalog/agent/skills/fix/SKILL.md +3 -0
  9. package/catalog/agent/skills/verify/SKILL.md +18 -0
  10. package/dist/esm/audit/blueprint-pr-coverage.js +68 -7
  11. package/dist/esm/audit/registry.d.ts +4 -4
  12. package/dist/esm/audit/registry.js +3 -0
  13. package/dist/esm/audit/roadmap-links.js +6 -0
  14. package/dist/esm/audit/test-scan-perf.d.ts +18 -0
  15. package/dist/esm/audit/test-scan-perf.js +275 -0
  16. package/dist/esm/blueprint/lifecycle/engine.d.ts +9 -0
  17. package/dist/esm/blueprint/lifecycle/engine.js +53 -6
  18. package/dist/esm/blueprint/lifecycle/review-provenance.d.ts +6 -0
  19. package/dist/esm/blueprint/lifecycle/review-provenance.js +93 -12
  20. package/dist/esm/blueprint/markdown/helpers.js +6 -2
  21. package/dist/esm/ci/act-replay.js +110 -7
  22. package/dist/esm/ci/act-runner.d.ts +4 -0
  23. package/dist/esm/ci/act-runner.js +85 -26
  24. package/dist/esm/ci/act-worktree-git.d.ts +11 -0
  25. package/dist/esm/ci/act-worktree-git.js +219 -0
  26. package/dist/esm/cli/commands/audit.js +3 -0
  27. package/dist/esm/cli/commands/blueprint/mutations.js +35 -18
  28. package/dist/esm/cli/commands/blueprint/router.js +27 -2
  29. package/dist/esm/cli/commands/ci.d.ts +8 -1
  30. package/dist/esm/cli/commands/ci.js +80 -21
  31. package/dist/esm/cli/commands/dash/keymap.js +1 -0
  32. package/dist/esm/cli/commands/dash/launch-preferences.d.ts +15 -0
  33. package/dist/esm/cli/commands/dash/launch-preferences.js +46 -17
  34. package/dist/esm/cli/commands/dash/plan-once-label.d.ts +7 -0
  35. package/dist/esm/cli/commands/dash/plan-once-label.js +5 -0
  36. package/dist/esm/cli/commands/dash/plan-once-membership.d.ts +67 -0
  37. package/dist/esm/cli/commands/dash/plan-once-membership.js +151 -0
  38. package/dist/esm/cli/commands/dash/plan-once-merge.d.ts +143 -0
  39. package/dist/esm/cli/commands/dash/plan-once-merge.js +238 -0
  40. package/dist/esm/cli/commands/dash/plan-once.d.ts +22 -2
  41. package/dist/esm/cli/commands/dash/plan-once.js +41 -4
  42. package/dist/esm/cli/commands/dash/tui/attention-hud.d.ts +9 -0
  43. package/dist/esm/cli/commands/dash/tui/attention-hud.js +9 -0
  44. package/dist/esm/cli/commands/dash/tui/chat-bridge.d.ts +41 -0
  45. package/dist/esm/cli/commands/dash/tui/chat-bridge.js +73 -0
  46. package/dist/esm/cli/commands/dash/tui/chat-mode.d.ts +47 -0
  47. package/dist/esm/cli/commands/dash/tui/chat-mode.js +98 -0
  48. package/dist/esm/cli/commands/dash/tui/frame.d.ts +3 -0
  49. package/dist/esm/cli/commands/dash/tui/frame.js +3 -1
  50. package/dist/esm/cli/commands/dash/tui/palette.d.ts +1 -1
  51. package/dist/esm/cli/commands/dash/tui/palette.js +14 -0
  52. package/dist/esm/cli/commands/dash/tui/runtime.js +239 -4
  53. package/dist/esm/cli/commands/dash/tui/state.d.ts +41 -0
  54. package/dist/esm/cli/commands/dash/tui/state.js +257 -4
  55. package/dist/esm/cli/commands/format.js +0 -2
  56. package/dist/esm/cli/commands/init/config.d.ts +0 -1
  57. package/dist/esm/cli/commands/init/config.js +1 -4
  58. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.d.ts +5 -6
  59. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/emitters/claude.js +6 -13
  60. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.d.ts +0 -2
  61. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/ir.js +0 -2
  62. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.d.ts +6 -3
  63. package/dist/esm/cli/commands/init/scaffolders/agent-hooks/merge.js +39 -26
  64. package/dist/esm/cli/commands/init/scaffolders/codex-mcp/index.js +61 -23
  65. package/dist/esm/cli/commands/logs.d.ts +15 -0
  66. package/dist/esm/cli/commands/logs.js +192 -3
  67. package/dist/esm/cli/commands/quality-log-store.d.ts +1 -0
  68. package/dist/esm/cli/commands/quality-log-store.js +187 -51
  69. package/dist/esm/cli/commands/review.js +5 -8
  70. package/dist/esm/cli/commands/secrets.d.ts +1 -0
  71. package/dist/esm/cli/commands/secrets.js +4 -1
  72. package/dist/esm/cli/direct-provider-launch.js +25 -1
  73. package/dist/esm/hooks/doctor.d.ts +15 -0
  74. package/dist/esm/hooks/doctor.js +56 -0
  75. package/dist/esm/hooks/pretool-guard/validators/forbidden-commands.js +4 -4
  76. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.d.ts +0 -1
  77. package/dist/esm/hooks/pretool-guard/validators/mcp-redirect.js +8 -5
  78. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.d.ts +9 -7
  79. package/dist/esm/hooks/pretool-guard/validators/worktree-discipline.js +126 -14
  80. package/dist/esm/mcp/tools/_shared/audit-kinds.d.ts +1 -1
  81. package/dist/esm/mcp/tools/audit.d.ts +2 -1
  82. package/dist/esm/mcp/tools/audit.js +10 -0
  83. package/dist/esm/mcp/tools/audits.d.ts +1 -0
  84. package/dist/esm/mcp/tools/ci-act.js +9 -11
  85. package/dist/esm/paths/state-root.js +3 -0
  86. package/dist/esm/review/authority.js +17 -2
  87. package/dist/esm/review/availability.js +18 -5
  88. package/dist/esm/review/subject.d.ts +15 -0
  89. package/dist/esm/review/subject.js +105 -10
  90. package/dist/esm/runtime/executor.d.ts +1 -0
  91. package/dist/esm/runtime/executor.js +2 -1
  92. package/dist/esm/secret-gate/runner.d.ts +1 -0
  93. package/dist/esm/secret-gate/runner.js +4 -1
  94. package/dist/esm/worktrees/current.d.ts +1 -0
  95. package/dist/esm/worktrees/current.js +1 -0
  96. package/dist/esm/worktrees/project-agent-surfaces.d.ts +1 -0
  97. package/dist/esm/worktrees/project-agent-surfaces.js +2 -1
  98. package/package.json +14 -14
@@ -25,13 +25,15 @@ const VALIDATOR_NAME = "worktree-discipline";
25
25
  * effective cwd elsewhere (e.g. CI) → ALLOW (rule scoped to local ~/repos primaries)
26
26
  * ```
27
27
  *
28
- * The "effective cwd" is the tool's ambient `input.cwd`, lowered only by a
29
- * success-gated cwd chain that directly governs the forbidden git op, e.g.
30
- * `cd <worktree> && git commit` or `(cd <worktree> && git commit)`, then by
31
- * EVERY `git -C <dir>` flag on the matched invocation (git applies them
32
- * cumulatively). Unsupported cwd-changing control flow, option-bearing `cd`,
33
- * `cd -`, or unresolvable `cd`/`-C` targets fail closed because they could hide
34
- * a primary-checkout mutation. Prefer `git -C <worktree> ...` or running the
28
+ * The "effective cwd" is the tool's ambient `input.cwd`, lowered by a
29
+ * success-gated `&&` chain that governs the forbidden git op, e.g.
30
+ * `cd <worktree> && git commit`, `cd <worktree> && ./bin/wp … && git commit`,
31
+ * or `(cd <worktree> && git commit)`, then by EVERY `git -C <dir>` flag on the
32
+ * matched invocation (git applies them cumulatively). Intermediate non-cwd
33
+ * commands after a proven `cd` leave the chain cwd unchanged. Unsupported
34
+ * control flow (`;` / `|` / braces), option-bearing `cd`, `cd -`, or
35
+ * unresolvable `cd`/`-C` targets fail closed because they could hide a
36
+ * primary-checkout mutation. Prefer `git -C <worktree> ...` or running the
35
37
  * command with the tool cwd already set to the worktree.
36
38
  *
37
39
  * Known best-effort limits (acceptable for an agent guard with a documented
@@ -634,12 +636,13 @@ const DIR_TOKEN = String.raw `(?:"[^"]+"|'[^']+'|[^\s;&|(){}]+)`;
634
636
  const QUOTED_ARG = String.raw `(?:"[^"]+"|'[^']+'|\S+)`;
635
637
  const CD_TARGET = String.raw `(?:--\s+)?(${DIR_TOKEN}|-)`;
636
638
  const CD_LEADER = String.raw `(?:[A-Za-z_]\w*=\S*\s+)*(?:command\s+|builtin\s+)?(?:cd|pushd)\s+`;
637
- // A success-gated cwd chain that directly governs the forbidden git op. We only
638
- // trust cwd changes in this shape; `;`, `||`, skipped/failed `cd`, quoted spoof
639
- // text, and other unsupported shell control flow fail closed instead.
639
+ // Pure cd/pushd segment that updates the success-gated cwd (optional leading env /
640
+ // command|builtin wrapper). Trailing whitespace is allowed; nothing else.
641
+ const PURE_CD_SEGMENT = new RegExp(String.raw `^\s*${CD_LEADER}${CD_TARGET}\s*$`, "u");
642
+ // Legacy pure trailing-cd chain (also used for subshell-local `(cd X && git …)`).
640
643
  const CD_CHAIN = new RegExp(String.raw `(?:^|[;&|(){}]\s*)?((?:\s*${CD_LEADER}${CD_TARGET}\s*&&)+)\s*$`, "u");
641
644
  const CD_IN_CHAIN = new RegExp(String.raw `(?:^|&&)\s*${CD_LEADER}${CD_TARGET}\s*(?=&&)`, "gu");
642
- const CWD_WORD = new RegExp(String.raw `(?:^|[;{}()&|]\s*|\s+)(?:[A-Za-z_]\w*=\S*\s+)*(?:command\s+|builtin\s+)?(?:cd|pushd)\b`, "gu");
645
+ const CWD_WORD = new RegExp(String.raw `(?:^|[;{}()&|]\s*|\s+)(?:[A-Za-z_]\w*=\S*\s+)*(?:command\s+|builtin\s+)?(?:cd|pushd|popd)\b`, "gu");
643
646
  const DASH_C = new RegExp(String.raw `-C\s+(${DIR_TOKEN})`, "gu");
644
647
  function isInsideSingleOrDoubleQuotes(command, index) {
645
648
  let quote = null;
@@ -667,6 +670,27 @@ function hasUnquotedCwdCommand(command) {
667
670
  }
668
671
  return false;
669
672
  }
673
+ /** True when unquoted `;` `|` `{` `}` appear (breaks a pure `&&` agent chain). */
674
+ function hasUnquotedUnsafeAndChainControl(command) {
675
+ for (let i = 0; i < command.length; i += 1) {
676
+ const ch = command[i];
677
+ if (ch !== ";" && ch !== "|" && ch !== "{" && ch !== "}" && ch !== "\n")
678
+ continue;
679
+ if (!isInsideSingleOrDoubleQuotes(command, i))
680
+ return true;
681
+ }
682
+ return false;
683
+ }
684
+ function hasUnquotedParen(command) {
685
+ for (let i = 0; i < command.length; i += 1) {
686
+ const ch = command[i];
687
+ if (ch !== "(" && ch !== ")")
688
+ continue;
689
+ if (!isInsideSingleOrDoubleQuotes(command, i))
690
+ return true;
691
+ }
692
+ return false;
693
+ }
670
694
  /** Apply one dir token to `cwd`, or signal ambiguity if it cannot be statically resolved. */
671
695
  function applyDir(token, cwd) {
672
696
  if (!token)
@@ -683,11 +707,85 @@ function isTrustedCwdChainStart(commandPrefix, chainStart) {
683
707
  const before = commandPrefix.slice(0, chainStart).trimEnd();
684
708
  return before.endsWith("&&") || before.endsWith("(") || before.endsWith("{");
685
709
  }
686
- function applySuccessGatedCwdChain(commandPrefix, baseCwd) {
710
+ /**
711
+ * Split a pure `&&` command prefix into segments. Returns null when the prefix
712
+ * uses `;` / `|` / braces / newlines we refuse to model.
713
+ */
714
+ function splitSuccessGatedAndSegments(commandPrefix) {
715
+ if (!commandPrefix.trim())
716
+ return [];
717
+ if (hasUnquotedUnsafeAndChainControl(commandPrefix))
718
+ return null;
719
+ const segments = [];
720
+ let current = "";
721
+ let quote = null;
722
+ let escaped = false;
723
+ for (let i = 0; i < commandPrefix.length; i += 1) {
724
+ const ch = commandPrefix[i] ?? "";
725
+ if (escaped) {
726
+ current += ch;
727
+ escaped = false;
728
+ continue;
729
+ }
730
+ if (quote !== "'" && ch === "\\") {
731
+ escaped = true;
732
+ current += ch;
733
+ continue;
734
+ }
735
+ if ((ch === '"' || ch === "'") && (quote === null || quote === ch)) {
736
+ quote = quote === ch ? null : ch;
737
+ current += ch;
738
+ continue;
739
+ }
740
+ if (!quote && ch === "&" && commandPrefix[i + 1] === "&") {
741
+ if (current.trim())
742
+ segments.push(current);
743
+ current = "";
744
+ i += 1;
745
+ continue;
746
+ }
747
+ current += ch;
748
+ }
749
+ if (quote || escaped)
750
+ return null;
751
+ if (current.trim())
752
+ segments.push(current);
753
+ return segments;
754
+ }
755
+ /**
756
+ * Walk pure `&&` segments: resolvable cd/pushd updates cwd; other segments must
757
+ * not smuggle unquoted cwd changes. Enables
758
+ * `cd <managed-worktree> && ./bin/wp … && git commit`.
759
+ */
760
+ function applyAndSegmentCwdChain(commandPrefix, baseCwd) {
761
+ const segments = splitSuccessGatedAndSegments(commandPrefix);
762
+ if (segments === null)
763
+ return { ambiguous: true };
764
+ if (segments.length === 0)
765
+ return { ambiguous: false, cwd: baseCwd };
766
+ let cwd = baseCwd;
767
+ for (const segment of segments) {
768
+ const pureCd = PURE_CD_SEGMENT.exec(segment);
769
+ if (pureCd) {
770
+ const next = applyDir(pureCd[1], cwd);
771
+ if (next === "ambiguous")
772
+ return { ambiguous: true };
773
+ cwd = next.cwd;
774
+ continue;
775
+ }
776
+ if (hasUnquotedCwdCommand(segment))
777
+ return { ambiguous: true };
778
+ }
779
+ return { ambiguous: false, cwd };
780
+ }
781
+ /**
782
+ * Legacy trailing pure-cd chain (and subshell-local `(cd X && git …)` forms).
783
+ * Kept for paren-scoped ops; intermediate non-cd commands are handled by
784
+ * {@link applyAndSegmentCwdChain} when no unquoted parens are present.
785
+ */
786
+ function applyLegacyTrailingCdChain(commandPrefix, baseCwd) {
687
787
  const chain = CD_CHAIN.exec(commandPrefix);
688
788
  const chainStart = chain?.index ?? commandPrefix.length;
689
- // If there is any other unquoted cwd-changing syntax before this simple chain,
690
- // we cannot prove the chain governs the op. Fail closed.
691
789
  if (hasUnquotedCwdCommand(commandPrefix.slice(0, chainStart)))
692
790
  return { ambiguous: true };
693
791
  if (!chain)
@@ -703,6 +801,20 @@ function applySuccessGatedCwdChain(commandPrefix, baseCwd) {
703
801
  }
704
802
  return { ambiguous: false, cwd };
705
803
  }
804
+ /**
805
+ * Success-gated cwd chain for a forbidden git op's command prefix.
806
+ *
807
+ * Prefer the `&&`-segment walk when the prefix has no unquoted parentheses so
808
+ * agents can run tools between a managed `cd` and a mutation. When parentheses
809
+ * appear (subshell-local ops), fall back to the legacy trailing-cd chain so
810
+ * non-persistent `(cd …) && git …` stays fail-closed.
811
+ */
812
+ function applySuccessGatedCwdChain(commandPrefix, baseCwd) {
813
+ if (!hasUnquotedParen(commandPrefix)) {
814
+ return applyAndSegmentCwdChain(commandPrefix, baseCwd);
815
+ }
816
+ return applyLegacyTrailingCdChain(commandPrefix, baseCwd);
817
+ }
706
818
  /**
707
819
  * The directory the git op actually runs in: `baseCwd` lowered by the
708
820
  * success-gated cwd chain that directly governs this op, then through EVERY
@@ -1,3 +1,3 @@
1
- export declare const MCP_AUDIT_KINDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "session-memory-hardcut", "worktree-main-ownership", "sast"];
1
+ export declare const MCP_AUDIT_KINDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "test-scan-perf", "session-memory-hardcut", "worktree-main-ownership", "sast"];
2
2
  export type MCPAuditKind = (typeof MCP_AUDIT_KINDS)[number];
3
3
  export declare function isMCPAuditKind(value: string): value is MCPAuditKind;
@@ -15,7 +15,7 @@
15
15
  */
16
16
  import { z } from "zod";
17
17
  import type { ToolDescriptor } from "#mcp/auto-discover";
18
- export declare const KINDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "session-memory-hardcut", "worktree-main-ownership", "sast"];
18
+ export declare const KINDS: readonly ["test-smells", "tph-e2e", "agents", "catalog-drift", "package-surface", "reference-parity-matrix", "docs-frontmatter", "blueprint-readme-drift", "blueprint-pr-coverage", "blueprint-lifecycle", "blueprint-trust", "architecture-drift", "cloudflare-deploy-contract", "absolute-path-policy", "no-first-party-mjs", "no-math-random", "roadmap-links", "bundle-budget", "commit-message", "tech-debt", "supported-agent-clis", "hook-surface", "harness-surfaces", "weakness-mining", "harness-overlay-evidence", "ai-contracts", "atomic-state-writes", "no-relative-package-scripts", "toolchain-isolation", "open-source-licenses", "secrets-policy", "no-dev-vars", "github-actions-secrets", "secret-provider-quarantine", "security-quality-regressions", "secrets-config", "consumer-agent-kit-dependency", "ci-test-perf", "test-scan-perf", "session-memory-hardcut", "worktree-main-ownership", "sast"];
19
19
  export declare const inputSchema: z.ZodObject<{
20
20
  kind: z.ZodEnum<{
21
21
  "absolute-path-policy": "absolute-path-policy";
@@ -54,6 +54,7 @@ export declare const inputSchema: z.ZodObject<{
54
54
  "session-memory-hardcut": "session-memory-hardcut";
55
55
  "supported-agent-clis": "supported-agent-clis";
56
56
  "tech-debt": "tech-debt";
57
+ "test-scan-perf": "test-scan-perf";
57
58
  "test-smells": "test-smells";
58
59
  "toolchain-isolation": "toolchain-isolation";
59
60
  "tph-e2e": "tph-e2e";
@@ -464,6 +464,16 @@ export async function dispatchAudit(input) {
464
464
  details: auditResult,
465
465
  };
466
466
  }
467
+ case "test-scan-perf": {
468
+ const { auditTestScanPerf } = await import("#audit/test-scan-perf");
469
+ const auditResult = auditTestScanPerf(input.cwd ?? input.directory ?? process.cwd());
470
+ return {
471
+ passed: auditResult.ok,
472
+ summary: summarizeRepoAudit(kind, auditResult),
473
+ kind,
474
+ details: auditResult,
475
+ };
476
+ }
467
477
  case "hook-surface": {
468
478
  const { auditHookSurface } = await import("#audit/hook-surface");
469
479
  const auditResult = auditHookSurface(input.cwd ?? input.directory);
@@ -50,6 +50,7 @@ declare const inputSchema: z.ZodObject<{
50
50
  "session-memory-hardcut": "session-memory-hardcut";
51
51
  "supported-agent-clis": "supported-agent-clis";
52
52
  "tech-debt": "tech-debt";
53
+ "test-scan-perf": "test-scan-perf";
53
54
  "test-smells": "test-smells";
54
55
  "toolchain-isolation": "toolchain-isolation";
55
56
  "tph-e2e": "tph-e2e";
@@ -1,6 +1,6 @@
1
1
  import { z } from "zod";
2
- import { DEFAULT_CI_ACT_TIMEOUT_MS, MAX_CI_ACT_TIMEOUT_MS } from "#cli/commands/ci";
3
- import { buildPublicCiActCommand, preparePublicCiActExecution, resolveCiActExecutionMode, resolveCiActSecretEnvProfile, sanitizePublicCiActArgv, } from "#ci/act-runner.js";
2
+ import { DEFAULT_CI_ACT_TIMEOUT_MS, MAX_CI_ACT_TIMEOUT_MS, SMOKE_CI_ACT_TIMEOUT_MS, } from "#cli/commands/ci";
3
+ import { buildPublicCiActCommand, preparePublicCiActExecution, resolveCiActExecutionMode, resolveCiActSecretEnvProfile, sanitizePublicCiActArgv, shouldForceCiActSecretGate, } from "#ci/act-runner.js";
4
4
  import { isSecretGateRuntimeProfile, runSecretGateCommand } from "#secret-gate/runner.js";
5
5
  import { clipRawOutput, createSummaryOutputSchema, createSummaryResult } from "./_shared/result.js";
6
6
  import { resolveProjectRoot } from "./_shared/project-root.js";
@@ -10,6 +10,7 @@ const inputSchema = z
10
10
  cwd: z.string().optional(),
11
11
  workflowPath: z.string(),
12
12
  job: z.string().optional(),
13
+ matrix: z.array(z.string()).optional().default([]),
13
14
  eventName: z
14
15
  .enum(["pull_request", "push", "workflow_dispatch"])
15
16
  .optional()
@@ -24,13 +25,8 @@ const inputSchema = z
24
25
  }),
25
26
  secretProfile: z.string().optional(),
26
27
  mode: z.enum(["direct", "replay"]).optional().default("direct"),
27
- timeoutMs: z
28
- .number()
29
- .int()
30
- .positive()
31
- .max(MAX_CI_ACT_TIMEOUT_MS)
32
- .optional()
33
- .default(DEFAULT_CI_ACT_TIMEOUT_MS),
28
+ smoke: z.boolean().optional().default(false),
29
+ timeoutMs: z.number().int().positive().max(MAX_CI_ACT_TIMEOUT_MS).optional(),
34
30
  containerArchitecture: z.string().optional(),
35
31
  platformImage: z.string().optional().default("ghcr.io/catthehacker/ubuntu:full-latest"),
36
32
  execute: z.boolean().optional().default(false),
@@ -96,6 +92,7 @@ const tool = {
96
92
  handler: async (raw, extra) => {
97
93
  const input = inputSchema.parse(raw ?? {});
98
94
  const cwd = resolveProjectRoot(input.cwd ? { cwd: input.cwd } : {});
95
+ const timeoutMs = input.timeoutMs ?? (input.smoke ? SMOKE_CI_ACT_TIMEOUT_MS : DEFAULT_CI_ACT_TIMEOUT_MS);
99
96
  if (!input.execute) {
100
97
  return createSummaryResult({
101
98
  passed: true,
@@ -108,13 +105,14 @@ const tool = {
108
105
  const secretProfile = resolveCiActSecretEnvProfile({ ...input, cwd }) ?? "preview";
109
106
  const result = await runSecretGateCommand({
110
107
  cwd,
108
+ commandCwd: prepared.cwd,
111
109
  sink: "act",
112
110
  profile: secretProfile,
113
111
  envProfile: input.envProfile,
114
- forceSecretGate: true,
112
+ forceSecretGate: shouldForceCiActSecretGate(input.envProfile),
115
113
  command: "act",
116
114
  args: prepared.command.actArgs,
117
- timeoutMs: input.timeoutMs,
115
+ timeoutMs,
118
116
  signal: extra?.signal,
119
117
  });
120
118
  const merged = [result.stdout, result.stderr].filter(Boolean).join("\n");
@@ -247,6 +247,9 @@ export async function getSurfacePathAsync(name, scope, cwd = resolveCwd()) {
247
247
  export async function withLock(scope, fn, cwd) {
248
248
  const lockTarget = await getSurfacePathAsync(".lock", scope, cwd);
249
249
  mkdirSync(dirname(lockTarget), { recursive: true });
250
+ // Keep retries short so lock-contention probes (review gate contender tests)
251
+ // still fail closed in a few seconds. Dual-writer create loops retry at the
252
+ // call site when they need longer budgets under CI load.
250
253
  const release = await lockfile.lock(lockTarget, {
251
254
  realpath: false,
252
255
  retries: { retries: 5, minTimeout: 50, maxTimeout: 500, factor: 2 },
@@ -9,7 +9,7 @@ import { createHash } from "node:crypto";
9
9
  import { existsSync, lstatSync, readFileSync } from "node:fs";
10
10
  import path from "node:path";
11
11
  import { evaluateReviewEvents, parseReviewEventLog } from "./events.js";
12
- import { createDeliverySubjectAtRef, createPlanSubjectAtRef, resolveReviewCommit, } from "./subject.js";
12
+ import { createDeliverySubjectAtRef, createLegacyPlanSubjectAtRef, createPlanSubjectAtRef, resolveReviewCommit, } from "./subject.js";
13
13
  const GIT_TIMEOUT_MS = 5_000;
14
14
  const GIT_MAX_BUFFER_BYTES = 128 * 1024 * 1024;
15
15
  const REGULAR_MODE_PATTERN = /^100(?:644|755)$/u;
@@ -126,12 +126,27 @@ export function readReviewAuthorityAtRef(cwd, ref, stableSlug, purpose) {
126
126
  const subject = purpose === "plan"
127
127
  ? createPlanSubjectAtRef(cwd, commit, stableSlug, overview.path)
128
128
  : createDeliverySubjectAtRef(cwd, commit, stableSlug, overview.path);
129
- const evaluation = evaluateReviewEvents(events, {
129
+ let evaluation = evaluateReviewEvents(events, {
130
130
  purpose,
131
131
  blueprintSlug: stableSlug,
132
132
  subjectScheme: subject.scheme,
133
133
  subjectDigest: subject.digest,
134
134
  }, compromisedIds);
135
+ if (purpose === "plan" && evaluation.approvals.length === 0) {
136
+ // Digest-compatibility fallback: events recorded before the lifecycle
137
+ // stripped-key extension bound their subjectDigest to the legacy
138
+ // normalization. Accept those for the same tracked content; new events
139
+ // always record the current digest (see legacyNormalizePlanSubjectContent).
140
+ const legacySubject = createLegacyPlanSubjectAtRef(cwd, commit, stableSlug, overview.path);
141
+ if (legacySubject.digest !== subject.digest) {
142
+ evaluation = evaluateReviewEvents(events, {
143
+ purpose,
144
+ blueprintSlug: stableSlug,
145
+ subjectScheme: legacySubject.scheme,
146
+ subjectDigest: legacySubject.digest,
147
+ }, compromisedIds);
148
+ }
149
+ }
135
150
  return {
136
151
  commit,
137
152
  blueprintPath: overview.path,
@@ -3,11 +3,7 @@ import path from "node:path";
3
3
  import { NotInGitRepoError, withLock } from "#paths/state-root.js";
4
4
  import { writeJsonFileAtomic } from "#shared-utils/write-json-file.js";
5
5
  export const REVIEW_AVAILABILITY_SCHEMA_VERSION = 1;
6
- const FAILURE_CLASSIFICATIONS = new Set([
7
- "provider-limit-reached",
8
- "provider-unavailable",
9
- "provider-failed",
10
- ]);
6
+ const FAILURE_CLASSIFICATIONS = new Set(["provider-limit-reached", "provider-unavailable"]);
11
7
  const SUCCESS_CLASSIFICATIONS = new Set([
12
8
  "complete",
13
9
  "approved",
@@ -117,6 +113,10 @@ export function saveReviewAvailability(projectRoot, state) {
117
113
  function shouldSuppress(event) {
118
114
  return FAILURE_CLASSIFICATIONS.has(event.terminalClassification);
119
115
  }
116
+ function suppressedRecordStillApplies(record) {
117
+ return (record.terminalClassification !== undefined &&
118
+ FAILURE_CLASSIFICATIONS.has(record.terminalClassification));
119
+ }
120
120
  function isAvailabilitySuccess(event) {
121
121
  return SUCCESS_CLASSIFICATIONS.has(event.terminalClassification);
122
122
  }
@@ -155,6 +155,15 @@ function nextRecord(previous, event, input) {
155
155
  const success = isAvailabilitySuccess(event);
156
156
  if (!input.suppress) {
157
157
  const preserveSuppression = !success && previous?.suppressed === true;
158
+ if (preserveSuppression) {
159
+ return {
160
+ ...previous,
161
+ ...(event.model ? { lastModel: event.model } : {}),
162
+ ...(event.effort ? { lastEffort: event.effort } : {}),
163
+ ...(event.stage ? { lastStage: event.stage } : {}),
164
+ suppressed: true,
165
+ };
166
+ }
158
167
  return {
159
168
  ...common,
160
169
  ...(success
@@ -244,6 +253,10 @@ export function suppressedReviewers(state) {
244
253
  export function pruneExpiredAvailabilitySuppressions(state, nowMs = Date.now()) {
245
254
  const pruned = [];
246
255
  const pruneMap = (records) => Object.fromEntries(Object.entries(records).map(([key, record]) => {
256
+ if (record.suppressed && !suppressedRecordStillApplies(record)) {
257
+ pruned.push(key);
258
+ return [key, { ...record, suppressed: false }];
259
+ }
247
260
  if (record.suppressed &&
248
261
  record.retryAfterMs !== undefined &&
249
262
  record.lastFailureAt !== undefined) {
@@ -18,7 +18,22 @@ declare function updateField(hash: Hash, value: Buffer | string): void;
18
18
  export declare function createDeliverySubjectAtRef(cwd: string, ref: string, blueprintSlug: string, blueprintPath?: string): ReviewSubject;
19
19
  export declare function createSourceSubjectAtRef(cwd: string, ref: string): ReviewSubject;
20
20
  export declare function normalizePlanSubjectContent(content: Buffer | string): Buffer;
21
+ /**
22
+ * Plan-subject digest for in-memory (working-tree) blueprint content. Used to
23
+ * accept working-ledger review events that digest-bind the exact bytes being
24
+ * committed (pre-commit runs before those events reach any git ref).
25
+ */
26
+ export declare function createPlanSubjectDigestForContent(blueprintSlug: string, content: Buffer | string): string;
27
+ /**
28
+ * Digest-compatibility variant for review events recorded before the
29
+ * stripped-key extension of `neutralizeLifecycleFrontmatter`. Verification
30
+ * accepts a match against either normalization; new digests always use
31
+ * `normalizePlanSubjectContent`.
32
+ */
33
+ export declare function legacyNormalizePlanSubjectContent(content: Buffer | string): Buffer;
21
34
  export declare function createPlanSubjectAtRef(cwd: string, ref: string, blueprintSlug: string, blueprintPath?: string): ReviewSubject;
35
+ /** Plan subject digested with the pre-extension normalization (see above). */
36
+ export declare function createLegacyPlanSubjectAtRef(cwd: string, ref: string, blueprintSlug: string, blueprintPath?: string): ReviewSubject;
22
37
  export declare const reviewSubjectInternals: {
23
38
  isExcludedReviewEnvelope: typeof isExcludedReviewEnvelope;
24
39
  isReviewEvidencePath: typeof isReviewEvidencePath;
@@ -255,12 +255,39 @@ function neutralizePromotionGateResults(text) {
255
255
  }
256
256
  return lines.join("\n");
257
257
  }
258
- // Neutralize the lifecycle-owned frontmatter fields (`status:` value and the
259
- // `completed_at:` line) INSIDE the leading frontmatter block only. A body line
258
+ // Frontmatter keys removed wholesale by the normalizer (value + folded
259
+ // continuation lines). Each mirrors exactly one lifecycle writer:
260
+ // `completed_at` (upsertCompletedAt) and the `wp blueprint start` /
261
+ // progress-stamp fields (setBlueprintFrontmatterFields lifecycle intents).
262
+ const PLAN_LIFECYCLE_STRIPPED_KEYS = [
263
+ "completed_at",
264
+ "last_updated",
265
+ "progress",
266
+ "worktree_owner_id",
267
+ "worktree_owner_branch",
268
+ ];
269
+ // Neutralize the lifecycle-owned frontmatter fields (`status:` value plus the
270
+ // stripped keys above) INSIDE the leading frontmatter block only. A body line
260
271
  // that merely looks like one of these (e.g. task text reading
261
272
  // "completed_at: must remain unset until release") is plan substance and must
262
- // keep affecting the digest.
273
+ // keep affecting the digest. Stripping removes the key line and any indented
274
+ // continuation lines so folded (`>-`) values normalize away too.
263
275
  function neutralizeLifecycleFrontmatter(text) {
276
+ const match = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(text);
277
+ if (!match)
278
+ return text;
279
+ let frontmatter = match[0];
280
+ frontmatter = frontmatter.replace(/^(status:\s*)(['"]?)[^'"\r\n]+?(['"]?)(\s*)$/m, `$1${PLAN_STATUS_PLACEHOLDER}$4`);
281
+ for (const key of PLAN_LIFECYCLE_STRIPPED_KEYS) {
282
+ frontmatter = frontmatter.replace(new RegExp(String.raw `^${key}:[^\n]*\n?(?:[ \t]+[^\n]*\n?)*`, "gmu"), "");
283
+ }
284
+ return `${frontmatter}${text.slice(match[0].length)}`;
285
+ }
286
+ // The pre-extension normalizer (status + completed_at only). Review events
287
+ // recorded before the stripped-key extension bound their subjectDigest to this
288
+ // normalization; verification accepts either digest so committed approvals are
289
+ // not stranded by the rollout. New events always record the current digest.
290
+ function legacyNeutralizeLifecycleFrontmatter(text) {
264
291
  const match = /^---\r?\n[\s\S]*?\r?\n---(?:\r?\n|$)/u.exec(text);
265
292
  if (!match)
266
293
  return text;
@@ -305,21 +332,85 @@ function neutralizeReadinessStamps(text) {
305
332
  // NO_CODE_CHANGE / GitLab patch-id semantics: approvals are re-derivable from
306
333
  // substance-only digests, not carried). Each rule mirrors exactly one
307
334
  // lifecycle writer, scoped to the location that writer owns:
308
- // - frontmatter `status:` value (updateFrontmatterStatus, blueprint mutations)
309
- // - frontmatter `completed_at:` line (upsertCompletedAt)
335
+ // - frontmatter status/progress/owner fields (blueprint lifecycle mutations)
336
+ // - frontmatter `completed_at:` line (upsertCompletedAt)
310
337
  // - Readiness Verdict `- verified-at:`/`- verified-head:` items (upsertReadinessValue)
311
338
  // - Promotion Gates "Last result" cells (updateGateLastResult)
312
339
  // Any other byte change — task text, acceptance criteria, scope, lookalike
313
340
  // lines outside the owned locations — still changes the digest and dismisses
314
341
  // prior plan approvals.
342
+ const PLAN_TASK_STATUS_PLACEHOLDER = "__wp-task-status__";
343
+ // Neutralize the task-execution state the lifecycle engine writes
344
+ // (updateTaskStatus, applyVerification): `**Status:** <status>` lines,
345
+ // acceptance checkbox marks, and `**Verification:**` +
346
+ // ```webpresso-evidence-v1 fenced evidence blocks. Task execution is lifecycle
347
+ // state, not plan substance — without this, the engine's own task_verify /
348
+ // finalize writes dismiss the plan approval and no completion commit can ever
349
+ // carry authority. Acceptance-criteria TEXT stays substance; only the check
350
+ // mark is neutralized.
351
+ function neutralizeTaskExecutionState(text) {
352
+ const lines = text.split("\n");
353
+ const out = [];
354
+ for (let index = 0; index < lines.length; index += 1) {
355
+ const line = lines[index];
356
+ if (/^\*\*Verification:\*\*\s*$/u.test(line)) {
357
+ // Skip the writer-owned block: "**Verification:**", blank(s), the
358
+ // evidence fence, and one trailing blank line.
359
+ let cursor = index + 1;
360
+ while (cursor < lines.length && lines[cursor].trim() === "")
361
+ cursor += 1;
362
+ if (/^```webpresso-evidence-v1\s*$/u.test(lines[cursor] ?? "")) {
363
+ cursor += 1;
364
+ while (cursor < lines.length && !/^```\s*$/u.test(lines[cursor]))
365
+ cursor += 1;
366
+ cursor += 1; // closing fence
367
+ if ((lines[cursor] ?? "").trim() === "")
368
+ cursor += 1;
369
+ index = cursor - 1;
370
+ continue;
371
+ }
372
+ }
373
+ if (/^\*\*Status:\*\* (?:todo|in-progress|blocked|done|dropped)\s*$/u.test(line)) {
374
+ out.push(`**Status:** ${PLAN_TASK_STATUS_PLACEHOLDER}`);
375
+ continue;
376
+ }
377
+ out.push(line.replace(/^(\s*)- \[[xX ]\] /u, "$1- [_] "));
378
+ }
379
+ return out.join("\n");
380
+ }
315
381
  export function normalizePlanSubjectContent(content) {
316
382
  let text = typeof content === "string" ? content : content.toString("utf8");
317
383
  text = neutralizeLifecycleFrontmatter(text);
318
384
  text = neutralizeReadinessStamps(text);
319
385
  text = neutralizePromotionGateResults(text);
386
+ text = neutralizeTaskExecutionState(text);
320
387
  return Buffer.from(text, "utf8");
321
388
  }
322
- export function createPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
389
+ /**
390
+ * Plan-subject digest for in-memory (working-tree) blueprint content. Used to
391
+ * accept working-ledger review events that digest-bind the exact bytes being
392
+ * committed (pre-commit runs before those events reach any git ref).
393
+ */
394
+ export function createPlanSubjectDigestForContent(blueprintSlug, content) {
395
+ const hash = createHash("sha256").update(PLAN_DOMAIN);
396
+ updateField(hash, blueprintSlug);
397
+ updateField(hash, normalizePlanSubjectContent(content));
398
+ return hash.digest("hex");
399
+ }
400
+ /**
401
+ * Digest-compatibility variant for review events recorded before the
402
+ * stripped-key extension of `neutralizeLifecycleFrontmatter`. Verification
403
+ * accepts a match against either normalization; new digests always use
404
+ * `normalizePlanSubjectContent`.
405
+ */
406
+ export function legacyNormalizePlanSubjectContent(content) {
407
+ let text = typeof content === "string" ? content : content.toString("utf8");
408
+ text = legacyNeutralizeLifecycleFrontmatter(text);
409
+ text = neutralizeReadinessStamps(text);
410
+ text = neutralizePromotionGateResults(text);
411
+ return Buffer.from(text, "utf8");
412
+ }
413
+ function planSubjectAtRef(cwd, ref, blueprintSlug, normalize, blueprintPath) {
323
414
  const commit = resolveReviewCommit(cwd, ref);
324
415
  const entries = parseTree(cwd, commit);
325
416
  const blueprintRoot = findBlueprintRoot(entries, blueprintSlug, blueprintPath);
@@ -333,10 +424,7 @@ export function createPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
333
424
  throw new Error("Tracked blueprint overview content is unavailable.");
334
425
  const hash = createHash("sha256").update(PLAN_DOMAIN);
335
426
  updateField(hash, blueprintSlug);
336
- // Hash normalized content. This redefines wp-blueprint-v1 in place, which is
337
- // safe pre-merge: no committed review-events.jsonl exists anywhere, so no
338
- // recorded plan digest can break.
339
- updateField(hash, normalizePlanSubjectContent(content));
427
+ updateField(hash, normalize(content));
340
428
  return {
341
429
  scheme: "wp-blueprint-v1",
342
430
  digest: hash.digest("hex"),
@@ -344,6 +432,13 @@ export function createPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
344
432
  files: [overviewPath.toString("utf8")],
345
433
  };
346
434
  }
435
+ export function createPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
436
+ return planSubjectAtRef(cwd, ref, blueprintSlug, normalizePlanSubjectContent, blueprintPath);
437
+ }
438
+ /** Plan subject digested with the pre-extension normalization (see above). */
439
+ export function createLegacyPlanSubjectAtRef(cwd, ref, blueprintSlug, blueprintPath) {
440
+ return planSubjectAtRef(cwd, ref, blueprintSlug, legacyNormalizePlanSubjectContent, blueprintPath);
441
+ }
347
442
  export const reviewSubjectInternals = {
348
443
  isExcludedReviewEnvelope,
349
444
  isReviewEvidencePath,
@@ -14,6 +14,7 @@ export interface ResolveRuntimeEnvironmentOptions {
14
14
  }
15
15
  export interface RuntimeSpawnOptions {
16
16
  readonly cwd?: string;
17
+ readonly envCwd?: string;
17
18
  readonly profile?: RuntimeSelector;
18
19
  readonly secretProfile?: string;
19
20
  readonly environment?: string;
@@ -82,9 +82,10 @@ export function resolveRuntimeEnvironment(options = {}) {
82
82
  }
83
83
  export function buildRuntimeSpawnOptions(options = {}) {
84
84
  const cwd = options.cwd ?? process.cwd();
85
+ const envCwd = options.envCwd ?? cwd;
85
86
  const cache = options.cache ?? createRuntimeEnvCache();
86
87
  const resolvedEnv = resolveRuntimeEnvironment({
87
- cwd,
88
+ cwd: envCwd,
88
89
  profile: options.profile,
89
90
  secretProfile: options.secretProfile,
90
91
  environment: options.environment,
@@ -11,6 +11,7 @@ export interface SecretGateCommandOptions {
11
11
  readonly command: string;
12
12
  readonly args?: readonly string[];
13
13
  readonly cwd?: string;
14
+ readonly commandCwd?: string;
14
15
  readonly timeoutMs?: number;
15
16
  readonly signal?: AbortSignal;
16
17
  readonly forceSecretGate?: boolean;
@@ -35,6 +35,7 @@ export function buildSecretGateCommand(options) {
35
35
  sink,
36
36
  "--profile",
37
37
  profile,
38
+ ...(options.commandCwd ? ["--command-cwd", options.commandCwd] : []),
38
39
  "--",
39
40
  options.command,
40
41
  ...(options.args ?? []),
@@ -45,9 +46,11 @@ export function runSecretGateCommand(options) {
45
46
  const timeoutMs = options.timeoutMs ?? 30_000;
46
47
  const maxOutputBytes = options.maxOutputBytes ?? DEFAULT_MAX_OUTPUT_BYTES;
47
48
  const command = buildSecretGateCommand(options);
49
+ const runsInnerCommandDirectly = command.command === options.command;
50
+ const spawnCwd = runsInnerCommandDirectly && options.commandCwd ? options.commandCwd : options.cwd;
48
51
  return new Promise((resolve) => {
49
52
  const child = spawn(command.command, [...command.args], {
50
- cwd: options.cwd,
53
+ cwd: spawnCwd,
51
54
  env: buildRuntimeProcessEnv(options.cwd, process.env),
52
55
  detached: process.platform !== "win32",
53
56
  });
@@ -1,5 +1,6 @@
1
1
  import { type WorktreeDependencyResult } from "./dependencies.js";
2
2
  export interface ManagedWorktreeContext {
3
+ readonly kind: "owner" | "scratch";
3
4
  readonly repoRoot: string;
4
5
  readonly worktreePath: string;
5
6
  }
@@ -20,6 +20,7 @@ export function resolveCurrentManagedWorktreeContext(cwd) {
20
20
  if (!candidate)
21
21
  return null;
22
22
  return {
23
+ kind: candidate.entry.kind,
23
24
  repoRoot: candidate.entry.repoRoot,
24
25
  worktreePath,
25
26
  };