@gajae-code/coding-agent 0.11.9 → 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 (150) hide show
  1. package/CHANGELOG.md +56 -1
  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/model-registry.d.ts +2 -0
  6. package/dist/types/config/settings-schema.d.ts +57 -2
  7. package/dist/types/config/settings.d.ts +1 -0
  8. package/dist/types/extensibility/extensions/types.d.ts +21 -0
  9. package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
  10. package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
  11. package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
  12. package/dist/types/gjc-runtime/state-writer.d.ts +1 -0
  13. package/dist/types/modes/acp/acp-agent.d.ts +14 -9
  14. package/dist/types/modes/components/queue-pane.d.ts +8 -0
  15. package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
  16. package/dist/types/modes/components/welcome.d.ts +1 -0
  17. package/dist/types/modes/controllers/selector-controller.d.ts +16 -8
  18. package/dist/types/modes/interactive-mode.d.ts +5 -1
  19. package/dist/types/runtime/memory-domain.d.ts +9 -0
  20. package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
  21. package/dist/types/runtime/memory-guard.d.ts +39 -0
  22. package/dist/types/runtime/memory-limit.d.ts +11 -0
  23. package/dist/types/runtime-mcp/manager.d.ts +8 -0
  24. package/dist/types/sdk/acp/final-text.d.ts +19 -0
  25. package/dist/types/sdk/acp/mcp.d.ts +23 -0
  26. package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
  27. package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
  28. package/dist/types/sdk/bus/daemon-paths.d.ts +1 -0
  29. package/dist/types/sdk/bus/index.d.ts +11 -2
  30. package/dist/types/sdk/bus/operator-runtime.d.ts +1 -0
  31. package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
  32. package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +14 -7
  33. package/dist/types/sdk/bus/telegram-daemon.d.ts +97 -8
  34. package/dist/types/sdk/host/control/operations.d.ts +1 -0
  35. package/dist/types/sdk/lifecycle-session.d.ts +10 -1
  36. package/dist/types/sdk/session.d.ts +7 -0
  37. package/dist/types/sdk/startup-capability.d.ts +6 -0
  38. package/dist/types/session/agent-session.d.ts +11 -11
  39. package/dist/types/session/fallback-chain-controller.d.ts +18 -2
  40. package/dist/types/session/internal/managed-session-scope.d.ts +28 -1
  41. package/dist/types/session/session-manager.d.ts +34 -0
  42. package/dist/types/task/executor.d.ts +6 -0
  43. package/dist/types/task/provider-retry-status.d.ts +13 -0
  44. package/dist/types/task/types.d.ts +39 -0
  45. package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
  46. package/dist/types/tools/index.d.ts +3 -1
  47. package/dist/types/tools/resource-gc.d.ts +19 -0
  48. package/package.json +8 -8
  49. package/scripts/generate-sdk-operation-inventory.ts +3 -0
  50. package/scripts/run-test-manifest.ts +6 -4
  51. package/src/cli/args.ts +14 -8
  52. package/src/cli/fast-help.ts +1 -1
  53. package/src/cli/notify-cli.ts +1 -0
  54. package/src/cli.ts +48 -2
  55. package/src/commands/sdk.ts +91 -7
  56. package/src/config/model-profile-activation.ts +1 -0
  57. package/src/config/model-profile-contract.ts +13 -0
  58. package/src/config/model-profiles.ts +31 -7
  59. package/src/config/model-registry.ts +4 -2
  60. package/src/config/settings-schema.ts +61 -2
  61. package/src/config/settings.ts +8 -0
  62. package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
  63. package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
  64. package/src/eval/js/tool-bridge.ts +1 -1
  65. package/src/extensibility/extensions/runner.ts +4 -0
  66. package/src/extensibility/extensions/types.ts +14 -0
  67. package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
  68. package/src/gjc-runtime/deep-interview-draft.ts +2 -1
  69. package/src/gjc-runtime/deep-interview-recorder.ts +16 -1
  70. package/src/gjc-runtime/deep-interview-state.ts +25 -3
  71. package/src/gjc-runtime/linux-proc.ts +70 -36
  72. package/src/gjc-runtime/ralplan-runtime.ts +252 -1
  73. package/src/gjc-runtime/state-runtime.ts +48 -11
  74. package/src/gjc-runtime/state-writer.ts +1 -1
  75. package/src/hooks/skill-state.ts +23 -1
  76. package/src/internal-urls/docs-index.generated.ts +7 -7
  77. package/src/internal-urls/local-protocol.ts +23 -4
  78. package/src/modes/acp/acp-agent.ts +554 -60
  79. package/src/modes/acp/acp-event-mapper.ts +93 -6
  80. package/src/modes/components/model-selector.ts +15 -1
  81. package/src/modes/components/notifications-settings-editor.ts +1 -1
  82. package/src/modes/components/queue-pane.ts +55 -9
  83. package/src/modes/components/queued-message-selector.ts +53 -10
  84. package/src/modes/components/tool-status-header.ts +1 -4
  85. package/src/modes/components/welcome.ts +18 -3
  86. package/src/modes/controllers/command-controller.ts +4 -0
  87. package/src/modes/controllers/extension-ui-controller.ts +4 -10
  88. package/src/modes/controllers/input-controller.ts +44 -3
  89. package/src/modes/controllers/selector-controller.ts +38 -8
  90. package/src/modes/interactive-mode.ts +65 -26
  91. package/src/modes/runtime-init.ts +1 -0
  92. package/src/prompts/agent-fragments/restricted-bash.md +1 -1
  93. package/src/prompts/agents/architect.md +8 -1
  94. package/src/prompts/agents/critic.md +8 -1
  95. package/src/prompts/agents/executor.md +1 -1
  96. package/src/prompts/agents/planner.md +8 -1
  97. package/src/runtime/memory-domain.ts +66 -0
  98. package/src/runtime/memory-guard-contract.ts +73 -0
  99. package/src/runtime/memory-guard.ts +292 -0
  100. package/src/runtime/memory-limit.ts +50 -0
  101. package/src/runtime-mcp/manager.ts +26 -6
  102. package/src/sdk/acp/adapter.ts +8 -1
  103. package/src/sdk/acp/final-text.ts +40 -0
  104. package/src/sdk/acp/mcp.ts +26 -0
  105. package/src/sdk/broker/identity.ts +22 -12
  106. package/src/sdk/broker/lifecycle.ts +82 -1
  107. package/src/sdk/bus/chat-daemon-cli.ts +2 -2
  108. package/src/sdk/bus/chat-daemon-control.ts +4 -3
  109. package/src/sdk/bus/config.ts +1 -1
  110. package/src/sdk/bus/daemon-paths.ts +2 -0
  111. package/src/sdk/bus/index.ts +222 -27
  112. package/src/sdk/bus/notification-service.ts +19 -5
  113. package/src/sdk/bus/operator-runtime.ts +31 -5
  114. package/src/sdk/bus/rate-limit-pool.ts +8 -0
  115. package/src/sdk/bus/telegram-cli.ts +6 -13
  116. package/src/sdk/bus/telegram-daemon-cli.ts +8 -3
  117. package/src/sdk/bus/telegram-daemon-contract.ts +15 -7
  118. package/src/sdk/bus/telegram-daemon-control.ts +14 -5
  119. package/src/sdk/bus/telegram-daemon.ts +1191 -164
  120. package/src/sdk/host/control/dispatch.ts +2 -0
  121. package/src/sdk/host/control/operations.ts +1 -0
  122. package/src/sdk/host/host.ts +2 -2
  123. package/src/sdk/lifecycle-session.ts +19 -3
  124. package/src/sdk/protocol/operation-inventory.generated.json +58 -0
  125. package/src/sdk/protocol/operation-registry.ts +1 -0
  126. package/src/sdk/session.ts +24 -1
  127. package/src/sdk/startup-capability.ts +6 -0
  128. package/src/session/agent-session.ts +290 -132
  129. package/src/session/fallback-chain-controller.ts +47 -8
  130. package/src/session/internal/managed-session-scope.ts +173 -127
  131. package/src/session/internal/native-publish-outcome.ts +8 -3
  132. package/src/session/session-manager.ts +608 -25
  133. package/src/skill-state/workflow-mutation-guard.ts +41 -7
  134. package/src/task/executor.ts +152 -42
  135. package/src/task/index.ts +2 -0
  136. package/src/task/provider-retry-status.ts +88 -0
  137. package/src/task/render.ts +45 -26
  138. package/src/task/types.ts +25 -11
  139. package/src/task/ultragoal-redteam-activation.ts +75 -0
  140. package/src/tools/bash-allowed-prefixes.ts +45 -1
  141. package/src/tools/index.ts +3 -1
  142. package/src/tools/resource-gc.ts +496 -120
  143. package/src/tools/subagent-render.ts +74 -16
  144. package/src/tools/subagent.ts +2 -0
  145. package/src/workflow/workflow-intent-diff.ts +4 -1
  146. package/vendor/insane-search/MANIFEST.json +4 -1
  147. package/vendor/insane-search/engine/bias_check.py +5 -0
  148. package/vendor/insane-search/engine/fetch_chain.py +67 -1
  149. package/vendor/insane-search/engine/transport.py +13 -2
  150. package/vendor/insane-search/engine/validators.py +4 -1
@@ -17,6 +17,7 @@ import { type DeepInterviewRepairResult, runDeepInterviewRepairCommand } from ".
17
17
  import { canonicalDeepInterviewJson, validateDeepInterviewV1Envelope } from "./deep-interview-state";
18
18
  import { modeStatePath } from "./session-layout";
19
19
  import {
20
+ isNativeDeepInterviewV1,
20
21
  readExistingStateForMutation,
21
22
  transformGuardedWorkflowEnvelopeAtomic,
22
23
  verifyWorkflowEnvelopeReceiptValue,
@@ -295,7 +296,7 @@ async function revision(cwd: string, session: string): Promise<{ revision: numbe
295
296
  if (receipt === "receipt-missing") throw new Error("DI_RECEIPT_MISSING");
296
297
  if (receipt === "checksum-mismatch") throw new Error("DI_RECEIPT_CHECKSUM_MISMATCH");
297
298
  try {
298
- if (receipt === "native-valid") validateDeepInterviewV1Envelope(observed.value);
299
+ if (isNativeDeepInterviewV1(observed.value)) validateDeepInterviewV1Envelope(observed.value);
299
300
  } catch {
300
301
  throw new Error("DI_STATE_SCHEMA_INVALID");
301
302
  }
@@ -101,6 +101,21 @@ export interface TransitionValidationResult {
101
101
  // Pure helpers: records
102
102
  // =============================================================================
103
103
 
104
+ /**
105
+ * `deepInterview.dimension` is agent-supplied free text on post-topology asks, but the persisted
106
+ * envelope only accepts canonical dimension ids. Fold obvious case/label variants down, and drop
107
+ * anything unrecognized rather than persisting a value the validator rejects.
108
+ */
109
+ function canonicalDimension(value: string | undefined): string | undefined {
110
+ if (value === undefined) return undefined;
111
+ const normalized = value.trim().toLowerCase();
112
+ if (normalized === "topology") return "topology";
113
+ for (const dimension of ["goal", "constraints", "criteria", "context"]) {
114
+ if (normalized === dimension || normalized.startsWith(`${dimension} `)) return dimension;
115
+ }
116
+ return undefined;
117
+ }
118
+
104
119
  export function buildAnswerShell(
105
120
  input: DeepInterviewAnswerInput,
106
121
  now: string = new Date().toISOString(),
@@ -116,7 +131,7 @@ export function buildAnswerShell(
116
131
  selected_options: input.selectedOptions,
117
132
  custom_input: input.customInput,
118
133
  component: input.component,
119
- dimension: input.dimension,
134
+ dimension: canonicalDimension(input.dimension),
120
135
  ambiguity_at_ask: input.ambiguity,
121
136
  lifecycle: "answered",
122
137
  answered_at: now,
@@ -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);