@gajae-code/coding-agent 0.15.3 → 0.15.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (55) hide show
  1. package/CHANGELOG.md +26 -1
  2. package/dist/types/cli/update-cli.d.ts +25 -5
  3. package/dist/types/config/autorouting-tier-map.d.ts +3 -0
  4. package/dist/types/config/settings-schema.d.ts +52 -0
  5. package/dist/types/extensibility/extensions/types.d.ts +5 -0
  6. package/dist/types/extensibility/shared-events.d.ts +2 -0
  7. package/dist/types/gjc-runtime/workflow-placeholder.d.ts +6 -0
  8. package/dist/types/modes/components/hook-selector.d.ts +1 -0
  9. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +8 -1
  10. package/dist/types/sdk/cli/session-cli.d.ts +42 -1
  11. package/dist/types/sdk/host/host.d.ts +2 -0
  12. package/dist/types/sdk/host/session-runtime.d.ts +4 -0
  13. package/dist/types/session/agent-session.d.ts +4 -2
  14. package/dist/types/session/internal/managed-session-storage.d.ts +4 -3
  15. package/dist/types/tools/ask.d.ts +1 -0
  16. package/dist/types/tools/path-utils.d.ts +5 -6
  17. package/dist/types/tools/tmux-self-injection-guard.d.ts +15 -0
  18. package/package.json +7 -7
  19. package/src/cli/update-cli.ts +102 -1
  20. package/src/commands/sdk.ts +5 -0
  21. package/src/config/autorouting-tier-map.ts +1 -0
  22. package/src/config/model-profiles.ts +5 -5
  23. package/src/config/model-registry.ts +10 -4
  24. package/src/config/settings-schema.ts +53 -0
  25. package/src/config/settings.ts +1 -1
  26. package/src/coordinator-mcp/policy.ts +1 -1
  27. package/src/deep-interview/render-middleware.ts +6 -2
  28. package/src/extensibility/extensions/types.ts +5 -0
  29. package/src/extensibility/shared-events.ts +2 -0
  30. package/src/gjc-runtime/launch-worktree.ts +44 -7
  31. package/src/gjc-runtime/ralplan-runtime.ts +20 -2
  32. package/src/gjc-runtime/tmux-sessions.ts +145 -48
  33. package/src/gjc-runtime/ultragoal-runtime.ts +5 -0
  34. package/src/gjc-runtime/workflow-placeholder.ts +33 -0
  35. package/src/internal-urls/docs-index.generated.ts +10 -9
  36. package/src/modes/components/hook-selector.ts +2 -1
  37. package/src/modes/controllers/extension-ui-controller.ts +1 -0
  38. package/src/modes/prompt-action-autocomplete.ts +26 -47
  39. package/src/sdk/bus/chat-daemon-control.ts +2 -2
  40. package/src/sdk/bus/kind-aware-reconciliation.ts +10 -2
  41. package/src/sdk/bus/prompt-reconciliation.ts +5 -2
  42. package/src/sdk/bus/telegram-daemon-contract.ts +8 -1
  43. package/src/sdk/cli/session-cli.ts +149 -4
  44. package/src/sdk/host/host.ts +7 -0
  45. package/src/sdk/host/session-runtime.test.ts +821 -22
  46. package/src/sdk/host/session-runtime.ts +669 -67
  47. package/src/sdk/router/session-router.ts +69 -15
  48. package/src/session/agent-session.ts +302 -25
  49. package/src/session/internal/managed-session-storage.ts +64 -26
  50. package/src/session/session-manager.ts +26 -8
  51. package/src/tools/ask-contract.ts +54 -3
  52. package/src/tools/ask.ts +46 -6
  53. package/src/tools/bash.ts +14 -0
  54. package/src/tools/path-utils.ts +77 -29
  55. package/src/tools/tmux-self-injection-guard.ts +359 -0
@@ -153,6 +153,8 @@ export interface ForceCloseOwnerDependencies {
153
153
  waitForOwnerExitVerdict?(): Promise<OwnerVerdict>;
154
154
  }
155
155
  const GJC_TMUX_PSMUX_INCARNATION_OPTION = "@gjc-psmux-incarnation";
156
+ const effectiveSessionEnvironments = new WeakMap<GjcTmuxSessionStatus, NodeJS.ProcessEnv>();
157
+ const fallbackSessionEnvironments = new WeakSet<GjcTmuxSessionStatus>();
156
158
 
157
159
  const FORCE_CLOSE_VERDICT_TIMEOUT_MS = 15_000;
158
160
  const FORCE_CLOSE_VERDICT_POLL_MS = 50;
@@ -305,20 +307,79 @@ function parseSessionLine(line: string): GjcTmuxSessionStatus | null {
305
307
  };
306
308
  }
307
309
 
308
- function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env): string[] {
310
+ /** tmux failure shapes that mean "this socket has no server", not "tmux is broken". */
311
+ function isMissingServerFailure(message: string): boolean {
312
+ return (
313
+ message.includes("no server running") ||
314
+ (message.includes("failed to connect to server") && /(?:no such file or directory|not found)/iu.test(message))
315
+ );
316
+ }
317
+
318
+ function isMissingSocketFailure(message: string): boolean {
319
+ return (
320
+ message.toLowerCase().includes("error connecting to ") &&
321
+ /(?:no such file or directory|not found)/iu.test(message)
322
+ );
323
+ }
324
+
325
+ /**
326
+ * `$TMUX` is inherited from whatever launched gjc and names the socket tmux
327
+ * talks to. Terminal hosts that emulate tmux for their own agents can export a
328
+ * `$TMUX` pointing at a socket they never created, and tmux then fails with
329
+ * `error connecting to <socket>` — the same message shape as a legitimate
330
+ * "no server" miss. Returning the socket path lets callers tell the two apart
331
+ * instead of reporting zero sessions while sessions are live on the default
332
+ * socket.
333
+ */
334
+ function inheritedTmuxSocketPath(env: NodeJS.ProcessEnv): string | null {
335
+ const socket = env.TMUX?.split(",")[0]?.trim();
336
+ return socket ? socket : null;
337
+ }
338
+
339
+ function namesInheritedTmuxSocket(message: string, socket: string): boolean {
340
+ const normalizedMessage = process.platform === "win32" ? message.toLowerCase() : message;
341
+ const normalizedSocket = process.platform === "win32" ? socket.toLowerCase() : socket;
342
+ return (
343
+ (normalizedMessage.includes(`error connecting to ${normalizedSocket} `) ||
344
+ normalizedMessage.includes(`failed to connect to server: ${normalizedSocket} `)) &&
345
+ /(?:no such file or directory|not found)/iu.test(message)
346
+ );
347
+ }
348
+
349
+ function envWithoutInheritedTmux(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv {
350
+ const withoutTmux: NodeJS.ProcessEnv = { ...env };
351
+ delete withoutTmux.TMUX;
352
+ return withoutTmux;
353
+ }
354
+
355
+ interface ListedTmuxSessions {
356
+ lines: string[];
357
+ env: NodeJS.ProcessEnv;
358
+ }
359
+
360
+ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env): ListedTmuxSessions {
361
+ let effectiveEnv = env;
309
362
  let output = "";
310
363
  try {
311
364
  output = runTmux(["list-sessions", "-F", format], env);
312
365
  } catch (error) {
313
366
  const message = error instanceof Error ? error.message : String(error);
314
- if (
315
- message.includes("no server running") ||
316
- message.includes("failed to connect to server") ||
317
- message.includes("error connecting to")
318
- ) {
319
- return [];
320
- }
321
- throw error;
367
+ const inheritedSocket = inheritedTmuxSocketPath(env);
368
+ // A miss on the default socket is a real empty list; only the inherited
369
+ // socket is suspect, so retry without it before concluding there is
370
+ // nothing to list.
371
+ if (inheritedSocket && namesInheritedTmuxSocket(message, inheritedSocket)) {
372
+ try {
373
+ effectiveEnv = envWithoutInheritedTmux(env);
374
+ output = runTmux(["list-sessions", "-F", format], effectiveEnv);
375
+ } catch (retryError) {
376
+ const retryMessage = retryError instanceof Error ? retryError.message : String(retryError);
377
+ if (isMissingServerFailure(retryMessage) || isMissingSocketFailure(retryMessage))
378
+ return { lines: [], env: effectiveEnv };
379
+ throw retryError;
380
+ }
381
+ } else if (isMissingServerFailure(message) || isMissingSocketFailure(message)) return { lines: [], env };
382
+ else throw error;
322
383
  }
323
384
  const lines = output
324
385
  .split("\n")
@@ -333,20 +394,25 @@ function runListSessions(format: string, env: NodeJS.ProcessEnv = process.env):
333
394
  if (lines.length > 0 && !lines[0].includes("\t")) {
334
395
  const binary = resolveGjcTmuxBinary({ env });
335
396
  if (binary.isPsmux) {
336
- return lines.map(line => {
337
- const match = line.match(/^([^:]+):\s*(\d+)\s+windows?\s+\(created\s+([^)]+)\)/);
338
- if (!match) return line;
339
- const [, name, windows, created] = match;
340
- const createdEpoch = String(Math.floor(new Date(`${created} UTC`).getTime() / 1000) || 0);
341
-
342
- return [name, windows, "0", createdEpoch, "", "", "0", "", "", "", "", "", "", "", "", "", ""].join("\t");
343
- });
397
+ return {
398
+ lines: lines.map(line => {
399
+ const match = line.match(/^([^:]+):\s*(\d+)\s+windows?\s+\(created\s+([^)]+)\)/);
400
+ if (!match) return line;
401
+ const [, name, windows, created] = match;
402
+ const createdEpoch = String(Math.floor(new Date(`${created} UTC`).getTime() / 1000) || 0);
403
+
404
+ return [name, windows, "0", createdEpoch, "", "", "0", "", "", "", "", "", "", "", "", "", ""].join(
405
+ "\t",
406
+ );
407
+ }),
408
+ env: effectiveEnv,
409
+ };
344
410
  }
345
411
  }
346
- return lines;
412
+ return { lines, env: effectiveEnv };
347
413
  }
348
414
 
349
- function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
415
+ function listSessionLines(env: NodeJS.ProcessEnv = process.env): ListedTmuxSessions {
350
416
  return runListSessions(
351
417
  `#{session_name}\t#{session_windows}\t#{session_attached}\t#{session_created}\t#{${GJC_TMUX_PROFILE_OPTION}}\t#{session_key_table}\t#{session_panes}\t#{pane_pid}\t#{${GJC_TMUX_BRANCH_OPTION}}\t#{${GJC_TMUX_BRANCH_SLUG_OPTION}}\t#{${GJC_TMUX_PROJECT_OPTION}}\t#{${GJC_TMUX_SESSION_ID_OPTION}}\t#{${GJC_TMUX_SESSION_STATE_FILE_OPTION}}\t#{${GJC_TMUX_OWNER_GENERATION_OPTION}}\t#{${GJC_TMUX_VERSION_OPTION}}\t#{${GJC_TMUX_PSMUX_INCARNATION_OPTION}}\t#{session_id}`,
352
418
 
@@ -355,8 +421,13 @@ function listSessionLines(env: NodeJS.ProcessEnv = process.env): string[] {
355
421
  }
356
422
 
357
423
  function listRawTmuxSessionNames(env: NodeJS.ProcessEnv = process.env): string[] {
358
- return runListSessions("#{session_name}", env).map(line => line.split("\t")[0] ?? line);
424
+ return runListSessions("#{session_name}", env).lines.map(line => line.split("\t")[0] ?? line);
425
+ }
426
+
427
+ function assertMutationAuthority(session: GjcTmuxSessionStatus): void {
428
+ if (fallbackSessionEnvironments.has(session)) throw new Error("gjc_tmux_fallback_authority_unconfirmed");
359
429
  }
430
+
360
431
  function canonicalProviderStateDirs(cwd: string): string[] {
361
432
  const gjcDir = path.join(cwd, GJC_DIR);
362
433
  let entries: fsSync.Dirent[];
@@ -424,13 +495,24 @@ function psmuxAuthorityEnvironments(env: NodeJS.ProcessEnv): NodeJS.ProcessEnv[]
424
495
 
425
496
  export function listGjcTmuxSessions(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionStatus[] {
426
497
  const discovered = psmuxAuthorityEnvironments(env).flatMap(authorityEnv => {
427
- const authority = psmuxAuthorityFromEnv(authorityEnv) ?? undefined;
428
- return listSessionLines(authorityEnv)
498
+ const listed = listSessionLines(authorityEnv);
499
+ const authority = psmuxAuthorityFromEnv(listed.env) ?? undefined;
500
+ return listed.lines
429
501
  .map(parseSessionLine)
430
502
  .filter((session): session is GjcTmuxSessionStatus => session != null)
431
- .map(session => hydrateSessionFromExactOptions(session, authorityEnv))
503
+ .map(session => {
504
+ const hydrated = hydrateSessionFromExactOptions(session, listed.env);
505
+ effectiveSessionEnvironments.set(hydrated, listed.env);
506
+ if (listed.env !== authorityEnv) fallbackSessionEnvironments.add(hydrated);
507
+ return hydrated;
508
+ })
432
509
  .filter((session): session is GjcTmuxSessionStatus => session?.profile === GJC_TMUX_PROFILE_VALUE)
433
- .map(session => (authority ? { ...session, providerAuthority: authority } : session));
510
+ .map(session => {
511
+ const result = authority ? { ...session, providerAuthority: authority } : session;
512
+ effectiveSessionEnvironments.set(result, listed.env);
513
+ if (listed.env !== authorityEnv) fallbackSessionEnvironments.add(result);
514
+ return result;
515
+ });
434
516
  });
435
517
  const names = new Set<string>();
436
518
  for (const session of discovered) {
@@ -443,12 +525,18 @@ export function listGjcTmuxSessions(env: NodeJS.ProcessEnv = process.env): GjcTm
443
525
  /** @internal */
444
526
  export function listTmuxSessionsForGc(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionsForGc {
445
527
  const authorityEnvironments = psmuxAuthorityEnvironments(env);
446
- const sessions = authorityEnvironments.flatMap(authorityEnv =>
447
- listSessionLines(authorityEnv)
528
+ const sessions = authorityEnvironments.flatMap(authorityEnv => {
529
+ const listed = listSessionLines(authorityEnv);
530
+ return listed.lines
448
531
  .map(parseSessionLine)
449
532
  .filter((session): session is GjcTmuxSessionStatus => session != null)
450
- .map(session => hydrateSessionFromExactOptions(session, authorityEnv)),
451
- );
533
+ .map(session => {
534
+ const hydrated = hydrateSessionFromExactOptions(session, listed.env);
535
+ effectiveSessionEnvironments.set(hydrated, listed.env);
536
+ if (listed.env !== authorityEnv) fallbackSessionEnvironments.add(hydrated);
537
+ return hydrated;
538
+ });
539
+ });
452
540
  const tagged = sessions
453
541
  .filter(session => session.profile === GJC_TMUX_PROFILE_VALUE)
454
542
  .sort((a, b) => a.name.localeCompare(b.name));
@@ -1148,17 +1236,18 @@ export function readTmuxSessionTagsForGc(
1148
1236
  env: NodeJS.ProcessEnv = process.env,
1149
1237
  ): GjcTmuxSessionTagsForGc {
1150
1238
  const session = listGjcTmuxSessions(env).find(candidate => candidate.name === sessionName);
1239
+ const sessionEnv = session ? (effectiveSessionEnvironments.get(session) ?? env) : env;
1151
1240
  return {
1152
- profile: readExactOptionForGc(sessionName, GJC_TMUX_PROFILE_OPTION, env),
1153
- project: readExactOptionForGc(sessionName, GJC_TMUX_PROJECT_OPTION, env),
1154
- psmuxIncarnation: readExactOptionForGc(sessionName, GJC_TMUX_PSMUX_INCARNATION_OPTION, env),
1155
- branch: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_OPTION, env),
1156
- branchSlug: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_SLUG_OPTION, env),
1157
- sessionId: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_ID_OPTION, env),
1158
- sessionStateFile: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_STATE_FILE_OPTION, env),
1159
- version: readExactOptionForGc(sessionName, GJC_TMUX_VERSION_OPTION, env),
1160
- ownerGeneration: readExactOptionForGc(sessionName, GJC_TMUX_OWNER_GENERATION_OPTION, env),
1161
- nativeSessionId: session?.nativeSessionId ?? readNativeTmuxSessionId(sessionName, env),
1241
+ profile: readExactOptionForGc(sessionName, GJC_TMUX_PROFILE_OPTION, sessionEnv),
1242
+ project: readExactOptionForGc(sessionName, GJC_TMUX_PROJECT_OPTION, sessionEnv),
1243
+ psmuxIncarnation: readExactOptionForGc(sessionName, GJC_TMUX_PSMUX_INCARNATION_OPTION, sessionEnv),
1244
+ branch: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_OPTION, sessionEnv),
1245
+ branchSlug: readExactOptionForGc(sessionName, GJC_TMUX_BRANCH_SLUG_OPTION, sessionEnv),
1246
+ sessionId: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_ID_OPTION, sessionEnv),
1247
+ sessionStateFile: readExactOptionForGc(sessionName, GJC_TMUX_SESSION_STATE_FILE_OPTION, sessionEnv),
1248
+ version: readExactOptionForGc(sessionName, GJC_TMUX_VERSION_OPTION, sessionEnv),
1249
+ ownerGeneration: readExactOptionForGc(sessionName, GJC_TMUX_OWNER_GENERATION_OPTION, sessionEnv),
1250
+ nativeSessionId: session?.nativeSessionId ?? readNativeTmuxSessionId(sessionName, sessionEnv),
1162
1251
  createdAt: session?.createdAt,
1163
1252
  attached: session?.attached,
1164
1253
  panePids: session?.panePids,
@@ -1171,6 +1260,9 @@ export function removeGjcTmuxSession(
1171
1260
  expectedIdentity?: ExpectedGjcTmuxSessionIdentity,
1172
1261
  ): GjcTmuxSessionStatus {
1173
1262
  const session = statusGjcTmuxSession(sessionName, env);
1263
+ assertMutationAuthority(session);
1264
+ const sessionEnv =
1265
+ effectiveSessionEnvironments.get(session) ?? environmentForProviderAuthority(env, session.providerAuthority);
1174
1266
  if (session.attached || session.panePids.length > 0) {
1175
1267
  throw new Error(`gjc_tmux_session_live:${sessionName}`);
1176
1268
  }
@@ -1185,32 +1277,33 @@ export function removeGjcTmuxSession(
1185
1277
  session.createdAt !== expectedIdentity.createdAt)
1186
1278
  )
1187
1279
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1188
- if (readProfileForExactTarget(session.name, env) !== GJC_TMUX_PROFILE_VALUE) {
1280
+ if (readProfileForExactTarget(session.name, sessionEnv) !== GJC_TMUX_PROFILE_VALUE) {
1189
1281
  throw new Error(`gjc_tmux_session_not_managed:${sessionName}`);
1190
1282
  }
1191
- const nativeSessionId = readNativeTmuxSessionId(session.name, env);
1283
+ const nativeSessionId = readNativeTmuxSessionId(session.name, sessionEnv);
1192
1284
  if (!nativeSessionId) throw new Error(`gjc_tmux_owner_unverifiable:${sessionName}`);
1193
1285
  if (expectedIdentity && nativeSessionId !== expectedIdentity.nativeSessionId)
1194
1286
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1195
1287
  if (
1196
1288
  expectedIdentity &&
1197
- readExactOptionForGc(session.name, GJC_TMUX_OWNER_GENERATION_OPTION, env) !== expectedIdentity.ownerGeneration
1289
+ readExactOptionForGc(session.name, GJC_TMUX_OWNER_GENERATION_OPTION, sessionEnv) !==
1290
+ expectedIdentity.ownerGeneration
1198
1291
  )
1199
1292
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1200
- const firstServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(env), env);
1293
+ const firstServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(sessionEnv), sessionEnv);
1201
1294
  if (
1202
- readNativeTmuxSessionId(nativeSessionId, env) !== nativeSessionId ||
1203
- readProfileForExactTarget(nativeSessionId, env) !== GJC_TMUX_PROFILE_VALUE
1295
+ readNativeTmuxSessionId(nativeSessionId, sessionEnv) !== nativeSessionId ||
1296
+ readProfileForExactTarget(nativeSessionId, sessionEnv) !== GJC_TMUX_PROFILE_VALUE
1204
1297
  )
1205
1298
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1206
- const finalServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(env), env);
1299
+ const finalServer = requireSafeTmuxServerForMutation(resolveGjcTmuxCommand(sessionEnv), sessionEnv);
1207
1300
  if (finalServer.pid !== firstServer.pid || finalServer.startTime !== firstServer.startTime)
1208
1301
  throw new Error(`gjc_tmux_owner_changed:${sessionName}`);
1209
1302
  runGuardedTmuxSessionCommand(
1210
1303
  nativeSessionId,
1211
1304
  session.name,
1212
1305
  finalServer,
1213
- env,
1306
+ sessionEnv,
1214
1307
  `kill-session -t '${nativeSessionId}'`,
1215
1308
  expectedIdentity?.ownerGeneration,
1216
1309
  );
@@ -1426,7 +1519,9 @@ export async function forceCloseGjcTmuxSession(
1426
1519
  deps: Partial<ForceCloseOwnerDependencies> = {},
1427
1520
  ): Promise<GjcTmuxSessionStatus> {
1428
1521
  const session = statusGjcTmuxSession(sessionName, env);
1429
- const sessionEnv = environmentForProviderAuthority(env, session.providerAuthority);
1522
+ assertMutationAuthority(session);
1523
+ const sessionEnv =
1524
+ effectiveSessionEnvironments.get(session) ?? environmentForProviderAuthority(env, session.providerAuthority);
1430
1525
  if (readProfileForExactTarget(session.name, sessionEnv) !== GJC_TMUX_PROFILE_VALUE)
1431
1526
  throw new Error(`gjc_tmux_session_not_managed:${sessionName}`);
1432
1527
  const exactPanePids = (deps.listPanePids ?? readExactSessionPanePids)(session.name, sessionEnv);
@@ -1560,7 +1655,9 @@ export async function forceCloseGjcTmuxSession(
1560
1655
 
1561
1656
  export function attachGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): never {
1562
1657
  const session = statusGjcTmuxSession(sessionName, env);
1563
- const sessionEnv = environmentForProviderAuthority(env, session.providerAuthority);
1658
+ assertMutationAuthority(session);
1659
+ const sessionEnv =
1660
+ effectiveSessionEnvironments.get(session) ?? environmentForProviderAuthority(env, session.providerAuthority);
1564
1661
  const authority = session.providerAuthority ?? psmuxAuthorityFromEnv(sessionEnv);
1565
1662
  const tmuxCommand = authority?.command ?? resolveGjcTmuxCommand(sessionEnv);
1566
1663
  if (authority) assertGjcTmuxMutationAuthoritySync(authority);
@@ -86,6 +86,7 @@ import {
86
86
  type UltragoalValidationLaneSelection,
87
87
  validationLaneSelectionFor,
88
88
  } from "./ultragoal-validation-policy";
89
+ import { isWorkflowPlaceholderText, WORKFLOW_PLACEHOLDER_CORRECTION } from "./workflow-placeholder";
89
90
  import { resolveWorkflowSetting } from "./workflow-settings";
90
91
 
91
92
  export {
@@ -1166,6 +1167,9 @@ function parseGoalsFromBrief(brief: string): ParsedGoal[] {
1166
1167
  if (!title && !body) {
1167
1168
  throw new Error(`ultragoal @goal block ${index + 1} has no title or objective`);
1168
1169
  }
1170
+ if (isWorkflowPlaceholderText(body || title)) {
1171
+ throw new Error(`ultragoal @goal block ${index + 1} requires ${WORKFLOW_PLACEHOLDER_CORRECTION}`);
1172
+ }
1169
1173
  return { title: clampTitle(title), objective: body || title };
1170
1174
  });
1171
1175
  }
@@ -1180,6 +1184,7 @@ export async function createUltragoalPlan(input: {
1180
1184
  }): Promise<UltragoalPlan> {
1181
1185
  const brief = input.brief.trim();
1182
1186
  if (!brief) throw new Error("ultragoal brief is required");
1187
+ if (isWorkflowPlaceholderText(brief)) throw new Error(`ultragoal brief requires ${WORKFLOW_PLACEHOLDER_CORRECTION}`);
1183
1188
  const now = new Date().toISOString();
1184
1189
  // Parse the untrimmed brief so the raw-line delimiter contract holds: a
1185
1190
  // leading-indented `@goal` on the first line must stay objective text rather
@@ -0,0 +1,33 @@
1
+ /** Shared placeholder semantics for model-authored workflow content. */
2
+
3
+ const WORKFLOW_PLACEHOLDER_TOKENS = new Set([
4
+ "empty",
5
+ "n/a",
6
+ "n-a",
7
+ "na",
8
+ "none",
9
+ "placeholder",
10
+ "stub",
11
+ "tbd",
12
+ "todo",
13
+ "unused",
14
+ ]);
15
+
16
+ export const WORKFLOW_PLACEHOLDER_CORRECTION =
17
+ "provide a specific, non-placeholder question or objective instead of empty, whitespace, unused, TODO, TBD, N/A, N-A, NA, none, placeholder, or stub";
18
+
19
+ /** Exact, case-insensitive placeholder detection; meaningful sentences remain valid. */
20
+ export function isWorkflowPlaceholderText(value: unknown): boolean {
21
+ if (typeof value !== "string") return false;
22
+ const normalized = value.trim().toLowerCase();
23
+ return normalized.length === 0 || WORKFLOW_PLACEHOLDER_TOKENS.has(normalized);
24
+ }
25
+
26
+ /** Detect a legacy formatted deep-interview ask whose visible body is a placeholder. */
27
+ export function isLegacyDeepInterviewPlaceholder(value: unknown): boolean {
28
+ if (typeof value !== "string") return false;
29
+ const lines = value.trim().split(/\r?\n/);
30
+ const headerIndex = lines.findIndex(line => /^Round\s+\d+\s+\|.*\bAmbiguity\b/i.test(line.trim()));
31
+ if (headerIndex < 0) return false;
32
+ return isWorkflowPlaceholderText(lines.slice(headerIndex + 1).join("\n"));
33
+ }