@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.
- package/CHANGELOG.md +38 -0
- package/dist/types/cli.d.ts +8 -1
- package/dist/types/config/model-profile-activation.d.ts +1 -0
- package/dist/types/config/model-profile-contract.d.ts +2 -0
- package/dist/types/config/settings-schema.d.ts +57 -2
- package/dist/types/config/settings.d.ts +1 -0
- package/dist/types/extensibility/extensions/types.d.ts +21 -0
- package/dist/types/gjc-runtime/linux-proc.d.ts +22 -14
- package/dist/types/gjc-runtime/ralplan-runtime.d.ts +60 -0
- package/dist/types/gjc-runtime/state-runtime.d.ts +3 -1
- package/dist/types/modes/acp/acp-agent.d.ts +14 -9
- package/dist/types/modes/components/queue-pane.d.ts +8 -0
- package/dist/types/modes/components/queued-message-selector.d.ts +6 -0
- package/dist/types/modes/components/welcome.d.ts +1 -0
- package/dist/types/modes/interactive-mode.d.ts +5 -1
- package/dist/types/runtime/memory-domain.d.ts +9 -0
- package/dist/types/runtime/memory-guard-contract.d.ts +71 -0
- package/dist/types/runtime/memory-guard.d.ts +39 -0
- package/dist/types/runtime/memory-limit.d.ts +11 -0
- package/dist/types/runtime-mcp/manager.d.ts +8 -0
- package/dist/types/sdk/acp/final-text.d.ts +19 -0
- package/dist/types/sdk/acp/mcp.d.ts +23 -0
- package/dist/types/sdk/broker/lifecycle.d.ts +2 -0
- package/dist/types/sdk/bus/chat-daemon-control.d.ts +2 -1
- package/dist/types/sdk/bus/index.d.ts +3 -1
- package/dist/types/sdk/bus/rate-limit-pool.d.ts +2 -0
- package/dist/types/sdk/bus/telegram-daemon-contract.d.ts +6 -4
- package/dist/types/sdk/bus/telegram-daemon.d.ts +23 -3
- package/dist/types/sdk/host/control/operations.d.ts +1 -0
- package/dist/types/sdk/lifecycle-session.d.ts +10 -1
- package/dist/types/sdk/startup-capability.d.ts +6 -0
- package/dist/types/session/agent-session.d.ts +9 -11
- package/dist/types/session/fallback-chain-controller.d.ts +18 -2
- package/dist/types/session/internal/managed-session-scope.d.ts +27 -0
- package/dist/types/session/session-manager.d.ts +34 -0
- package/dist/types/task/executor.d.ts +6 -0
- package/dist/types/task/types.d.ts +36 -0
- package/dist/types/task/ultragoal-redteam-activation.d.ts +33 -0
- package/dist/types/tools/resource-gc.d.ts +19 -0
- package/package.json +8 -8
- package/scripts/generate-sdk-operation-inventory.ts +2 -0
- package/scripts/run-test-manifest.ts +6 -4
- package/src/cli/args.ts +14 -8
- package/src/cli/fast-help.ts +1 -1
- package/src/cli.ts +48 -2
- package/src/commands/sdk.ts +91 -7
- package/src/config/model-profile-activation.ts +1 -0
- package/src/config/model-profile-contract.ts +13 -0
- package/src/config/model-profiles.ts +24 -0
- package/src/config/settings-schema.ts +61 -2
- package/src/config/settings.ts +8 -0
- package/src/defaults/gjc/skills/ralplan/SKILL.md +21 -2
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +1 -1
- package/src/extensibility/extensions/runner.ts +4 -0
- package/src/extensibility/extensions/types.ts +14 -0
- package/src/gjc-runtime/deep-interview-ambiguity.ts +16 -2
- package/src/gjc-runtime/deep-interview-state.ts +25 -3
- package/src/gjc-runtime/linux-proc.ts +70 -36
- package/src/gjc-runtime/ralplan-runtime.ts +252 -1
- package/src/gjc-runtime/state-runtime.ts +48 -11
- package/src/internal-urls/docs-index.generated.ts +4 -4
- package/src/internal-urls/local-protocol.ts +23 -4
- package/src/modes/acp/acp-agent.ts +554 -60
- package/src/modes/acp/acp-event-mapper.ts +93 -6
- package/src/modes/components/model-selector.ts +15 -1
- package/src/modes/components/notifications-settings-editor.ts +1 -1
- package/src/modes/components/queue-pane.ts +55 -9
- package/src/modes/components/queued-message-selector.ts +53 -10
- package/src/modes/components/tool-status-header.ts +1 -4
- package/src/modes/components/welcome.ts +18 -3
- package/src/modes/controllers/command-controller.ts +4 -0
- package/src/modes/controllers/extension-ui-controller.ts +4 -10
- package/src/modes/controllers/input-controller.ts +44 -3
- package/src/modes/interactive-mode.ts +65 -26
- package/src/modes/runtime-init.ts +1 -0
- package/src/prompts/agents/executor.md +1 -1
- package/src/runtime/memory-domain.ts +66 -0
- package/src/runtime/memory-guard-contract.ts +73 -0
- package/src/runtime/memory-guard.ts +292 -0
- package/src/runtime/memory-limit.ts +50 -0
- package/src/runtime-mcp/manager.ts +26 -6
- package/src/sdk/acp/adapter.ts +8 -1
- package/src/sdk/acp/final-text.ts +40 -0
- package/src/sdk/acp/mcp.ts +26 -0
- package/src/sdk/broker/lifecycle.ts +82 -1
- package/src/sdk/bus/chat-daemon-cli.ts +2 -2
- package/src/sdk/bus/chat-daemon-control.ts +4 -3
- package/src/sdk/bus/config.ts +1 -1
- package/src/sdk/bus/index.ts +162 -22
- package/src/sdk/bus/rate-limit-pool.ts +8 -0
- package/src/sdk/bus/telegram-daemon-contract.ts +6 -4
- package/src/sdk/bus/telegram-daemon.ts +531 -53
- package/src/sdk/host/control/dispatch.ts +2 -0
- package/src/sdk/host/control/operations.ts +1 -0
- package/src/sdk/host/host.ts +2 -2
- package/src/sdk/lifecycle-session.ts +19 -3
- package/src/sdk/protocol/operation-inventory.generated.json +47 -0
- package/src/sdk/protocol/operation-registry.ts +1 -0
- package/src/sdk/session.ts +12 -1
- package/src/sdk/startup-capability.ts +6 -0
- package/src/session/agent-session.ts +256 -142
- package/src/session/fallback-chain-controller.ts +47 -8
- package/src/session/internal/managed-session-scope.ts +40 -10
- package/src/session/internal/native-publish-outcome.ts +8 -3
- package/src/session/session-manager.ts +608 -25
- package/src/task/executor.ts +11 -3
- package/src/task/index.ts +2 -0
- package/src/task/types.ts +22 -11
- package/src/task/ultragoal-redteam-activation.ts +75 -0
- package/src/tools/resource-gc.ts +496 -120
- 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
|
-
|
|
211
|
-
|
|
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())
|
|
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
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
export
|
|
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 || !/^[
|
|
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
|
-
|
|
42
|
+
if (!ttyDevice || !/^-?\d+$/.test(ttyDevice) || !/^\d+$/.test(startTime)) return null;
|
|
43
|
+
return { startTime, ttyDevice };
|
|
39
44
|
}
|
|
40
45
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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
|
|
69
|
+
} catch (error) {
|
|
70
|
+
return classifyProcReadError(error);
|
|
53
71
|
}
|
|
54
|
-
|
|
72
|
+
const identity = parseLinuxProcIdentity(stat);
|
|
73
|
+
return identity ? { kind: "live", ...identity } : { kind: "unverifiable", reason: "malformed_stat" };
|
|
55
74
|
}
|
|
56
75
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
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
|
|
82
|
+
} catch (error) {
|
|
83
|
+
return classifyProcReadError(error);
|
|
69
84
|
}
|
|
70
|
-
|
|
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);
|