jeo-code 0.11.2 → 0.11.3

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.
@@ -1,109 +1,241 @@
1
- /**
2
- * `jeo notify setup|status` — configure and inspect the remote subagent
3
- * notification/control channel (gjc `gjc notify setup`/`gjc notify status`
4
- * parity, scoped to jeo's Telegram + subagent surface; see `src/agent/notify/`).
5
- */
1
+ /** Configure and inspect Telegram, Discord, and Slack notification channels. */
6
2
  import { createInterface } from "node:readline/promises";
7
3
  import { readGlobalConfig, saveConfigPatch } from "../agent/state";
8
- import { TelegramApi, maskToken } from "../agent/notify/telegram-api";
9
- import { daemonStatus } from "../agent/notify/daemon-control";
4
+ import { TelegramApi } from "../agent/notify/telegram-api";
5
+ import { DiscordApi, DiscordApiError } from "../agent/notify/discord-api";
6
+ import { SlackApi, SlackApiError } from "../agent/notify/slack-api";
7
+ import { acquireDaemonLock, daemonStatus } from "../agent/notify/daemon-control";
10
8
 
11
- function parseFlags(args: string[]): { token?: string; chatId?: string } {
12
- const out: { token?: string; chatId?: string } = {};
9
+ type Provider = "telegram" | "discord" | "slack";
10
+ interface NotifyOptions {
11
+ fetchImpl?: typeof fetch;
12
+ prompt?: (question: string) => Promise<string>;
13
+ pairingChallenge?: () => string;
14
+ now?: () => number;
15
+ }
16
+ interface Flags {
17
+ provider: Provider;
18
+ token?: string;
19
+ tokenEnv?: string;
20
+ appTokenEnv?: string;
21
+ chatId?: string;
22
+ channelId?: string;
23
+ allowedUserIds?: string[];
24
+ }
25
+
26
+ function parseFlags(args: string[]): Flags {
27
+ const out: Flags = { provider: "telegram" };
13
28
  for (let i = 0; i < args.length; i++) {
14
- if (args[i] === "--token" && args[i + 1]) out.token = args[++i];
15
- else if (args[i] === "--chat-id" && args[i + 1]) out.chatId = args[++i];
29
+ const flag = args[i];
30
+ const value = args[++i];
31
+ if (!value || value.startsWith("--")) throw new Error("Missing value for notify option (use --token-env NAME for credentials)");
32
+ if (flag === "--provider" || flag === "--platform") {
33
+ if (value !== "telegram" && value !== "discord" && value !== "slack") throw new Error("Provider must be telegram, discord or slack");
34
+ out.provider = value;
35
+ } else if (flag === "--token") out.token = value;
36
+ else if (flag === "--token-env") out.tokenEnv = value;
37
+ else if (flag === "--app-token-env") out.appTokenEnv = value;
38
+ else if (flag === "--chat-id") out.chatId = value;
39
+ else if (flag === "--channel-id") out.channelId = value;
40
+ else if (flag === "--allowed-user-ids") {
41
+ out.allowedUserIds = [...new Set(value.split(",").map(id => id.trim()))];
42
+ } else throw new Error("Unknown notify option; use --provider, --token-env, --app-token-env, --chat-id, --channel-id or --allowed-user-ids");
43
+ }
44
+ if (out.chatId && !/^-?[1-9]\d*$/.test(out.chatId)) throw new Error("Telegram chat ID must be a nonzero decimal ID");
45
+ if (out.provider === "slack") {
46
+ if (out.channelId && !/^[CGD][A-Z0-9]+$/.test(out.channelId)) throw new Error("Slack channel ID must start with C, G or D and contain uppercase letters and digits");
47
+ if (out.allowedUserIds && !out.allowedUserIds.every(id => /^[UW][A-Z0-9]+$/.test(id))) throw new Error("Slack allowed user IDs must start with U or W and contain uppercase letters and digits");
48
+ } else {
49
+ if (out.channelId && !/^[1-9]\d{0,19}$/.test(out.channelId)) throw new Error("Discord channel ID must be a positive decimal ID of at most 20 digits");
50
+ if (out.allowedUserIds && !out.allowedUserIds.every(id => /^[1-9]\d{0,19}$/.test(id))) throw new Error("Allowed user IDs must be positive decimal IDs (up to 20 digits) separated by commas");
16
51
  }
17
52
  return out;
18
53
  }
19
54
 
20
- async function runSetup(args: string[]): Promise<void> {
21
- const flags = parseFlags(args);
22
- let token = flags.token;
23
- let chatId = flags.chatId;
55
+ async function question(text: string, opts: NotifyOptions): Promise<string> {
56
+ if (opts.prompt) return (await opts.prompt(text)).trim();
57
+ if (!process.stdin.isTTY) throw new Error("Missing setup arguments; use --token-env and explicit destination/user IDs without a TTY");
58
+ const rl = createInterface({ input: process.stdin, output: process.stdout });
59
+ try { return (await rl.question(text)).trim(); } finally { rl.close(); }
60
+ }
24
61
 
25
- if (!token || !chatId) {
26
- if (!process.stdin.isTTY) {
27
- console.log(
28
- "jeo notify setup needs an interactive terminal (TTY) unless both --token and --chat-id are given.\n" +
29
- "Non-interactive: jeo notify setup --token <botToken> --chat-id <chatId>",
30
- );
31
- return;
62
+ // Telegram puts credentials in request URLs. Never print underlying fetch errors.
63
+ function boundedFetch(opts: NotifyOptions): typeof fetch {
64
+ return ((input, init) => (opts.fetchImpl ?? fetch)(input, { ...init, signal: init?.signal ?? AbortSignal.timeout(15_000) })) as typeof fetch;
65
+ }
66
+
67
+ async function verifySlack(api: SlackApi, channelId: string): Promise<void> {
68
+ const me = await api.getMe();
69
+ const channel = await api.getChannel(channelId);
70
+ if (channel.id !== channelId || (channel.context_team_id && channel.context_team_id !== me.team_id)) throw new Error("Slack bot and destination workspace validation failed");
71
+ await api.openConnection();
72
+ }
73
+
74
+ async function runSetup(flags: Flags, opts: NotifyOptions): Promise<void> {
75
+ const envName = flags.tokenEnv ?? `JEO_${flags.provider.toUpperCase()}_BOT_TOKEN`;
76
+ let token = flags.token ?? process.env[envName]?.trim();
77
+ if (!token) {
78
+ const name = await question(`Set the bot token in an environment variable, then enter its NAME (default ${envName}): `, opts);
79
+ token = process.env[name || envName]?.trim();
80
+ }
81
+ if (!token) throw new Error(`Bot token environment variable is empty; use --token-env NAME (preferred over --token)`);
82
+ const existing = (await readGlobalConfig()).notifications;
83
+ let allowedUserIds = flags.allowedUserIds;
84
+ if (flags.provider === "slack") {
85
+ const appEnvName = flags.appTokenEnv ?? "SLACK_APP_TOKEN";
86
+ let appToken = process.env[appEnvName]?.trim();
87
+ if (!appToken) {
88
+ const name = await question(`Set the Slack app token in an environment variable, then enter its NAME (default ${appEnvName}): `, opts);
89
+ appToken = process.env[name || appEnvName]?.trim();
32
90
  }
33
- const rl = createInterface({ input: process.stdin, output: process.stdout });
91
+ if (!appToken) throw new Error("Slack app token environment variable is empty; use --app-token-env NAME");
92
+ const channelId = flags.channelId ?? existing?.slack?.channelId ?? await question("Slack channel ID: ", opts);
93
+ if (!/^[CGD][A-Z0-9]+$/.test(channelId)) throw new Error("Slack requires a valid channel ID");
94
+ const sameDestination = existing?.slack?.botToken === token && existing.slack.appToken === appToken && existing.slack.channelId === channelId;
95
+ if (!allowedUserIds?.length && (!sameDestination || !existing?.slack?.allowedUserIds.length)) allowedUserIds = (await question("Allowed Slack human user IDs (comma separated; required after token/destination changes): ", opts)).split(",").map(id => id.trim());
96
+ const api = new SlackApi(token, appToken, { fetchImpl: boundedFetch(opts) });
34
97
  try {
35
- console.log("\n=== jeo notify setup (Telegram) ===");
36
- console.log("Create a bot with @BotFather (https://core.telegram.org/bots/features#botfather), then paste its token below.\n");
37
- if (!token) token = (await rl.question("Telegram BotFather token: ")).trim();
38
- if (!token) {
39
- console.log("No token given — aborting.");
40
- return;
41
- }
42
- const api = new TelegramApi(token);
43
- const me = await api.getMe();
44
- if (!me.ok || !me.result) {
45
- console.log(`Telegram getMe failed: ${me.description ?? "invalid token"}. Re-copy the token from BotFather and retry.`);
46
- return;
47
- }
48
- console.log(`Bot verified: @${me.result.username}`);
49
-
50
- if (!chatId) {
51
- console.log(`\nMessage @${me.result.username} from your Telegram account now (any text). Waiting...`);
98
+ await verifySlack(api, channelId);
99
+ await saveConfigPatch(raw => {
100
+ const previous = raw.notifications?.slack;
101
+ const unchanged = previous?.botToken === token && previous.appToken === appToken && previous.channelId === channelId;
102
+ const owners = allowedUserIds ?? (unchanged ? previous.allowedUserIds : undefined);
103
+ if (!owners?.length || !owners.every(id => /^[UW][A-Z0-9]+$/.test(id))) throw new Error("Slack requires explicit allowed human user IDs; repeat --allowed-user-ids after token or destination changes");
104
+ return { notifications: { ...raw.notifications, enabled: true, slack: { botToken: token, appToken, channelId, allowedUserIds: owners } } };
105
+ });
106
+ } finally { api.stop(); }
107
+ console.log(`Slack bot and channel verified; channelId=${channelId}. Notifications enabled. Setup did not send a message.`);
108
+ console.log("Socket Mode URL verified; app-token ownership and socket connectivity are checked by the daemon handshake. Token rotation requires an explicit user allowlist.");
109
+ } else if (flags.provider === "discord") {
110
+ const channelId = flags.channelId ?? existing?.discord?.channelId ?? await question("Discord channel ID: ", opts);
111
+ const sameDestination = existing?.discord?.botToken === token && existing.discord.channelId === channelId;
112
+ if (!allowedUserIds?.length && (!sameDestination || !existing?.discord?.allowedUserIds?.length)) allowedUserIds = (await question("Allowed Discord user IDs (comma separated; required after token/destination changes): ", opts)).split(",").map(id => id.trim());
113
+ if (!/^[1-9]\d{0,19}$/.test(channelId)) throw new Error("Discord requires a positive channel ID (up to 20 digits)");
114
+ const api = new DiscordApi(token, { fetchImpl: boundedFetch(opts) });
115
+ const me = await api.getMe();
116
+ if (!me.bot) throw new Error("Discord identity is not a bot");
117
+ const channel = await api.getChannel(channelId);
118
+ if (channel.id !== channelId || ![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Discord destination must be a text channel, thread, or bot DM");
119
+ await saveConfigPatch(raw => {
120
+ const previous = raw.notifications?.discord;
121
+ const unchanged = previous?.botToken === token && previous.channelId === channelId;
122
+ const owners = allowedUserIds ?? (unchanged ? previous.allowedUserIds : undefined);
123
+ if (!owners?.length || !owners.every(id => /^[1-9]\d{0,19}$/.test(id))) throw new Error("Discord requires explicit allowed user IDs; repeat --allowed-user-ids after token or destination changes");
124
+ return { notifications: { ...raw.notifications, enabled: true, discord: { ...(unchanged ? previous : {}), botToken: token, channelId, allowedUserIds: owners } } };
125
+ });
126
+ console.log(`Discord bot verified; channelId=${channelId}. Notifications enabled.`);
127
+ console.log("Guild channels require MESSAGE CONTENT intent and View Channel/Send Messages permissions. Setup did not send a message.");
128
+ console.log("Token rotation or destination changes require an explicit user allowlist.");
129
+ } else {
130
+ const api = new TelegramApi(token, boundedFetch(opts));
131
+ const me = await api.getMe();
132
+ if (!me.ok || !me.result?.is_bot) throw new Error("Telegram getMe failed: invalid bot credentials or API unavailable");
133
+ const botId = String(me.result.id);
134
+ let chatId = flags.chatId;
135
+ let pairedUserId: string | undefined;
136
+ if (!chatId) {
137
+ if (!opts.prompt && !process.stdin.isTTY) throw new Error("Telegram auto-pairing requires a TTY; use explicit --chat-id otherwise");
138
+ const lock = await acquireDaemonLock("pairing");
139
+ if (!lock) throw new Error("A daemon or pairing process owns getUpdates; stop the daemon before auto-pairing, or use explicit --chat-id");
140
+ try {
141
+ const challenge = `/start jeo_${opts.pairingChallenge?.() ?? crypto.randomUUID()}`;
142
+ console.log(`Send exactly '${challenge}' to @${me.result.username} in a private chat within 120 seconds.`);
52
143
  let offset: number | undefined;
53
- const deadline = Date.now() + 120_000;
54
- while (Date.now() < deadline && !chatId) {
144
+ const now = opts.now ?? Date.now;
145
+ const startedAt = now();
146
+ const deadline = startedAt + 120_000;
147
+ while (now() < deadline && !chatId) {
55
148
  const updates = await api.getUpdates(offset, 5);
56
- if (updates.ok) {
57
- for (const u of updates.result) {
58
- offset = u.update_id + 1;
59
- const chat = u.message?.chat;
60
- if (chat && chat.type === "private") {
61
- chatId = String(chat.id);
62
- break;
63
- }
149
+ if (!updates.ok || !Array.isArray(updates.result)) throw new Error("Telegram pairing getUpdates failed (check bot polling ownership or credentials)");
150
+ for (const update of updates.result) {
151
+ if (!Number.isSafeInteger(update.update_id) || (offset !== undefined && update.update_id < offset)) continue;
152
+ offset = update.update_id + 1;
153
+ const msg = update.message;
154
+ if (msg?.chat.type === "private" && msg.date >= Math.floor(startedAt / 1000) && msg.text === challenge && msg.from && !msg.from.is_bot && String(msg.from.id) === String(msg.chat.id)) {
155
+ chatId = String(msg.chat.id);
156
+ pairedUserId = String(msg.from.id);
157
+ break;
64
158
  }
65
159
  }
66
160
  }
67
- if (!chatId) {
68
- console.log("Timed out waiting for a private message. Re-run 'jeo notify setup' and message the bot promptly.");
69
- return;
70
- }
71
- console.log(`Paired private chat id ${chatId}.`);
72
- }
73
- } finally {
74
- rl.close();
75
- }
76
- } else {
77
- const api = new TelegramApi(token);
78
- const me = await api.getMe();
79
- if (!me.ok) {
80
- console.log(`Telegram getMe failed: ${me.description ?? "invalid token"}.`);
81
- return;
161
+ if (!chatId) throw new Error("Telegram pairing timed out; no matching private challenge received");
162
+ } finally { await lock.release(); }
82
163
  }
164
+ const chat = await api.getChat(chatId);
165
+ if (!chat.ok || !chat.result?.type) throw new Error("Telegram chat lookup failed; check chat ID and bot access");
166
+ await saveConfigPatch(raw => {
167
+ const previous = raw.notifications?.telegram;
168
+ const sameDestination = previous?.chatId === chatId && (
169
+ previous.botToken === token || previous.botToken?.match(/^([1-9]\d*):/)?.[1] === botId
170
+ );
171
+ const owners = allowedUserIds ?? (sameDestination ? previous.allowedUserIds : undefined) ?? (pairedUserId ? [pairedUserId] : undefined);
172
+ if (chat.result!.type !== "private" && !owners?.length) throw new Error("Telegram groups require explicit --allowed-user-ids");
173
+ return { notifications: { ...raw.notifications, enabled: true, telegram: { ...(sameDestination ? previous : {}), botToken: token, chatId, ...(owners ? { allowedUserIds: owners } : {}) } } };
174
+ });
175
+ console.log(`Telegram bot verified; chatId=${chatId}. Notifications enabled. Setup did not send a message.`);
83
176
  }
84
-
85
- await saveConfigPatch(raw => ({
86
- notifications: { ...raw.notifications, enabled: true, telegram: { botToken: token, chatId } },
87
- }));
88
- console.log(`\nNotifications enabled. botToken=${maskToken(token!)} chatId=${chatId}`);
89
- console.log("Start the daemon with 'jeo daemon start', then run a detached subagent (task {detached:true}) to see it appear in Telegram.");
177
+ console.log("Run 'jeo daemon start' (or reload if already running) to apply. Use 'jeo notify health --provider " + flags.provider + "' for read-only API checks; 'jeo notify test --provider " + flags.provider + "' explicitly sends a test message.");
90
178
  }
91
179
 
92
- async function runStatus(): Promise<void> {
180
+ async function runStatus(provider: Provider): Promise<void> {
93
181
  const config = await readGlobalConfig();
94
- const enabled = Boolean(config.notifications?.enabled);
95
- const botToken = config.notifications?.telegram?.botToken;
96
- const chatId = config.notifications?.telegram?.chatId;
182
+ const n = config.notifications;
183
+ const channel = n?.[provider];
97
184
  const status = await daemonStatus();
98
- console.log(`enabled=${enabled}`);
99
- console.log(`botToken=${botToken ? maskToken(botToken) : "(not set)"}`);
100
- console.log(`chatId=${chatId ?? "(not set)"}`);
101
- console.log(`daemon=${status.running ? `running (pid ${status.pid})` : status.stale ? "stale (crashed without cleanup)" : "stopped"}`);
185
+ console.log(`provider=${provider} enabled=${Boolean(n?.enabled)}`);
186
+ console.log(`botToken=${channel?.botToken ? "(set)" : "(not set)"}`);
187
+ if (provider === "slack") console.log(`appToken=${n?.slack?.appToken ? "(set)" : "(not set)"}`);
188
+ console.log(provider === "telegram" ? `chatId=${n?.telegram?.chatId ?? "(not set)"}` : `channelId=${n?.[provider]?.channelId ?? "(not set)"}`);
189
+ console.log(`allowedUserIds=${channel?.allowedUserIds?.join(",") ?? (provider === "telegram" ? "private chat owner only" : "(not set; inbound disabled)")}`);
190
+ console.log(`daemon=${status.pairing ? "pairing owns polling (daemon not running)" : status.running ? `process ${status.ready ? "initialized" : "initializing"} (pid ${status.pid}; transport connectivity not checked)` : status.stale ? "stale" : "stopped"}`);
191
+ console.log("Channel health not checked; use 'jeo notify health --provider " + provider + "'.");
102
192
  }
103
193
 
104
- export async function runNotifyCommand(args: string[]): Promise<void> {
194
+ async function runHealth(provider: Provider, send: boolean, opts: NotifyOptions): Promise<void> {
195
+ const n = (await readGlobalConfig()).notifications;
196
+ if (provider === "slack") {
197
+ const cfg = n?.slack;
198
+ if (!cfg?.botToken || !cfg.appToken || !cfg.channelId || !cfg.allowedUserIds?.length) throw new Error("Slack is incomplete: bot token, app token, channel ID and allowed user IDs are required");
199
+ const api = new SlackApi(cfg.botToken, cfg.appToken, { fetchImpl: boundedFetch(opts) });
200
+ try {
201
+ await verifySlack(api, cfg.channelId);
202
+ if (send) await api.sendMessage(cfg.channelId, "jeo notification test");
203
+ } finally { api.stop(); }
204
+ if (!send) console.log("Slack Socket Mode URL verified; app-token ownership, socket connectivity and send permissions are not tested until daemon handshake/test.");
205
+ } else if (provider === "discord") {
206
+ const cfg = n?.discord;
207
+ if (!cfg?.botToken || !cfg.channelId || !cfg.allowedUserIds?.length) throw new Error("Discord is incomplete: bot token, channel ID and allowed user IDs are required");
208
+ const api = new DiscordApi(cfg.botToken, { fetchImpl: boundedFetch(opts) });
209
+ const me = await api.getMe();
210
+ const channel = await api.getChannel(cfg.channelId);
211
+ if (!me.bot || channel.id !== cfg.channelId || ![0, 1, 5, 10, 11, 12].includes(channel.type)) throw new Error("Discord identity/channel validation failed");
212
+ if (send) await api.sendMessage(cfg.channelId, "jeo notification test");
213
+ } else {
214
+ const cfg = n?.telegram;
215
+ if (!cfg?.botToken || !cfg.chatId) throw new Error("Telegram is incomplete: bot token and chat ID are required");
216
+ const api = new TelegramApi(cfg.botToken, boundedFetch(opts));
217
+ const me = await api.getMe();
218
+ const chat = await api.getChat(cfg.chatId);
219
+ if (!me.ok || !me.result?.is_bot || !chat.ok || !chat.result?.type) throw new Error("Telegram bot/chat validation failed (check credentials and access)");
220
+ if (chat.result.type !== "private" && !cfg.allowedUserIds?.length) throw new Error("Telegram groups require an explicit user allowlist");
221
+ if (send && !(await api.sendMessage(cfg.chatId, "jeo notification test", { messageThreadId: cfg.topicId })).ok) throw new Error("Telegram test send failed (check permissions/rate limit)");
222
+ }
223
+ console.log(`${provider}: ${send ? "test message sent" : "bot identity and destination accessible (read-only; Gateway/poll connectivity and send permissions not tested)"}. enabled=${Boolean(n?.enabled)}`);
224
+ }
225
+
226
+ export async function runNotifyCommand(args: string[], opts: NotifyOptions = {}): Promise<void> {
105
227
  const sub = args[0] ?? "status";
106
- if (sub === "setup") return runSetup(args.slice(1));
107
- if (sub === "status") return runStatus();
108
- console.log(`Unknown 'jeo notify' subcommand '${sub}'. Usage: jeo notify [setup|status]`);
228
+ try {
229
+ const flags = parseFlags(args.slice(1));
230
+ if (sub === "setup") return await runSetup(flags, opts);
231
+ if (sub === "status") return await runStatus(flags.provider);
232
+ if (sub === "health" || sub === "test") return await runHealth(flags.provider, sub === "test", opts);
233
+ throw new Error("Usage: jeo notify [setup|status|health|test] [--provider telegram|discord|slack]");
234
+ } catch (error) {
235
+ // Only emit our known configuration/API errors, never fetch URLs or token-bearing bodies.
236
+ const message = error instanceof Error ? error.message : "request failed";
237
+ const safe = /^(Missing |Unknown notify|Provider |Allowed user|Telegram |Discord |Slack |Bot token|Missing setup|A daemon|Notification lock recovery|Usage:)/.test(message) && !message.includes("https:") && !message.includes("http:");
238
+ console.error(`Notification ${sub} failed: ${safe ? message : "API or storage request failed; check credentials, connectivity and permissions"}${(error instanceof DiscordApiError || error instanceof SlackApiError) && error.status ? ` (HTTP ${error.status})` : ""}`);
239
+ process.exitCode = 1;
240
+ }
109
241
  }
@@ -403,5 +403,6 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
403
403
  }
404
404
 
405
405
  export async function runRalplanCommand(): Promise<void> {
406
- await runRalplanEngine();
406
+ const result = await runRalplanEngine();
407
+ if (!result.ok) process.exitCode = 1;
407
408
  }
@@ -220,16 +220,18 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
220
220
  return { ok: false, reason: err.message };
221
221
  }
222
222
 
223
- // Round-13: verify the plan's hash matches the consensus hash to prevent silent edits after approval
224
- if (planState.consensus_hash) {
225
- const currentHash = createHash("sha256").update(planContent).digest("hex");
226
- if (currentHash !== planState.consensus_hash) {
227
- log(
228
- `[ERROR] Plan file has been modified since it was reviewed by the consensus critic.\n` +
229
- ` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve and execute again.`
230
- );
231
- return { ok: false, reason: "Plan file modified since consensus review" };
232
- }
223
+ // Execution requires the exact reviewed bytes, including for legacy state.
224
+ if (!planState.consensus_hash) {
225
+ log(`[ERROR] Plan lacks a consensus review hash. Re-run 'jeo ralplan', then approve and execute again.`);
226
+ return { ok: false, reason: "Plan lacks a consensus review hash" };
227
+ }
228
+ const currentHash = createHash("sha256").update(planContent).digest("hex");
229
+ if (currentHash !== planState.consensus_hash) {
230
+ log(
231
+ `[ERROR] Plan file has been modified since it was reviewed by the consensus critic.\n` +
232
+ ` Re-run 'jeo ralplan' to let the critic review the updated plan, then approve and execute again.`
233
+ );
234
+ return { ok: false, reason: "Plan file modified since consensus review" };
233
235
  }
234
236
 
235
237
  let rawPlan: any;
@@ -288,6 +290,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
288
290
  skill: "team" as const,
289
291
  slug: planState.slug,
290
292
  plan_path: planPath,
293
+ consensus_hash: planState.consensus_hash,
291
294
  completed_tasks: [],
292
295
  pending_tasks: [...tasks],
293
296
  };
@@ -302,8 +305,9 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
302
305
  // Round-7 #1 (architect ref 7-Round7Workflow): a team-state left over from a
303
306
  // PREVIOUS plan must never be reused — pending=[] from plan A would make plan B
304
307
  // no-op into a false "all executed" success, and a mid-flight leftover would run
305
- // plan-A task text under plan-B roles. A different plan reinitializes execution.
306
- if (teamState.plan_path !== planPath || teamState.slug !== planState.slug) {
308
+ // plan-A task text under plan-B roles. Changed bytes or legacy unbound state
309
+ // also reinitialize execution, even when the plan path and slug are unchanged.
310
+ if (teamState.plan_path !== planPath || teamState.slug !== planState.slug || teamState.consensus_hash !== planState.consensus_hash) {
307
311
  log(`${categoryBadge("progress")} New plan detected (${planPath}) — restarting execution from its task list.`);
308
312
  teamState = {
309
313
  active: true,
@@ -311,6 +315,7 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
311
315
  skill: "team" as const,
312
316
  slug: planState.slug,
313
317
  plan_path: planPath,
318
+ consensus_hash: planState.consensus_hash,
314
319
  completed_tasks: [],
315
320
  pending_tasks: [...tasks],
316
321
  };
@@ -209,5 +209,6 @@ export async function runUltragoalEngine(opts: UltragoalEngineOptions = {}): Pro
209
209
  }
210
210
 
211
211
  export async function runUltragoalCommand(): Promise<void> {
212
- await runUltragoalEngine();
212
+ const result = await runUltragoalEngine();
213
+ if (!result.ok) process.exitCode = 1;
213
214
  }