@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
@@ -1,7 +1,8 @@
1
- import { buildPublicCiActCommand, resolveCiActExecutionMode, sanitizePublicCiActArgv, } from "#ci/act-runner.js";
1
+ import { buildPublicCiActCommand, preparePublicCiActExecution, resolveCiActExecutionMode, resolveCiActSecretEnvProfile, sanitizePublicCiActArgv, shouldForceCiActSecretGate, } from "#ci/act-runner.js";
2
2
  import { redactText } from "#mcp/tools/_shared/redact.js";
3
3
  import { runSecretGateCommand } from "#secret-gate/runner.js";
4
4
  export const DEFAULT_CI_ACT_TIMEOUT_MS = 20 * 60_000;
5
+ export const SMOKE_CI_ACT_TIMEOUT_MS = 30_000;
5
6
  export const MAX_CI_ACT_TIMEOUT_MS = 60 * 60_000;
6
7
  export const CI_COMMAND_HELP = [
7
8
  "Run repository CI helpers through the portable, secret-safe wp surface.",
@@ -10,6 +11,8 @@ export const CI_COMMAND_HELP = [
10
11
  "Examples:",
11
12
  " wp ci act --workflow ci-e2e",
12
13
  " wp ci act --workflow ci-e2e --execute",
14
+ " wp ci act --workflow ci-e2e --execute --smoke",
15
+ " wp ci act --workflow ci-e2e --job test --matrix target:beta",
13
16
  " wp ci act --workflow-path .github/workflows/ci.yml --job test",
14
17
  ].join("\n");
15
18
  export function registerCiCommand(cli) {
@@ -20,6 +23,7 @@ export function registerCiCommand(cli) {
20
23
  })
21
24
  .option("--workflow-path <path>", "Explicit workflow file path")
22
25
  .option("--job <id>", "Workflow job id")
26
+ .option("--matrix <key:value>", "Run only matching act matrix selector(s) (repeatable)")
23
27
  .option("--event-name <name>", "act event name: pull_request | push | workflow_dispatch")
24
28
  .option("--event-path <path>", "Use an existing event JSON file")
25
29
  .option("--env-profile <profile>", "Secret-gate runtime profile", { default: "secrets-only" })
@@ -28,6 +32,7 @@ export function registerCiCommand(cli) {
28
32
  .option("--container-architecture <arch>", "act container architecture override")
29
33
  .option("--platform-image <image>", "act runner image for ubicloud-standard-2")
30
34
  .option("--timeout-ms <ms>", `act execution timeout in milliseconds (default: ${DEFAULT_CI_ACT_TIMEOUT_MS})`)
35
+ .option("--smoke", `Use a short ${SMOKE_CI_ACT_TIMEOUT_MS}ms timeout for targeted local proof`)
31
36
  .option("--execute", "Run act; default is a redacted dry-run preview")
32
37
  .option("--dry-run", "Print the resolved command without executing it")
33
38
  .action((action, flags) => {
@@ -39,14 +44,16 @@ export function registerCiCommand(cli) {
39
44
  workflow: flags.workflow,
40
45
  workflowPath: flags.workflowPath,
41
46
  job: flags.job,
42
- eventName: flags.eventName,
47
+ matrix: parseCiActMatrixFlags(flags.matrix),
48
+ eventName: parseCiActEventName(flags.eventName),
43
49
  envProfile: flags.envProfile,
44
50
  secretProfile: flags.secretProfile,
45
- mode: flags.mode,
51
+ mode: parseCiActExecutionMode(flags.mode),
46
52
  containerArchitecture: flags.containerArchitecture,
47
53
  platformImage: flags.platformImage,
48
54
  eventPath: flags.eventPath,
49
55
  execute: Boolean(flags.execute) && !flags.dryRun,
56
+ smoke: Boolean(flags.smoke),
50
57
  timeoutMs: parseCiActTimeoutMs(flags.timeoutMs),
51
58
  });
52
59
  });
@@ -60,8 +67,8 @@ export function validateCiActCommand(..._legacyArgs) {
60
67
  }
61
68
  export async function runCiActCommand(options = {}, deps = {}) {
62
69
  const cwd = deps.cwd ?? process.cwd();
63
- const command = buildPublicCiActCommand({ ...options, cwd });
64
70
  if (!options.execute) {
71
+ const command = buildPublicCiActCommand({ ...options, cwd });
65
72
  const preview = sanitizePublicCiActArgv(command);
66
73
  (deps.stdout ?? process.stdout).write(`${JSON.stringify({ command: preview.command, args: preview.args })}\n`);
67
74
  return 0;
@@ -69,23 +76,34 @@ export async function runCiActCommand(options = {}, deps = {}) {
69
76
  if (resolveCiActExecutionMode(options) === "replay") {
70
77
  (deps.stderr ?? process.stderr).write("Warning: replay mode is a generated local approximation and is not security-equivalent to GitHub CI or OIDC.\n");
71
78
  }
72
- const result = await (deps.run ?? runSecretGateCommand)({
73
- cwd,
74
- envProfile: "none",
75
- command: command.command,
76
- args: command.args,
77
- timeoutMs: normalizeCiActTimeoutMs(options.timeoutMs),
78
- });
79
- const stdout = redactText(result.stdout) ?? "";
80
- const stderr = redactText(result.stderr) ?? "";
81
- if (stdout)
82
- (deps.stdout ?? process.stdout).write(stdout);
83
- if (stderr)
84
- (deps.stderr ?? process.stderr).write(stderr);
85
- return result.exitCode;
79
+ const prepared = preparePublicCiActExecution({ ...options, cwd });
80
+ try {
81
+ const secretProfile = resolveCiActSecretEnvProfile({ ...options, cwd }) ?? "preview";
82
+ const result = await (deps.run ?? runSecretGateCommand)({
83
+ cwd,
84
+ commandCwd: prepared.cwd,
85
+ sink: "act",
86
+ profile: secretProfile,
87
+ envProfile: options.envProfile ?? "secrets-only",
88
+ forceSecretGate: shouldForceCiActSecretGate(options.envProfile),
89
+ command: "act",
90
+ args: prepared.command.actArgs,
91
+ timeoutMs: normalizeCiActTimeoutMs(options.timeoutMs, { smoke: options.smoke }),
92
+ });
93
+ const stdout = redactText(result.stdout) ?? "";
94
+ const stderr = redactText(result.stderr) ?? "";
95
+ if (stdout)
96
+ (deps.stdout ?? process.stdout).write(stdout);
97
+ if (stderr)
98
+ (deps.stderr ?? process.stderr).write(stderr);
99
+ return result.exitCode;
100
+ }
101
+ finally {
102
+ prepared.cleanup();
103
+ }
86
104
  }
87
- export function normalizeCiActTimeoutMs(value) {
88
- const timeoutMs = value ?? DEFAULT_CI_ACT_TIMEOUT_MS;
105
+ export function normalizeCiActTimeoutMs(value, options = {}) {
106
+ const timeoutMs = value ?? (options.smoke ? SMOKE_CI_ACT_TIMEOUT_MS : DEFAULT_CI_ACT_TIMEOUT_MS);
89
107
  if (!Number.isInteger(timeoutMs) || timeoutMs <= 0) {
90
108
  throw new Error("--timeout-ms must be a positive integer");
91
109
  }
@@ -100,7 +118,48 @@ export function parseCiActTimeoutMs(value) {
100
118
  const parsed = typeof value === "number"
101
119
  ? value
102
120
  : typeof value === "string"
103
- ? Number.parseInt(value, 10)
121
+ ? parseCiActTimeoutString(value)
104
122
  : Number.NaN;
105
123
  return normalizeCiActTimeoutMs(parsed);
106
124
  }
125
+ export function parseCiActExecutionMode(value) {
126
+ if (value === undefined || value === null || value === "")
127
+ return undefined;
128
+ if (value === "direct" || value === "replay")
129
+ return value;
130
+ throw new Error('--mode must be "direct" or "replay"');
131
+ }
132
+ export function parseCiActEventName(value) {
133
+ if (value === undefined || value === null || value === "")
134
+ return undefined;
135
+ if (value === "pull_request" || value === "push" || value === "workflow_dispatch")
136
+ return value;
137
+ throw new Error('--event-name must be "pull_request", "push", or "workflow_dispatch"');
138
+ }
139
+ function parseCiActTimeoutString(value) {
140
+ const trimmed = value.trim();
141
+ if (!/^[1-9]\d*$/u.test(trimmed)) {
142
+ throw new Error("--timeout-ms must be a positive integer");
143
+ }
144
+ return Number(trimmed);
145
+ }
146
+ function parseCiActMatrixFlags(value) {
147
+ if (value === undefined)
148
+ return [];
149
+ if (typeof value === "string") {
150
+ if (value.trim().length === 0) {
151
+ throw new Error("--matrix must be provided as one or more key:value selectors");
152
+ }
153
+ return [value];
154
+ }
155
+ if (Array.isArray(value) && value.every((entry) => typeof entry === "string")) {
156
+ if (value.some((entry) => entry.trim().length === 0)) {
157
+ throw new Error("--matrix must be provided as one or more key:value selectors");
158
+ }
159
+ return [...value];
160
+ }
161
+ if (value === true) {
162
+ throw new Error("--matrix must be provided as one or more key:value selectors");
163
+ }
164
+ throw new Error("--matrix must be provided as one or more key:value selectors");
165
+ }
@@ -27,6 +27,7 @@ const MENU_BINDINGS = [
27
27
  { keys: "x", label: "actions", footerPriority: 0 },
28
28
  { keys: "⇥/^s", label: "sessions" },
29
29
  { keys: "b", label: "blueprints" },
30
+ { keys: "c", label: "chat" },
30
31
  { keys: "d/^o", label: "mode" },
31
32
  { keys: "^p", label: "palette", footerPriority: 1 },
32
33
  { keys: "?/^x", label: "help" },
@@ -1,7 +1,9 @@
1
1
  import { type DashPermissionMode, type DashProvider } from "#cli/commands/dash/provider-capabilities.js";
2
+ import { type ControlPlaneChatMode } from "#cli/commands/dash/tui/chat-mode.js";
2
3
  export type LaunchPreferences = {
3
4
  readonly version: number;
4
5
  readonly yolo: boolean;
6
+ readonly chatMode?: ControlPlaneChatMode;
5
7
  };
6
8
  export type YoloPreferenceState = {
7
9
  readonly enabled: boolean;
@@ -9,9 +11,22 @@ export type YoloPreferenceState = {
9
11
  /** The preference file exists but could not be parsed (fails safe to off). */
10
12
  readonly corrupt: boolean;
11
13
  };
14
+ export type ChatModePreferenceState = {
15
+ readonly mode: ControlPlaneChatMode;
16
+ readonly persisted: boolean;
17
+ /** The preference file exists but could not be parsed (fails safe to host-pane). */
18
+ readonly corrupt: boolean;
19
+ };
12
20
  export declare function readYoloPreferenceState(): YoloPreferenceState;
13
21
  export declare function readYoloPreference(): boolean;
14
22
  export declare function writeYoloPreference(enabled: boolean): void;
23
+ /**
24
+ * Read persisted control-plane chat mode. Missing, corrupt, or invalid values
25
+ * fail safe to host-pane (never open integrated-chat chrome from bad prefs).
26
+ */
27
+ export declare function readChatModePreferenceState(): ChatModePreferenceState;
28
+ export declare function readChatModePreference(): ControlPlaneChatMode;
29
+ export declare function writeChatModePreference(mode: ControlPlaneChatMode): void;
15
30
  /**
16
31
  * Precedence: explicit `--wp-permission-mode` > provider-native permission flags
17
32
  * already in args > persisted yolo (true → 'dangerous') > 'safe'. When `provider`
@@ -1,12 +1,12 @@
1
- // User-scope persistence for the personal "yolo" launch preference. This is a
2
- // per-user risk choice (like ~/.codex/config.toml), deliberately NOT the tracked
3
- // .webpressorc.json a repo file would let one contributor flip teammates into
4
- // bypass via git pull. The file lives under the env-paths state root and any
5
- // corrupt/missing read fails safe (yolo off).
1
+ // User-scope persistence for personal dash launch preferences (yolo + chat mode).
2
+ // Deliberately NOT the tracked .webpressorc.json a repo file would let one
3
+ // contributor flip teammates into bypass via git pull. The file lives under the
4
+ // env-paths state root; corrupt/missing reads fail safe (yolo off, host-pane).
6
5
  import { randomUUID } from "node:crypto";
7
6
  import { existsSync, mkdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
8
7
  import { dirname, join } from "node:path";
9
8
  import { hasProviderNativePermissionFlag, parseDashPermissionMode, } from "#cli/commands/dash/provider-capabilities.js";
9
+ import { DEFAULT_CONTROL_PLANE_CHAT_MODE, isControlPlaneChatMode, } from "#cli/commands/dash/tui/chat-mode.js";
10
10
  import { getStateRoot } from "#paths/state-root.js";
11
11
  const LAUNCH_PREFERENCES_FILENAME = "launch-preferences.json";
12
12
  const LAUNCH_PREFERENCES_VERSION = 1;
@@ -31,6 +31,23 @@ function readRawPreferences() {
31
31
  return null;
32
32
  }
33
33
  }
34
+ /** Atomic read-merge-write so unknown fields survive partial updates. */
35
+ function writePreferencesPatch(patch) {
36
+ const path = launchPreferencesPath();
37
+ mkdirSync(dirname(path), { recursive: true });
38
+ // A corrupt existing file is discarded (readRawPreferences → null).
39
+ const existing = readRawPreferences() ?? {};
40
+ const payload = { ...existing, version: LAUNCH_PREFERENCES_VERSION, ...patch };
41
+ const tmpPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
42
+ try {
43
+ writeFileSync(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
44
+ renameSync(tmpPath, path);
45
+ }
46
+ catch (error) {
47
+ rmSync(tmpPath, { force: true });
48
+ throw error;
49
+ }
50
+ }
34
51
  export function readYoloPreferenceState() {
35
52
  const path = launchPreferencesPath();
36
53
  if (!existsSync(path))
@@ -44,21 +61,33 @@ export function readYoloPreference() {
44
61
  return readYoloPreferenceState().enabled;
45
62
  }
46
63
  export function writeYoloPreference(enabled) {
64
+ writePreferencesPatch({ yolo: enabled });
65
+ }
66
+ /**
67
+ * Read persisted control-plane chat mode. Missing, corrupt, or invalid values
68
+ * fail safe to host-pane (never open integrated-chat chrome from bad prefs).
69
+ */
70
+ export function readChatModePreferenceState() {
47
71
  const path = launchPreferencesPath();
48
- mkdirSync(dirname(path), { recursive: true });
49
- // Read-merge-write so unknown fields written by a newer wp survive an older
50
- // wp's toggle. A corrupt existing file is discarded (readRawPreferences → null).
51
- const existing = readRawPreferences() ?? {};
52
- const payload = { ...existing, version: LAUNCH_PREFERENCES_VERSION, yolo: enabled };
53
- const tmpPath = `${path}.${process.pid}.${Date.now()}.${randomUUID()}.tmp`;
54
- try {
55
- writeFileSync(tmpPath, `${JSON.stringify(payload, null, 2)}\n`, "utf8");
56
- renameSync(tmpPath, path);
72
+ if (!existsSync(path)) {
73
+ return { mode: DEFAULT_CONTROL_PLANE_CHAT_MODE, persisted: false, corrupt: false };
57
74
  }
58
- catch (error) {
59
- rmSync(tmpPath, { force: true });
60
- throw error;
75
+ const raw = readRawPreferences();
76
+ if (raw === null) {
77
+ return { mode: DEFAULT_CONTROL_PLANE_CHAT_MODE, persisted: false, corrupt: true };
61
78
  }
79
+ const value = raw.chatMode;
80
+ if (typeof value === "string" && isControlPlaneChatMode(value)) {
81
+ return { mode: value, persisted: true, corrupt: false };
82
+ }
83
+ // File present but chatMode absent or invalid — not corrupt JSON.
84
+ return { mode: DEFAULT_CONTROL_PLANE_CHAT_MODE, persisted: true, corrupt: false };
85
+ }
86
+ export function readChatModePreference() {
87
+ return readChatModePreferenceState().mode;
88
+ }
89
+ export function writeChatModePreference(mode) {
90
+ writePreferencesPatch({ chatMode: mode });
62
91
  }
63
92
  /**
64
93
  * Precedence: explicit `--wp-permission-mode` > provider-native permission flags
@@ -0,0 +1,7 @@
1
+ /**
2
+ * Shared plan-once run label contract.
3
+ * Exact equality is SSOT for membership ↔ registry join; keep one helper.
4
+ */
5
+ import type { PlanOnceHostEntry } from "#cli/commands/dash/plan-once-schema.js";
6
+ /** Durable run label: `plan-once:<planId>:<label|host>`. */
7
+ export declare function planOnceRunLabel(planId: string, host: PlanOnceHostEntry): string;
@@ -0,0 +1,5 @@
1
+ /** Durable run label: `plan-once:<planId>:<label|host>`. */
2
+ export function planOnceRunLabel(planId, host) {
3
+ const base = host.label?.trim() || host.host;
4
+ return `plan-once:${planId}:${base}`;
5
+ }
@@ -0,0 +1,67 @@
1
+ import { z } from "zod";
2
+ import { type MultiHostPlanProposal } from "#cli/commands/dash/plan-once-schema.js";
3
+ export declare const PLAN_MEMBERSHIP_SCHEMA_VERSION: 1;
4
+ export declare const planMembershipHostSchema: z.ZodObject<{
5
+ host: z.ZodEnum<{
6
+ claude: "claude";
7
+ codex: "codex";
8
+ grok: "grok";
9
+ opencode: "opencode";
10
+ }>;
11
+ workdir: z.ZodString;
12
+ label: z.ZodString;
13
+ }, z.core.$strict>;
14
+ export type PlanMembershipHost = z.infer<typeof planMembershipHostSchema>;
15
+ export declare const planMembershipSchema: z.ZodObject<{
16
+ schemaVersion: z.ZodLiteral<1>;
17
+ planId: z.ZodString;
18
+ hosts: z.ZodArray<z.ZodObject<{
19
+ host: z.ZodEnum<{
20
+ claude: "claude";
21
+ codex: "codex";
22
+ grok: "grok";
23
+ opencode: "opencode";
24
+ }>;
25
+ workdir: z.ZodString;
26
+ label: z.ZodString;
27
+ }, z.core.$strict>>;
28
+ confirmedAt: z.ZodString;
29
+ }, z.core.$strict>;
30
+ export type PlanMembership = z.infer<typeof planMembershipSchema>;
31
+ export declare const planMembershipStoreSchema: z.ZodObject<{
32
+ version: z.ZodLiteral<1>;
33
+ plans: z.ZodRecord<z.ZodString, z.ZodObject<{
34
+ schemaVersion: z.ZodLiteral<1>;
35
+ planId: z.ZodString;
36
+ hosts: z.ZodArray<z.ZodObject<{
37
+ host: z.ZodEnum<{
38
+ claude: "claude";
39
+ codex: "codex";
40
+ grok: "grok";
41
+ opencode: "opencode";
42
+ }>;
43
+ workdir: z.ZodString;
44
+ label: z.ZodString;
45
+ }, z.core.$strict>>;
46
+ confirmedAt: z.ZodString;
47
+ }, z.core.$strict>>;
48
+ }, z.core.$strict>;
49
+ export type PlanMembershipStore = z.infer<typeof planMembershipStoreSchema>;
50
+ /** Pure: build immutable membership from a confirmed multi-host plan. */
51
+ export declare function buildPlanMembership(plan: MultiHostPlanProposal, confirmedAt?: string): PlanMembership;
52
+ /** Persist membership for a planId (idempotent overwrite for same planId). */
53
+ export declare function writePlanMembership(membership: PlanMembership, options?: {
54
+ readonly cwd?: string;
55
+ }): Promise<PlanMembership>;
56
+ /** Read membership for planId, or null if absent. */
57
+ export declare function readPlanMembership(planId: string, options?: {
58
+ readonly cwd?: string;
59
+ }): Promise<PlanMembership | null>;
60
+ /** List all durable plan memberships (operator / merge projection scan). */
61
+ export declare function listPlanMemberships(options?: {
62
+ readonly cwd?: string;
63
+ }): Promise<readonly PlanMembership[]>;
64
+ /** Test helper: clear membership store file content via rewrite empty. */
65
+ export declare function clearPlanMembershipStoreForTests(options: {
66
+ readonly cwd?: string;
67
+ }): Promise<void>;
@@ -0,0 +1,151 @@
1
+ /**
2
+ * Durable plan-once membership (expected host set for a planId).
3
+ *
4
+ * Labels alone are not N-of-N SSOT: a host that never creates a run disappears
5
+ * from registry-only views. Membership is written on confirm (not cancel) and
6
+ * is a plan-record sidecar — not a second run-registry.
7
+ */
8
+ import { copyFileSync, existsSync, mkdirSync, readFileSync } from "node:fs";
9
+ import { randomUUID } from "node:crypto";
10
+ import { dirname } from "node:path";
11
+ import { z } from "zod";
12
+ import { planOnceRunLabel } from "#cli/commands/dash/plan-once-label.js";
13
+ import { planOnceHostSchema, } from "#cli/commands/dash/plan-once-schema.js";
14
+ import { getSurfacePathAsync, withLock } from "#paths/state-root.js";
15
+ import { writeJsonFileAtomic } from "#shared-utils/write-json-file.js";
16
+ export const PLAN_MEMBERSHIP_SCHEMA_VERSION = 1;
17
+ const MEMBERSHIP_SURFACE = "dash/plan-once-memberships.json";
18
+ export const planMembershipHostSchema = z
19
+ .object({
20
+ host: planOnceHostSchema,
21
+ workdir: z.string().min(1),
22
+ /** Expected run label: plan-once:<planId>:<label|host> */
23
+ label: z.string().min(1),
24
+ })
25
+ .strict();
26
+ export const planMembershipSchema = z
27
+ .object({
28
+ schemaVersion: z.literal(PLAN_MEMBERSHIP_SCHEMA_VERSION),
29
+ planId: z.string().uuid(),
30
+ hosts: z.array(planMembershipHostSchema).min(1),
31
+ confirmedAt: z.string().datetime(),
32
+ })
33
+ .strict();
34
+ export const planMembershipStoreSchema = z
35
+ .object({
36
+ version: z.literal(1),
37
+ plans: z.record(z.string(), planMembershipSchema),
38
+ })
39
+ .strict();
40
+ function emptyStore() {
41
+ return { version: 1, plans: {} };
42
+ }
43
+ /** Pure: build immutable membership from a confirmed multi-host plan. */
44
+ export function buildPlanMembership(plan, confirmedAt = new Date().toISOString()) {
45
+ const hosts = plan.hosts.map((entry) => ({
46
+ host: entry.host,
47
+ workdir: entry.workdir,
48
+ label: planOnceRunLabel(plan.planId, entry),
49
+ }));
50
+ return planMembershipSchema.parse({
51
+ schemaVersion: PLAN_MEMBERSHIP_SCHEMA_VERSION,
52
+ planId: plan.planId,
53
+ hosts,
54
+ confirmedAt,
55
+ });
56
+ }
57
+ async function membershipPath(cwd) {
58
+ return getSurfacePathAsync(MEMBERSHIP_SURFACE, "repo", cwd);
59
+ }
60
+ function corruptCopyPath(path) {
61
+ return `${path}.corrupt-${new Date().toISOString().replaceAll(":", "-")}-${randomUUID()}`;
62
+ }
63
+ function preserveStore(path) {
64
+ if (!existsSync(path))
65
+ return undefined;
66
+ const preservedPath = corruptCopyPath(path);
67
+ try {
68
+ copyFileSync(path, preservedPath);
69
+ return preservedPath;
70
+ }
71
+ catch {
72
+ return undefined;
73
+ }
74
+ }
75
+ /**
76
+ * Read membership store. Missing file → empty (ok).
77
+ * Corrupt/unreadable file → ok:false with quarantine path; never treat as empty
78
+ * for write merge (would wipe sibling plan memberships).
79
+ */
80
+ function readStoreFromPath(path) {
81
+ if (!existsSync(path))
82
+ return { ok: true, store: emptyStore() };
83
+ try {
84
+ const raw = JSON.parse(readFileSync(path, "utf8"));
85
+ const parsed = planMembershipStoreSchema.safeParse(raw);
86
+ if (parsed.success)
87
+ return { ok: true, store: parsed.data };
88
+ const preservedPath = preserveStore(path);
89
+ return {
90
+ ok: false,
91
+ reason: "plan membership store failed schema validation",
92
+ ...(preservedPath ? { preservedPath } : {}),
93
+ };
94
+ }
95
+ catch (error) {
96
+ const preservedPath = preserveStore(path);
97
+ return {
98
+ ok: false,
99
+ reason: error instanceof Error ? error.message : "plan membership store unreadable",
100
+ ...(preservedPath ? { preservedPath } : {}),
101
+ };
102
+ }
103
+ }
104
+ function storeForRead(path) {
105
+ const read = readStoreFromPath(path);
106
+ // Projection reads fail closed to empty (no memberships) without wiping disk.
107
+ return read.ok ? read.store : emptyStore();
108
+ }
109
+ /** Persist membership for a planId (idempotent overwrite for same planId). */
110
+ export async function writePlanMembership(membership, options = {}) {
111
+ const validated = planMembershipSchema.parse(membership);
112
+ const path = await membershipPath(options.cwd);
113
+ return withLock("repo", () => {
114
+ const read = readStoreFromPath(path);
115
+ if (!read.ok) {
116
+ const preserved = read.preservedPath ? ` (preserved ${read.preservedPath})` : "";
117
+ throw new Error(`Refusing to write plan membership: store corrupt — ${read.reason}${preserved}`);
118
+ }
119
+ const next = {
120
+ version: 1,
121
+ plans: { ...read.store.plans, [validated.planId]: validated },
122
+ };
123
+ mkdirSync(dirname(path), { recursive: true });
124
+ writeJsonFileAtomic(path, planMembershipStoreSchema.parse(next));
125
+ return validated;
126
+ }, options.cwd);
127
+ }
128
+ /** Read membership for planId, or null if absent. */
129
+ export async function readPlanMembership(planId, options = {}) {
130
+ const path = await membershipPath(options.cwd);
131
+ return withLock("repo", () => {
132
+ const store = storeForRead(path);
133
+ return store.plans[planId] ?? null;
134
+ }, options.cwd);
135
+ }
136
+ /** List all durable plan memberships (operator / merge projection scan). */
137
+ export async function listPlanMemberships(options = {}) {
138
+ const path = await membershipPath(options.cwd);
139
+ return withLock("repo", () => {
140
+ const store = storeForRead(path);
141
+ return Object.values(store.plans);
142
+ }, options.cwd);
143
+ }
144
+ /** Test helper: clear membership store file content via rewrite empty. */
145
+ export async function clearPlanMembershipStoreForTests(options) {
146
+ const path = await membershipPath(options.cwd);
147
+ await withLock("repo", () => {
148
+ mkdirSync(dirname(path), { recursive: true });
149
+ writeJsonFileAtomic(path, emptyStore());
150
+ }, options.cwd);
151
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Merge-set projection: membership (expected hosts) + registry runs → rollup.
3
+ * Pure given membership + run observations — no second run store.
4
+ *
5
+ * Operator surface: summary + per-host matrix only. Git/PR auto-merge is out of
6
+ * scope (no silent git merge of host work).
7
+ */
8
+ import { z } from "zod";
9
+ import { type FanOutAttentionProjection } from "#cli/commands/dash/plan-once-attention.js";
10
+ import { type PlanMembership } from "#cli/commands/dash/plan-once-membership.js";
11
+ export declare const MERGE_ROLLUPS: readonly ["all-ok", "partial", "all-failed", "incomplete"];
12
+ export type MergeRollup = (typeof MERGE_ROLLUPS)[number];
13
+ /** Rollups that should occupy the attention strip (all-ok does not). */
14
+ export declare const MERGE_ATTENTION_ROLLUPS: readonly ["incomplete", "partial", "all-failed"];
15
+ export declare const mergeRollupSchema: z.ZodEnum<{
16
+ "all-failed": "all-failed";
17
+ "all-ok": "all-ok";
18
+ incomplete: "incomplete";
19
+ partial: "partial";
20
+ }>;
21
+ export declare const mergeHostStatusSchema: z.ZodEnum<{
22
+ failed: "failed";
23
+ missing: "missing";
24
+ ok: "ok";
25
+ unknown: "unknown";
26
+ }>;
27
+ export declare const mergeHostObservationSchema: z.ZodObject<{
28
+ host: z.ZodString;
29
+ workdir: z.ZodString;
30
+ expectedLabel: z.ZodString;
31
+ runId: z.ZodNullable<z.ZodString>;
32
+ state: z.ZodNullable<z.ZodString>;
33
+ error: z.ZodNullable<z.ZodString>;
34
+ status: z.ZodEnum<{
35
+ failed: "failed";
36
+ missing: "missing";
37
+ ok: "ok";
38
+ unknown: "unknown";
39
+ }>;
40
+ }, z.core.$strict>;
41
+ export type MergeHostObservation = z.infer<typeof mergeHostObservationSchema>;
42
+ export type MergeSetProjection = {
43
+ readonly planId: string;
44
+ readonly expectedCount: number;
45
+ readonly observedCount: number;
46
+ readonly missingCount: number;
47
+ readonly failedCount: number;
48
+ readonly okCount: number;
49
+ readonly rollup: MergeRollup;
50
+ readonly hosts: readonly MergeHostObservation[];
51
+ readonly attention: FanOutAttentionProjection;
52
+ };
53
+ /** Serializable subset of merge projection (attention omitted). */
54
+ export declare const mergeSetCoreSchema: z.ZodObject<{
55
+ planId: z.ZodString;
56
+ expectedCount: z.ZodNumber;
57
+ observedCount: z.ZodNumber;
58
+ missingCount: z.ZodNumber;
59
+ failedCount: z.ZodNumber;
60
+ okCount: z.ZodNumber;
61
+ rollup: z.ZodEnum<{
62
+ "all-failed": "all-failed";
63
+ "all-ok": "all-ok";
64
+ incomplete: "incomplete";
65
+ partial: "partial";
66
+ }>;
67
+ hosts: z.ZodArray<z.ZodObject<{
68
+ host: z.ZodString;
69
+ workdir: z.ZodString;
70
+ expectedLabel: z.ZodString;
71
+ runId: z.ZodNullable<z.ZodString>;
72
+ state: z.ZodNullable<z.ZodString>;
73
+ error: z.ZodNullable<z.ZodString>;
74
+ status: z.ZodEnum<{
75
+ failed: "failed";
76
+ missing: "missing";
77
+ ok: "ok";
78
+ unknown: "unknown";
79
+ }>;
80
+ }, z.core.$strict>>;
81
+ }, z.core.$strict>;
82
+ export type MergeRunRow = {
83
+ readonly id: string;
84
+ readonly provider: string;
85
+ readonly state: string;
86
+ readonly error?: string | null;
87
+ readonly label?: string | null;
88
+ /** ISO timestamp when present — used to prefer newest re-apply label match. */
89
+ readonly updatedAt?: string | null;
90
+ };
91
+ /**
92
+ * Project merge set from durable membership + registry-visible runs.
93
+ * Missing expected host → incomplete (never all-ok).
94
+ */
95
+ export declare function projectMergeSet(input: {
96
+ readonly membership: PlanMembership;
97
+ readonly runs: readonly MergeRunRow[];
98
+ readonly now?: string;
99
+ }): MergeSetProjection;
100
+ export type ProjectMergeSetForPlanResult = {
101
+ readonly ok: true;
102
+ readonly projection: MergeSetProjection;
103
+ } | {
104
+ readonly ok: false;
105
+ readonly error: "membership-missing";
106
+ readonly planId: string;
107
+ };
108
+ /**
109
+ * planId → load durable membership + readDashRuns → merge projection.
110
+ * Uses run-registry only (no second run store). Missing membership fails closed.
111
+ * Includes archived runs so archived hosts do not look missing after cleanup.
112
+ */
113
+ export declare function projectMergeSetForPlan(planId: string, options?: {
114
+ readonly cwd?: string;
115
+ readonly now?: string;
116
+ }): Promise<ProjectMergeSetForPlanResult>;
117
+ /** Project merge sets for every durable membership under the state root. */
118
+ export declare function projectAllMergeSets(options?: {
119
+ readonly cwd?: string;
120
+ readonly now?: string;
121
+ }): Promise<readonly MergeSetProjection[]>;
122
+ /** One-line operator rollup for HUD/attention strip. */
123
+ export declare function formatMergeSetSummary(projection: MergeSetProjection): string;
124
+ /** Per-host matrix lines for operator surface. */
125
+ export declare function formatMergeSetMatrix(projection: MergeSetProjection): readonly string[];
126
+ export type MergeOperatorSurface = {
127
+ readonly summary: string;
128
+ readonly matrix: readonly string[];
129
+ /**
130
+ * Explicit scope boundary: this surface is status/summary only.
131
+ * Silent git/PR auto-merge of host worktrees remains out of scope.
132
+ */
133
+ readonly scopeNote: string;
134
+ };
135
+ /** Operator-visible merge surface (HUD/browser). No silent git merge. */
136
+ export declare function formatMergeOperatorSurface(projection: MergeSetProjection): MergeOperatorSurface;
137
+ /** Pick the highest-severity merge projection for a single attention line. */
138
+ export declare function pickPrimaryMergeProjection(projections: readonly MergeSetProjection[]): MergeSetProjection | null;
139
+ /**
140
+ * Attention-strip line for the worst non-ok merge set.
141
+ * all-ok is omitted so generic per-run attention remains visible.
142
+ */
143
+ export declare function formatPrimaryMergeAttentionLine(projections: readonly MergeSetProjection[]): string | null;