codexteer 0.18.2 → 0.20.0
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 +27 -0
- package/README.md +13 -5
- package/bin/codexteer.mjs +1 -1
- package/docs/distribution.md +23 -6
- package/docs/orchestration.md +4 -4
- package/package.json +1 -1
- package/src/app-server.mjs +4 -4
- package/src/checkpoint.mjs +10 -10
- package/src/cli.mjs +90 -79
- package/src/cloud-cli.mjs +29 -33
- package/src/connection-error.mjs +7 -0
- package/src/cursor.mjs +370 -0
- package/src/desktop-cloud-id.mjs +9 -1
- package/src/desktop-cloud-project.mjs +37 -4
- package/src/desktop-cloud.mjs +10 -2
- package/src/desktop.mjs +9 -9
- package/src/directive.mjs +7 -6
- package/src/distribution.mjs +58 -33
- package/src/evidence.mjs +3 -3
- package/src/findings.mjs +13 -8
- package/src/help-cloud-remote.mjs +399 -0
- package/src/help-errors.mjs +10 -0
- package/src/help-intervention.mjs +492 -0
- package/src/help-local.mjs +510 -0
- package/src/help-observation.mjs +200 -0
- package/src/help-supervision.mjs +257 -0
- package/src/help.mjs +126 -304
- package/src/journal.mjs +33 -12
- package/src/launcher.mjs +6 -6
- package/src/monitor.mjs +5 -4
- package/src/notify.mjs +3 -3
- package/src/observe.mjs +11 -40
- package/src/orchestrate-prompt.mjs +14 -12
- package/src/orchestrate.mjs +4 -7
- package/src/paged-read.mjs +10 -17
- package/src/prompt-command.mjs +45 -7
- package/src/prompt.mjs +15 -12
- package/src/resource.mjs +6 -6
- package/src/runner.mjs +5 -5
- package/src/store.mjs +2 -2
- package/src/supervise.mjs +5 -7
- package/src/task-id.mjs +29 -4
- package/src/thread-id.mjs +5 -5
- package/src/unknown-options.mjs +17 -0
- package/src/wrapper.mjs +2 -2
package/src/desktop-cloud.mjs
CHANGED
|
@@ -52,8 +52,8 @@ export async function sendDesktopCloudInput(target, text, { dryRun = false, newT
|
|
|
52
52
|
const plan = { thread_id: id, deep_link: desktopCloudLink(id), client_message_id: clientMessageId, backend: "desktop-cloud", connection_mode: "desktop-cloud", delivery_action: newTurn ? "new-turn" : "steer", dry_run: dryRun, message_characters: [...text].length };
|
|
53
53
|
if (dryRun) return { ...plan, sent: false, delivery_status: "not_sent" };
|
|
54
54
|
return withStoreLock(`desktop-cloud-send-${id}`, async () => {
|
|
55
|
-
const pending = (await listRecords("desktop-cloud-sends", storage)).find(row => row.thread_id === id && row.delivery_status === "unknown");
|
|
56
|
-
if (pending) throw failure(
|
|
55
|
+
const pending = (await listRecords("desktop-cloud-sends", storage)).find(row => row.thread_id === id && row.delivery_status === "unknown" && !row.resolution);
|
|
56
|
+
if (pending) throw Object.assign(failure(`An earlier Desktop Cloud delivery is unknown and is never replayed automatically. Run history check ${id} ${pending.client_message_id}; if it stays unconfirmed, the user (not a supervisor) can release it with history mark ${id} ${pending.client_message_id} --resolve-unknown --note TEXT.`, "DESKTOP_CLOUD_DELIVERY_PENDING"), { existing_message_id: pending.client_message_id });
|
|
57
57
|
let client, dispatching = false, receipt = { ...plan, prompt_sha256: hash(text), sent: false, delivery_status: "not_sent" };
|
|
58
58
|
const save = () => writeRecord("desktop-cloud-sends", clientMessageId, receipt, storage);
|
|
59
59
|
try {
|
|
@@ -96,3 +96,11 @@ export async function confirmDesktopCloudDelivery(entry, verification, storage =
|
|
|
96
96
|
await writeRecord("desktop-cloud-sends", entry.client_message_id, { ...receipt, delivery_status: "accepted", sent: true, turn_id: verification.turn_id, delivery_verification: "stored" }, storage);
|
|
97
97
|
}, storage);
|
|
98
98
|
}
|
|
99
|
+
export async function resolveDesktopCloudDelivery(target, clientMessageId, resolution, storage = {}) {
|
|
100
|
+
const id = desktopCloudId(target);
|
|
101
|
+
return withStoreLock(`desktop-cloud-send-${id}`, async () => {
|
|
102
|
+
const receipt = await readRecord("desktop-cloud-sends", clientMessageId, storage);
|
|
103
|
+
if (receipt?.thread_id !== id || receipt.delivery_status !== "unknown" || receipt.resolution) throw failure("This message has no unresolved unknown Desktop Cloud delivery.");
|
|
104
|
+
const updated = { ...receipt, resolution }; await writeRecord("desktop-cloud-sends", clientMessageId, updated, storage); return updated;
|
|
105
|
+
}, storage);
|
|
106
|
+
}
|
package/src/desktop.mjs
CHANGED
|
@@ -61,7 +61,7 @@ export function openDesktopThread(threadId, { focusComposer = false, prompt, bac
|
|
|
61
61
|
const deepLink = focusComposer ? threadComposerDeepLink(threadId, prompt) : threadDeepLink(threadId);
|
|
62
62
|
const result = run(OPEN, buildOpenArgs(deepLink, { background }));
|
|
63
63
|
if (result.status !== 0) {
|
|
64
|
-
throw new Error(result.stderr.trim() || `Failed to open ${deepLink}`);
|
|
64
|
+
throw Object.assign(new Error(result.stderr.trim() || `Failed to open ${deepLink}`), { code: "DESKTOP_UI_FAILED" });
|
|
65
65
|
}
|
|
66
66
|
return {
|
|
67
67
|
thread_id: threadId,
|
|
@@ -74,7 +74,7 @@ export function openDesktopThread(threadId, { focusComposer = false, prompt, bac
|
|
|
74
74
|
|
|
75
75
|
function validateWaitMs(waitMs) {
|
|
76
76
|
if (!Number.isInteger(waitMs) || waitMs < 0 || waitMs > 30000) {
|
|
77
|
-
throw new Error("--wait-ms must be an integer between 0 and 30000.");
|
|
77
|
+
throw Object.assign(new Error("--wait-ms must be an integer between 0 and 30000."), { code: "OPTIONS_INVALID" });
|
|
78
78
|
}
|
|
79
79
|
}
|
|
80
80
|
|
|
@@ -85,7 +85,7 @@ function requireReadyDesktop() {
|
|
|
85
85
|
.filter(([, ok]) => !ok)
|
|
86
86
|
.map(([name]) => name)
|
|
87
87
|
.join(", ");
|
|
88
|
-
throw new Error(`Desktop backend is not ready: ${missing}. ${doctor.remediation ?? ""}`.trim());
|
|
88
|
+
throw Object.assign(new Error(`Desktop backend is not ready: ${missing}. ${doctor.remediation ?? ""}`.trim()), { code: "DESKTOP_UI_NOT_READY" });
|
|
89
89
|
}
|
|
90
90
|
|
|
91
91
|
function frontmostProcessId() {
|
|
@@ -96,7 +96,7 @@ function frontmostProcessId() {
|
|
|
96
96
|
if (result.error) throw result.error;
|
|
97
97
|
const processId = Number.parseInt(result.stdout.trim(), 10);
|
|
98
98
|
if (result.status !== 0 || !Number.isInteger(processId) || processId < 1) {
|
|
99
|
-
throw new Error(result.stderr.trim() || "Could not identify the previously focused application.");
|
|
99
|
+
throw Object.assign(new Error(result.stderr.trim() || "Could not identify the previously focused application."), { code: "DESKTOP_UI_FAILED" });
|
|
100
100
|
}
|
|
101
101
|
return processId;
|
|
102
102
|
}
|
|
@@ -105,7 +105,7 @@ function enableDesktopAccessibility() {
|
|
|
105
105
|
const result = run(OSASCRIPT, [ACCESSIBILITY_SCRIPT, "enable"], { timeout: 5000 });
|
|
106
106
|
if (result.error) throw result.error;
|
|
107
107
|
if (result.status !== 0) {
|
|
108
|
-
throw new Error(result.stderr.trim() || "Could not enable the Codex accessibility tree.");
|
|
108
|
+
throw Object.assign(new Error(result.stderr.trim() || "Could not enable the Codex accessibility tree."), { code: "DESKTOP_UI_FAILED" });
|
|
109
109
|
}
|
|
110
110
|
return result.stdout.trim() || "unchanged";
|
|
111
111
|
}
|
|
@@ -115,7 +115,7 @@ function restoreDesktopAccessibility(mode) {
|
|
|
115
115
|
const result = run(OSASCRIPT, [ACCESSIBILITY_SCRIPT, "restore", mode], { timeout: 5000 });
|
|
116
116
|
if (result.error) throw result.error;
|
|
117
117
|
if (result.status !== 0) {
|
|
118
|
-
throw new Error(result.stderr.trim() || "Could not restore the Codex accessibility state.");
|
|
118
|
+
throw Object.assign(new Error(result.stderr.trim() || "Could not restore the Codex accessibility state."), { code: "DESKTOP_UI_FAILED" });
|
|
119
119
|
}
|
|
120
120
|
}
|
|
121
121
|
|
|
@@ -134,7 +134,7 @@ export function inspectDesktopUi(threadId, { waitMs = 1500 } = {}) {
|
|
|
134
134
|
}
|
|
135
135
|
if (result.error) throw result.error;
|
|
136
136
|
if (result.status !== 0) {
|
|
137
|
-
throw new Error(result.stderr.trim() || "Could not inspect the Codex Desktop UI.");
|
|
137
|
+
throw Object.assign(new Error(result.stderr.trim() || "Could not inspect the Codex Desktop UI."), { code: "DESKTOP_UI_FAILED" });
|
|
138
138
|
}
|
|
139
139
|
return {
|
|
140
140
|
thread_id: threadId,
|
|
@@ -181,7 +181,7 @@ export function sendDesktopMessage(
|
|
|
181
181
|
{ dryRun = false, waitMs = 1500, newTurn = false, keepFocus = false } = {},
|
|
182
182
|
) {
|
|
183
183
|
if ((parseThreadTarget(threadId).hostId ?? targetHost()) !== "local") throw remoteReadOnly();
|
|
184
|
-
if (message.trim() === "") throw new Error("Message must not be empty.");
|
|
184
|
+
if (message.trim() === "") throw Object.assign(new Error("Message must not be empty."), { code: "OPTIONS_INVALID" });
|
|
185
185
|
validateWaitMs(waitMs);
|
|
186
186
|
|
|
187
187
|
const desktopSettings = readDesktopSettings();
|
|
@@ -202,7 +202,7 @@ export function sendDesktopMessage(
|
|
|
202
202
|
});
|
|
203
203
|
if (result.error) throw result.error;
|
|
204
204
|
if (result.status !== 0) {
|
|
205
|
-
throw new Error(result.stderr.trim() || "Codex Desktop did not accept the steering message.");
|
|
205
|
+
throw Object.assign(new Error(result.stderr.trim() || "Codex Desktop did not accept the steering message."), { code: "DESKTOP_UI_FAILED" });
|
|
206
206
|
}
|
|
207
207
|
|
|
208
208
|
return { ...plan, dry_run: false, sent: null, delivery_status: "submitted_unverified" };
|
package/src/directive.mjs
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { collectEvidence, verifyEvidence } from "./evidence.mjs";
|
|
2
|
-
import {
|
|
2
|
+
import { threadSnapshot, fetchThread } from "./observe.mjs";
|
|
3
|
+
import { parseCursor } from "./cursor.mjs";
|
|
3
4
|
import { getCheckpoint, verifyCheckpoint } from "./checkpoint.mjs";
|
|
4
5
|
import { verifyPagedFreshness } from "./paged-read.mjs";
|
|
5
6
|
import { assertFindingSend, getFinding } from "./findings.mjs";
|
|
@@ -11,11 +12,11 @@ export async function prepareDirective(threadId, body, options, id, storage = {}
|
|
|
11
12
|
if (!source && !kind && !evidence.length && !basedOn && !supersedes && !retracts && !expiresAt && !checkpoint && !finding && !supervisor) return { wireText: body, metadata: null };
|
|
12
13
|
const concern = finding ? await assertFindingSend(threadId, finding, storage) : null;
|
|
13
14
|
if (checkpoint) await getCheckpoint(threadId, checkpoint, storage);
|
|
14
|
-
if (supersedes && retracts) throw new Error("Use either supersedes or retracts, not both.");
|
|
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.");
|
|
16
|
-
if (kind && !Object.hasOwn(kindLabels, kind)) throw new Error("--kind must be decision, review, hypothesis, or suggestion.");
|
|
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).");
|
|
18
|
-
const observed = basedOn ?
|
|
15
|
+
if (supersedes && retracts) throw Object.assign(new Error("Use either supersedes or retracts, not both."), { code: "OPTIONS_INVALID" });
|
|
16
|
+
if (expiresAt && (!/(Z|[+-]\d{2}:\d{2})$/.test(expiresAt) || !Number.isFinite(Date.parse(expiresAt)) || Date.parse(expiresAt) <= Date.now())) throw Object.assign(new Error("--expires-at must be a future ISO timestamp with timezone."), { code: "OPTIONS_INVALID" });
|
|
17
|
+
if (kind && !Object.hasOwn(kindLabels, kind)) throw Object.assign(new Error("--kind must be decision, review, hypothesis, or suggestion."), { code: "OPTIONS_INVALID" });
|
|
18
|
+
if (source != null && (!source.trim() || source.length > 80 || /[\r\n\x00-\x1f]/.test(source))) throw Object.assign(new Error("--source must be a non-empty single line (up to 80 characters)."), { code: "OPTIONS_INVALID" });
|
|
19
|
+
const observed = basedOn ? parseCursor(basedOn, threadId) : null;
|
|
19
20
|
const refs = await collectEvidence(evidence);
|
|
20
21
|
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
22
|
...(concern ? { finding_id: concern.id, finding_revision: concern.revision } : {}), ...(supervisor ? { supervisor_id: supervisor } : {}) };
|
package/src/distribution.mjs
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { constants } from "node:fs";
|
|
2
2
|
import { lstat, mkdir, mkdtemp, open, readdir, realpath, rename, rm } from "node:fs/promises";
|
|
3
|
-
import { createHash } from "node:crypto";
|
|
3
|
+
import { createHash, randomBytes } from "node:crypto";
|
|
4
4
|
import { fileURLToPath } from "node:url";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { setTimeout as delay } from "node:timers/promises";
|
|
@@ -77,6 +77,25 @@ async function readSafe(file, privateMode) {
|
|
|
77
77
|
} finally { await handle.close(); }
|
|
78
78
|
}
|
|
79
79
|
|
|
80
|
+
const present = file => lstat(file).then(() => true, e => { if (e.code !== "ENOENT") throw e; return false; });
|
|
81
|
+
|
|
82
|
+
// An abandoned lock is never stolen. A concurrent caller can reuse the
|
|
83
|
+
// completed placement after its owner releases this lock.
|
|
84
|
+
async function exclusively(lock, operation) {
|
|
85
|
+
let lockInfo;
|
|
86
|
+
for (let attempt = 0; attempt < 100; attempt++) {
|
|
87
|
+
try { await mkdir(lock, { mode: 0o700 }); lockInfo = await ownedDirectory(lock); break; }
|
|
88
|
+
catch (error) {
|
|
89
|
+
if (error.code !== "EEXIST") throw error;
|
|
90
|
+
await ownedDirectory(lock).catch(e => { if (e.code !== "ENOENT") throw e; });
|
|
91
|
+
await delay(50);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
if (!lockInfo) throw failure(`Placement is busy or an earlier placement was interrupted. Inspect ${lock} before retrying.`, "DEPLOYMENT_BUSY");
|
|
95
|
+
try { return await operation(); }
|
|
96
|
+
finally { if (sameFile(lockInfo, await lstat(lock).catch(() => ({})))) await rm(lock, { recursive: true }); }
|
|
97
|
+
}
|
|
98
|
+
|
|
80
99
|
async function writeExclusive(file, bytes, mode) {
|
|
81
100
|
const handle = await open(file, constants.O_WRONLY | constants.O_CREAT | constants.O_EXCL | constants.O_NOFOLLOW, mode);
|
|
82
101
|
try { await handle.writeFile(bytes); await handle.sync(); } finally { await handle.close(); }
|
|
@@ -155,38 +174,44 @@ export async function prepareDeployment(home, { sourceRoot = PACKAGE_ROOT } = {}
|
|
|
155
174
|
const canonicalHome = await prepareCodexHome(home);
|
|
156
175
|
const stateRoot = path.join(canonicalHome, "codex-steer"), root = path.join(stateRoot, "runtimes");
|
|
157
176
|
await ownedDirectory(stateRoot, true); await ownedDirectory(root, true);
|
|
158
|
-
const target = path.join(root, distribution.id)
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
177
|
+
const target = path.join(root, distribution.id);
|
|
178
|
+
return exclusively(path.join(root, `.install-${distribution.id}`), async () => {
|
|
179
|
+
let stage, stageInfo;
|
|
180
|
+
try {
|
|
181
|
+
if (await present(target)) return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: true };
|
|
182
|
+
stage = await mkdtemp(path.join(root, ".stage-")); stageInfo = await ownedDirectory(stage);
|
|
183
|
+
for (const file of distribution.manifest.files) {
|
|
184
|
+
const parts = file.path.split("/"); parts.pop(); let parent = stage;
|
|
185
|
+
for (const part of parts) { parent = path.join(parent, part); await ownedDirectory(parent, true); }
|
|
186
|
+
await writeExclusive(path.join(stage, file.path), distribution.contents.get(file.path), file.mode);
|
|
187
|
+
}
|
|
188
|
+
await writeExclusive(path.join(stage, RECORD), JSON.stringify({ manifest: distribution.manifest, sha256: distribution.sha256 }) + "\n", 0o600);
|
|
189
|
+
await verifyDeployment(stage, distribution);
|
|
190
|
+
// All cooperating installers hold the exclusive lock, and the parent is
|
|
191
|
+
// private. Refuse even an empty existing directory rather than replacing it.
|
|
192
|
+
if (await present(target)) throw failure("Deployment destination appeared during placement.");
|
|
193
|
+
await rename(stage, target); stage = null;
|
|
194
|
+
return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: false };
|
|
195
|
+
} finally {
|
|
196
|
+
if (stage && sameFile(stageInfo, await lstat(stage).catch(() => ({})))) await rm(stage, { recursive: true });
|
|
168
197
|
}
|
|
169
|
-
}
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
198
|
+
});
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
// A launcher is named by its own content, so reuse and tamper detection are
|
|
202
|
+
// one comparison. An existing file is verified, never rewritten.
|
|
203
|
+
export async function placeLauncher(deployment, kind, script) {
|
|
204
|
+
const bytes = Buffer.from(script), stateRoot = path.join(deployment.codex_home, "codex-steer"), bin = path.join(stateRoot, "bin");
|
|
205
|
+
await ownedDirectory(stateRoot); await ownedDirectory(bin, true);
|
|
206
|
+
const name = `codexteer-${kind}-${hash(bytes).slice(0, 12)}`, file = path.join(bin, name);
|
|
207
|
+
return exclusively(path.join(bin, `.install-${name}`), async () => {
|
|
208
|
+
if (!await present(file)) {
|
|
209
|
+
const stage = path.join(bin, `.stage-${randomBytes(8).toString("hex")}`);
|
|
210
|
+
try { await writeExclusive(stage, bytes, 0o700); await rename(stage, file); }
|
|
211
|
+
finally { await rm(stage, { force: true }); }
|
|
180
212
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
if (await lstat(target).catch(e => { if (e.code !== "ENOENT") throw e; return null; })) throw failure("Deployment destination appeared during placement.");
|
|
186
|
-
await rename(stage, target); stage = null;
|
|
187
|
-
return { ...await verifyDeployment(target, distribution), codex_home: canonicalHome, reused: false };
|
|
188
|
-
} finally {
|
|
189
|
-
if (stage && sameFile(stageInfo, await lstat(stage).catch(() => ({})))) await rm(stage, { recursive: true });
|
|
190
|
-
if (sameFile(lockInfo, await lstat(lock).catch(() => ({})))) await rm(lock, { recursive: true });
|
|
191
|
-
}
|
|
213
|
+
const { bytes: found, info } = await readSafe(file, true);
|
|
214
|
+
if ((info.mode & 0o777) !== 0o700 || !found.equals(bytes)) throw failure(`The launcher ${file} differs from the one this CLI generates. It was left unchanged; ask the user to inspect or remove it before generating again.`, "LAUNCHER_MODIFIED");
|
|
215
|
+
return file;
|
|
216
|
+
});
|
|
192
217
|
}
|
package/src/evidence.mjs
CHANGED
|
@@ -8,11 +8,11 @@ import { remoteFingerprintFile } from "./remote-io.mjs";
|
|
|
8
8
|
export async function fingerprintFile(input) {
|
|
9
9
|
if (targetHost() !== "local") return remoteFingerprintFile(input);
|
|
10
10
|
const ref = path.resolve(input), canonical = await realpath(ref), before = await stat(canonical);
|
|
11
|
-
if (!before.isFile()) throw new Error("Evidence must be a file or an http(s)/codex URL.");
|
|
11
|
+
if (!before.isFile()) throw Object.assign(new Error("Evidence must be a file or an http(s)/codex URL."), { code: "OPTIONS_INVALID" });
|
|
12
12
|
const hash = createHash("sha256");
|
|
13
13
|
for await (const data of createReadStream(canonical)) hash.update(data);
|
|
14
14
|
const after = await stat(canonical);
|
|
15
|
-
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || (await realpath(ref)) !== canonical) throw new Error("File changed while being fingerprinted. Try again after editing finishes.");
|
|
15
|
+
if (before.size !== after.size || before.mtimeMs !== after.mtimeMs || before.ctimeMs !== after.ctimeMs || (await realpath(ref)) !== canonical) throw Object.assign(new Error("File changed while being fingerprinted. Try again after editing finishes."), { code: "STALE_EVIDENCE" });
|
|
16
16
|
return { type: "file", ref, path: canonical, size: after.size, mode: after.mode & 0o777, sha256: hash.digest("hex") };
|
|
17
17
|
}
|
|
18
18
|
|
|
@@ -20,7 +20,7 @@ export async function collectEvidence(refs = []) {
|
|
|
20
20
|
return Promise.all(refs.map(async ref => {
|
|
21
21
|
if (/^(https?|codex):\/\//i.test(ref)) {
|
|
22
22
|
const url = new URL(ref);
|
|
23
|
-
if (url.username || url.password) throw new Error("Evidence URLs must not contain credentials.");
|
|
23
|
+
if (url.username || url.password) throw Object.assign(new Error("Evidence URLs must not contain credentials."), { code: "OPTIONS_INVALID" });
|
|
24
24
|
return { type: "url", ref: url.href, verification: "reference_only" };
|
|
25
25
|
}
|
|
26
26
|
return fingerprintFile(ref);
|
package/src/findings.mjs
CHANGED
|
@@ -2,7 +2,8 @@ import { randomUUID } from "node:crypto";
|
|
|
2
2
|
import { normalizeTaskId } from "./task-id.mjs";
|
|
3
3
|
import { readRecord, writeRecord, listRecords, withStoreLock } from "./store.mjs";
|
|
4
4
|
import { collectEvidence } from "./evidence.mjs";
|
|
5
|
-
import {
|
|
5
|
+
import { digest } from "./observe.mjs";
|
|
6
|
+
import { parseCursor, withCursorReference } from "./cursor.mjs";
|
|
6
7
|
import { verifyCheckpoint } from "./checkpoint.mjs";
|
|
7
8
|
|
|
8
9
|
const failure = (message, code) => Object.assign(new Error(message), { code });
|
|
@@ -14,6 +15,7 @@ const text = (value, label, max = 8000) => {
|
|
|
14
15
|
// A cursor can advance because our own intervention appeared as a userMessage.
|
|
15
16
|
// It is observation context, never sufficient evidence for another intervention.
|
|
16
17
|
const contextHash = record => digest({ condition: record.condition, evidence: record.evidence });
|
|
18
|
+
const presentFinding = record => ({ ...withCursorReference(record), ...(Array.isArray(record.revisions) ? { revisions: record.revisions.map(withCursorReference) } : {}) });
|
|
17
19
|
|
|
18
20
|
export async function getFinding(threadInput, id, storage = {}) {
|
|
19
21
|
const threadId = normalizeTaskId(threadInput), record = await readRecord("findings", id, storage);
|
|
@@ -33,7 +35,7 @@ export async function createFinding(threadInput, { key, title, condition, eviden
|
|
|
33
35
|
const threadId = normalizeTaskId(threadInput);
|
|
34
36
|
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
37
|
text(title, "--title", 240); text(condition, "--condition");
|
|
36
|
-
const refs = await collectEvidence(evidence), observed = basedOn ?
|
|
38
|
+
const refs = await collectEvidence(evidence), observed = basedOn ? parseCursor(basedOn, threadId) : null;
|
|
37
39
|
return withStoreLock(`message-${threadId}`, async () => {
|
|
38
40
|
const existing = (await listRecords("findings", storage)).find(record => record?.thread_id === threadId && record.key === key);
|
|
39
41
|
if (existing) return { ...await findingStatus(threadId, existing.id, storage), reused: true };
|
|
@@ -42,16 +44,19 @@ export async function createFinding(threadInput, { key, title, condition, eviden
|
|
|
42
44
|
evidence: refs, based_on: basedOn ?? null, user_revision: observed?.user_revision ?? null, disposition: "open", resolution: null,
|
|
43
45
|
evaluation: { rating: "unrated" }, revisions: [], created_at: now, updated_at: now };
|
|
44
46
|
await writeRecord("findings", record.id, record, storage);
|
|
45
|
-
return { ...record, status: "open", messages: [], verification: null, reused: false };
|
|
47
|
+
return presentFinding({ ...record, status: "open", messages: [], verification: null, reused: false });
|
|
46
48
|
}, storage);
|
|
47
49
|
}
|
|
48
50
|
|
|
51
|
+
// The user's noted resolution of an unknown delivery releases it; absence alone never does.
|
|
52
|
+
export const releasedDelivery = entry => entry.delivery_status === "unknown" && Boolean(entry.resolution);
|
|
53
|
+
|
|
49
54
|
export async function assertFindingSend(threadInput, id, storage = {}) {
|
|
50
55
|
const record = await getFinding(threadInput, id, storage);
|
|
51
56
|
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
57
|
// The journal is the reservation: even a crash before writing a receipt leaves
|
|
53
58
|
// 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");
|
|
59
|
+
const previous = (await findingMessages(record, storage)).find(message => message.delivery_status !== "not_sent" && !releasedDelivery(message));
|
|
55
60
|
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
61
|
return record;
|
|
57
62
|
}
|
|
@@ -60,7 +65,7 @@ export async function findingStatus(threadInput, id, storage = {}) {
|
|
|
60
65
|
const record = await getFinding(threadInput, id, storage), entries = await findingMessages(record, storage);
|
|
61
66
|
const messages = entries.map(entry => ({ id: entry.id, delivery_status: entry.delivery_status, attempt_state: entry.attempt_state,
|
|
62
67
|
response: entry.response, stored: entry.verification?.status ?? "unchecked" }));
|
|
63
|
-
const latest = entries.filter(entry => entry.delivery_status !== "not_sent").at(-1);
|
|
68
|
+
const latest = entries.filter(entry => entry.delivery_status !== "not_sent" && !releasedDelivery(entry)).at(-1);
|
|
64
69
|
let status = record.disposition, verification = null;
|
|
65
70
|
if (record.disposition === "open" && latest) status = latest.delivery_status === "unknown" ? "delivery_unknown"
|
|
66
71
|
: latest.response?.status === "applied" ? "awaiting_verification" : "awaiting_response";
|
|
@@ -73,7 +78,7 @@ export async function findingStatus(threadInput, id, storage = {}) {
|
|
|
73
78
|
} catch (error) { verification = { valid: false, checkpoint_id: record.resolution.checkpoint_id, error_code: error.code ?? "CHECKPOINT_UNAVAILABLE", condition_assessment: "explicit_report" }; }
|
|
74
79
|
if (!verification.valid && record.disposition === "resolved") status = "needs_review";
|
|
75
80
|
}
|
|
76
|
-
return { ...record, status, messages, verification };
|
|
81
|
+
return presentFinding({ ...record, status, messages, verification });
|
|
77
82
|
}
|
|
78
83
|
|
|
79
84
|
export async function listFindings(threadInput, { all = false, limit = 50, ...storage } = {}) {
|
|
@@ -100,7 +105,7 @@ async function changeFinding(threadInput, id, update, storage) {
|
|
|
100
105
|
}
|
|
101
106
|
|
|
102
107
|
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");
|
|
108
|
+
if ((await findingMessages(record, storage)).some(entry => entry.delivery_status === "unknown" && !releasedDelivery(entry))) throw failure("Delivery is unknown. Reconcile the existing message before closing or revising this finding.", "FINDING_DELIVERY_UNKNOWN");
|
|
104
109
|
}
|
|
105
110
|
|
|
106
111
|
export async function resolveFinding(threadInput, id, { checkpoint, note, ...storage }) {
|
|
@@ -127,7 +132,7 @@ export async function reopenFinding(threadInput, id, { reason, evidence, basedOn
|
|
|
127
132
|
const threadId = normalizeTaskId(threadInput);
|
|
128
133
|
text(reason, "--reason"); if (condition !== undefined) text(condition, "--condition");
|
|
129
134
|
const refs = evidence === undefined ? undefined : await collectEvidence(evidence);
|
|
130
|
-
const observed = basedOn === undefined ? undefined :
|
|
135
|
+
const observed = basedOn === undefined ? undefined : parseCursor(basedOn, threadId);
|
|
131
136
|
return changeFinding(threadId, id, async record => {
|
|
132
137
|
await knownDelivery(record, storage);
|
|
133
138
|
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 };
|