@gajae-code/coding-agent 0.7.2 → 0.7.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (154) hide show
  1. package/CHANGELOG.md +86 -0
  2. package/bin/gjc.js +4 -0
  3. package/dist/types/cli/mcp-cli.d.ts +25 -0
  4. package/dist/types/cli/plugin-cli.d.ts +2 -0
  5. package/dist/types/cli.d.ts +6 -0
  6. package/dist/types/commands/mcp.d.ts +70 -0
  7. package/dist/types/commands/plugin.d.ts +6 -0
  8. package/dist/types/commands/session.d.ts +6 -0
  9. package/dist/types/config/keybindings.d.ts +2 -2
  10. package/dist/types/config/model-profile-activation.d.ts +8 -1
  11. package/dist/types/deep-interview/plaintext-gate-guard.d.ts +11 -0
  12. package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
  13. package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
  14. package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
  15. package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
  16. package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
  17. package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
  18. package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
  19. package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
  20. package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
  21. package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
  22. package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
  23. package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
  24. package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
  25. package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
  26. package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
  27. package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
  28. package/dist/types/gjc-runtime/tmux-common.d.ts +20 -1
  29. package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
  30. package/dist/types/main.d.ts +2 -0
  31. package/dist/types/modes/components/custom-editor.d.ts +1 -1
  32. package/dist/types/modes/components/model-selector.d.ts +8 -0
  33. package/dist/types/modes/components/status-line/git-utils.d.ts +6 -0
  34. package/dist/types/modes/theme/defaults/index.d.ts +99 -0
  35. package/dist/types/notifications/html-format.d.ts +11 -0
  36. package/dist/types/notifications/index.d.ts +149 -1
  37. package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
  38. package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
  39. package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
  40. package/dist/types/notifications/operator-runtime.d.ts +52 -0
  41. package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
  42. package/dist/types/notifications/recent-activity.d.ts +35 -0
  43. package/dist/types/notifications/telegram-daemon.d.ts +114 -16
  44. package/dist/types/notifications/telegram-reference.d.ts +3 -1
  45. package/dist/types/notifications/topic-registry.d.ts +12 -9
  46. package/dist/types/runtime-mcp/types.d.ts +7 -0
  47. package/dist/types/sdk.d.ts +2 -0
  48. package/dist/types/session/agent-session.d.ts +14 -4
  49. package/dist/types/session/blob-store.d.ts +25 -0
  50. package/dist/types/session/session-manager.d.ts +57 -0
  51. package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
  52. package/dist/types/system-prompt.d.ts +2 -0
  53. package/dist/types/task/executor.d.ts +9 -1
  54. package/dist/types/tools/composer-bash-policy.d.ts +14 -0
  55. package/dist/types/tools/index.d.ts +3 -1
  56. package/dist/types/utils/changelog.d.ts +1 -0
  57. package/dist/types/web/insane/url-guard.d.ts +6 -3
  58. package/dist/types/web/scrapers/types.d.ts +5 -0
  59. package/dist/types/web/scrapers/utils.d.ts +7 -1
  60. package/package.json +11 -9
  61. package/scripts/g004-tmux-smoke.ts +100 -0
  62. package/scripts/g005-daemon-smoke.ts +181 -0
  63. package/scripts/g011-daemon-path-smoke.ts +153 -0
  64. package/src/cli/mcp-cli.ts +272 -0
  65. package/src/cli/plugin-cli.ts +66 -3
  66. package/src/cli.ts +27 -6
  67. package/src/commands/mcp.ts +117 -0
  68. package/src/commands/plugin.ts +4 -0
  69. package/src/commands/session.ts +18 -0
  70. package/src/config/keybindings.ts +2 -2
  71. package/src/config/model-profile-activation.ts +55 -7
  72. package/src/deep-interview/plaintext-gate-guard.ts +94 -0
  73. package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
  74. package/src/defaults/gjc/skills/deep-interview/SKILL.md +7 -6
  75. package/src/defaults/gjc/skills/team/SKILL.md +5 -3
  76. package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
  77. package/src/export/html/index.ts +2 -2
  78. package/src/extensibility/extensions/runner.ts +1 -0
  79. package/src/extensibility/gjc-plugins/compiler.ts +351 -0
  80. package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
  81. package/src/extensibility/gjc-plugins/index.ts +9 -0
  82. package/src/extensibility/gjc-plugins/injection.ts +109 -0
  83. package/src/extensibility/gjc-plugins/installer.ts +434 -0
  84. package/src/extensibility/gjc-plugins/loader.ts +3 -1
  85. package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
  86. package/src/extensibility/gjc-plugins/observability.ts +84 -0
  87. package/src/extensibility/gjc-plugins/paths.ts +1 -1
  88. package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
  89. package/src/extensibility/gjc-plugins/registry.ts +180 -0
  90. package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
  91. package/src/extensibility/gjc-plugins/schema.ts +250 -20
  92. package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
  93. package/src/extensibility/gjc-plugins/types.ts +199 -3
  94. package/src/extensibility/gjc-plugins/validation.ts +80 -0
  95. package/src/extensibility/skills.ts +15 -0
  96. package/src/gjc-runtime/launch-tmux.ts +61 -7
  97. package/src/gjc-runtime/psmux-detect.ts +239 -0
  98. package/src/gjc-runtime/team-runtime.ts +56 -23
  99. package/src/gjc-runtime/tmux-common.ts +30 -3
  100. package/src/gjc-runtime/tmux-sessions.ts +51 -1
  101. package/src/gjc-runtime/ultragoal-guard.ts +25 -8
  102. package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
  103. package/src/hooks/skill-state.ts +57 -0
  104. package/src/internal-urls/docs-index.generated.ts +12 -8
  105. package/src/main.ts +14 -3
  106. package/src/modes/bridge/bridge-mode.ts +11 -0
  107. package/src/modes/components/custom-editor.ts +2 -0
  108. package/src/modes/components/footer.ts +2 -3
  109. package/src/modes/components/hook-editor.ts +1 -1
  110. package/src/modes/components/hook-selector.ts +67 -43
  111. package/src/modes/components/model-selector.ts +56 -11
  112. package/src/modes/components/status-line/git-utils.ts +25 -0
  113. package/src/modes/components/status-line.ts +10 -11
  114. package/src/modes/components/welcome.ts +2 -3
  115. package/src/modes/controllers/extension-ui-controller.ts +0 -27
  116. package/src/modes/controllers/selector-controller.ts +53 -11
  117. package/src/modes/interactive-mode.ts +4 -1
  118. package/src/modes/shared/agent-wire/scopes.ts +1 -1
  119. package/src/modes/theme/defaults/gruvbox-dark.json +99 -0
  120. package/src/modes/theme/defaults/index.ts +2 -0
  121. package/src/modes/utils/hotkeys-markdown.ts +1 -1
  122. package/src/notifications/html-format.ts +38 -0
  123. package/src/notifications/index.ts +242 -12
  124. package/src/notifications/lifecycle-commands.ts +228 -0
  125. package/src/notifications/lifecycle-control-runtime.ts +400 -0
  126. package/src/notifications/lifecycle-orchestrator.ts +358 -0
  127. package/src/notifications/operator-runtime.ts +171 -0
  128. package/src/notifications/rate-limit-pool.ts +19 -0
  129. package/src/notifications/recent-activity.ts +132 -0
  130. package/src/notifications/telegram-daemon.ts +778 -257
  131. package/src/notifications/telegram-reference.ts +25 -7
  132. package/src/notifications/topic-registry.ts +23 -9
  133. package/src/prompts/agents/executor.md +2 -2
  134. package/src/runtime-mcp/transports/stdio.ts +38 -4
  135. package/src/runtime-mcp/types.ts +7 -0
  136. package/src/sdk.ts +157 -10
  137. package/src/session/agent-session.ts +166 -74
  138. package/src/session/blob-store.ts +196 -8
  139. package/src/session/session-manager.ts +678 -7
  140. package/src/slash-commands/builtin-registry.ts +23 -3
  141. package/src/slash-commands/helpers/fast-status-report.ts +13 -3
  142. package/src/slash-commands/helpers/parse.ts +2 -1
  143. package/src/system-prompt.ts +9 -0
  144. package/src/task/executor.ts +31 -7
  145. package/src/task/index.ts +2 -0
  146. package/src/tools/ask.ts +5 -1
  147. package/src/tools/bash.ts +9 -0
  148. package/src/tools/composer-bash-policy.ts +96 -0
  149. package/src/tools/fetch.ts +18 -2
  150. package/src/tools/index.ts +3 -1
  151. package/src/utils/changelog.ts +8 -0
  152. package/src/web/insane/url-guard.ts +18 -14
  153. package/src/web/scrapers/types.ts +143 -45
  154. package/src/web/scrapers/utils.ts +70 -19
@@ -1,3 +1,5 @@
1
+ import { resolveGjcTmuxBinary } from "./psmux-detect";
2
+
1
3
  export const GJC_DEFAULT_TMUX_SESSION = "gajae_code";
2
4
  export const GJC_TMUX_SESSION_PREFIX = `${GJC_DEFAULT_TMUX_SESSION}_`;
3
5
  export const GJC_TMUX_COMMAND_ENV = "GJC_TMUX_COMMAND";
@@ -31,10 +33,33 @@ export function envDisabled(value: string | undefined): boolean {
31
33
  return normalized === "0" || normalized === "false" || normalized === "off" || normalized === "no";
32
34
  }
33
35
 
34
- export function resolveGjcTmuxCommand(env: NodeJS.ProcessEnv = process.env): string {
35
- return env[GJC_TMUX_COMMAND_ENV]?.trim() || env.GJC_TEAM_TMUX_COMMAND?.trim() || "tmux";
36
+ /**
37
+ * Resolve the tmux (or tmux-compatible multiplexer) command GJC should invoke.
38
+ *
39
+ * This is the shared entry point used by every GJC code path that needs to talk
40
+ * to a multiplexer: `gjc --tmux` planning, `gjc session ...`, `gjc team ...`,
41
+ * the lifecycle controller, and the harness resident owner. Routing all of
42
+ * them through the same resolver means a single `GJC_TMUX_COMMAND` override or
43
+ * a single Windows psmux / pmux detection wins for the whole process — the
44
+ * failure mode where `gjc --tmux` creates a psmux-backed session and then
45
+ * `gjc session status` fails because it queries literal `tmux` is closed off.
46
+ *
47
+ * Explicit `GJC_TMUX_COMMAND` / `GJC_TEAM_TMUX_COMMAND` overrides are honored on
48
+ * every platform. On native Windows without an override the resolver walks
49
+ * `psmux`, then `pmux`, then `tmux` and uses the first binary present on PATH.
50
+ * On POSIX the resolver returns `tmux` (the historical default) and only
51
+ * falls through to the platform-aware walker if the caller opts in.
52
+ */
53
+ export function resolveGjcTmuxCommand(
54
+ env: NodeJS.ProcessEnv = process.env,
55
+ platform: NodeJS.Platform = process.platform,
56
+ ): string {
57
+ return resolveGjcTmuxBinary({ env, platform }).command;
36
58
  }
37
59
 
60
+ export type { PsmuxProbe, ResolvedTmuxBinary, ResolveGjcTmuxBinaryOptions } from "./psmux-detect";
61
+ export { clearPsmuxDetectionCache, detectPsmux, probePsmux, resolveGjcTmuxBinary } from "./psmux-detect";
62
+
38
63
  /**
39
64
  * Build the exact-session target for tmux *option* commands
40
65
  * (`show-options` / `set-option`) and `display-message -t`.
@@ -55,7 +80,9 @@ export function buildGjcTmuxUntaggedSessionHint(tmuxCommand: string): string {
55
80
  return (
56
81
  `the active multiplexer "${tmuxCommand}" lists this session but did not return GJC's ${GJC_TMUX_PROFILE_OPTION} ownership tag; ` +
57
82
  "GJC-managed sessions and `gjc team` require a tmux provider that round-trips tmux user options. " +
58
- "Alternative multiplexers such as psmux on Windows do not persist user options yet, so the Windows-native psmux path is not fully supported; " +
83
+ "For psmux on Windows, cwd/start-directory flags such as `-c` do not isolate the server namespace; psmux uses the tmux-compatible global `-L <namespace>` flag for that. " +
84
+ "GJC_TMUX_COMMAND and GJC_TEAM_TMUX_COMMAND are binary overrides, not shell command lines, so `psmux -L name` is not a supported value. " +
85
+ "Alternative multiplexers such as psmux on Windows do not reliably persist user options yet, so the Windows-native psmux path is not fully supported; " +
59
86
  "use real tmux for GJC-managed session and team flows."
60
87
  );
61
88
  }
@@ -221,7 +221,13 @@ export function statusGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
221
221
  export function createGjcTmuxSession(env: NodeJS.ProcessEnv = process.env): GjcTmuxSessionStatus {
222
222
  const tmuxCommand = resolveGjcTmuxCommand(env);
223
223
  const sessionName = buildGjcTmuxSessionName(env);
224
- const command = "exec env GJC_TMUX_LAUNCHED=1 gjc";
224
+ // Build a shell-bootstrap command appropriate for the host shell. Psmux on
225
+ // Windows runs the new-session command through PowerShell, so we use the
226
+ // $env:VAR = ... assignment form there. POSIX keeps the historical exec
227
+ // env form so the launched gjc inherits GJC_TMUX_LAUNCHED without leaking
228
+ // into the parent tmux server.
229
+ const platform = process.platform;
230
+ const command = platform === "win32" ? "$env:GJC_TMUX_LAUNCHED = '1'; gjc" : "exec env GJC_TMUX_LAUNCHED=1 gjc";
225
231
  const created = Bun.spawnSync([tmuxCommand, "new-session", "-d", "-s", sessionName, command], {
226
232
  stdout: "pipe",
227
233
  stderr: "pipe",
@@ -306,6 +312,50 @@ export function removeGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv
306
312
  return session;
307
313
  }
308
314
 
315
+ /**
316
+ * Force-close a GJC-managed tmux session, even if a live pane is attached.
317
+ *
318
+ * This is the lifecycle-control counterpart to {@link removeGjcTmuxSession}: it
319
+ * intentionally does NOT refuse live/attached panes (hard-kill is the contract),
320
+ * but it keeps every safety check so it can only ever kill a genuinely
321
+ * GJC-managed session:
322
+ * - re-reads the exact tmux profile immediately before kill (never a non-GJC
323
+ * session, even one that collides by name);
324
+ * - when `expectedSessionId` is given, requires the `@gjc-session-id` tag match;
325
+ * - when `expectedStateFile` is given, requires the `@gjc-session-state-file`
326
+ * tag match.
327
+ *
328
+ * Returns the prior status (for audit). Throws a tagged error otherwise:
329
+ * `gjc_tmux_session_not_found`, `gjc_tmux_session_not_managed`,
330
+ * `gjc_tmux_session_id_mismatch`, or `gjc_tmux_session_state_file_mismatch`.
331
+ */
332
+ export function forceCloseGjcTmuxSession(
333
+ sessionName: string,
334
+ env: NodeJS.ProcessEnv = process.env,
335
+ expectedSessionId?: string,
336
+ expectedStateFile?: string,
337
+ ): GjcTmuxSessionStatus {
338
+ const session = statusGjcTmuxSession(sessionName, env);
339
+ if (readProfileForExactTarget(session.name, env) !== GJC_TMUX_PROFILE_VALUE) {
340
+ throw new Error(`gjc_tmux_session_not_managed:${sessionName}`);
341
+ }
342
+ if (expectedSessionId !== undefined) {
343
+ const actual = readExactOptionForGc(session.name, GJC_TMUX_SESSION_ID_OPTION, env);
344
+ if (actual !== expectedSessionId) {
345
+ throw new Error(`gjc_tmux_session_id_mismatch:${sessionName}`);
346
+ }
347
+ }
348
+ if (expectedStateFile !== undefined) {
349
+ const actual = readExactOptionForGc(session.name, GJC_TMUX_SESSION_STATE_FILE_OPTION, env);
350
+ if (actual !== expectedStateFile) {
351
+ throw new Error(`gjc_tmux_session_state_file_mismatch:${sessionName}`);
352
+ }
353
+ }
354
+ // Intentionally NOT refusing live/attached panes — force-close is hard-kill.
355
+ runTmux(["kill-session", "-t", `=${session.name}`], env);
356
+ return session;
357
+ }
358
+
309
359
  export function attachGjcTmuxSession(sessionName: string, env: NodeJS.ProcessEnv = process.env): never {
310
360
  const session = statusGjcTmuxSession(sessionName, env);
311
361
  const tmuxCommand = resolveGjcTmuxCommand(env);
@@ -65,15 +65,17 @@ function isKnownUltragoalObjective(currentObjective: string): boolean {
65
65
  );
66
66
  }
67
67
 
68
- async function ultragoalReadPaths(cwd: string): Promise<UltragoalPaths> {
68
+ async function ultragoalReadPaths(cwd: string): Promise<{ paths: UltragoalPaths; sessionId: string | null }> {
69
69
  const envSessionId = process.env.GJC_SESSION_ID?.trim();
70
- if (envSessionId) return getUltragoalPaths(cwd, envSessionId);
70
+ if (envSessionId) return { paths: getUltragoalPaths(cwd, envSessionId), sessionId: envSessionId };
71
71
  try {
72
72
  const session = await resolveGjcSessionForRead(cwd, { envSessionId: process.env.GJC_SESSION_ID });
73
- return getUltragoalPaths(cwd, session.gjcSessionId);
73
+ return { paths: getUltragoalPaths(cwd, session.gjcSessionId), sessionId: session.gjcSessionId };
74
74
  } catch (error) {
75
75
  if (error instanceof SessionResolutionError && error.code === "no_session") {
76
- return getUltragoalPaths(cwd, null);
76
+ // No session could be resolved (no env, no auto-detectable active session).
77
+ // Surface the null session id so callers can decide; ask-guard treats it as inactive.
78
+ return { paths: getUltragoalPaths(cwd, null), sessionId: null };
77
79
  }
78
80
  throw error;
79
81
  }
@@ -82,7 +84,7 @@ async function ultragoalReadPaths(cwd: string): Promise<UltragoalPaths> {
82
84
  async function hasDurableUltragoalState(cwd: string): Promise<boolean> {
83
85
  let paths: UltragoalPaths;
84
86
  try {
85
- paths = await ultragoalReadPaths(cwd);
87
+ ({ paths } = await ultragoalReadPaths(cwd));
86
88
  } catch (error) {
87
89
  if (error instanceof SessionResolutionError) return true;
88
90
  throw error;
@@ -368,14 +370,26 @@ export async function readUltragoalVerificationState(input: {
368
370
 
369
371
  export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBlockDiagnostic> {
370
372
  let paths: UltragoalPaths;
373
+ let sessionId: string | null;
371
374
  try {
372
- paths = await ultragoalReadPaths(cwd);
375
+ ({ paths, sessionId } = await ultragoalReadPaths(cwd));
373
376
  } catch (error) {
374
377
  return activeAskDiagnostic({
375
378
  reason: `Unable to resolve durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,
376
379
  source: "durable_state_unreadable",
377
380
  });
378
381
  }
382
+ // Ultragoal state is session-scoped. When no session can be resolved (no env,
383
+ // no auto-detectable active session) there is no active run to protect, so the
384
+ // ask guard must fall open rather than block on legacy/global durable state.
385
+ if (sessionId === null) {
386
+ return inactiveAskDiagnostic({
387
+ reason: "No active GJC session resolved; ultragoal is inactive.",
388
+ source: "absent",
389
+ goalsPath: paths.goalsPath,
390
+ ledgerPath: paths.ledgerPath,
391
+ });
392
+ }
379
393
  try {
380
394
  await fs.stat(paths.dir);
381
395
  } catch (error) {
@@ -398,8 +412,8 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
398
412
  let plan: UltragoalPlan | null;
399
413
  let ledger: UltragoalLedgerEvent[];
400
414
  try {
401
- plan = await readUltragoalPlan(cwd);
402
- ledger = await readUltragoalLedger(cwd);
415
+ plan = await readUltragoalPlan(cwd, sessionId);
416
+ ledger = await readUltragoalLedger(cwd, sessionId);
403
417
  } catch (error) {
404
418
  return activeAskDiagnostic({
405
419
  reason: `Unable to read durable Ultragoal state: ${error instanceof Error ? error.message : String(error)}`,
@@ -409,6 +423,9 @@ export async function isUltragoalAskBlocked(cwd: string): Promise<UltragoalAskBl
409
423
  });
410
424
  }
411
425
  if (!plan) {
426
+ // goals.json absent or empty while the state dir exists is an inconsistent
427
+ // durable state, not a clean "no run". Fail closed so the pause guard (which
428
+ // relies on this `durable_state_unreadable` signal) keeps blocking give-ups.
412
429
  return activeAskDiagnostic({
413
430
  reason: "Durable .gjc/ultragoal state exists but goals.json is missing or empty.",
414
431
  source: "durable_state_unreadable",
@@ -1,11 +1,12 @@
1
1
  import * as crypto from "node:crypto";
2
2
  import * as path from "node:path";
3
3
  import { inflateSync } from "node:zlib";
4
-
4
+ import { normalizeGoal } from "../goals/state";
5
+ import { buildSessionContext, loadEntriesFromFile, type SessionEntry } from "../session/session-manager";
5
6
  import type { WorkflowHudSummary } from "../skill-state/active-state";
6
7
  import { buildUltragoalHudSummary as buildWorkflowUltragoalHudSummary } from "../skill-state/workflow-hud";
7
8
  import { renderCliWriteReceipt } from "./cli-write-receipt";
8
- import { DEFAULT_ULTRAGOAL_OBJECTIVE } from "./goal-mode-request";
9
+ import { DEFAULT_ULTRAGOAL_OBJECTIVE, GJC_SESSION_FILE_ENV } from "./goal-mode-request";
9
10
  import { gjcRoot, sessionUltragoalDir } from "./session-layout";
10
11
  import { resolveGjcSessionForRead, resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
11
12
  import { renderUltragoalStatusMarkdown } from "./state-renderer";
@@ -831,6 +832,14 @@ function normalizedEvidenceKind(row: JsonObject): string {
831
832
  function evidenceKindMatches(kind: string, words: string[]): boolean {
832
833
  return words.some(word => kind.includes(word));
833
834
  }
835
+ function formatActualArtifactKinds(artifactIds: string[], kinds: string[]): string {
836
+ if (artifactIds.length === 0) return "none";
837
+ return artifactIds.map((id, index) => `${id}=${kinds[index] ?? "<missing-kind>"}`).join(", ");
838
+ }
839
+
840
+ function formatExpectedKindWords(words: string[]): string {
841
+ return words.map(word => `"${word}"`).join(", ");
842
+ }
834
843
 
835
844
  type SurfaceFamily = "web" | "cli" | "native" | "api-package" | "algorithm-math" | "unknown";
836
845
 
@@ -899,9 +908,18 @@ function categorizeComputerChangePath(value: string): UltragoalChangeCategory {
899
908
  }
900
909
 
901
910
  function isComputerControlSurfaceCategory(category: UltragoalChangeCategory): boolean {
902
- return (
903
- category === "code" || category === "generated-binding" || category === "tool" || category === "settings-registry"
904
- );
911
+ // The computer-use red-team suite is conditional, not universal (see the
912
+ // ultragoal SKILL): require it only when the change actually touches
913
+ // computer-control source — the computer tool (`tool`), its settings/registry
914
+ // wiring (`settings-registry`), or computer Rust (`code`). A bare regeneration
915
+ // of the SHARED native binding (`generated-binding`: packages/natives/native/
916
+ // index.{d.ts,js}) is NOT by itself a computer-use change: that file is
917
+ // generated from Rust, so any real computer-use behavior change must also
918
+ // touch one of the categories above and will still trigger the suite. Treating
919
+ // the regenerated aggregate binding as a computer surface forced the suite on
920
+ // unrelated features (e.g. notifications), which the SKILL explicitly warns
921
+ // against, so it is excluded here.
922
+ return category === "code" || category === "tool" || category === "settings-registry";
905
923
  }
906
924
 
907
925
  function isComputerControlSurfaceChangePath(row: UltragoalChangeSetPath): boolean {
@@ -967,7 +985,7 @@ function validateSurfaceArtifactCompatibility(
967
985
  const hasVisual = kinds.some(kind => evidenceKindMatches(kind, ["screenshot", "image", "visual"]));
968
986
  if (!hasBrowser || !hasVisual) {
969
987
  throw new Error(
970
- `qualityGate ${fieldName} for GUI/web surfaces must reference browser automation plus screenshot or image-verdict artifacts`,
988
+ `qualityGate ${fieldName} for GUI/web surfaces must reference browser automation plus screenshot or image-verdict artifacts; surface "${surface}" expected one artifact kind containing one of ${formatExpectedKindWords(["browser", "playwright", "pandawright", "automation"])} and one containing one of ${formatExpectedKindWords(["screenshot", "image", "visual"])}; actual artifact kinds: ${formatActualArtifactKinds(artifactIds, kinds)}`,
971
989
  );
972
990
  }
973
991
  return;
@@ -994,7 +1012,7 @@ function validateSurfaceArtifactCompatibility(
994
1012
  const expected = surfaceFamilies[family];
995
1013
  if (!kinds.some(kind => evidenceKindMatches(kind, expected.evidence))) {
996
1014
  throw new Error(
997
- `qualityGate ${fieldName} for ${expected.label} surfaces must reference compatible artifact kinds`,
1015
+ `qualityGate ${fieldName} for ${expected.label} surfaces must reference compatible artifact kinds; surface "${surface}" expected at least one artifact kind containing one of ${formatExpectedKindWords(expected.evidence)}; actual artifact kinds: ${formatActualArtifactKinds(artifactIds, kinds)}`,
998
1016
  );
999
1017
  }
1000
1018
  }
@@ -1490,6 +1508,20 @@ function isAllowedCliReplayCommand(command: readonly string[]): boolean {
1490
1508
  if (executable === "gjc") return args.length === 1 && ["read", "status"].includes(args[0] ?? "");
1491
1509
  return false;
1492
1510
  }
1511
+ function summarizeBlockedCliReplayCommand(command: readonly string[]): string {
1512
+ const executable = command[0] ? basenameCommand(command[0]) : "<missing>";
1513
+ const argCount = Math.max(0, command.length - 1);
1514
+ return `${JSON.stringify(executable)} with ${argCount} arg${argCount === 1 ? "" : "s"}`;
1515
+ }
1516
+
1517
+ function cliReplayAllowlistDescription(): string {
1518
+ return [
1519
+ '`bun --version`, `node --version`, or deterministic `bun/node -e "console.log(...)"`',
1520
+ "`npm|pnpm|yarn --version` or `npm|pnpm|yarn list`",
1521
+ "read-only `git status|rev-parse|merge-base|diff|show|log` with safe args",
1522
+ "`gjc read` or `gjc status`",
1523
+ ].join("; ");
1524
+ }
1493
1525
 
1494
1526
  function resolveCliReplayCommand(command: string[]): string[] {
1495
1527
  if (basenameCommand(command[0]!) === "bun") return [process.execPath, ...command.slice(1)];
@@ -1578,8 +1610,11 @@ function parseCliReplayRecord(
1578
1610
  if (!command) throw new Error(`qualityGate ${fieldName}.command must be a non-empty string array`);
1579
1611
  if (record.replaySafe !== true)
1580
1612
  throw new Error(`qualityGate ${fieldName}.replaySafe must be true before CLI replay executes`);
1581
- if (!isAllowedCliReplayCommand(command))
1582
- throw new Error(`qualityGate ${fieldName}.command is not in the conservative CLI replay allowlist`);
1613
+ if (!isAllowedCliReplayCommand(command)) {
1614
+ throw new Error(
1615
+ `qualityGate ${fieldName}.command is not in the conservative CLI replay allowlist; command ${summarizeBlockedCliReplayCommand(command)} is blocked. Allowed replay commands: ${cliReplayAllowlistDescription()}. For other commands, provide audited replayExempt metadata with reasonCode, reason, approvedBy, and fallbackArtifactRefs that point to a structurally valid fallback artifact.`,
1616
+ );
1617
+ }
1583
1618
  if (record.normalization !== undefined && record.normalization !== "default") {
1584
1619
  throw new Error(`qualityGate ${fieldName}.normalization must be default when provided`);
1585
1620
  }
@@ -2231,6 +2266,28 @@ function snapshotUpdatedAtMilliseconds(value: unknown): number | null {
2231
2266
  const parsed = Date.parse(trimmed);
2232
2267
  return Number.isFinite(parsed) ? parsed : null;
2233
2268
  }
2269
+
2270
+ function singleSessionLeafId(entries: readonly SessionEntry[]): string | undefined {
2271
+ if (entries.length === 0) return undefined;
2272
+ const parentIds = new Set(
2273
+ entries.map(entry => entry.parentId).filter((parentId): parentId is string => typeof parentId === "string"),
2274
+ );
2275
+ const leafIds = entries.map(entry => entry.id).filter(id => !parentIds.has(id));
2276
+ return leafIds.length === 1 ? leafIds[0] : undefined;
2277
+ }
2278
+
2279
+ async function readCurrentSessionGjcGoalSnapshot(): Promise<unknown | undefined> {
2280
+ const sessionFile = process.env[GJC_SESSION_FILE_ENV]?.trim();
2281
+ if (!sessionFile) return undefined;
2282
+ const fileEntries = await loadEntriesFromFile(sessionFile);
2283
+ const entries = fileEntries.filter((entry): entry is SessionEntry => entry.type !== "session");
2284
+ const leafId = singleSessionLeafId(entries);
2285
+ if (!leafId) return undefined;
2286
+ const context = buildSessionContext(entries, leafId);
2287
+ if (context.mode !== "goal" && context.mode !== "goal_paused") return undefined;
2288
+ const goal = normalizeGoal(context.modeData?.goal);
2289
+ return goal ? { goal } : undefined;
2290
+ }
2234
2291
  async function readGjcGoalSnapshot(input: {
2235
2292
  cwd: string;
2236
2293
  value: string | undefined;
@@ -2240,13 +2297,15 @@ async function readGjcGoalSnapshot(input: {
2240
2297
  errorPrefix: string;
2241
2298
  allowCompletedLegacyBlocker?: boolean;
2242
2299
  }): Promise<unknown> {
2243
- if (!input.value?.trim()) {
2300
+ const snapshot = input.value?.trim()
2301
+ ? await readStructuredValue(input.cwd, input.value)
2302
+ : await readCurrentSessionGjcGoalSnapshot();
2303
+ if (snapshot === undefined) {
2244
2304
  if (!input.required) return undefined;
2245
2305
  throw new Error(
2246
- `${input.errorPrefix} require --gjc-goal-json from a fresh active goal({"op":"get"}) snapshot; this is the GJC goal-mode receipt, not the .gjc/ultragoal/goals.json goal record`,
2306
+ `${input.errorPrefix} require an active GJC goal-mode snapshot from the current session or --gjc-goal-json; this is the GJC goal-mode receipt, not the .gjc/ultragoal/goals.json goal record`,
2247
2307
  );
2248
2308
  }
2249
- const snapshot = await readStructuredValue(input.cwd, input.value);
2250
2309
  const snapshotObject = qualityGateObject(snapshot);
2251
2310
  const detailsObject = qualityGateObject(snapshotObject?.details);
2252
2311
  const goalObject = qualityGateObject(snapshotObject?.goal) ?? qualityGateObject(detailsObject?.goal);
@@ -3331,18 +3390,19 @@ function renderUltragoalHelp(args: readonly string[]): string | null {
3331
3390
  " --status=<value> pending|active|complete|failed|blocked|review_blocked|superseded",
3332
3391
  " --evidence=<value> Completion or checkpoint evidence text",
3333
3392
  " --quality-gate-json=<value> JSON string or path for complete checkpoints",
3334
- ' --gjc-goal-json=<value> JSON string or path containing the current goal({"op":"get"}) snapshot',
3393
+ " --gjc-goal-json=<value> Optional JSON/path override for current goal snapshot; omitted complete checkpoints read current session goal state",
3335
3394
  " --json Output a machine-readable receipt",
3336
3395
  "",
3337
3396
  "COMPLETE CHECKPOINT RECEIPTS",
3338
3397
  " --quality-gate-json must be an object with architectReview, executorQa, and iteration.",
3339
3398
  " executorQa.contractCoverage[] rows require an obligation field; description is not a substitute.",
3340
- ' --gjc-goal-json must contain the active GJC goal-mode snapshot from goal({"op":"get"}), not the .gjc/ultragoal/goals.json goal record.',
3399
+ " Complete checkpoints use the current session's active GJC goal-mode snapshot when --gjc-goal-json is omitted.",
3400
+ " Explicit --gjc-goal-json values must contain an active GJC goal-mode snapshot, not the .gjc/ultragoal/goals.json goal record.",
3341
3401
  " goal.updatedAt may be epoch milliseconds or an ISO timestamp and must be fresh.",
3342
3402
  "",
3343
3403
  "EXAMPLES",
3344
3404
  ' $ gjc ultragoal checkpoint --goal-id G001 --status blocked --evidence "waiting on review"',
3345
- ' $ gjc ultragoal checkpoint --goal-id G001 --status complete --evidence "tests passed" --gjc-goal-json ./goal.json --quality-gate-json ./quality-gate.json --json',
3405
+ ' $ gjc ultragoal checkpoint --goal-id G001 --status complete --evidence "tests passed" --quality-gate-json ./quality-gate.json --json',
3346
3406
  "",
3347
3407
  ].join("\n");
3348
3408
  }
@@ -2,6 +2,7 @@ import { existsSync } from "node:fs";
2
2
  import * as path from "node:path";
3
3
  import { logger } from "@gajae-code/utils";
4
4
  import type { SkillDiscoverySettings } from "../config/skill-settings-defaults";
5
+ import { detectDeepInterviewPlaintextAskLeak } from "../deep-interview/plaintext-gate-guard";
5
6
  import { activeSnapshotPath, modeStatePath as sessionModeStatePath } from "../gjc-runtime/session-layout";
6
7
  import { resolveGjcSessionForRead } from "../gjc-runtime/session-resolution";
7
8
  import { ModeStateSchema, SkillActiveStateSchema } from "../gjc-runtime/state-schema";
@@ -675,6 +676,52 @@ async function readCurrentGoalObjectiveFromSessionFile(sessionFile: string | und
675
676
  return typeof objective === "string" && objective.trim().length > 0 ? objective.trim() : null;
676
677
  }
677
678
 
679
+ async function readLatestAssistantTextFromSessionFile(sessionFile: string | undefined): Promise<string | null> {
680
+ const trimmed = sessionFile?.trim();
681
+ if (!trimmed) return null;
682
+ let entries: SessionEntry[];
683
+ try {
684
+ entries = (await loadEntriesFromFile(trimmed)).filter((entry): entry is SessionEntry => entry.type !== "session");
685
+ } catch {
686
+ return null;
687
+ }
688
+ if (entries.length === 0) return null;
689
+ const context = buildSessionContext(entries);
690
+ for (let index = context.messages.length - 1; index >= 0; index--) {
691
+ const message = context.messages[index];
692
+ if (message?.role !== "assistant") continue;
693
+ const text = message.content
694
+ .filter(block => block.type === "text")
695
+ .map(block => block.text)
696
+ .join("");
697
+ const trimmedText = text.trim();
698
+ return trimmedText.length > 0 ? trimmedText : null;
699
+ }
700
+ return null;
701
+ }
702
+
703
+ async function shouldRescueDeepInterviewPlaintextAskLeak(
704
+ skill: GjcWorkflowSkill,
705
+ state: ModeState | null,
706
+ cwd: string,
707
+ sessionFile: string | undefined,
708
+ ): Promise<boolean> {
709
+ if (skill !== "deep-interview") return false;
710
+ if (state?.active !== true) return false;
711
+ const phase = String(state.current_phase ?? "")
712
+ .trim()
713
+ .toLowerCase();
714
+ if (DEEP_INTERVIEW_ABORT_PHASES.has(phase)) return false;
715
+ if (await deepInterviewSpecCrystallized(state, cwd)) return false;
716
+ const latestAssistantText = await readLatestAssistantTextFromSessionFile(sessionFile);
717
+ if (!latestAssistantText) return false;
718
+ return detectDeepInterviewPlaintextAskLeak(latestAssistantText) !== null;
719
+ }
720
+
721
+ function buildDeepInterviewPlaintextAskLeakMessage(statePath: string): string {
722
+ return `GJC deep-interview emitted a Deep Interview question/options block as plain text (${statePath}). It must not wait for a prose answer. Continue immediately by calling the ask tool with the Restate gate question and options: Yes, crystallize; Adjust wording; Missing scope; plus free text/custom input.`;
723
+ }
724
+
678
725
  export async function buildActiveUltragoalPromptContext(input: UserPromptSubmitStateInput): Promise<string | null> {
679
726
  const resolvedSessionId = await resolveBoundarySessionId(input.cwd, input.sessionId);
680
727
  const visibleModeState = await readVisibleModeState(input.cwd, "ultragoal", resolvedSessionId, input.stateDir);
@@ -756,6 +803,16 @@ export async function buildSkillStopOutput(input: StopHookInput): Promise<Record
756
803
  systemMessage: recoveryMessage,
757
804
  };
758
805
  }
806
+ if (await shouldRescueDeepInterviewPlaintextAskLeak(entry.skill, modeState, input.cwd, input.sessionFile)) {
807
+ const statePath = modeStatePath(input.cwd, entry.skill, resolvedSessionId);
808
+ const rescueMessage = buildDeepInterviewPlaintextAskLeakMessage(statePath);
809
+ return {
810
+ decision: "block",
811
+ reason: rescueMessage,
812
+ stopReason: "gjc_skill_deep_interview_plaintext_ask_leak",
813
+ systemMessage: rescueMessage,
814
+ };
815
+ }
759
816
  if (modeStateReleasesStop(modeState, handoffRequired)) {
760
817
  // A mode-state that claims it releases the Stop block must agree with
761
818
  // authoritative durable state. If a stale/incoherent mode-state would