@gajae-code/coding-agent 0.7.3 → 0.7.5
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +58 -0
- package/bin/gjc.js +4 -0
- package/dist/types/cli/plugin-cli.d.ts +2 -0
- package/dist/types/commands/plugin.d.ts +6 -0
- package/dist/types/commands/session.d.ts +6 -0
- package/dist/types/config/model-profile-activation.d.ts +8 -1
- package/dist/types/extensibility/gjc-plugins/compiler.d.ts +19 -0
- package/dist/types/extensibility/gjc-plugins/constrained-hooks.d.ts +29 -0
- package/dist/types/extensibility/gjc-plugins/index.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/injection.d.ts +9 -0
- package/dist/types/extensibility/gjc-plugins/installer.d.ts +13 -0
- package/dist/types/extensibility/gjc-plugins/mcp-policy.d.ts +26 -0
- package/dist/types/extensibility/gjc-plugins/observability.d.ts +27 -0
- package/dist/types/extensibility/gjc-plugins/prompt-appendix.d.ts +16 -0
- package/dist/types/extensibility/gjc-plugins/registry.d.ts +32 -0
- package/dist/types/extensibility/gjc-plugins/runtime-adapters.d.ts +64 -0
- package/dist/types/extensibility/gjc-plugins/session-validation.d.ts +42 -0
- package/dist/types/extensibility/gjc-plugins/types.d.ts +158 -2
- package/dist/types/extensibility/gjc-plugins/validation.d.ts +8 -1
- package/dist/types/gjc-runtime/launch-tmux.d.ts +1 -0
- package/dist/types/gjc-runtime/psmux-detect.d.ts +78 -0
- package/dist/types/gjc-runtime/team-runtime.d.ts +2 -0
- package/dist/types/gjc-runtime/tmux-common.d.ts +30 -2
- package/dist/types/gjc-runtime/tmux-sessions.d.ts +18 -0
- package/dist/types/main.d.ts +2 -0
- package/dist/types/modes/components/model-selector.d.ts +6 -0
- package/dist/types/notifications/html-format.d.ts +11 -0
- package/dist/types/notifications/index.d.ts +149 -1
- package/dist/types/notifications/lifecycle-commands.d.ts +72 -0
- package/dist/types/notifications/lifecycle-control-runtime.d.ts +98 -0
- package/dist/types/notifications/lifecycle-orchestrator.d.ts +144 -0
- package/dist/types/notifications/rate-limit-pool.d.ts +2 -0
- package/dist/types/notifications/recent-activity.d.ts +35 -0
- package/dist/types/notifications/telegram-daemon.d.ts +60 -0
- package/dist/types/notifications/telegram-reference.d.ts +3 -1
- package/dist/types/notifications/topic-registry.d.ts +10 -9
- package/dist/types/runtime-mcp/types.d.ts +7 -0
- package/dist/types/sdk.d.ts +2 -0
- package/dist/types/session/agent-session.d.ts +14 -4
- package/dist/types/session/blob-store.d.ts +25 -0
- package/dist/types/session/session-manager.d.ts +57 -0
- package/dist/types/slash-commands/helpers/fast-status-report.d.ts +6 -0
- package/dist/types/system-prompt.d.ts +2 -0
- package/dist/types/task/executor.d.ts +9 -1
- package/dist/types/tools/index.d.ts +3 -1
- package/dist/types/utils/changelog.d.ts +1 -0
- package/package.json +11 -9
- package/scripts/g004-tmux-smoke.ts +100 -0
- package/scripts/g005-daemon-smoke.ts +181 -0
- package/scripts/g011-daemon-path-smoke.ts +153 -0
- package/src/cli/plugin-cli.ts +66 -3
- package/src/cli.ts +21 -4
- package/src/commands/plugin.ts +4 -0
- package/src/commands/session.ts +18 -0
- package/src/config/model-profile-activation.ts +55 -7
- package/src/defaults/gjc/extensions/grok-cli-vendor/biome.json +1 -1
- package/src/defaults/gjc/skills/deep-interview/SKILL.md +3 -3
- package/src/defaults/gjc/skills/team/SKILL.md +5 -4
- package/src/defaults/gjc/skills/ultragoal/SKILL.md +41 -13
- package/src/export/html/index.ts +2 -2
- package/src/extensibility/gjc-plugins/compiler.ts +351 -0
- package/src/extensibility/gjc-plugins/constrained-hooks.ts +170 -0
- package/src/extensibility/gjc-plugins/index.ts +9 -0
- package/src/extensibility/gjc-plugins/injection.ts +109 -0
- package/src/extensibility/gjc-plugins/installer.ts +434 -0
- package/src/extensibility/gjc-plugins/loader.ts +3 -1
- package/src/extensibility/gjc-plugins/mcp-policy.ts +239 -0
- package/src/extensibility/gjc-plugins/observability.ts +84 -0
- package/src/extensibility/gjc-plugins/paths.ts +1 -1
- package/src/extensibility/gjc-plugins/prompt-appendix.ts +109 -0
- package/src/extensibility/gjc-plugins/registry.ts +180 -0
- package/src/extensibility/gjc-plugins/runtime-adapters.ts +234 -0
- package/src/extensibility/gjc-plugins/schema.ts +250 -20
- package/src/extensibility/gjc-plugins/session-validation.ts +147 -0
- package/src/extensibility/gjc-plugins/types.ts +199 -3
- package/src/extensibility/gjc-plugins/validation.ts +80 -0
- package/src/extensibility/skills.ts +15 -0
- package/src/gjc-runtime/launch-tmux.ts +58 -15
- package/src/gjc-runtime/psmux-detect.ts +239 -0
- package/src/gjc-runtime/team-runtime.ts +56 -23
- package/src/gjc-runtime/tmux-common.ts +85 -3
- package/src/gjc-runtime/tmux-sessions.ts +111 -9
- package/src/gjc-runtime/ultragoal-runtime.ts +75 -15
- package/src/internal-urls/docs-index.generated.ts +5 -4
- package/src/main.ts +14 -3
- package/src/modes/components/assistant-message.ts +49 -1
- package/src/modes/components/hook-editor.ts +1 -1
- package/src/modes/components/hook-selector.ts +67 -43
- package/src/modes/components/model-selector.ts +44 -11
- package/src/modes/controllers/extension-ui-controller.ts +0 -27
- package/src/modes/controllers/selector-controller.ts +50 -11
- package/src/modes/interactive-mode.ts +2 -0
- package/src/modes/utils/hotkeys-markdown.ts +1 -1
- package/src/notifications/html-format.ts +38 -0
- package/src/notifications/index.ts +242 -12
- package/src/notifications/lifecycle-commands.ts +228 -0
- package/src/notifications/lifecycle-control-runtime.ts +400 -0
- package/src/notifications/lifecycle-orchestrator.ts +358 -0
- package/src/notifications/rate-limit-pool.ts +19 -0
- package/src/notifications/recent-activity.ts +132 -0
- package/src/notifications/telegram-daemon.ts +433 -8
- package/src/notifications/telegram-reference.ts +25 -7
- package/src/notifications/topic-registry.ts +18 -9
- package/src/prompts/agents/executor.md +2 -2
- package/src/runtime-mcp/transports/stdio.ts +38 -4
- package/src/runtime-mcp/types.ts +7 -0
- package/src/sdk.ts +157 -10
- package/src/session/agent-session.ts +166 -74
- package/src/session/blob-store.ts +196 -8
- package/src/session/session-manager.ts +739 -12
- package/src/slash-commands/builtin-registry.ts +23 -3
- package/src/slash-commands/helpers/fast-status-report.ts +13 -3
- package/src/system-prompt.ts +9 -0
- package/src/task/executor.ts +31 -7
- package/src/task/index.ts +2 -0
- package/src/tools/ask.ts +5 -1
- package/src/tools/index.ts +3 -1
- package/src/utils/changelog.ts +8 -0
|
@@ -10,9 +10,32 @@ import type { DaemonRuntimeInfo } from "../daemon/control-types";
|
|
|
10
10
|
import { resolveGjcRuntimeSpawnInfo } from "../daemon/runtime";
|
|
11
11
|
import { getNotificationConfig, isGloballyConfigured, tokenFingerprint } from "./config";
|
|
12
12
|
import { parseInThreadConfigCommand } from "./config-commands";
|
|
13
|
-
import {
|
|
13
|
+
import { buildCompactChoiceGrid, TELEGRAM_PARSE_MODE } from "./html-format";
|
|
14
|
+
import type {
|
|
15
|
+
SessionCloseTarget,
|
|
16
|
+
SessionCreateTarget,
|
|
17
|
+
SessionLifecycleRequest,
|
|
18
|
+
SessionLifecycleResponse,
|
|
19
|
+
SessionResumeTarget,
|
|
20
|
+
} from "./index";
|
|
21
|
+
import {
|
|
22
|
+
formatLifecycleOutcome,
|
|
23
|
+
isLifecycleCommandText,
|
|
24
|
+
lifecycleUsage,
|
|
25
|
+
parseLifecycleCommand,
|
|
26
|
+
validateLifecycleTarget,
|
|
27
|
+
} from "./lifecycle-commands";
|
|
28
|
+
import {
|
|
29
|
+
attachLifecycleControl,
|
|
30
|
+
buildOrchestratorDeps,
|
|
31
|
+
type ControlServerLike,
|
|
32
|
+
createNativeControlServer,
|
|
33
|
+
type LifecycleControlServer,
|
|
34
|
+
type LifecycleControlServerFactory,
|
|
35
|
+
} from "./lifecycle-control-runtime";
|
|
14
36
|
import { NotificationOperatorRuntime, OperatorBackoffPolicy, OperatorEventRouter } from "./operator-runtime";
|
|
15
37
|
import { RateLimitPool } from "./rate-limit-pool";
|
|
38
|
+
import { listRecentSessions } from "./recent-activity";
|
|
16
39
|
import {
|
|
17
40
|
type AliasTable,
|
|
18
41
|
buildActionMessage,
|
|
@@ -173,6 +196,31 @@ export function daemonPaths(agentDir: string): DaemonPaths {
|
|
|
173
196
|
};
|
|
174
197
|
}
|
|
175
198
|
|
|
199
|
+
/**
|
|
200
|
+
* Attach session-lifecycle control (create/close/resume) to the running daemon.
|
|
201
|
+
*
|
|
202
|
+
* Wires an already-started, authenticated control server to the lifecycle
|
|
203
|
+
* orchestrator with real daemon-side effects (tmux launcher / force-close /
|
|
204
|
+
* resume), a durable fsynced idempotency ledger + audit JSONL under the agent
|
|
205
|
+
* notifications dir, and strict paired-chat gating. The control server itself
|
|
206
|
+
* (NotificationControlServer) is owned/started by the daemon process; this
|
|
207
|
+
* function only connects it to policy. Returns the orchestrator deps for tests.
|
|
208
|
+
*/
|
|
209
|
+
export function startDaemonLifecycleControl(input: {
|
|
210
|
+
controlServer: ControlServerLike;
|
|
211
|
+
pairedChatId: string;
|
|
212
|
+
agentDir: string;
|
|
213
|
+
env?: NodeJS.ProcessEnv;
|
|
214
|
+
}): void {
|
|
215
|
+
const deps = buildOrchestratorDeps({
|
|
216
|
+
pairedChatId: input.pairedChatId,
|
|
217
|
+
agentNotificationsDir: daemonPaths(input.agentDir).dir,
|
|
218
|
+
sessionsRoot: path.join(input.agentDir, "sessions"),
|
|
219
|
+
env: input.env,
|
|
220
|
+
});
|
|
221
|
+
attachLifecycleControl(input.controlServer, deps);
|
|
222
|
+
}
|
|
223
|
+
|
|
176
224
|
async function ensureDir(fsImpl: TelegramDaemonFs, dir: string): Promise<void> {
|
|
177
225
|
await fsImpl.mkdir(dir, { recursive: true, mode: 0o700 });
|
|
178
226
|
await fsImpl.chmod(dir, 0o700).catch(() => undefined);
|
|
@@ -706,8 +754,17 @@ export interface TelegramDaemonOptions {
|
|
|
706
754
|
idleTimeoutMs?: number;
|
|
707
755
|
scanIntervalMs?: number;
|
|
708
756
|
pid?: number;
|
|
757
|
+
/** Liveness probe for skipping dead-PID endpoint records in {@link TelegramNotificationDaemon.scanRoots}. */
|
|
758
|
+
pidAlive?: (pid: number) => boolean;
|
|
709
759
|
botApi?: BotApi;
|
|
710
760
|
control?: DaemonControlHooks;
|
|
761
|
+
/**
|
|
762
|
+
* Factory for the session-lifecycle control server. Defaults to the real
|
|
763
|
+
* native NotificationControlServer; tests inject a fake to verify the
|
|
764
|
+
* owner-bound start/stop lifecycle without a socket. When `undefined` AND no
|
|
765
|
+
* default applies (e.g. lifecycle control disabled), no control server starts.
|
|
766
|
+
*/
|
|
767
|
+
createLifecycleControlServer?: LifecycleControlServerFactory | null;
|
|
711
768
|
}
|
|
712
769
|
|
|
713
770
|
interface SessionSocket {
|
|
@@ -763,6 +820,25 @@ export class TelegramNotificationDaemon {
|
|
|
763
820
|
private get inboundReactions(): Map<number, { messageId: number }> {
|
|
764
821
|
return this.dispatchState.inboundReactions;
|
|
765
822
|
}
|
|
823
|
+
/**
|
|
824
|
+
* The owner-bound session-lifecycle control server (create/close/resume).
|
|
825
|
+
* Started in {@link run} after ownership is confirmed (so exactly one owner
|
|
826
|
+
* ever runs one), stopped in run()'s finally on any exit path.
|
|
827
|
+
*/
|
|
828
|
+
private controlServer: LifecycleControlServer | undefined;
|
|
829
|
+
/** True while lifecycle control is active, so the loop keeps polling at idle. */
|
|
830
|
+
private lifecycleControlActive = false;
|
|
831
|
+
/** Control token (in-memory) the loopback client presents; never persisted/logged. */
|
|
832
|
+
private controlToken: string | undefined;
|
|
833
|
+
/** Loopback WS client to the daemon's own control endpoint (Option A real wire path). */
|
|
834
|
+
private controlClient: WebSocket | undefined;
|
|
835
|
+
/** Pending lifecycle responses awaiting a control-endpoint reply, by requestId. */
|
|
836
|
+
private readonly pendingLifecycle = new Map<
|
|
837
|
+
string,
|
|
838
|
+
{ resolve: (r: SessionLifecycleResponse) => void; timer: ReturnType<typeof setTimeout> }
|
|
839
|
+
>();
|
|
840
|
+
/** Monotonic counter for unique lifecycle request ids. */
|
|
841
|
+
private lifecycleSeq = 0;
|
|
766
842
|
|
|
767
843
|
/**
|
|
768
844
|
* Cooperatively stop the daemon: set the stop flag and abort the in-flight
|
|
@@ -774,6 +850,282 @@ export class TelegramNotificationDaemon {
|
|
|
774
850
|
this.running = false;
|
|
775
851
|
}
|
|
776
852
|
|
|
853
|
+
/**
|
|
854
|
+
* Start the owner-bound lifecycle control server and wire it to the
|
|
855
|
+
* orchestrator. Called from {@link run} ONLY after ownership is confirmed, so
|
|
856
|
+
* exactly one owner ever starts exactly one control server (no second poller
|
|
857
|
+
* / 409). A control-server failure degrades gracefully: the daemon keeps
|
|
858
|
+
* serving notifications without lifecycle control. Returns true when started.
|
|
859
|
+
*/
|
|
860
|
+
private async startLifecycleControl(): Promise<boolean> {
|
|
861
|
+
const factory =
|
|
862
|
+
this.opts.createLifecycleControlServer === null
|
|
863
|
+
? undefined
|
|
864
|
+
: (this.opts.createLifecycleControlServer ?? createNativeControlServer);
|
|
865
|
+
if (!factory) return false;
|
|
866
|
+
let server: LifecycleControlServer | undefined;
|
|
867
|
+
try {
|
|
868
|
+
// High-entropy, in-memory control token (never persisted raw / logged).
|
|
869
|
+
const token = crypto.randomBytes(32).toString("base64url");
|
|
870
|
+
const agentDir = this.opts.settings.getAgentDir();
|
|
871
|
+
server = factory({ token, ownerId: this.opts.ownerId, agentDir });
|
|
872
|
+
const deps = buildOrchestratorDeps({
|
|
873
|
+
pairedChatId: this.opts.chatId,
|
|
874
|
+
agentNotificationsDir: daemonPaths(agentDir).dir,
|
|
875
|
+
sessionsRoot: path.join(agentDir, "sessions"),
|
|
876
|
+
});
|
|
877
|
+
// Register the lifecycle-request handler BEFORE start(): the native
|
|
878
|
+
// control server captures the callback at start time, so wiring must
|
|
879
|
+
// precede start or forwarded requests never reach the orchestrator.
|
|
880
|
+
attachLifecycleControl(server, deps);
|
|
881
|
+
const endpoint = (await server.start()) as { url?: string } | undefined;
|
|
882
|
+
this.controlServer = server;
|
|
883
|
+
this.controlToken = token;
|
|
884
|
+
// Option A: connect a loopback WS client to our own control endpoint so
|
|
885
|
+
// parsed /session_* commands traverse the real authenticated wire path.
|
|
886
|
+
// Mark control active ONLY after the client is open, so a first-poll
|
|
887
|
+
// /session_create never races a still-CONNECTING socket.
|
|
888
|
+
const opened = endpoint?.url ? await this.connectControlClient(endpoint.url, token) : false;
|
|
889
|
+
this.lifecycleControlActive = opened;
|
|
890
|
+
if (!opened) {
|
|
891
|
+
logger.warn("notifications: lifecycle control client did not open; lifecycle commands disabled");
|
|
892
|
+
}
|
|
893
|
+
return opened;
|
|
894
|
+
} catch (e) {
|
|
895
|
+
// Never let lifecycle-control startup kill the notifications daemon.
|
|
896
|
+
// Stop any partially-started server so it cannot leak.
|
|
897
|
+
try {
|
|
898
|
+
server?.stop();
|
|
899
|
+
} catch {
|
|
900
|
+
// best-effort
|
|
901
|
+
}
|
|
902
|
+
logger.warn(`notifications: lifecycle control failed to start: ${String(e)}`);
|
|
903
|
+
this.controlServer = undefined;
|
|
904
|
+
this.lifecycleControlActive = false;
|
|
905
|
+
return false;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
|
|
909
|
+
/** Stop the lifecycle control server (idempotent); called from run()'s finally. */
|
|
910
|
+
private stopLifecycleControl(): void {
|
|
911
|
+
this.lifecycleControlActive = false;
|
|
912
|
+
this.controlToken = undefined;
|
|
913
|
+
const client = this.controlClient;
|
|
914
|
+
this.controlClient = undefined;
|
|
915
|
+
try {
|
|
916
|
+
client?.close();
|
|
917
|
+
} catch {
|
|
918
|
+
// best-effort
|
|
919
|
+
}
|
|
920
|
+
// Reject any in-flight lifecycle requests so callers do not hang.
|
|
921
|
+
for (const [requestId, pending] of this.pendingLifecycle) {
|
|
922
|
+
clearTimeout(pending.timer);
|
|
923
|
+
pending.resolve({
|
|
924
|
+
type: "session_lifecycle_error",
|
|
925
|
+
requestId,
|
|
926
|
+
status: "error",
|
|
927
|
+
reason: "terminal_uncertain",
|
|
928
|
+
message: "control server stopped",
|
|
929
|
+
});
|
|
930
|
+
}
|
|
931
|
+
this.pendingLifecycle.clear();
|
|
932
|
+
const server = this.controlServer;
|
|
933
|
+
this.controlServer = undefined;
|
|
934
|
+
try {
|
|
935
|
+
server?.stop();
|
|
936
|
+
} catch (e) {
|
|
937
|
+
logger.warn(`notifications: lifecycle control failed to stop cleanly: ${String(e)}`);
|
|
938
|
+
}
|
|
939
|
+
}
|
|
940
|
+
|
|
941
|
+
/**
|
|
942
|
+
* Connect the loopback control client and resolve responses by requestId.
|
|
943
|
+
* Resolves true once the socket is OPEN (bounded), false on error/timeout, so
|
|
944
|
+
* the caller only marks lifecycle control active when commands can be sent.
|
|
945
|
+
*/
|
|
946
|
+
private connectControlClient(url: string, token: string): Promise<boolean> {
|
|
947
|
+
return new Promise<boolean>(resolve => {
|
|
948
|
+
let settled = false;
|
|
949
|
+
const finish = (ok: boolean) => {
|
|
950
|
+
if (settled) return;
|
|
951
|
+
settled = true;
|
|
952
|
+
resolve(ok);
|
|
953
|
+
};
|
|
954
|
+
try {
|
|
955
|
+
const WsCtor = this.opts.WebSocketImpl ?? WebSocket;
|
|
956
|
+
const client = new WsCtor(`${url}/?token=${encodeURIComponent(token)}`);
|
|
957
|
+
this.controlClient = client;
|
|
958
|
+
const openTimer = (this.opts.setTimeoutImpl ?? setTimeout)(() => finish(false), 5_000);
|
|
959
|
+
client.addEventListener("open", () => {
|
|
960
|
+
clearTimeout(openTimer);
|
|
961
|
+
finish(true);
|
|
962
|
+
});
|
|
963
|
+
client.addEventListener("error", () => {
|
|
964
|
+
clearTimeout(openTimer);
|
|
965
|
+
finish(false);
|
|
966
|
+
});
|
|
967
|
+
client.addEventListener("message", (ev: MessageEvent) => {
|
|
968
|
+
let msg: SessionLifecycleResponse;
|
|
969
|
+
try {
|
|
970
|
+
msg = JSON.parse(String((ev as { data: unknown }).data)) as SessionLifecycleResponse;
|
|
971
|
+
} catch {
|
|
972
|
+
return;
|
|
973
|
+
}
|
|
974
|
+
const requestId = (msg as { requestId?: string }).requestId;
|
|
975
|
+
if (!requestId) return;
|
|
976
|
+
const pending = this.pendingLifecycle.get(requestId);
|
|
977
|
+
if (!pending) return;
|
|
978
|
+
clearTimeout(pending.timer);
|
|
979
|
+
this.pendingLifecycle.delete(requestId);
|
|
980
|
+
pending.resolve(msg);
|
|
981
|
+
});
|
|
982
|
+
} catch (e) {
|
|
983
|
+
logger.warn(`notifications: lifecycle control client failed to connect: ${String(e)}`);
|
|
984
|
+
finish(false);
|
|
985
|
+
}
|
|
986
|
+
});
|
|
987
|
+
}
|
|
988
|
+
|
|
989
|
+
/** Send a lifecycle frame over the loopback client and await the response. */
|
|
990
|
+
private submitLifecycleFrame(frame: SessionLifecycleRequest): Promise<SessionLifecycleResponse> {
|
|
991
|
+
return new Promise<SessionLifecycleResponse>(resolve => {
|
|
992
|
+
const client = this.controlClient;
|
|
993
|
+
if (!client || client.readyState !== WebSocket.OPEN) {
|
|
994
|
+
resolve({
|
|
995
|
+
type: "session_lifecycle_error",
|
|
996
|
+
requestId: frame.requestId,
|
|
997
|
+
status: "error",
|
|
998
|
+
reason: "terminal_uncertain",
|
|
999
|
+
message: "lifecycle control unavailable",
|
|
1000
|
+
});
|
|
1001
|
+
return;
|
|
1002
|
+
}
|
|
1003
|
+
const timer = (this.opts.setTimeoutImpl ?? setTimeout)(() => {
|
|
1004
|
+
this.pendingLifecycle.delete(frame.requestId);
|
|
1005
|
+
resolve({
|
|
1006
|
+
type: "session_lifecycle_error",
|
|
1007
|
+
requestId: frame.requestId,
|
|
1008
|
+
status: "error",
|
|
1009
|
+
reason: "readiness_timeout",
|
|
1010
|
+
message: "lifecycle request timed out",
|
|
1011
|
+
});
|
|
1012
|
+
}, 120_000);
|
|
1013
|
+
this.pendingLifecycle.set(frame.requestId, { resolve, timer });
|
|
1014
|
+
try {
|
|
1015
|
+
client.send(JSON.stringify(frame));
|
|
1016
|
+
} catch (e) {
|
|
1017
|
+
clearTimeout(timer);
|
|
1018
|
+
this.pendingLifecycle.delete(frame.requestId);
|
|
1019
|
+
resolve({
|
|
1020
|
+
type: "session_lifecycle_error",
|
|
1021
|
+
requestId: frame.requestId,
|
|
1022
|
+
status: "error",
|
|
1023
|
+
reason: "terminal_uncertain",
|
|
1024
|
+
message: `lifecycle send failed: ${String(e)}`,
|
|
1025
|
+
});
|
|
1026
|
+
}
|
|
1027
|
+
});
|
|
1028
|
+
}
|
|
1029
|
+
|
|
1030
|
+
private nextLifecycleRequestId(): string {
|
|
1031
|
+
this.lifecycleSeq += 1;
|
|
1032
|
+
return `tg-${this.opts.ownerId}-${this.lifecycleSeq}-${crypto.randomBytes(4).toString("hex")}`;
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
/** Build an authenticated lifecycle frame from a parsed command + identity. */
|
|
1036
|
+
private buildLifecycleFrame(
|
|
1037
|
+
parsed:
|
|
1038
|
+
| { kind: "create"; target: SessionCreateTarget }
|
|
1039
|
+
| { kind: "close"; target: SessionCloseTarget }
|
|
1040
|
+
| { kind: "resume"; target: SessionResumeTarget },
|
|
1041
|
+
updateId: number,
|
|
1042
|
+
): SessionLifecycleRequest {
|
|
1043
|
+
const requestId = this.nextLifecycleRequestId();
|
|
1044
|
+
const token = this.controlToken ?? "";
|
|
1045
|
+
const chatId = this.opts.chatId;
|
|
1046
|
+
if (parsed.kind === "create") {
|
|
1047
|
+
return {
|
|
1048
|
+
type: "session_create",
|
|
1049
|
+
requestId,
|
|
1050
|
+
lifecycleRequestId: requestId,
|
|
1051
|
+
intendedSessionId: `s${crypto.randomBytes(6).toString("hex")}`,
|
|
1052
|
+
updateId,
|
|
1053
|
+
chatId,
|
|
1054
|
+
token,
|
|
1055
|
+
target: parsed.target,
|
|
1056
|
+
};
|
|
1057
|
+
}
|
|
1058
|
+
if (parsed.kind === "close") {
|
|
1059
|
+
return { type: "session_close", requestId, updateId, chatId, token, target: parsed.target, force: true };
|
|
1060
|
+
}
|
|
1061
|
+
return { type: "session_resume", requestId, updateId, chatId, token, target: parsed.target };
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
/**
|
|
1065
|
+
* Handle a paired-chat /session_* command: validate (shared validator),
|
|
1066
|
+
* route to the control endpoint, and reply with the outcome. Returns true
|
|
1067
|
+
* when the message was a lifecycle command (so the caller stops processing).
|
|
1068
|
+
*/
|
|
1069
|
+
private async handleLifecycleCommand(
|
|
1070
|
+
text: string | undefined,
|
|
1071
|
+
updateId: number | undefined,
|
|
1072
|
+
threadId: number | undefined,
|
|
1073
|
+
): Promise<boolean> {
|
|
1074
|
+
if (!isLifecycleCommandText(text)) return false;
|
|
1075
|
+
const reply = (body: string) =>
|
|
1076
|
+
this.botApi
|
|
1077
|
+
.call("sendMessage", {
|
|
1078
|
+
chat_id: this.opts.chatId,
|
|
1079
|
+
...(threadId !== undefined ? { message_thread_id: threadId } : {}),
|
|
1080
|
+
text: body,
|
|
1081
|
+
})
|
|
1082
|
+
.catch(() => undefined);
|
|
1083
|
+
|
|
1084
|
+
if (!this.lifecycleControlActive) {
|
|
1085
|
+
await reply("Session lifecycle control is not available right now.");
|
|
1086
|
+
return true;
|
|
1087
|
+
}
|
|
1088
|
+
if (updateId !== undefined && this.dispatchState.seenUpdateIds.has(updateId)) return true;
|
|
1089
|
+
if (updateId !== undefined) this.dispatchState.seenUpdateIds.add(updateId);
|
|
1090
|
+
|
|
1091
|
+
const parsed = parseLifecycleCommand(text);
|
|
1092
|
+
if (parsed.kind === "none") return false;
|
|
1093
|
+
if (parsed.kind === "usage" || parsed.kind === "reject") {
|
|
1094
|
+
await reply(parsed.message);
|
|
1095
|
+
return true;
|
|
1096
|
+
}
|
|
1097
|
+
if (parsed.kind === "recent") {
|
|
1098
|
+
const recent = listRecentSessions({
|
|
1099
|
+
sessionsRoot: path.join(this.opts.settings.getAgentDir(), "sessions"),
|
|
1100
|
+
limit: 10,
|
|
1101
|
+
});
|
|
1102
|
+
const lines = recent.length
|
|
1103
|
+
? recent.map(e => `\u2022 ${e.sessionId}${e.path ? ` (${e.path})` : ""}`).join("\n")
|
|
1104
|
+
: "No recent sessions.";
|
|
1105
|
+
await reply(lines);
|
|
1106
|
+
return true;
|
|
1107
|
+
}
|
|
1108
|
+
|
|
1109
|
+
// Defensive shared-validator pre-check before any effect.
|
|
1110
|
+
const verb =
|
|
1111
|
+
parsed.kind === "create" ? "session_create" : parsed.kind === "close" ? "session_close" : "session_resume";
|
|
1112
|
+
const valid = validateLifecycleTarget(verb, parsed.target);
|
|
1113
|
+
if (!valid.ok) {
|
|
1114
|
+
await reply(`${valid.message}\n\n${lifecycleUsage()}`);
|
|
1115
|
+
return true;
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
const frame = this.buildLifecycleFrame(parsed, updateId ?? Date.now());
|
|
1119
|
+
const response = await this.submitLifecycleFrame(frame);
|
|
1120
|
+
await reply(this.formatLifecycleResponse(response));
|
|
1121
|
+
return true;
|
|
1122
|
+
}
|
|
1123
|
+
|
|
1124
|
+
/** Map a lifecycle response/error to a user-facing message (G010 surfacing). */
|
|
1125
|
+
private formatLifecycleResponse(r: SessionLifecycleResponse): string {
|
|
1126
|
+
return formatLifecycleOutcome(r);
|
|
1127
|
+
}
|
|
1128
|
+
|
|
777
1129
|
constructor(private readonly opts: TelegramDaemonOptions) {
|
|
778
1130
|
this.fsImpl = opts.fs ?? nodeFs;
|
|
779
1131
|
this.aliasTable = createAliasTable();
|
|
@@ -847,6 +1199,14 @@ export class TelegramNotificationDaemon {
|
|
|
847
1199
|
await this.setReaction(target.messageId, CONSUMED_REACTION);
|
|
848
1200
|
}
|
|
849
1201
|
},
|
|
1202
|
+
})
|
|
1203
|
+
.add({
|
|
1204
|
+
name: "session_closed",
|
|
1205
|
+
matches: msg => msg.type === "session_closed",
|
|
1206
|
+
handle: async session => {
|
|
1207
|
+
this.busy.delete(session.sessionId);
|
|
1208
|
+
await this.deleteTopic(session.sessionId);
|
|
1209
|
+
},
|
|
850
1210
|
});
|
|
851
1211
|
}
|
|
852
1212
|
|
|
@@ -877,6 +1237,11 @@ export class TelegramNotificationDaemon {
|
|
|
877
1237
|
if (this.sessions.has(sessionId)) continue;
|
|
878
1238
|
try {
|
|
879
1239
|
const endpoint = readEndpoint(path.join(dir, file));
|
|
1240
|
+
// Skip endpoint files whose owning process is gone or that are
|
|
1241
|
+
// explicitly stale (e.g. a hard-closed session): reconnecting
|
|
1242
|
+
// would chase a dead, token-bearing record forever.
|
|
1243
|
+
const pidAlive = this.opts.pidAlive ?? defaultPidAlive;
|
|
1244
|
+
if (endpoint.stale || (endpoint.pid !== undefined && !pidAlive(endpoint.pid))) continue;
|
|
880
1245
|
this.connectSession(sessionId, endpoint.url, endpoint.token);
|
|
881
1246
|
} catch {}
|
|
882
1247
|
}
|
|
@@ -912,6 +1277,12 @@ export class TelegramNotificationDaemon {
|
|
|
912
1277
|
);
|
|
913
1278
|
} catch {}
|
|
914
1279
|
}
|
|
1280
|
+
// Eagerly create the session's Telegram topic as soon as it connects, so
|
|
1281
|
+
// a thread exists the moment a notifications-enabled session is live —
|
|
1282
|
+
// not lazily on the first delivered frame (which only arrives once the
|
|
1283
|
+
// user sends a prompt). A provisional "GJC <id>" name is used; the
|
|
1284
|
+
// identity_header frame renames it to "{repo}/{branch} - {title}" later.
|
|
1285
|
+
void this.ensureTopic(sessionId, this.topicNameFor(sessionId, {})).catch(() => undefined);
|
|
915
1286
|
});
|
|
916
1287
|
ws.addEventListener("message", ev => {
|
|
917
1288
|
// Identity guard: a delayed frame from a superseded socket must not act
|
|
@@ -1077,8 +1448,12 @@ export class TelegramNotificationDaemon {
|
|
|
1077
1448
|
return String(tid);
|
|
1078
1449
|
},
|
|
1079
1450
|
this.opts.now,
|
|
1451
|
+
// The create winner records the name it actually used; callers that
|
|
1452
|
+
// merely JOIN an in-flight create must not overwrite it locally, or a
|
|
1453
|
+
// later identity rename would be wrongly skipped (topic stuck at the
|
|
1454
|
+
// provisional name on Telegram).
|
|
1455
|
+
name,
|
|
1080
1456
|
);
|
|
1081
|
-
this.topics.applyName(sessionId, name);
|
|
1082
1457
|
await this.persistTopics();
|
|
1083
1458
|
return rec.topicId;
|
|
1084
1459
|
} catch {
|
|
@@ -1086,6 +1461,31 @@ export class TelegramNotificationDaemon {
|
|
|
1086
1461
|
}
|
|
1087
1462
|
}
|
|
1088
1463
|
|
|
1464
|
+
/** Best-effort delete of a session topic once its local notification endpoint shuts down. */
|
|
1465
|
+
private async deleteTopic(sessionId: string): Promise<void> {
|
|
1466
|
+
const record = this.topics.get(sessionId);
|
|
1467
|
+
if (!record) return;
|
|
1468
|
+
try {
|
|
1469
|
+
// Drop queued sends for this session before deleting the topic; otherwise
|
|
1470
|
+
// rate-limited frames can flush later into a deleted topic or across resume.
|
|
1471
|
+
this.pool.removeWhere(item => item.sessionId === sessionId);
|
|
1472
|
+
await this.flushPool();
|
|
1473
|
+
const res = (await this.botApi.call("deleteForumTopic", {
|
|
1474
|
+
chat_id: this.opts.chatId,
|
|
1475
|
+
message_thread_id: Number(record.topicId),
|
|
1476
|
+
})) as { ok?: boolean };
|
|
1477
|
+
if (res?.ok === false) return;
|
|
1478
|
+
this.topics.delete(sessionId);
|
|
1479
|
+
this.topicOwnerByIdentity.forEach((ownerSessionId, identityKey) => {
|
|
1480
|
+
if (ownerSessionId === sessionId) this.topicOwnerByIdentity.delete(identityKey);
|
|
1481
|
+
});
|
|
1482
|
+
this.pendingThreadedFrames.delete(sessionId);
|
|
1483
|
+
await this.persistTopics();
|
|
1484
|
+
} catch {
|
|
1485
|
+
// Best-effort: missing Telegram topic permissions must not stop teardown.
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
|
|
1089
1489
|
private async persistTopics(): Promise<void> {
|
|
1090
1490
|
const paths = daemonPaths(this.opts.settings.getAgentDir());
|
|
1091
1491
|
await ensureDir(this.fsImpl, paths.dir);
|
|
@@ -1434,9 +1834,9 @@ export class TelegramNotificationDaemon {
|
|
|
1434
1834
|
summary: msg.summary,
|
|
1435
1835
|
});
|
|
1436
1836
|
const options = Array.isArray(msg.options) ? msg.options : [];
|
|
1437
|
-
// Daemon keyboards
|
|
1438
|
-
//
|
|
1439
|
-
const inline_keyboard =
|
|
1837
|
+
// Daemon keyboards use alias callback data with compact one-based tap targets;
|
|
1838
|
+
// full option text is rendered in the message body by buildActionMessage.
|
|
1839
|
+
const inline_keyboard = buildCompactChoiceGrid(options, (i: number) =>
|
|
1440
1840
|
this.aliasTable.put({ sessionId: session.sessionId, actionId: msg.id, answer: i }),
|
|
1441
1841
|
);
|
|
1442
1842
|
const result = (await this.botApi.call("sendMessage", {
|
|
@@ -1480,6 +1880,20 @@ export class TelegramNotificationDaemon {
|
|
|
1480
1880
|
}
|
|
1481
1881
|
|
|
1482
1882
|
async handleTelegramUpdate(update: unknown): Promise<void> {
|
|
1883
|
+
// Session-lifecycle command (/session_*): handled ONLY from the paired chat,
|
|
1884
|
+
// gated before any arg parsing or side effect, and routed through the control
|
|
1885
|
+
// endpoint. Must run before threaded-injection so commands are not treated as
|
|
1886
|
+
// session input.
|
|
1887
|
+
{
|
|
1888
|
+
const m = (update as { update_id?: number; message?: Record<string, unknown> }).message;
|
|
1889
|
+
const chatId = (m?.chat as { id?: unknown } | undefined)?.id;
|
|
1890
|
+
const cmdText = typeof m?.text === "string" ? m.text : undefined;
|
|
1891
|
+
if (m !== undefined && String(chatId) === String(this.opts.chatId) && isLifecycleCommandText(cmdText)) {
|
|
1892
|
+
const updateId = (update as { update_id?: number }).update_id;
|
|
1893
|
+
const threadId = typeof m.message_thread_id === "number" ? (m.message_thread_id as number) : undefined;
|
|
1894
|
+
if (await this.handleLifecycleCommand(cmdText, updateId, threadId)) return;
|
|
1895
|
+
}
|
|
1896
|
+
}
|
|
1483
1897
|
// Threaded injection: a free-text message in a known topic (not a button
|
|
1484
1898
|
// tap and not a reply to a specific ask message) injects a user turn or an
|
|
1485
1899
|
// in-thread config command. Fail-closed: paired chat + known topic +
|
|
@@ -1616,6 +2030,9 @@ export class TelegramNotificationDaemon {
|
|
|
1616
2030
|
await this.loadAliases();
|
|
1617
2031
|
await this.loadTopics();
|
|
1618
2032
|
await this.runScan();
|
|
2033
|
+
// Owner-only: start the session-lifecycle control server now that
|
|
2034
|
+
// ownership is confirmed (singleton-safe). Best-effort; degrades.
|
|
2035
|
+
await this.startLifecycleControl();
|
|
1619
2036
|
let idleSince = this.runtime.now();
|
|
1620
2037
|
while (this.running) {
|
|
1621
2038
|
if (await this.controlStopRequested()) break;
|
|
@@ -1631,10 +2048,17 @@ export class TelegramNotificationDaemon {
|
|
|
1631
2048
|
break;
|
|
1632
2049
|
await this.runScan();
|
|
1633
2050
|
if (await this.controlStopRequested()) break;
|
|
1634
|
-
|
|
1635
|
-
|
|
2051
|
+
const idleElapsed = this.runtime.now() - idleSince >= (this.opts.idleTimeoutMs ?? 60_000);
|
|
2052
|
+
if (this.sessions.size === 0 && !this.lifecycleControlActive) {
|
|
2053
|
+
// No sessions and no lifecycle control: idle-exit on timeout.
|
|
2054
|
+
if (idleElapsed) break;
|
|
1636
2055
|
} else {
|
|
1637
|
-
|
|
2056
|
+
// Poll getUpdates when sessions exist OR lifecycle control is active
|
|
2057
|
+
// (so phone /session_* commands are received even with zero sessions).
|
|
2058
|
+
// With zero sessions, still idle-exit after the timeout so the owner
|
|
2059
|
+
// does not run forever; an active session resets the idle window.
|
|
2060
|
+
if (this.sessions.size > 0) idleSince = this.runtime.now();
|
|
2061
|
+
else if (idleElapsed) break;
|
|
1638
2062
|
const activePoll = this.runtime.createAbortController();
|
|
1639
2063
|
try {
|
|
1640
2064
|
await this.pollOnce(activePoll.signal);
|
|
@@ -1659,6 +2083,7 @@ export class TelegramNotificationDaemon {
|
|
|
1659
2083
|
this.stopFlushTimer();
|
|
1660
2084
|
this.stopScanTimer();
|
|
1661
2085
|
this.stopTypingTimer();
|
|
2086
|
+
this.stopLifecycleControl();
|
|
1662
2087
|
await this.cleanupAllAttachmentDirs();
|
|
1663
2088
|
// Persist durable state before releasing ownership so a fresh daemon
|
|
1664
2089
|
// (e.g. after reload) reloads aliases/topics seamlessly.
|
|
@@ -15,7 +15,14 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import * as fs from "node:fs";
|
|
18
|
-
import {
|
|
18
|
+
import {
|
|
19
|
+
bold,
|
|
20
|
+
buildCompactChoiceGrid,
|
|
21
|
+
escapeHtml,
|
|
22
|
+
numberedOptionList,
|
|
23
|
+
TELEGRAM_PARSE_MODE,
|
|
24
|
+
truncateTelegramHtml,
|
|
25
|
+
} from "./html-format";
|
|
19
26
|
import { renderThreadedFrame } from "./threaded-render";
|
|
20
27
|
|
|
21
28
|
/** One inline-keyboard button. */
|
|
@@ -129,8 +136,9 @@ export function buildActionMessage(action: {
|
|
|
129
136
|
const text = `❓ ${bold(action.question ?? "Question")}`;
|
|
130
137
|
const options = action.options ?? [];
|
|
131
138
|
if (options.length === 0) return { text: truncateTelegramHtml(`${text}\n\n(reply with text)`) };
|
|
132
|
-
const
|
|
133
|
-
|
|
139
|
+
const body = `${text}\n\n${numberedOptionList(options)}`;
|
|
140
|
+
const inline_keyboard = buildCompactChoiceGrid(options, i => encodeCallbackData(action.id, i));
|
|
141
|
+
return { text: truncateTelegramHtml(body), inline_keyboard };
|
|
134
142
|
}
|
|
135
143
|
|
|
136
144
|
/** A protocol `reply` frame the client should send to the server. */
|
|
@@ -235,13 +243,23 @@ export function routeInboundUpdate(update: unknown, ctx: RouteInboundContext): R
|
|
|
235
243
|
return { kind: "ignore" };
|
|
236
244
|
}
|
|
237
245
|
|
|
238
|
-
/** Read `{url, token}` from an endpoint discovery file. */
|
|
239
|
-
export function readEndpoint(path: string): { url: string; token: string } {
|
|
240
|
-
const raw = JSON.parse(fs.readFileSync(path, "utf8")) as {
|
|
246
|
+
/** Read `{url, token, pid?, stale?}` from an endpoint discovery file. */
|
|
247
|
+
export function readEndpoint(path: string): { url: string; token: string; pid?: number; stale?: boolean } {
|
|
248
|
+
const raw = JSON.parse(fs.readFileSync(path, "utf8")) as {
|
|
249
|
+
url?: unknown;
|
|
250
|
+
token?: unknown;
|
|
251
|
+
pid?: unknown;
|
|
252
|
+
stale?: unknown;
|
|
253
|
+
};
|
|
241
254
|
if (typeof raw.url !== "string" || typeof raw.token !== "string") {
|
|
242
255
|
throw new Error(`invalid endpoint file: ${path}`);
|
|
243
256
|
}
|
|
244
|
-
return {
|
|
257
|
+
return {
|
|
258
|
+
url: raw.url,
|
|
259
|
+
token: raw.token,
|
|
260
|
+
pid: typeof raw.pid === "number" ? raw.pid : undefined,
|
|
261
|
+
stale: raw.stale === true,
|
|
262
|
+
};
|
|
245
263
|
}
|
|
246
264
|
|
|
247
265
|
/** Options for {@link runTelegramReferenceClient}. */
|
|
@@ -1,11 +1,11 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Per-session forum-topic registry for the threaded session surface.
|
|
3
3
|
*
|
|
4
|
-
* Each GJC session owns
|
|
5
|
-
* DM. The topic is created
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
4
|
+
* Each GJC session owns one active Telegram forum topic in the paired private
|
|
5
|
+
* DM. The topic is created via `createForumTopic`, reused while the session
|
|
6
|
+
* remains active, and removed from the registry when the daemon deletes it on
|
|
7
|
+
* shutdown. The registry also tracks whether the one-time identity header has
|
|
8
|
+
* already been pinned, so it is sent exactly once per active topic, even across
|
|
9
9
|
* reconnects.
|
|
10
10
|
*
|
|
11
11
|
* State is a plain serialisable map persisted beside the daemon state files;
|
|
@@ -76,14 +76,14 @@ export class TopicRegistry {
|
|
|
76
76
|
}
|
|
77
77
|
|
|
78
78
|
/**
|
|
79
|
-
* Return the existing topic for `sessionId`, or create one via
|
|
80
|
-
* (called only on first use).
|
|
81
|
-
* returned without invoking `create`.
|
|
79
|
+
* Return the existing active topic for `sessionId`, or create one via
|
|
80
|
+
* `create` (called only on first use).
|
|
82
81
|
*/
|
|
83
82
|
async getOrCreateTopic(
|
|
84
83
|
sessionId: string,
|
|
85
84
|
create: () => Promise<string>,
|
|
86
85
|
now: () => number = Date.now,
|
|
86
|
+
name?: string,
|
|
87
87
|
): Promise<TopicRecord> {
|
|
88
88
|
const existing = this.topics.get(sessionId);
|
|
89
89
|
if (existing) return existing;
|
|
@@ -95,7 +95,7 @@ export class TopicRegistry {
|
|
|
95
95
|
if (pending) return pending;
|
|
96
96
|
const promise = (async () => {
|
|
97
97
|
const topicId = await create();
|
|
98
|
-
const record: TopicRecord = { topicId, identitySent: false, createdAt: now() };
|
|
98
|
+
const record: TopicRecord = { topicId, name, identitySent: false, createdAt: now() };
|
|
99
99
|
this.topics.set(sessionId, record);
|
|
100
100
|
this.byTopic.set(topicId, sessionId);
|
|
101
101
|
return record;
|
|
@@ -131,6 +131,15 @@ export class TopicRegistry {
|
|
|
131
131
|
return true;
|
|
132
132
|
}
|
|
133
133
|
|
|
134
|
+
/** Remove a session topic record after Telegram deletes the topic. */
|
|
135
|
+
delete(sessionId: string): boolean {
|
|
136
|
+
const record = this.topics.get(sessionId);
|
|
137
|
+
if (!record) return false;
|
|
138
|
+
this.topics.delete(sessionId);
|
|
139
|
+
this.byTopic.delete(record.topicId);
|
|
140
|
+
return true;
|
|
141
|
+
}
|
|
142
|
+
|
|
134
143
|
/** Serialise for atomic persistence beside the daemon state. */
|
|
135
144
|
serialize(): TopicRegistryState {
|
|
136
145
|
return { topics: Object.fromEntries(this.topics) };
|
|
@@ -36,8 +36,8 @@ This mode activates only when the assignment explicitly labels Executor as Ultra
|
|
|
36
36
|
|
|
37
37
|
When active:
|
|
38
38
|
- Start from the approved plan/spec/acceptance criteria, then user-facing contracts, then implementation code only as supporting evidence. Treat plan/code mismatches as blockers.
|
|
39
|
-
- Exercise the real user-facing invocation rather than inspecting internals alone. Live artifacts must be runtime-valid: GUI/web needs a real automation transcript plus non-uniform screenshot; CLI needs executed argv-only replay; native/desktop/TUI needs a real screenshot, PTY capture with control codes, or app-automation transcript. `inlineEvidence` is supplemental only and is never sole proof for live surfaces.
|
|
40
|
-
- For CLI evidence, emit argv-only replay JSON with `schemaVersion: 1`, `kind: "cli-replay"`, `replaySafe: true`, and `command` as a string array. Use only allowlisted deterministic executables/arguments
|
|
39
|
+
- Exercise the real user-facing invocation rather than inspecting internals alone. Live artifacts must be runtime-valid: GUI/web needs a real automation transcript plus non-uniform screenshot; CLI needs executed argv-only replay; native/desktop/TUI needs a real screenshot, PTY capture with control codes, or app-automation transcript. API/package surfaces need a real artifact file or typed receipt whose artifact `kind` contains `api`, `package`, `consumer`, `black-box`, or `test-report`; good kinds include `api-package-test-report`, `package-consumer-report`, and `black-box-api-receipt`. Algorithm/math surfaces need a real artifact file or typed receipt whose artifact `kind` contains `property`, `boundary`, `edge`, `adversarial`, `failure`, `math`, `algorithm`, or `test-report`; good kinds include `property-test-report` and `algorithm-boundary-report`. `inlineEvidence` is supplemental only and is never sole proof for live surfaces.
|
|
40
|
+
- For CLI evidence, emit argv-only replay JSON with `schemaVersion: 1`, `kind: "cli-replay"`, `replaySafe: true`, and `command` as a string array. Use only allowlisted deterministic executables/arguments: `bun --version`, `node --version`, deterministic `bun/node -e "console.log(...)"`, `npm|pnpm|yarn --version`, `npm|pnpm|yarn list`, read-only `git status|rev-parse|merge-base|diff|show|log` with safe args, and `gjc read|status`. Mark any other command with audited `replayExempt` metadata plus a valid structural fallback artifact. `replayExempt` must use exact fields `reasonCode`, `reason`, `approvedBy`, and `fallbackArtifactRefs`; allowed `reasonCode` values are exactly `unsafe_side_effect`, `requires_credentials`, `requires_network`, `non_deterministic_external`, `destructive`, `interactive_only`, and `platform_unavailable`.
|
|
41
41
|
- Native/TUI evidence must be structural, not prose-only: screenshot, app transcript, or PTY artifact with terminal control codes.
|
|
42
42
|
- Do not call the `ask` tool while an Ultragoal run is active; record unresolved decisions with `gjc ultragoal record-review-blockers`.
|
|
43
43
|
- Try to break the work with adversarial cases, not just happy-path confirmations.
|