@gajae-code/coding-agent 0.11.10 → 0.11.11

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 (111) hide show
  1. package/CHANGELOG.md +38 -0
  2. package/dist/types/cli.d.ts +8 -1
  3. package/dist/types/config/model-profile-activation.d.ts +1 -0
  4. package/dist/types/config/model-profile-contract.d.ts +2 -0
  5. package/dist/types/config/settings-schema.d.ts +57 -2
  6. package/dist/types/config/settings.d.ts +1 -0
  7. package/dist/types/extensibility/extensions/types.d.ts +21 -0
  8. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  9. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
  10. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  11. package/dist/types/modes/acp/acp-agent.d.ts +14 -9
  12. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  13. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  14. package/dist/types/modes/components/welcome.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +5 -1
  16. package/dist/types/runtime/memory-domain.d.ts +9 -0
  17. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  18. package/dist/types/runtime/memory-guard.d.ts +39 -0
  19. package/dist/types/runtime/memory-limit.d.ts +11 -0
  20. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  21. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  22. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  23. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  24. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  25. package/dist/types/sdk/bus/index.d.ts +3 -1
  26. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  27. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
  28. package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
  29. package/dist/types/sdk/host/control/operations.d.ts +1 -0
  30. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  31. package/dist/types/sdk/startup-capability.d.ts +6 -0
  32. package/dist/types/session/agent-session.d.ts +9 -11
  33. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  34. package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
  35. package/dist/types/session/session-manager.d.ts +34 -0
  36. package/dist/types/task/executor.d.ts +6 -0
  37. package/dist/types/task/types.d.ts +36 -0
  38. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  39. package/dist/types/tools/resource-gc.d.ts +19 -0
  40. package/package.json +8 -8
  41. package/scripts/generate-sdk-operation-inventory.ts +2 -0
  42. package/scripts/run-test-manifest.ts +6 -4
  43. package/src/cli/args.ts +14 -8
  44. package/src/cli/fast-help.ts +1 -1
  45. package/src/cli.ts +48 -2
  46. package/src/commands/sdk.ts +91 -7
  47. package/src/config/model-profile-activation.ts +1 -0
  48. package/src/config/model-profile-contract.ts +13 -0
  49. package/src/config/model-profiles.ts +24 -0
  50. package/src/config/settings-schema.ts +61 -2
  51. package/src/config/settings.ts +8 -0
  52. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
  53. package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
  54. package/src/extensibility/extensions/runner.ts +4 -0
  55. package/src/extensibility/extensions/types.ts +14 -0
  56. package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
  57. package/src/gjc-runtime/deep-interview-state.ts +25 -3
  58. package/src/gjc-runtime/linux-proc.ts +70 -36
  59. package/src/gjc-runtime/ralplan-runtime.ts +252 -1
  60. package/src/gjc-runtime/state-runtime.ts +48 -11
  61. package/src/internal-urls/docs-index.generated.ts +4 -4
  62. package/src/internal-urls/local-protocol.ts +23 -4
  63. package/src/modes/acp/acp-agent.ts +554 -60
  64. package/src/modes/acp/acp-event-mapper.ts +93 -6
  65. package/src/modes/components/model-selector.ts +15 -1
  66. package/src/modes/components/notifications-settings-editor.ts +1 -1
  67. package/src/modes/components/queue-pane.ts +55 -9
  68. package/src/modes/components/queued-message-selector.ts +53 -10
  69. package/src/modes/components/tool-status-header.ts +1 -4
  70. package/src/modes/components/welcome.ts +18 -3
  71. package/src/modes/controllers/command-controller.ts +4 -0
  72. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  73. package/src/modes/controllers/input-controller.ts +44 -3
  74. package/src/modes/interactive-mode.ts +65 -26
  75. package/src/modes/runtime-init.ts +1 -0
  76. package/src/prompts/agents/executor.md +1 -1
  77. package/src/runtime/memory-domain.ts +66 -0
  78. package/src/runtime/memory-guard-contract.ts +73 -0
  79. package/src/runtime/memory-guard.ts +292 -0
  80. package/src/runtime/memory-limit.ts +50 -0
  81. package/src/runtime-mcp/manager.ts +26 -6
  82. package/src/sdk/acp/adapter.ts +8 -1
  83. package/src/sdk/acp/final-text.ts +40 -0
  84. package/src/sdk/acp/mcp.ts +26 -0
  85. package/src/sdk/broker/lifecycle.ts +82 -1
  86. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  87. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  88. package/src/sdk/bus/config.ts +1 -1
  89. package/src/sdk/bus/index.ts +162 -22
  90. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  91. package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
  92. package/src/sdk/bus/telegram-daemon.ts +531 -53
  93. package/src/sdk/host/control/dispatch.ts +2 -0
  94. package/src/sdk/host/control/operations.ts +1 -0
  95. package/src/sdk/host/host.ts +2 -2
  96. package/src/sdk/lifecycle-session.ts +19 -3
  97. package/src/sdk/protocol/operation-inventory.generated.json +47 -0
  98. package/src/sdk/protocol/operation-registry.ts +1 -0
  99. package/src/sdk/session.ts +12 -1
  100. package/src/sdk/startup-capability.ts +6 -0
  101. package/src/session/agent-session.ts +256 -142
  102. package/src/session/fallback-chain-controller.ts +47 -8
  103. package/src/session/internal/managed-session-scope.ts +40 -10
  104. package/src/session/internal/native-publish-outcome.ts +8 -3
  105. package/src/session/session-manager.ts +608 -25
  106. package/src/task/executor.ts +11 -3
  107. package/src/task/index.ts +2 -0
  108. package/src/task/types.ts +22 -11
  109. package/src/task/ultragoal-redteam-activation.ts +75 -0
  110. package/src/tools/resource-gc.ts +496 -120
  111. package/src/workflow/workflow-intent-diff.ts +4 -1
@@ -201,6 +201,7 @@ export class ExtensionRunner {
201
201
  #getAllToolsFn: ExtensionContext["getAllTools"] = () => [];
202
202
  #getResolveToolFn: ExtensionContext["resolveTool"] = () => undefined;
203
203
  #cycleModelFn: ExtensionContextActions["cycleModel"] = undefined;
204
+ #setModelProfileFn: ExtensionContextActions["setModelProfile"] = undefined;
204
205
  #cycleThinkingLevelFn: ExtensionContextActions["cycleThinkingLevel"] = undefined;
205
206
  #setQueueModeFn: ExtensionContextActions["setQueueMode"] = undefined;
206
207
  #getSkillStateFn: ExtensionContextActions["getSkillState"] = undefined;
@@ -313,6 +314,7 @@ export class ExtensionRunner {
313
314
  this.#getAllToolsFn = contextActions.getAllTools ?? (() => []);
314
315
  this.#getResolveToolFn = contextActions.resolveTool ?? (() => undefined);
315
316
  this.#cycleModelFn = contextActions.cycleModel;
317
+ this.#setModelProfileFn = contextActions.setModelProfile;
316
318
  this.#cycleThinkingLevelFn = contextActions.cycleThinkingLevel;
317
319
  this.#setQueueModeFn = contextActions.setQueueMode;
318
320
  this.#getSkillStateFn = contextActions.getSkillState;
@@ -562,6 +564,7 @@ export class ExtensionRunner {
562
564
  getAllTools: () => this.#getAllToolsFn(),
563
565
  resolveTool: name => this.#getResolveToolFn(name),
564
566
  cycleModel: async () => await this.#cycleModelFn?.(),
567
+ setModelProfile: async name => (await this.#setModelProfileFn?.(name)) ?? false,
565
568
  cycleThinkingLevel: () => this.#cycleThinkingLevelFn?.(),
566
569
  setQueueMode: (kind, mode) => this.#setQueueModeFn?.(kind, mode) ?? false,
567
570
  invokeSkill: async (name, args) => await this.#invokeSkillFn?.(name, args),
@@ -581,6 +584,7 @@ export class ExtensionRunner {
581
584
  setSdkPermissionProvider: provider => this.#setSdkPermissionProviderFn?.(provider),
582
585
  sdkBindings: () => [
583
586
  ...(this.#cycleModelFn ? ["cycleModel"] : []),
587
+ ...(this.#setModelProfileFn ? ["setModelProfile"] : []),
584
588
  ...(this.#cycleThinkingLevelFn ? ["cycleThinkingLevel"] : []),
585
589
  ...(this.#setQueueModeFn ? ["setQueueMode"] : []),
586
590
  ...(this.#getSkillStateFn ? ["getSkillState"] : []),
@@ -290,6 +290,18 @@ export interface ExtensionTranscriptEntry {
290
290
  textSummary: string;
291
291
  ts: string;
292
292
  body?: string;
293
+ /**
294
+ * Durable, image-free message blocks used by rich transcript consumers such
295
+ * as ACP replay. Binary image payloads intentionally remain unavailable.
296
+ */
297
+ content?: Array<
298
+ | { type: "text"; text: string }
299
+ | { type: "thinking"; thinking: string }
300
+ | { type: "toolCall"; id: string; name: string; arguments: unknown }
301
+ >;
302
+ toolCallId?: string;
303
+ toolName?: string;
304
+ isError?: boolean;
293
305
  }
294
306
 
295
307
  export interface ContextUsage {
@@ -361,6 +373,7 @@ export interface ExtensionContext {
361
373
  resolveTool(name: string): Pick<Tool, "safeSummary" | "safeSummaryFields"> | undefined;
362
374
  /** Session control seams used by the SDK host. */
363
375
  cycleModel(): Promise<{ model: Model; thinkingLevel: ThinkingLevel | undefined } | undefined>;
376
+ setModelProfile?(name: string): Promise<boolean>;
364
377
  cycleThinkingLevel(): ThinkingLevel | undefined;
365
378
  setQueueMode(kind: "steering" | "follow_up" | "interrupt", mode: unknown): boolean;
366
379
  getSkillState(): unknown;
@@ -1429,6 +1442,7 @@ export interface ExtensionContextActions {
1429
1442
  clearContext?: () => Promise<boolean>;
1430
1443
  /** Session control and query seams exposed to the per-session SDK host. */
1431
1444
  cycleModel?: () => Promise<{ model: Model; thinkingLevel: ThinkingLevel | undefined } | undefined>;
1445
+ setModelProfile?: (name: string) => Promise<boolean>;
1432
1446
  cycleThinkingLevel?: () => ThinkingLevel | undefined;
1433
1447
  setQueueMode?: (kind: "steering" | "follow_up" | "interrupt", mode: unknown) => boolean;
1434
1448
  getSkillState?: () => unknown;
@@ -207,8 +207,22 @@ export function deriveAmbiguityMilestone(
207
207
 
208
208
  export function scoreToUnits(score: number): number {
209
209
  if (!Number.isFinite(score) || score < 0 || score > 1) throw new RangeError("score must be finite in [0, 1]");
210
- const units = score * 10_000;
211
- if (!Number.isSafeInteger(units)) throw new RangeError("score must be expressed in integral 1e-4 units");
210
+ /**
211
+ * Scores carry at most four decimal places, but `score * 10_000` misses the
212
+ * integer grid for ordinary values: `0.69 * 10_000` is `6900.000000000001` and
213
+ * `0.07 * 10_000` is `700.0000000000001`. Ambiguity is fed straight back in as
214
+ * `units / 10_000`, so testing the float product rejected most real scores.
215
+ *
216
+ * Decide from the shortest round-trip decimal instead. That is exact: every
217
+ * genuine 1e-4 value has at most four fraction digits, while off-grid
218
+ * precision keeps its digits (`0.00005`, `0.05000000000000001`) and is
219
+ * rejected. An epsilon on the product cannot separate those two cases -- both
220
+ * residuals are ~1e-13. The `[0, 1]` guard plus the four-digit cap already
221
+ * bound the result to an integer in `[0, 10_000]`.
222
+ */
223
+ const decimal = /^(\d+)(?:\.(\d{1,4}))?$/.exec(String(score));
224
+ if (!decimal) throw new RangeError("score must be expressed in integral 1e-4 units");
225
+ const units = Number(decimal[1]) * 10_000 + Number((decimal[2] ?? "").padEnd(4, "0"));
212
226
  return units;
213
227
  }
214
228
 
@@ -937,8 +937,20 @@ function canonicalJsonValue(value: unknown): unknown {
937
937
  if (value === undefined) throw new TypeError("canonical JSON rejects undefined");
938
938
  return value;
939
939
  }
940
+ /**
941
+ * Object properties set to `undefined` are dropped, matching `JSON.stringify`:
942
+ * an omitted optional key and a present-but-undefined one must digest
943
+ * identically. Decoded requests routinely materialize every optional key (an
944
+ * omitted `targeting` becomes `targeting: undefined`), so rejecting them here
945
+ * would make every request carrying an absent optional field unserializable.
946
+ * Array elements and the top-level value stay strict: `undefined` there is a
947
+ * real encoding bug, not an absent field.
948
+ */
940
949
  const output: Record<string, unknown> = {};
941
- for (const key of Object.keys(value).sort()) output[key] = canonicalJsonValue(value[key]);
950
+ for (const key of Object.keys(value).sort()) {
951
+ if (value[key] === undefined) continue;
952
+ output[key] = canonicalJsonValue(value[key]);
953
+ }
942
954
  return output;
943
955
  }
944
956
 
@@ -1004,7 +1016,7 @@ export function deepInterviewAnswerIdentityEqual(
1004
1016
  component: a.component ?? null,
1005
1017
  dimension: a.dimension ?? null,
1006
1018
  question_text: a.question_text ?? null,
1007
- question_hash: a.question_hash,
1019
+ question_hash: a.question_hash ?? null,
1008
1020
  selected_options: a.selected_options ?? [],
1009
1021
  custom_input: a.custom_input ?? null,
1010
1022
  }) ===
@@ -1016,7 +1028,7 @@ export function deepInterviewAnswerIdentityEqual(
1016
1028
  component: b.component ?? null,
1017
1029
  dimension: b.dimension ?? null,
1018
1030
  question_text: b.question_text ?? null,
1019
- question_hash: b.question_hash,
1031
+ question_hash: b.question_hash ?? null,
1020
1032
  selected_options: b.selected_options ?? [],
1021
1033
  custom_input: b.custom_input ?? null,
1022
1034
  })
@@ -1061,10 +1073,20 @@ export function applyDeepInterviewRoundResultV1(
1061
1073
  typeof value === "number" && Number.isFinite(value) && value >= 0 && value <= 1;
1062
1074
  if (!dimensions.every(dimension => finiteScore(result.global_scores[dimension])))
1063
1075
  throw new Error("DI_STATE_SCHEMA_INVALID");
1076
+ /**
1077
+ * Rounds must be scored in order, but the Round 0 topology gate is not a
1078
+ * scorable round: the recorder persists it as an `answered` shell to bind the
1079
+ * locked intent contract, `apply-round-result` rejects `--round 0`, and
1080
+ * `validateDeepInterviewV1Envelope` refuses to persist a round-0 record that
1081
+ * is anything other than an answered, score-less shell. Counting it here would
1082
+ * deadlock every later round forever, so the gate is excluded by identity
1083
+ * rather than by an ordering range.
1084
+ */
1064
1085
  if (
1065
1086
  rounds.some(
1066
1087
  round =>
1067
1088
  round.lifecycle !== "scored" &&
1089
+ round.round !== 0 &&
1068
1090
  (round.round < shell.round || (round.round === shell.round && round.round_key < shell.round_key)),
1069
1091
  )
1070
1092
  )
@@ -1,26 +1,30 @@
1
1
  /**
2
- * Shared helpers for reading Linux `/proc/<pid>/stat` process start time.
2
+ * Shared helpers for reading Linux `/proc/<pid>/stat` process identity fields.
3
3
  *
4
4
  * The `comm` field (field 2, wrapped in parentheses) may itself contain spaces
5
5
  * and parentheses, so the only robust anchor is the *last* `)` in the stat
6
6
  * string. Field 22 (the process start time in clock ticks since boot) is the
7
- * 20th whitespace-separated token after that closing paren (index 19).
8
- *
9
- * Every caller previously parsed this format independently, with subtly
10
- * different failure handling. This module fails closed: any malformed input
11
- * (missing `)`, non-numeric field 22, unreadable `/proc` file, non-Linux
12
- * platform) yields `null` rather than an inconsistent sentinel.
7
+ * 20th whitespace-separated token after that closing paren (index 19). Field 7
8
+ * (`tty_nr`) is the 5th token after the closing paren (index 4).
13
9
  */
14
10
 
15
11
  import * as nodeFsSync from "node:fs";
16
12
  import * as nodeFs from "node:fs/promises";
17
13
 
18
- /**
19
- * Parse field 22 (process start time, in clock ticks since boot) from one
20
- * `/proc/<pid>/stat` record. Returns the raw numeric token, or `null` when the
21
- * record shape is malformed or field 22 is absent/non-numeric.
22
- */
23
- export function parseLinuxProcStartTime(stat: string | null | undefined): string | null {
14
+ export interface LinuxProcStatIdentity {
15
+ startTime: string;
16
+ ttyDevice: string;
17
+ }
18
+
19
+ export type LinuxProcPidProbeResult =
20
+ | ({ kind: "live" } & LinuxProcStatIdentity)
21
+ | { kind: "absent" }
22
+ | {
23
+ kind: "unverifiable";
24
+ reason: "unsupported_platform" | "invalid_pid" | "permission_denied" | "read_error" | "malformed_stat";
25
+ };
26
+
27
+ function parseLinuxProcIdentity(stat: string | null | undefined): LinuxProcStatIdentity | null {
24
28
  if (!stat || stat.includes("\0") || stat.includes("\r")) return null;
25
29
  const record = stat.endsWith("\n") ? stat.slice(0, -1) : stat;
26
30
  if (!record || record.includes("\n")) return null;
@@ -32,40 +36,70 @@ export function parseLinuxProcStartTime(stat: string | null | undefined): string
32
36
  const suffix = record.slice(close + 1);
33
37
  if (!/^[ \t]+/.test(suffix)) return null;
34
38
  const fields = suffix.trim().split(/[ \t]+/);
35
- if (fields.length < 20 || !/^[RSDTtXZPI]$/.test(fields[0])) return null;
36
-
39
+ if (fields.length < 20 || !/^[A-Za-z]$/.test(fields[0])) return null;
40
+ const ttyDevice = fields[4];
37
41
  const startTime = fields[19];
38
- return /^\d+$/.test(startTime) ? startTime : null;
42
+ if (!ttyDevice || !/^-?\d+$/.test(ttyDevice) || !/^\d+$/.test(startTime)) return null;
43
+ return { startTime, ttyDevice };
39
44
  }
40
45
 
41
- /**
42
- * Read `/proc/<pid>/stat` synchronously and return the parsed start time.
43
- * Returns `null` on non-Linux platforms, unreadable files, or malformed input.
44
- */
45
- export function readLinuxProcStartTimeSync(pid: number): string | null {
46
- if (process.platform !== "linux") return null;
47
- if (!Number.isSafeInteger(pid) || pid <= 0) return null;
46
+ function classifyProcReadError(error: unknown): Extract<LinuxProcPidProbeResult, { kind: "absent" | "unverifiable" }> {
47
+ const code = (error as NodeJS.ErrnoException | undefined)?.code;
48
+ if (code === "ENOENT" || code === "ESRCH") return { kind: "absent" };
49
+ if (code === "EACCES" || code === "EPERM") return { kind: "unverifiable", reason: "permission_denied" };
50
+ return { kind: "unverifiable", reason: "read_error" };
51
+ }
52
+
53
+ /** Parse field 22 (start time) from a `/proc/<pid>/stat` record. */
54
+ export function parseLinuxProcStartTime(stat: string | null | undefined): string | null {
55
+ return parseLinuxProcIdentity(stat)?.startTime ?? null;
56
+ }
57
+
58
+ /** Parse field 7 (`tty_nr`) from a `/proc/<pid>/stat` record. */
59
+ export function parseLinuxProcTtyDevice(stat: string | null | undefined): string | null {
60
+ return parseLinuxProcIdentity(stat)?.ttyDevice ?? null;
61
+ }
62
+
63
+ export function probeLinuxProcPidSync(pid: number): LinuxProcPidProbeResult {
64
+ if (!Number.isSafeInteger(pid) || pid <= 0) return { kind: "unverifiable", reason: "invalid_pid" };
65
+ if (process.platform !== "linux") return { kind: "unverifiable", reason: "unsupported_platform" };
48
66
  let stat: string;
49
67
  try {
50
68
  stat = nodeFsSync.readFileSync(`/proc/${pid}/stat`, "utf8");
51
- } catch {
52
- return null;
69
+ } catch (error) {
70
+ return classifyProcReadError(error);
53
71
  }
54
- return parseLinuxProcStartTime(stat);
72
+ const identity = parseLinuxProcIdentity(stat);
73
+ return identity ? { kind: "live", ...identity } : { kind: "unverifiable", reason: "malformed_stat" };
55
74
  }
56
75
 
57
- /**
58
- * Read `/proc/<pid>/stat` asynchronously and return the parsed start time.
59
- * Returns `null` on non-Linux platforms, unreadable files, or malformed input.
60
- */
61
- export async function readLinuxProcStartTime(pid: number): Promise<string | null> {
62
- if (process.platform !== "linux") return null;
63
- if (!Number.isSafeInteger(pid) || pid <= 0) return null;
76
+ export async function probeLinuxProcPid(pid: number): Promise<LinuxProcPidProbeResult> {
77
+ if (!Number.isSafeInteger(pid) || pid <= 0) return { kind: "unverifiable", reason: "invalid_pid" };
78
+ if (process.platform !== "linux") return { kind: "unverifiable", reason: "unsupported_platform" };
64
79
  let stat: string;
65
80
  try {
66
81
  stat = await nodeFs.readFile(`/proc/${pid}/stat`, "utf8");
67
- } catch {
68
- return null;
82
+ } catch (error) {
83
+ return classifyProcReadError(error);
69
84
  }
70
- return parseLinuxProcStartTime(stat);
85
+ const identity = parseLinuxProcIdentity(stat);
86
+ return identity ? { kind: "live", ...identity } : { kind: "unverifiable", reason: "malformed_stat" };
87
+ }
88
+
89
+ /**
90
+ * Read `/proc/<pid>/stat` synchronously and return the parsed start time.
91
+ * Returns `null` when the probe is absent or unverifiable.
92
+ */
93
+ export function readLinuxProcStartTimeSync(pid: number): string | null {
94
+ const probe = probeLinuxProcPidSync(pid);
95
+ return probe.kind === "live" ? probe.startTime : null;
96
+ }
97
+
98
+ /**
99
+ * Read `/proc/<pid>/stat` asynchronously and return the parsed start time.
100
+ * Returns `null` when the probe is absent or unverifiable.
101
+ */
102
+ export async function readLinuxProcStartTime(pid: number): Promise<string | null> {
103
+ const probe = await probeLinuxProcPid(pid);
104
+ return probe.kind === "live" ? probe.startTime : null;
71
105
  }
@@ -1,5 +1,6 @@
1
1
  import { createHash, randomBytes } from "node:crypto";
2
2
  import * as fs from "node:fs/promises";
3
+ import * as os from "node:os";
3
4
  import * as path from "node:path";
4
5
  import { syncSkillActiveState } from "../skill-state/active-state";
5
6
  import { buildRalplanHudSummary } from "../skill-state/workflow-hud";
@@ -21,7 +22,7 @@ import {
21
22
  RepositoryBindingError,
22
23
  } from "./repository-binding";
23
24
  import { GJC_RALPLAN_ARTIFACT_ENV, isRestrictedRoleAgentBash } from "./restricted-role-agent-bash";
24
- import { modeStatePath, sessionPlansDir } from "./session-layout";
25
+ import { gjcRoot, modeStatePath, sessionPlansDir } from "./session-layout";
25
26
  import { resolveGjcSessionForWrite, writeSessionActivityMarker } from "./session-resolution";
26
27
  import { migrateWorkflowState } from "./state-migrations";
27
28
  import { runNativeStateCommand } from "./state-runtime";
@@ -63,6 +64,231 @@ export interface RalplanCommandResult {
63
64
 
64
65
  const KNOWN_STAGES = ["planner", "architect", "critic", "revision", "post-interview", "adr", "final"] as const;
65
66
  type RalplanStage = (typeof KNOWN_STAGES)[number];
67
+ /** Default consensus iterations (planner + revision openers) per run. Matches SKILL.md re-review cap. */
68
+ export const RALPLAN_DEFAULT_MAX_ITERATIONS = 5;
69
+ /** Inclusive upper bound for `gjc.ralplan.maxIterations` settings overrides. */
70
+ export const RALPLAN_MAX_ITERATIONS_LIMIT = 20;
71
+ /** Operator-visible stuck signal for headless/CI orchestration (#3165). */
72
+ export const PLANNING_STUCK_MARKER = "PLANNING-STUCK";
73
+
74
+ const RALPLAN_ITERATION_OPENER_STAGES = new Set<RalplanStage>(["planner", "revision"]);
75
+
76
+ export type RalplanIterationCapDecision =
77
+ | {
78
+ allowed: true;
79
+ currentIterations: number;
80
+ projectedIterations: number;
81
+ maxIterations: number;
82
+ }
83
+ | {
84
+ allowed: false;
85
+ currentIterations: number;
86
+ projectedIterations: number;
87
+ maxIterations: number;
88
+ reason: string;
89
+ };
90
+
91
+ /**
92
+ * Pure consensus-iteration budget gate (#3165).
93
+ *
94
+ * A `planner` or `revision` write opens a new iteration (same definition as
95
+ * `summarizeRalplanIndex`). Other stages never open iterations and are always
96
+ * allowed by this gate — including `final` after the cap is already reached.
97
+ *
98
+ * `iterationFloor` raises the observed opener count when on-disk evidence or a
99
+ * recovered ledger is higher than the parsed index (fail-closed vs wipe/truncate).
100
+ */
101
+ export function evaluateRalplanIterationCap(input: {
102
+ rows: readonly RalplanIndexRow[];
103
+ stage: string;
104
+ maxIterations?: number;
105
+ /** Minimum opener count (e.g. on-disk stage-*-{planner,revision}.md). */
106
+ iterationFloor?: number;
107
+ }): RalplanIterationCapDecision {
108
+ const maxIterations =
109
+ typeof input.maxIterations === "number" &&
110
+ Number.isInteger(input.maxIterations) &&
111
+ input.maxIterations >= 1 &&
112
+ input.maxIterations <= RALPLAN_MAX_ITERATIONS_LIMIT
113
+ ? input.maxIterations
114
+ : RALPLAN_DEFAULT_MAX_ITERATIONS;
115
+ const fromIndex = summarizeRalplanIndex(input.rows).iteration;
116
+ const floor =
117
+ typeof input.iterationFloor === "number" && Number.isInteger(input.iterationFloor) && input.iterationFloor > 0
118
+ ? input.iterationFloor
119
+ : 0;
120
+ const currentIterations = Math.max(fromIndex, floor);
121
+ if (!RALPLAN_ITERATION_OPENER_STAGES.has(input.stage as RalplanStage)) {
122
+ return {
123
+ allowed: true,
124
+ currentIterations,
125
+ projectedIterations: currentIterations,
126
+ maxIterations,
127
+ };
128
+ }
129
+ const projectedIterations = currentIterations + 1;
130
+ if (projectedIterations > maxIterations) {
131
+ const ledgerNote = floor > fromIndex ? ` (ledger under-count: index=${fromIndex}, on-disk openers=${floor})` : "";
132
+ return {
133
+ allowed: false,
134
+ currentIterations,
135
+ projectedIterations,
136
+ maxIterations,
137
+ reason:
138
+ `ralplan consensus iteration cap exceeded: opening ${input.stage} would start ` +
139
+ `iteration ${projectedIterations} (max ${maxIterations})${ledgerNote}`,
140
+ };
141
+ }
142
+ return {
143
+ allowed: true,
144
+ currentIterations,
145
+ projectedIterations,
146
+ maxIterations,
147
+ };
148
+ }
149
+
150
+ /** Filename pattern for persisted planner/revision stage artifacts. */
151
+ const OPENER_ARTIFACT_RE = /^stage-\d{2,}-(planner|revision)\.md$/;
152
+
153
+ /**
154
+ * Count on-disk planner/revision stage artifacts for a run. Used as a floor when
155
+ * `index.jsonl` is missing, empty, truncated, or otherwise under-counts openers.
156
+ */
157
+ export async function countRalplanOnDiskOpeners(cwd: string, sessionId: string, runId: string): Promise<number> {
158
+ const runDir = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId);
159
+ try {
160
+ const entries = await fs.readdir(runDir);
161
+ let count = 0;
162
+ for (const name of entries) {
163
+ if (OPENER_ARTIFACT_RE.test(name)) count += 1;
164
+ }
165
+ return count;
166
+ } catch {
167
+ return 0;
168
+ }
169
+ }
170
+
171
+ /**
172
+ * Load index rows for cap enforcement. Unlike HUD reads, returns structural
173
+ * signals so callers can fail closed when the ledger is empty/malformed while
174
+ * opener artifacts already exist on disk.
175
+ */
176
+ export async function loadRalplanIndexForCap(
177
+ cwd: string,
178
+ sessionId: string,
179
+ runId: string,
180
+ ): Promise<{ rows: RalplanIndexRow[]; indexPresent: boolean; parseableLines: number; rawLineCount: number }> {
181
+ const indexPath = path.join(sessionPlansDir(cwd, sessionId), "ralplan", runId, "index.jsonl");
182
+ try {
183
+ const text = await fs.readFile(indexPath, "utf8");
184
+ const lines = text.split(/\r?\n/).filter(line => line.trim().length > 0);
185
+ const rows: RalplanIndexRow[] = [];
186
+ for (const line of lines) {
187
+ const row = parseRalplanIndexLine(line);
188
+ if (row) rows.push(row);
189
+ }
190
+ return {
191
+ rows,
192
+ indexPresent: true,
193
+ parseableLines: rows.length,
194
+ rawLineCount: lines.length,
195
+ };
196
+ } catch (error) {
197
+ const code =
198
+ error && typeof error === "object" && "code" in error ? (error as { code?: string }).code : undefined;
199
+ if (code === "ENOENT") {
200
+ return { rows: [], indexPresent: false, parseableLines: 0, rawLineCount: 0 };
201
+ }
202
+ // Unreadable index: treat as present-but-untrusted empty parse.
203
+ return { rows: [], indexPresent: true, parseableLines: 0, rawLineCount: 0 };
204
+ }
205
+ }
206
+
207
+ function parseMaxIterationsValue(value: unknown): number | null {
208
+ return typeof value === "number" &&
209
+ Number.isFinite(value) &&
210
+ Number.isInteger(value) &&
211
+ value >= 1 &&
212
+ value <= RALPLAN_MAX_ITERATIONS_LIMIT
213
+ ? value
214
+ : null;
215
+ }
216
+
217
+ async function readSettingsMaxIterations(settingsPath: string): Promise<number | null> {
218
+ try {
219
+ const raw = await Bun.file(settingsPath).text();
220
+ const parsed = JSON.parse(raw) as Record<string, unknown>;
221
+ const flat = parseMaxIterationsValue(parsed["gjc.ralplan.maxIterations"]);
222
+ if (flat !== null) return flat;
223
+ const gjc = parsed.gjc;
224
+ if (gjc && typeof gjc === "object") {
225
+ const ralplan = (gjc as Record<string, unknown>).ralplan;
226
+ if (ralplan && typeof ralplan === "object") {
227
+ return parseMaxIterationsValue((ralplan as Record<string, unknown>).maxIterations);
228
+ }
229
+ }
230
+ return null;
231
+ } catch {
232
+ return null;
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Resolve ralplan consensus iteration cap. Project `./.gjc/settings.json` overrides
238
+ * user settings, else default 5.
239
+ */
240
+ export async function resolveRalplanMaxIterations(cwd: string): Promise<{ maxIterations: number; source: string }> {
241
+ const projectPath = path.join(gjcRoot(cwd), "settings.json");
242
+ const project = await readSettingsMaxIterations(projectPath);
243
+ if (project !== null) return { maxIterations: project, source: projectPath };
244
+ const userDir = process.env.GJC_CONFIG_DIR?.trim() || path.join(os.homedir(), ".gjc");
245
+ const userPath = path.join(userDir, "settings.json");
246
+ const user = await readSettingsMaxIterations(userPath);
247
+ if (user !== null) return { maxIterations: user, source: userPath };
248
+ return { maxIterations: RALPLAN_DEFAULT_MAX_ITERATIONS, source: "default" };
249
+ }
250
+
251
+ function buildPlanningStuckResult(input: {
252
+ json: boolean;
253
+ stage: RalplanStage;
254
+ stageN: number;
255
+ runId: string;
256
+ decision: Extract<RalplanIterationCapDecision, { allowed: false }>;
257
+ source: string;
258
+ }): RalplanCommandResult {
259
+ const detail =
260
+ `${PLANNING_STUCK_MARKER}: ${input.decision.reason} ` +
261
+ `(run_id=${input.runId}, stage=${input.stage}, stage_n=${input.stageN}, source=${input.source}). ` +
262
+ `Stop opening planner/revision passes; escalate the best existing plan via final/pending-approval without auto-implementation.`;
263
+ if (input.json) {
264
+ return {
265
+ status: 3,
266
+ stdout: `${JSON.stringify(
267
+ {
268
+ ok: false,
269
+ planning_stuck: true,
270
+ marker: PLANNING_STUCK_MARKER,
271
+ run_id: input.runId,
272
+ stage: input.stage,
273
+ stage_n: input.stageN,
274
+ iteration: input.decision.currentIterations,
275
+ projected_iteration: input.decision.projectedIterations,
276
+ max_iterations: input.decision.maxIterations,
277
+ max_iterations_source: input.source,
278
+ reason: input.decision.reason,
279
+ },
280
+ null,
281
+ 2,
282
+ )}\n`,
283
+ stderr: `${detail}\n`,
284
+ };
285
+ }
286
+ return {
287
+ status: 3,
288
+ stdout: `${PLANNING_STUCK_MARKER}\n`,
289
+ stderr: `${detail}\n`,
290
+ };
291
+ }
66
292
 
67
293
  const KNOWN_ARCHITECT_KINDS = new Set(["openai-code"]);
68
294
  const KNOWN_CRITIC_KINDS = new Set(["openai-code"]);
@@ -738,6 +964,31 @@ async function handleArtifactWrite(args: readonly string[], cwd: string): Promis
738
964
  return buildDeduplicatedResult(resolved, existingArtifact, sha256, cwd, repositoryBinding);
739
965
  }
740
966
 
967
+ // Consensus iteration budget (#3165): refuse new planner/revision openers past the cap.
968
+ // Dedupe returns above so identical re-writes never stuck-signal. Non-openers (architect,
969
+ // critic, final, …) remain allowed so operators can escalate without auto-implementation.
970
+ // On-disk opener artifacts floor the count so a wiped/truncated/malformed index.jsonl cannot
971
+ // under-count and fail open after prior planner/revision writes.
972
+ const indexLoad = await loadRalplanIndexForCap(cwd, resolved.sessionId, resolved.runId);
973
+ const onDiskOpeners = await countRalplanOnDiskOpeners(cwd, resolved.sessionId, resolved.runId);
974
+ const { maxIterations, source: maxIterationsSource } = await resolveRalplanMaxIterations(cwd);
975
+ const capDecision = evaluateRalplanIterationCap({
976
+ rows: indexLoad.rows,
977
+ stage: resolved.stage,
978
+ maxIterations,
979
+ iterationFloor: onDiskOpeners,
980
+ });
981
+ if (!capDecision.allowed) {
982
+ return buildPlanningStuckResult({
983
+ json: resolved.json,
984
+ stage: resolved.stage,
985
+ stageN: resolved.stageN,
986
+ runId: resolved.runId,
987
+ decision: capDecision,
988
+ source: maxIterationsSource,
989
+ });
990
+ }
991
+
741
992
  // Keep run-state `current_phase` coherent with the stage being persisted.
742
993
  await persistActiveRunId(cwd, resolved.sessionId, resolved.runId, resolved.stage);
743
994
  const persisted = await persistArtifact(resolved, cwd, content, sha256);