codexteer 0.14.1 → 0.15.1
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 +30 -0
- package/README.md +129 -28
- package/docs/distribution.md +40 -11
- package/package.json +3 -1
- package/src/app-server.mjs +26 -7
- package/src/cli.mjs +105 -13
- package/src/compatibility.mjs +3 -0
- package/src/connection.mjs +107 -0
- package/src/directive.mjs +12 -3
- package/src/findings.mjs +160 -0
- package/src/help.mjs +62 -23
- package/src/journal.mjs +12 -3
- package/src/launcher.mjs +4 -3
- package/src/monitor.mjs +13 -2
- package/src/observe.mjs +17 -5
- package/src/paged-read.mjs +2 -2
- package/src/prompt.mjs +43 -14
- package/src/rpc.mjs +9 -2
- package/src/supervise.mjs +27 -6
- package/src/supervision.mjs +134 -0
package/src/cli.mjs
CHANGED
|
@@ -11,6 +11,9 @@ import { createCheckpoint, listCheckpoints, getCheckpoint, checkpointSummary, ve
|
|
|
11
11
|
import { acquireResource, resourceStatus, renewResource, releaseResource, listResources, runWithResource } from "./resource.mjs";
|
|
12
12
|
import { playSendSound } from "./sound.mjs";
|
|
13
13
|
import { prepareSupervisorPrompt, superviseAgent } from "./supervise.mjs";
|
|
14
|
+
import { supervisorId, registerSupervisor, getSupervisor, assertSupervisor, controlSupervisor, supervisorStatus, listSupervisors } from "./supervision.mjs";
|
|
15
|
+
import { createFinding, findingStatus, listFindings, resolveFinding, dismissFinding, reopenFinding, evaluateFinding, findingStats } from "./findings.mjs";
|
|
16
|
+
import { connectionMode, observationConnection, desktopReadOnly } from "./connection.mjs";
|
|
14
17
|
|
|
15
18
|
const DEFAULT_SEND_BACKEND = "app-server";
|
|
16
19
|
|
|
@@ -26,6 +29,8 @@ function fail(error, json) {
|
|
|
26
29
|
operation: error.operation, capability: error.capability,
|
|
27
30
|
sent: error.sent, thread_id: error.thread_id, rpc_code: error.rpc_code ?? error.rpcCode,
|
|
28
31
|
message_id: error.message_id, client_message_id: error.client_message_id, journal_update_required: error.journal_update_required,
|
|
32
|
+
existing_message_id: error.existing_message_id,
|
|
33
|
+
supervision_update_required: error.supervision_update_required,
|
|
29
34
|
} }));
|
|
30
35
|
else console.error(`codexteer: ${message}`);
|
|
31
36
|
process.exitCode = 1;
|
|
@@ -76,11 +81,33 @@ function printThreads(threads) {
|
|
|
76
81
|
}
|
|
77
82
|
}
|
|
78
83
|
|
|
84
|
+
function printSupervisor(data) {
|
|
85
|
+
if (Array.isArray(data)) {
|
|
86
|
+
if (!data.length) console.log("No supervisor sessions registered.");
|
|
87
|
+
for (const entry of data) console.log(`${entry.thread_id} ${entry.state} ${entry.owner} ${entry.connection} observed:${entry.observation?.last_observed_at ?? "none"}`);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
console.log(`${data.thread_id} ${data.state}${data.owner ? ` ${data.owner} ${data.connection}` : ""}`);
|
|
91
|
+
if (data.session_id) console.log(`session: ${data.session_id}`);
|
|
92
|
+
if (Object.hasOwn(data, "observation")) console.log(`observation: ${data.observation?.connection ?? "none"} last:${data.observation?.last_observed_at ?? "none"} age_ms:${data.observation_age_ms ?? "unknown"}`);
|
|
93
|
+
if (Object.hasOwn(data, "launcher_alive")) console.log(`launcher: ${data.launcher_alive === null ? "unverified (copied prompt)" : data.launcher_alive ? "alive" : "exited"}`);
|
|
94
|
+
if (data.last_delivery) console.log(`delivery: ${data.last_delivery.status} message:${data.last_delivery.message_id ?? "none"}`);
|
|
95
|
+
if (data.last_error) console.log(`last error: ${data.last_error.code}`);
|
|
96
|
+
if (data.unresolved_findings) {
|
|
97
|
+
console.log(`unresolved findings: ${data.unresolved_findings.total}`);
|
|
98
|
+
for (const finding of data.unresolved_findings.findings) console.log(` ${finding.id} ${finding.status} ${finding.title}`);
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
79
102
|
export async function main(argv) {
|
|
80
103
|
const args = [...argv];
|
|
81
104
|
let json = takeFlag(args, "--json");
|
|
82
105
|
|
|
83
106
|
try {
|
|
107
|
+
const sessionOption = takeOption(args, "--supervisor", undefined);
|
|
108
|
+
const supervisor = sessionOption === undefined ? undefined : supervisorId(sessionOption);
|
|
109
|
+
const connection = connectionMode(takeOption(args, "--connection", "shared"));
|
|
110
|
+
const connectionDependencies = observationConnection(connection);
|
|
84
111
|
const requiredNode = takeOption(args, "--require-node-version", undefined);
|
|
85
112
|
if (requiredNode !== undefined) {
|
|
86
113
|
if (!/^v\d+\.\d+\.\d+(?:-[\w.-]+)?(?:\+[\w.-]+)?$/.test(requiredNode)) throw new Error("--require-node-version requires a Node version such as v25.1.0.");
|
|
@@ -103,22 +130,73 @@ export async function main(argv) {
|
|
|
103
130
|
}
|
|
104
131
|
|
|
105
132
|
let command = args.shift();
|
|
133
|
+
// An explicit observation endpoint can never silently route a mutation to
|
|
134
|
+
// the shared wrapper or UI. Local records/help remain available.
|
|
135
|
+
if (connection === "desktop" && !["supervise", "findings", "history", "instructions", "checkpoint", "resource", "read", "status", "watch", "doctor", "threads", "thread"].includes(command)) throw desktopReadOnly();
|
|
136
|
+
if (command === "supervise" && ["register", "status", "list", "pause", "resume", "stop"].includes(args[0])) {
|
|
137
|
+
const action = args.shift(), owner = action === "register" ? takeOption(args, "--owner", "external") : undefined;
|
|
138
|
+
if (args.length !== (action === "list" ? 0 : 1)) throw new Error(`Expected: supervise ${action}${action === "list" ? "" : " <THREAD>"}.`);
|
|
139
|
+
let data;
|
|
140
|
+
if (action === "register") {
|
|
141
|
+
if (!supervisor) throw new Error("supervise register requires the --supervisor ID from a generated prompt.");
|
|
142
|
+
data = await registerSupervisor(args[0], supervisor, { owner, connection });
|
|
143
|
+
} else {
|
|
144
|
+
if (supervisor && action !== "status") throw Object.assign(new Error("Supervisors cannot change their own control state. Run this control command from the user's terminal without --supervisor."), { code: "SUPERVISOR_CONTROL_FORBIDDEN" });
|
|
145
|
+
if (supervisor) {
|
|
146
|
+
const record = await getSupervisor(args[0]);
|
|
147
|
+
if (record?.session_id !== supervisor) throw Object.assign(new Error("This supervisor belongs to a different session or task."), { code: "SUPERVISOR_MISMATCH" });
|
|
148
|
+
}
|
|
149
|
+
data = action === "list" ? await listSupervisors() : action === "status" ? await supervisorStatus(args[0]) : await controlSupervisor(args[0], action);
|
|
150
|
+
if (action === "status") {
|
|
151
|
+
const pending = await listFindings(args[0], { limit: 20 });
|
|
152
|
+
data.unresolved_findings = { total: pending.total, has_more: pending.has_more,
|
|
153
|
+
findings: pending.findings.map(finding => ({ id: finding.id, title: finding.title, status: finding.status, revision: finding.revision })) };
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
success(`supervise.${action}`, data, json);
|
|
157
|
+
if (!json) printSupervisor(data);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
if (command === "findings") {
|
|
161
|
+
const action = args.shift();
|
|
162
|
+
const flags = names => Object.fromEntries(names.map(([field, option]) => [field, takeOption(args, option, undefined)]));
|
|
163
|
+
let options = {}, data;
|
|
164
|
+
if (action === "create") options = { ...flags([["key", "--key"], ["title", "--title"], ["condition", "--condition"], ["basedOn", "--based-on"]]), evidence: takeOptions(args, "--evidence") };
|
|
165
|
+
else if (action === "resolve") options = flags([["checkpoint", "--checkpoint"], ["note", "--note"]]);
|
|
166
|
+
else if (action === "dismiss") options = flags([["reason", "--reason"]]);
|
|
167
|
+
else if (action === "reopen") {
|
|
168
|
+
options = flags([["reason", "--reason"], ["condition", "--condition"], ["basedOn", "--based-on"]]);
|
|
169
|
+
const evidence = takeOptions(args, "--evidence"); if (evidence.length) options.evidence = evidence;
|
|
170
|
+
} else if (action === "evaluate") options = flags([["rating", "--rating"], ["reason", "--reason"]]);
|
|
171
|
+
else if (action === "list") options = { all: takeFlag(args, "--all"), limit: Number(takeOption(args, "--limit", "50")) };
|
|
172
|
+
const handlers = { create: createFinding, show: findingStatus, list: listFindings, resolve: resolveFinding, dismiss: dismissFinding, reopen: reopenFinding, evaluate: evaluateFinding, stats: findingStats };
|
|
173
|
+
const single = ["create", "list", "stats"].includes(action);
|
|
174
|
+
if (!Object.hasOwn(handlers, action) || args.length !== (single ? 1 : 2) || args.some(arg => arg.startsWith("--"))) throw new Error("See codexteer help findings.");
|
|
175
|
+
if (supervisor) await assertSupervisor(args[0], supervisor, { connection });
|
|
176
|
+
data = single ? await handlers[action](args[0], options) : await handlers[action](args[0], args[1], options);
|
|
177
|
+
success(`findings.${action}`, data, json);
|
|
178
|
+
if (!json) console.log(JSON.stringify(data, null, 2));
|
|
179
|
+
return;
|
|
180
|
+
}
|
|
106
181
|
if (command === "supervise" && args[0] === "prompt") {
|
|
107
182
|
args.shift();
|
|
108
|
-
|
|
109
|
-
|
|
183
|
+
const agent = takeOption(args, "--agent", "claude");
|
|
184
|
+
if (args.length < 1 || args.length > 2 || args.some(arg => arg.startsWith("--"))) throw new Error("Expected: supervise prompt <THREAD> [MESSAGE]. See codexteer help supervise prompt.");
|
|
185
|
+
if (supervisor) throw new Error("A supervisor cannot generate a replacement session.");
|
|
186
|
+
const data = await prepareSupervisorPrompt(args[0], args[1], { agent, connection });
|
|
110
187
|
success("supervise.prompt", data, json);
|
|
111
188
|
if (!json) console.log(data.prompt);
|
|
112
189
|
return;
|
|
113
190
|
}
|
|
114
191
|
if (command === "supervise") {
|
|
192
|
+
if (supervisor) throw new Error("A supervisor cannot start a replacement session.");
|
|
115
193
|
if (json) throw new Error("supervise inherits agent output and does not support --json. Use help supervise --json or supervise prompt <THREAD> --json.");
|
|
116
194
|
const separator = args.indexOf("--");
|
|
117
195
|
const ownArgs = separator < 0 ? args : args.slice(0, separator);
|
|
118
196
|
const agentArgs = separator < 0 ? [] : args.slice(separator + 1);
|
|
119
|
-
const agent = takeOption(ownArgs, "--agent",
|
|
120
|
-
if (ownArgs.length
|
|
121
|
-
process.exitCode = await superviseAgent(ownArgs[0], { agent, agentArgs });
|
|
197
|
+
const agent = takeOption(ownArgs, "--agent", "claude");
|
|
198
|
+
if (ownArgs.length < 1 || ownArgs.length > 2 || ownArgs.some(arg => arg.startsWith("--"))) throw new Error("Expected: supervise <THREAD> [MESSAGE] [--agent claude|codex] [-- <AGENT-ARGS...>].");
|
|
199
|
+
process.exitCode = await superviseAgent(ownArgs[0], { agent, agentArgs, policy: ownArgs[1], connection });
|
|
122
200
|
return;
|
|
123
201
|
}
|
|
124
202
|
if (command === "resource") {
|
|
@@ -148,6 +226,7 @@ export async function main(argv) {
|
|
|
148
226
|
}
|
|
149
227
|
if (command === "checkpoint") {
|
|
150
228
|
const action = args.shift(), includeOutput = takeFlag(args, "--include-output");
|
|
229
|
+
if (supervisor) await assertSupervisor(args[0], supervisor, { connection });
|
|
151
230
|
let data;
|
|
152
231
|
if (action === "capture") {
|
|
153
232
|
const paths = takeOptions(args, "--path"), excludes = takeOptions(args, "--exclude");
|
|
@@ -180,16 +259,18 @@ export async function main(argv) {
|
|
|
180
259
|
if (action === "list") {
|
|
181
260
|
const all = takeFlag(args, "--all");
|
|
182
261
|
if (args.length !== 1) throw new Error("Expected: instructions list <THREAD> [--all].");
|
|
262
|
+
if (supervisor) await assertSupervisor(args[0], supervisor, { connection });
|
|
183
263
|
const data = await listInstructions(args[0], { all });
|
|
184
264
|
success("instructions.list", data, json);
|
|
185
265
|
if (!json) { for (const entry of data.instructions) console.log(`${entry.id} ${entry.instruction_status} ${entry.metadata?.source ?? "unspecified"}\n${entry.body}`); if (data.pending_changes.length) console.log(`配送未確認: ${data.pending_changes.map(e => e.id).join(", ")}`); }
|
|
186
266
|
return;
|
|
187
267
|
}
|
|
188
268
|
if (action !== "retract") throw new Error("See codexteer help instructions.");
|
|
269
|
+
if (connection === "desktop") throw desktopReadOnly();
|
|
189
270
|
const reason = takeOption(args, "--reason", undefined), source = takeOption(args, "--source", undefined), basedOn = takeOption(args, "--based-on", undefined);
|
|
190
271
|
const dryRun = takeFlag(args, "--dry-run"), newTurn = takeFlag(args, "--new-turn");
|
|
191
272
|
if (args.length !== 2 || !reason?.trim()) throw new Error("Expected: instructions retract <THREAD> <MESSAGE-ID> --reason TEXT.");
|
|
192
|
-
const data = await sendTrackedMessage(args[0], reason, { retracts: args[1], source, basedOn, dryRun, newTurn });
|
|
273
|
+
const data = await sendTrackedMessage(args[0], reason, { retracts: args[1], source, basedOn, dryRun, newTurn, supervisor, connection });
|
|
193
274
|
success("instructions.retract", data, json);
|
|
194
275
|
if (!json) console.log(dryRun ? "撤回の送信プレビューです。" : `撤回を受け付けました。message_id: ${data.message_id}`);
|
|
195
276
|
return;
|
|
@@ -201,7 +282,8 @@ export async function main(argv) {
|
|
|
201
282
|
const report = action === "mark" ? { status: takeOption(args, "--status", undefined), note: takeOption(args, "--note", undefined), evidence: takeOptions(args, "--evidence"), by: takeOption(args, "--by", "local") } : {};
|
|
202
283
|
const [threadId, id] = args;
|
|
203
284
|
if (!threadId || args.length > 2 || !["list", "show", "check", "mark"].includes(action) || (["show", "mark"].includes(action) && !id) || (action === "list" && id)) throw new Error("See codexteer help history.");
|
|
204
|
-
|
|
285
|
+
if (supervisor) await assertSupervisor(threadId, supervisor, { connection });
|
|
286
|
+
const data = action === "list" ? await listMessages(threadId, { includeText, pending }) : action === "show" ? messageSummary(await getMessage(threadId, id), includeText) : action === "check" ? await reconcileMessages(threadId, id, { includeText }, connectionDependencies) : await markMessage(threadId, id, report);
|
|
205
287
|
success(`history.${action}`, data, json);
|
|
206
288
|
if (!json) for (const entry of Array.isArray(data) ? data : [data]) console.log(`${entry.id} ${entry.delivery_status} history:${entry.verification?.status ?? "unchecked"} response:${entry.response.status}${entry.body ? "\n" + entry.body : ""}`);
|
|
207
289
|
return;
|
|
@@ -215,6 +297,7 @@ export async function main(argv) {
|
|
|
215
297
|
maxChars: Number(takeOption(args, "--max-chars", "2000")),
|
|
216
298
|
includeOutput: takeFlag(args, "--include-output"),
|
|
217
299
|
};
|
|
300
|
+
options.supervisor = supervisor; options.connection = connection;
|
|
218
301
|
const stream = command === "watch" && takeFlag(args, "--stream");
|
|
219
302
|
if (stream) json = true;
|
|
220
303
|
if (command === "watch") {
|
|
@@ -223,8 +306,9 @@ export async function main(argv) {
|
|
|
223
306
|
Object.assign(options, { watch: true, until: until ?? "change", timeoutMs: Number(timeout ?? "30000"), pollMs: Number(takeOption(args, "--poll-ms", "1000")) });
|
|
224
307
|
}
|
|
225
308
|
if (args.length !== 1) throw new Error(`Expected: ${command} <THREAD>. See codexteer help ${command}.`);
|
|
226
|
-
if (stream) { await monitorCommand(args[0], options); return; }
|
|
227
|
-
const data = await observeThread(args[0], options);
|
|
309
|
+
if (stream) { await monitorCommand(args[0], options, connectionDependencies); return; }
|
|
310
|
+
const data = await observeThread(args[0], options, connectionDependencies);
|
|
311
|
+
data.connection_mode = connection;
|
|
228
312
|
if (command === "status") delete data.events;
|
|
229
313
|
success(command, data, json);
|
|
230
314
|
if (!json) printObservation(data, command === "status");
|
|
@@ -232,11 +316,18 @@ export async function main(argv) {
|
|
|
232
316
|
}
|
|
233
317
|
if (command === "doctor") {
|
|
234
318
|
const backend = backendOption(args, "app-server");
|
|
319
|
+
if (connection === "desktop" && backend === "ui") throw desktopReadOnly();
|
|
235
320
|
const target = takeOption(args, "--thread", undefined);
|
|
236
321
|
if (target !== undefined && backend !== "app-server") throw new Error("doctor --thread requires --backend app-server.");
|
|
237
322
|
const threadId = target === undefined ? undefined : normalizeThreadId(target);
|
|
323
|
+
if (supervisor && threadId) {
|
|
324
|
+
const record = await getSupervisor(threadId);
|
|
325
|
+
if (record && record.session_id !== supervisor && record.state !== "stopped") throw Object.assign(new Error("Another supervisor is registered for this task."), { code: "SUPERVISOR_CONFLICT" });
|
|
326
|
+
if (record?.session_id === supervisor) await assertSupervisor(threadId, supervisor, { connection });
|
|
327
|
+
}
|
|
238
328
|
if (args.length) throw new Error(`Unexpected argument: ${args[0]}`);
|
|
239
|
-
const report = backend === "ui" ? desktopDoctor() : await appServerDoctor({ threadId });
|
|
329
|
+
const report = backend === "ui" ? desktopDoctor() : await appServerDoctor({ threadId, ...connectionDependencies });
|
|
330
|
+
if (report.connection) Object.assign(report.connection, { mode: connection, observation_only: connection === "desktop" });
|
|
240
331
|
report.default_send_backend = DEFAULT_SEND_BACKEND;
|
|
241
332
|
report.rollout_status = "enabled";
|
|
242
333
|
success("doctor", report, json);
|
|
@@ -322,13 +413,14 @@ export async function main(argv) {
|
|
|
322
413
|
|
|
323
414
|
const dryRun = takeFlag(args, "--dry-run");
|
|
324
415
|
const backend = backendOption(args, DEFAULT_SEND_BACKEND);
|
|
416
|
+
if (supervisor && backend !== "app-server") throw new Error("Managed supervision requires the app-server backend.");
|
|
325
417
|
const explicitSound = takeFlag(args, "--sound");
|
|
326
418
|
const noSound = takeFlag(args, "--no-sound");
|
|
327
419
|
if (explicitSound && noSound) throw new Error("--sound and --no-sound cannot be combined.");
|
|
328
420
|
if (explicitSound && backend !== "app-server") throw new Error("--sound requires --backend app-server so receipt can be confirmed.");
|
|
329
421
|
const newTurn = takeFlag(args, "--new-turn");
|
|
330
|
-
const directive = { source: takeOption(args, "--source", undefined), kind: takeOption(args, "--kind", undefined), evidence: takeOptions(args, "--evidence"), basedOn: takeOption(args, "--based-on", undefined), supersedes: takeOption(args, "--supersedes", undefined), expiresAt: takeOption(args, "--expires-at", undefined), checkpoint: takeOption(args, "--checkpoint", undefined) };
|
|
331
|
-
if (backend === "ui" && (directive.source || directive.kind || directive.evidence.length || directive.basedOn || directive.supersedes || directive.expiresAt || directive.checkpoint)) throw new Error("Directive metadata requires --backend app-server.");
|
|
422
|
+
const directive = { source: takeOption(args, "--source", undefined), kind: takeOption(args, "--kind", undefined), evidence: takeOptions(args, "--evidence"), basedOn: takeOption(args, "--based-on", undefined), supersedes: takeOption(args, "--supersedes", undefined), expiresAt: takeOption(args, "--expires-at", undefined), checkpoint: takeOption(args, "--checkpoint", undefined), finding: takeOption(args, "--finding", undefined) };
|
|
423
|
+
if (backend === "ui" && (directive.source || directive.kind || directive.evidence.length || directive.basedOn || directive.supersedes || directive.expiresAt || directive.checkpoint || directive.finding)) throw new Error("Directive metadata requires --backend app-server.");
|
|
332
424
|
const keepFocus = takeFlag(args, "--keep-focus");
|
|
333
425
|
const waitInput = takeOption(args, "--wait-ms", undefined);
|
|
334
426
|
if (backend !== "ui" && (keepFocus || waitInput != null)) throw new Error("--keep-focus and --wait-ms require --backend ui.");
|
|
@@ -339,7 +431,7 @@ export async function main(argv) {
|
|
|
339
431
|
if (message.trim() === "") throw new Error("send requires a non-empty message.");
|
|
340
432
|
const threadId = normalizeThreadId(threadInput);
|
|
341
433
|
const data = backend === "app-server"
|
|
342
|
-
? await sendTrackedMessage(threadId, message, { dryRun, newTurn, ...directive })
|
|
434
|
+
? await sendTrackedMessage(threadId, message, { dryRun, newTurn, ...directive, supervisor, connection })
|
|
343
435
|
: sendDesktopMessage(threadId, message, { dryRun, waitMs, newTurn, keepFocus });
|
|
344
436
|
data.sound = noSound ? { played: false, reason: "disabled" } : playSendSound(data);
|
|
345
437
|
success("send", data, json);
|
package/src/compatibility.mjs
CHANGED
|
@@ -63,6 +63,9 @@ export function runtimeCompatibility(state, { verifiedMethods = [], unsupportedM
|
|
|
63
63
|
}
|
|
64
64
|
}
|
|
65
65
|
const probes = name === "thread_read" ? ["thread/read"] : name === "history_pagination" ? ["thread/turns/list", "thread/items/list"] : [];
|
|
66
|
+
if (state?.native_observation_only && ["turn_steer", "turn_start", "desktop_subscribe"].includes(name)) {
|
|
67
|
+
status = "unsupported"; source = "observation-only"; versions = [];
|
|
68
|
+
}
|
|
66
69
|
if (source === "probe" && probes.length) {
|
|
67
70
|
// One missing required API disproves support even if another probe succeeded.
|
|
68
71
|
if (probes.some(method => unsupportedMethods.includes(method))) {
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { lstat, realpath } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { codexHome } from "./runtime.mjs";
|
|
4
|
+
import { RpcClient } from "./rpc.mjs";
|
|
5
|
+
|
|
6
|
+
const failure = (message, code) => Object.assign(new Error(message), { code });
|
|
7
|
+
const reads = new Set(["thread/loaded/list", "thread/read", "thread/turns/list", "thread/items/list"]);
|
|
8
|
+
export function connectionMode(value = "shared") {
|
|
9
|
+
if (!["shared", "desktop"].includes(value)) throw new Error("--connection must be shared or desktop.");
|
|
10
|
+
return value;
|
|
11
|
+
}
|
|
12
|
+
export function desktopReadOnly() {
|
|
13
|
+
return failure("The desktop connection supports observation only. Use the existing shared connection for sends; connections are never switched automatically.", "DESKTOP_READ_ONLY");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
async function identity(home) {
|
|
17
|
+
const directory = path.join(home, "app-server-control"), socket = path.join(directory, "app-server-control.sock");
|
|
18
|
+
const fingerprint = [];
|
|
19
|
+
for (const [file, type] of [[home, "home"], [directory, "directory"], [socket, "socket"]]) {
|
|
20
|
+
const info = await lstat(file);
|
|
21
|
+
if (info.uid !== process.getuid() || info.isSymbolicLink() || (info.mode & (type === "home" ? 0o022 : 0o077)) !== 0
|
|
22
|
+
|| (type === "socket" ? !info.isSocket() : !info.isDirectory())) {
|
|
23
|
+
throw failure("Desktop control endpoint has unsafe ownership, permissions, or file type.", "DESKTOP_CONNECTION_UNSAFE");
|
|
24
|
+
}
|
|
25
|
+
// Home directory timestamps change for unrelated profile activity.
|
|
26
|
+
fingerprint.push([info.dev, info.ino, type === "socket" ? info.ctimeMs : null]);
|
|
27
|
+
}
|
|
28
|
+
return { home, directory, socket, fingerprint: JSON.stringify(fingerprint) };
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export async function discoverDesktopRuntime(home = codexHome()) {
|
|
32
|
+
try {
|
|
33
|
+
const paths = await identity(await realpath(home));
|
|
34
|
+
return { paths, state: { codex_home: paths.home, native_observation_only: true } };
|
|
35
|
+
} catch (error) {
|
|
36
|
+
if (error.code === "ENOENT") throw failure("No existing Desktop control socket was found in this CODEX_HOME. Direct observation requires Desktop to be using its shared local daemon. This command does not start or restart it.", "DESKTOP_CONNECTION_UNAVAILABLE");
|
|
37
|
+
if (["EPERM", "EACCES"].includes(error.code)) throw failure("Cannot inspect the Desktop control endpoint.", "PERMISSION_DENIED");
|
|
38
|
+
throw error;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function stillCurrent(paths) {
|
|
43
|
+
let current;
|
|
44
|
+
try { current = await identity(paths.home); }
|
|
45
|
+
catch (error) {
|
|
46
|
+
if (error.code === "ENOENT") throw failure("The Desktop endpoint disappeared. Recheck the selected connection before observing again.", "DESKTOP_CONNECTION_CHANGED");
|
|
47
|
+
throw error;
|
|
48
|
+
}
|
|
49
|
+
if (current.fingerprint !== paths.fingerprint) throw failure("The Desktop endpoint changed. Recheck the selected connection before observing again.", "DESKTOP_CONNECTION_CHANGED");
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
async function requireLoaded(client, threadId) {
|
|
53
|
+
const seen = new Set(); let cursor;
|
|
54
|
+
for (let page = 0; page < 100; page++) {
|
|
55
|
+
const result = await client.request("thread/loaded/list", { limit: 100, ...(cursor ? { cursor } : {}) });
|
|
56
|
+
if (!Array.isArray(result?.data) || !result.data.every(id => typeof id === "string")
|
|
57
|
+
|| (result.nextCursor != null && (typeof result.nextCursor !== "string" || !result.nextCursor || seen.has(result.nextCursor)))) {
|
|
58
|
+
throw failure("Invalid loaded-task list from Desktop.", "PROTOCOL_ERROR");
|
|
59
|
+
}
|
|
60
|
+
if (result.data.includes(threadId)) return;
|
|
61
|
+
cursor = result.nextCursor;
|
|
62
|
+
if (!cursor) throw failure("The target is not loaded in this Desktop endpoint. Select the matching profile and loaded task; codexteer will not resume it implicitly.", "DESKTOP_THREAD_NOT_LOADED");
|
|
63
|
+
seen.add(cursor);
|
|
64
|
+
}
|
|
65
|
+
throw failure("The loaded-task list exceeded the observation limit.", "PROTOCOL_ERROR");
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
export async function connectDesktop(paths, options, connect = RpcClient.connect) {
|
|
69
|
+
await stillCurrent(paths);
|
|
70
|
+
const client = await connect(paths.socket, options);
|
|
71
|
+
try { await stillCurrent(paths); } catch (error) { client.close(); throw error; }
|
|
72
|
+
return {
|
|
73
|
+
close: () => client.close(),
|
|
74
|
+
async request(method, params = {}, requestOptions = {}) {
|
|
75
|
+
if (!reads.has(method) || requestOptions.mutation) throw desktopReadOnly();
|
|
76
|
+
await stillCurrent(paths);
|
|
77
|
+
if (method !== "thread/loaded/list") {
|
|
78
|
+
if (typeof params.threadId !== "string" || !params.threadId) throw failure("A target task is required for Desktop observation.", "PROTOCOL_ERROR");
|
|
79
|
+
await requireLoaded(client, params.threadId);
|
|
80
|
+
await stillCurrent(paths);
|
|
81
|
+
}
|
|
82
|
+
const result = await client.request(method, params, requestOptions);
|
|
83
|
+
await stillCurrent(paths);
|
|
84
|
+
return result;
|
|
85
|
+
},
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// Each operation owns its discovery result. No implicit fallback to another
|
|
90
|
+
// profile, daemon, or wrapper, including after connection failures.
|
|
91
|
+
export function observationConnection(mode) {
|
|
92
|
+
if (connectionMode(mode) === "shared") return {};
|
|
93
|
+
let selected;
|
|
94
|
+
return {
|
|
95
|
+
async discover(home) {
|
|
96
|
+
const discovered = await discoverDesktopRuntime(home);
|
|
97
|
+
if (selected && (selected.paths.home !== discovered.paths.home || selected.paths.fingerprint !== discovered.paths.fingerprint)) {
|
|
98
|
+
throw failure("The Desktop endpoint changed during observation. Check it before explicitly starting another watch.", "DESKTOP_CONNECTION_CHANGED");
|
|
99
|
+
}
|
|
100
|
+
selected = discovered; return selected;
|
|
101
|
+
},
|
|
102
|
+
async connect(socket, options) {
|
|
103
|
+
if (!selected || selected.paths.socket !== socket) throw failure("Desktop discovery is required before connection.", "DESKTOP_CONNECTION_UNAVAILABLE");
|
|
104
|
+
return connectDesktop(selected.paths, options);
|
|
105
|
+
},
|
|
106
|
+
};
|
|
107
|
+
}
|
package/src/directive.mjs
CHANGED
|
@@ -2,12 +2,14 @@ import { collectEvidence, verifyEvidence } from "./evidence.mjs";
|
|
|
2
2
|
import { decodeCursor, threadSnapshot, fetchThread } from "./observe.mjs";
|
|
3
3
|
import { getCheckpoint, verifyCheckpoint } from "./checkpoint.mjs";
|
|
4
4
|
import { verifyPagedFreshness } from "./paged-read.mjs";
|
|
5
|
+
import { assertFindingSend, getFinding } from "./findings.mjs";
|
|
5
6
|
|
|
6
7
|
export const kindLabels = { decision: "ユーザー決定の伝達", review: "レビュー", hypothesis: "仮説(未確定)", suggestion: "任意提案" };
|
|
7
8
|
|
|
8
9
|
export async function prepareDirective(threadId, body, options, id, storage = {}) {
|
|
9
|
-
const { source, kind, evidence = [], basedOn, supersedes, retracts, expiresAt, checkpoint } = options;
|
|
10
|
-
if (!source && !kind && !evidence.length && !basedOn && !supersedes && !retracts && !expiresAt && !checkpoint) return { wireText: body, metadata: null };
|
|
10
|
+
const { source, kind, evidence = [], basedOn, supersedes, retracts, expiresAt, checkpoint, finding, supervisor } = options;
|
|
11
|
+
if (!source && !kind && !evidence.length && !basedOn && !supersedes && !retracts && !expiresAt && !checkpoint && !finding && !supervisor) return { wireText: body, metadata: null };
|
|
12
|
+
const concern = finding ? await assertFindingSend(threadId, finding, storage) : null;
|
|
11
13
|
if (checkpoint) await getCheckpoint(threadId, checkpoint, storage);
|
|
12
14
|
if (supersedes && retracts) throw new Error("Use either supersedes or retracts, not both.");
|
|
13
15
|
if (expiresAt && (!/(Z|[+-]\d{2}:\d{2})$/.test(expiresAt) || !Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())) throw new Error("--expires-at must be a future ISO timestamp with timezone.");
|
|
@@ -15,7 +17,8 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
|
|
|
15
17
|
if (source != null && (!source.trim() || source.length > 80 || /[\r\n\x00-\x1f]/.test(source))) throw new Error("--source must be a non-empty single line (up to 80 characters).");
|
|
16
18
|
const observed = basedOn ? decodeCursor(basedOn, threadId) : null;
|
|
17
19
|
const refs = await collectEvidence(evidence);
|
|
18
|
-
const metadata = { source: source ?? "external", kind: kind ?? "review", evidence: refs, based_on: basedOn ?? null, supersedes: supersedes ?? null, retracts: retracts ?? null, expires_at: expiresAt ?? null, checkpoint_id: checkpoint ?? null
|
|
20
|
+
const metadata = { source: source ?? "external", kind: kind ?? "review", evidence: refs, based_on: basedOn ?? null, supersedes: supersedes ?? null, retracts: retracts ?? null, expires_at: expiresAt ?? null, checkpoint_id: checkpoint ?? null,
|
|
21
|
+
...(concern ? { finding_id: concern.id, finding_revision: concern.revision } : {}), ...(supervisor ? { supervisor_id: supervisor } : {}) };
|
|
19
22
|
const lines = [`[codexteer ${id}]`, `送信者: ${metadata.source}`, `種類: ${kindLabels[metadata.kind]}`];
|
|
20
23
|
for (const ref of refs) lines.push(`根拠: ${JSON.stringify(ref.ref)}${ref.sha256 ? ` (sha256:${ref.sha256})` : " (参照URL)"}`);
|
|
21
24
|
if (observed) lines.push(`観測対象ターン: ${observed.active_turn_id ?? "停止中"}`);
|
|
@@ -23,6 +26,7 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
|
|
|
23
26
|
if (retracts) lines.push(`撤回する指示: ${retracts}。以後の方針から除外してください。`);
|
|
24
27
|
if (expiresAt) lines.push(`有効期限: ${expiresAt}`);
|
|
25
28
|
if (checkpoint) lines.push(`検証チェックポイント: ${checkpoint}`);
|
|
29
|
+
if (concern) lines.push(`指摘: ${concern.id} (revision ${concern.revision})`, `解決条件: ${JSON.stringify(concern.condition)}`);
|
|
26
30
|
return { metadata, wireText: lines.join("\n") + "\n\n" + body, async beforeSend(thread, client) {
|
|
27
31
|
if (expiresAt && Date.parse(expiresAt) <= Date.now()) throw Object.assign(new Error("Directive expired before sending."), { code: "EXPIRED_DIRECTIVE" });
|
|
28
32
|
if (observed) {
|
|
@@ -35,5 +39,10 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
|
|
|
35
39
|
}
|
|
36
40
|
await verifyEvidence(refs);
|
|
37
41
|
if (checkpoint && !(await verifyCheckpoint(threadId, checkpoint, storage)).valid) throw Object.assign(new Error("Checkpoint is no longer valid. Verify the inputs and results before sending."), { code: "STALE_CHECKPOINT" });
|
|
42
|
+
if (concern) {
|
|
43
|
+
const current = await getFinding(threadId, concern.id, storage);
|
|
44
|
+
if (current.revision !== concern.revision || current.disposition !== "open") throw Object.assign(new Error("The finding changed before sending. Reassess it before intervening."), { code: "STALE_FINDING" });
|
|
45
|
+
await verifyEvidence(concern.evidence);
|
|
46
|
+
}
|
|
38
47
|
} };
|
|
39
48
|
}
|
package/src/findings.mjs
ADDED
|
@@ -0,0 +1,160 @@
|
|
|
1
|
+
import { randomUUID } from "node:crypto";
|
|
2
|
+
import { normalizeThreadId } from "./thread-id.mjs";
|
|
3
|
+
import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
|
|
4
|
+
import { collectEvidence } from "./evidence.mjs";
|
|
5
|
+
import { decodeCursor, digest } from "./observe.mjs";
|
|
6
|
+
import { verifyCheckpoint } from "./checkpoint.mjs";
|
|
7
|
+
|
|
8
|
+
const failure = (message, code) => Object.assign(new Error(message), { code });
|
|
9
|
+
const ratings = ["useful", "unnecessary", "incorrect", "unrated"];
|
|
10
|
+
const text = (value, label, max = 8000) => {
|
|
11
|
+
if (typeof value !== "string" || !value.trim() || value.length > max || value.includes("\0")) throw failure(`${label} requires non-empty text (up to ${max} characters).`, "FINDING_INVALID");
|
|
12
|
+
return value;
|
|
13
|
+
};
|
|
14
|
+
// A cursor can advance because our own intervention appeared as a userMessage.
|
|
15
|
+
// It is observation context, never sufficient evidence for another intervention.
|
|
16
|
+
const contextHash = record => digest({ condition: record.condition, evidence: record.evidence });
|
|
17
|
+
|
|
18
|
+
export async function getFinding(threadInput, id, storage = {}) {
|
|
19
|
+
const threadId = normalizeThreadId(threadInput), record = await readRecord("findings", id, storage);
|
|
20
|
+
if (!record || record.schema !== 1 || record.id !== id || record.thread_id !== threadId) throw failure("Finding not found for this task.", "FINDING_NOT_FOUND");
|
|
21
|
+
if (!Number.isSafeInteger(record.revision) || record.revision < 1 || !["open", "resolved", "dismissed"].includes(record.disposition)
|
|
22
|
+
|| !Array.isArray(record.evidence) || typeof record.condition !== "string" || !ratings.includes(record.evaluation?.rating)) throw failure("Invalid finding record.", "FINDING_INVALID");
|
|
23
|
+
return record;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export async function findingMessages(record, storage = {}) {
|
|
27
|
+
return (await listRecords("messages", storage)).filter(message => message?.schema === 1 && message.thread_id === record.thread_id
|
|
28
|
+
&& message.metadata?.finding_id === record.id && message.metadata?.finding_revision === record.revision)
|
|
29
|
+
.sort((a, b) => a.created_at.localeCompare(b.created_at));
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export async function createFinding(threadInput, { key, title, condition, evidence = [], basedOn, ...storage }) {
|
|
33
|
+
const threadId = normalizeThreadId(threadInput);
|
|
34
|
+
if (typeof key !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._-]{0,150}$/.test(key)) throw failure("--key requires a stable short identifier for this concern.", "FINDING_INVALID");
|
|
35
|
+
text(title, "--title", 240); text(condition, "--condition");
|
|
36
|
+
const refs = await collectEvidence(evidence), observed = basedOn ? decodeCursor(basedOn, threadId) : null;
|
|
37
|
+
return withStoreLock(`message-${threadId}`, async () => {
|
|
38
|
+
const existing = (await listRecords("findings", storage)).find(record => record?.thread_id === threadId && record.key === key);
|
|
39
|
+
if (existing) return { ...await findingStatus(threadId, existing.id, storage), reused: true };
|
|
40
|
+
const now = new Date().toISOString();
|
|
41
|
+
const record = { schema: 1, id: randomUUID(), thread_id: threadId, key, title, condition, revision: 1,
|
|
42
|
+
evidence: refs, based_on: basedOn ?? null, user_revision: observed?.user_revision ?? null, disposition: "open", resolution: null,
|
|
43
|
+
evaluation: { rating: "unrated" }, revisions: [], created_at: now, updated_at: now };
|
|
44
|
+
await writeRecord("findings", record.id, record, storage);
|
|
45
|
+
return { ...record, status: "open", messages: [], verification: null, reused: false };
|
|
46
|
+
}, storage);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export async function assertFindingSend(threadInput, id, storage = {}) {
|
|
50
|
+
const record = await getFinding(threadInput, id, storage);
|
|
51
|
+
if (record.disposition !== "open") throw failure("This finding is closed. Reopen it with new evidence or a changed requirement before sending again.", "FINDING_CLOSED");
|
|
52
|
+
// The journal is the reservation: even a crash before writing a receipt leaves
|
|
53
|
+
// an unknown attempt here. Never infer not-sent from a missing response.
|
|
54
|
+
const previous = (await findingMessages(record, storage)).find(message => message.delivery_status !== "not_sent");
|
|
55
|
+
if (previous) throw Object.assign(failure("This finding revision already has a pending or accepted intervention. Check its history; do not resend it.", "DUPLICATE_INTERVENTION"), { existing_message_id: previous.id });
|
|
56
|
+
return record;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function findingStatus(threadInput, id, storage = {}) {
|
|
60
|
+
const record = await getFinding(threadInput, id, storage), entries = await findingMessages(record, storage);
|
|
61
|
+
const messages = entries.map(entry => ({ id: entry.id, delivery_status: entry.delivery_status, attempt_state: entry.attempt_state,
|
|
62
|
+
response: entry.response, stored: entry.verification?.status ?? "unchecked" }));
|
|
63
|
+
const latest = entries.filter(entry => entry.delivery_status !== "not_sent").at(-1);
|
|
64
|
+
let status = record.disposition, verification = null;
|
|
65
|
+
if (record.disposition === "open" && latest) status = latest.delivery_status === "unknown" ? "delivery_unknown"
|
|
66
|
+
: latest.response?.status === "applied" ? "awaiting_verification" : "awaiting_response";
|
|
67
|
+
if (record.resolution?.checkpoint_id) {
|
|
68
|
+
try {
|
|
69
|
+
const current = await verifyCheckpoint(record.thread_id, record.resolution.checkpoint_id, storage);
|
|
70
|
+
verification = { valid: current.valid === true && current.latest_run?.id === record.resolution.run_id && current.snapshot.digest === record.resolution.input_digest,
|
|
71
|
+
checkpoint_id: current.id, run_id: current.latest_run?.id ?? null, inputs_current: current.inputs_current,
|
|
72
|
+
checked_at: new Date().toISOString(), condition_assessment: "explicit_report" };
|
|
73
|
+
} catch (error) { verification = { valid: false, checkpoint_id: record.resolution.checkpoint_id, error_code: error.code ?? "CHECKPOINT_UNAVAILABLE", condition_assessment: "explicit_report" }; }
|
|
74
|
+
if (!verification.valid && record.disposition === "resolved") status = "needs_review";
|
|
75
|
+
}
|
|
76
|
+
return { ...record, status, messages, verification };
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function listFindings(threadInput, { all = false, limit = 50, ...storage } = {}) {
|
|
80
|
+
const threadId = normalizeThreadId(threadInput);
|
|
81
|
+
if (!Number.isInteger(limit) || limit < 1 || limit > 1000) throw failure("--limit must be between 1 and 1000.", "FINDING_INVALID");
|
|
82
|
+
const records = (await listRecords("findings", storage)).filter(record => record?.thread_id === threadId).sort((a, b) => b.updated_at.localeCompare(a.updated_at));
|
|
83
|
+
// Recheck closed resolutions too: changed inputs make them unresolved again.
|
|
84
|
+
const findings = [];
|
|
85
|
+
for (const record of records) {
|
|
86
|
+
const current = await findingStatus(threadId, record.id, storage);
|
|
87
|
+
if (all || !["resolved", "dismissed"].includes(current.status)) findings.push(current);
|
|
88
|
+
}
|
|
89
|
+
return { thread_id: threadId, findings: findings.slice(0, limit), total: findings.length, has_more: findings.length > limit };
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
async function changeFinding(threadInput, id, update, storage) {
|
|
93
|
+
const threadId = normalizeThreadId(threadInput);
|
|
94
|
+
return withStoreLock(`message-${threadId}`, async () => {
|
|
95
|
+
const record = await getFinding(threadId, id, storage);
|
|
96
|
+
const updated = { ...await update(record), updated_at: new Date().toISOString() };
|
|
97
|
+
await writeRecord("findings", id, updated, storage);
|
|
98
|
+
return findingStatus(threadId, id, storage);
|
|
99
|
+
}, storage);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async function knownDelivery(record, storage) {
|
|
103
|
+
if ((await findingMessages(record, storage)).some(entry => entry.delivery_status === "unknown")) throw failure("Delivery is unknown. Reconcile the existing message before closing or revising this finding.", "FINDING_DELIVERY_UNKNOWN");
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
export async function resolveFinding(threadInput, id, { checkpoint, note, ...storage }) {
|
|
107
|
+
text(note, "--note");
|
|
108
|
+
if (!checkpoint) throw failure("Resolution requires a --checkpoint and an explicit --note explaining how its result satisfies the condition.", "FINDING_INVALID");
|
|
109
|
+
return changeFinding(threadInput, id, async record => {
|
|
110
|
+
await knownDelivery(record, storage);
|
|
111
|
+
const verified = await verifyCheckpoint(record.thread_id, checkpoint, storage);
|
|
112
|
+
if (!verified.valid) throw failure("The checkpoint is not currently valid. Verify current inputs and a successful run before resolving.", "STALE_CHECKPOINT");
|
|
113
|
+
return { ...record, disposition: "resolved", resolution: { checkpoint_id: checkpoint, run_id: verified.latest_run.id,
|
|
114
|
+
input_digest: verified.snapshot.digest, note, basis: "explicit_report", at: new Date().toISOString() } };
|
|
115
|
+
}, storage);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
export async function dismissFinding(threadInput, id, { reason, ...storage }) {
|
|
119
|
+
text(reason, "--reason");
|
|
120
|
+
return changeFinding(threadInput, id, async record => {
|
|
121
|
+
await knownDelivery(record, storage);
|
|
122
|
+
return { ...record, disposition: "dismissed", resolution: { reason, basis: "explicit_report", at: new Date().toISOString() } };
|
|
123
|
+
}, storage);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export async function reopenFinding(threadInput, id, { reason, evidence, basedOn, condition, ...storage }) {
|
|
127
|
+
const threadId = normalizeThreadId(threadInput);
|
|
128
|
+
text(reason, "--reason"); if (condition !== undefined) text(condition, "--condition");
|
|
129
|
+
const refs = evidence === undefined ? undefined : await collectEvidence(evidence);
|
|
130
|
+
const observed = basedOn === undefined ? undefined : decodeCursor(basedOn, threadId);
|
|
131
|
+
return changeFinding(threadId, id, async record => {
|
|
132
|
+
await knownDelivery(record, storage);
|
|
133
|
+
const updated = { ...record, condition: condition ?? record.condition, evidence: refs ?? record.evidence, based_on: basedOn ?? record.based_on, user_revision: observed?.user_revision ?? record.user_revision };
|
|
134
|
+
if (contextHash(record) === contextHash(updated)) throw failure("Reopening requires changed evidence or a changed resolution condition. A new reason or cursor alone does not authorize a duplicate intervention; reflect a changed user requirement in the condition.", "FINDING_UNCHANGED");
|
|
135
|
+
return { ...updated, revision: record.revision + 1, disposition: "open", resolution: null, evaluation: { rating: "unrated" },
|
|
136
|
+
revisions: [...record.revisions, { revision: record.revision, condition: record.condition, evidence: record.evidence,
|
|
137
|
+
based_on: record.based_on, user_revision: record.user_revision, resolution: record.resolution, evaluation: record.evaluation, reason, at: new Date().toISOString() }] };
|
|
138
|
+
}, storage);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
export async function evaluateFinding(threadInput, id, { rating, reason, ...storage }) {
|
|
142
|
+
if (!ratings.includes(rating) || rating === "unrated") throw failure("--rating must be useful, unnecessary or incorrect.", "FINDING_INVALID");
|
|
143
|
+
text(reason, "--reason");
|
|
144
|
+
return changeFinding(threadInput, id, async record => ({ ...record, evaluation: { rating, reason, basis: "explicit_report", at: new Date().toISOString() } }), storage);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
export async function findingStats(threadInput, storage = {}) {
|
|
148
|
+
const threadId = normalizeThreadId(threadInput), records = (await listRecords("findings", storage)).filter(record => record?.thread_id === threadId);
|
|
149
|
+
const counts = Object.fromEntries(ratings.map(rating => [rating, 0]));
|
|
150
|
+
let sent = 0;
|
|
151
|
+
for (const entry of records) {
|
|
152
|
+
const record = await getFinding(threadId, entry.id, storage);
|
|
153
|
+
counts[record.evaluation.rating]++;
|
|
154
|
+
if ((await findingMessages(record, storage)).some(message => message.delivery_status !== "not_sent")) sent++;
|
|
155
|
+
}
|
|
156
|
+
const evaluated = records.length - counts.unrated;
|
|
157
|
+
return { thread_id: threadId, scope: "current_finding_revisions_including_unsent_candidates", total: records.length,
|
|
158
|
+
sent_or_unknown: sent, unsent_candidates: records.length - sent, ratings: counts, evaluated,
|
|
159
|
+
useful_fraction: evaluated ? counts.useful / evaluated : null };
|
|
160
|
+
}
|