worktrust 0.8.5 → 0.8.7
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/README.md +3 -2
- package/log-session.mjs +14 -2
- package/package.json +1 -1
- package/preserve-lines.mjs +10 -4
- package/preserve.mjs +9 -3
- package/stretch-evidence.mjs +21 -2
- package/transcript-readers.mjs +9 -3
- package/worktrust.mjs +1 -1
package/README.md
CHANGED
|
@@ -157,7 +157,7 @@ nothing it does not:
|
|
|
157
157
|
| `commits` | the hashes of commits made in the stretch, never a message: what ties AI time to work in the repository |
|
|
158
158
|
| `steered_from` | `remote` when the session ran over SSH |
|
|
159
159
|
| `duration_basis`, `token_basis`, `turn_basis` | how the seconds, tokens and turns were measured |
|
|
160
|
-
| `project` | a one-way hash of the project's name: which days belong together, never which project |
|
|
160
|
+
| `project` | a one-way hash of the project's name, keyed with this computer's own secret (`~/.worktrust/archive-salt`, never in the archive) so a guessed name cannot be tested: which days belong together, never which project |
|
|
161
161
|
| `stretch_ref` | a hash of the session's id and the day, so a stretch is archived once |
|
|
162
162
|
| `device_id`, `profile_id` | which computer and which user profile on it, as one-way hashes (the machine's own id never appears) |
|
|
163
163
|
| `signals`, `analyzer_version` | the behaviour signals of that stretch as keys and counts from the local counter's rubric (framing, steering, verification, recovery), and the rubric's version; derived on this computer from your own turns, never a word of them (0.7.0) |
|
|
@@ -171,6 +171,7 @@ nothing it does not:
|
|
|
171
171
|
| `supplements` | a supplement line: when a newer CLI measures more of a stretch already archived, the new fields go into a line of their own that names that stretch; nothing earlier is rewritten, and the chain and the day's root cover it (0.8.2) |
|
|
172
172
|
| `evidence` (sent) | the same derived record now also travels with each stretch to WorkTrust, kinds and counts only, and `npx worktrust status` shows what this computer measured in the last thirty days (0.8.3) |
|
|
173
173
|
| `unconfirmed` | checks the stretch ran whose outcome no reader could see (Codex's code mode prints no exit code; a background terminal), per kind: counted, never as a pass (0.8.5) |
|
|
174
|
+
| `autonomy`, `authorship` | the permission mode the AI app ran in, on one scale (ask, edits, plan, auto, full), how often it changed and the turns in plan mode; of the stretch's commits, how many name an AI as co-author (the trailer is read here, its names never leave) (0.8.7) |
|
|
174
175
|
| `collector_version` | the CLI version that measured it |
|
|
175
176
|
| `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
|
|
176
177
|
|
|
@@ -259,7 +260,7 @@ which would delete every password on the computer, and many people rightly keep
|
|
|
259
260
|
| **Reads** | Which AI apps are installed (the settings folders of Claude Code, Codex, Cursor, Gemini CLI, Antigravity, VS Code, Windsurf, Hermes Agent, Goose, OpenCode, OpenClaw), and the chats Cursor and Copilot keep (clocks, models and token counts only). Afterwards, the session hook reads Claude Code's, Codex's and Antigravity's session files, the session databases of Hermes, Goose, OpenCode and OpenClaw, and the chats Cursor and Copilot keep, on this computer to measure durations and token counts. |
|
|
260
261
|
| **Writes** | The key file (above). In each AI app's MCP settings (`~/.claude.json` through `claude mcp`, `~/.cursor/mcp.json`, `~/.codex/config.toml`, `~/.gemini/settings.json`, `~/.gemini/config/mcp_config.json` (Antigravity), VS Code's `mcp.json`, Windsurf's `mcp_config.json`, Hermes's and Goose's `config.yaml`, OpenCode's `opencode.json`, OpenClaw through `openclaw mcp set`) a WorkTrust entry that runs the local bridge, with no key in it; three hooks in `~/.claude/settings.json`; the bridge, the hook and the counter in `~/.worktrust/`. |
|
|
261
262
|
| **Sends, to pair** | This computer's system (macOS, Windows, Linux) and host name. WorkTrust also records the network address the request arrives from, as a security record kept 90 days and seen only by WorkTrust staff (to answer a theft or fraud report). |
|
|
262
|
-
| **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`, `interrupts`, `steers`), when it began and ended and your computer's offset from UTC (`started_at`, `at`, `utc_offset`), what the CLI derived from the stretch as kinds and counts (`evidence`: checks run and failed, delivery steps and whether a check passed first, recoveries, delegation, context, routing, steering, refusals, planning, files and test files changed, the complexity class; never a word, a command or a path; since 0.8.3), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo
|
|
263
|
+
| **Sends, afterwards** | Per measured stretch of work, and nothing else: its durations (`seconds`, `duration_basis`, `model_seconds`, `tool_seconds`, `human_seconds`, `idle_seconds`), token counts (`tokens_in`, `tokens_out`, `tokens_cache_read`, `tokens_cache_write`, `token_basis`), the model's name (`model`), the kind of work and one layer keyword such as frontend or backend (`title`, `kind`, `layer`, `layers`), counts of how you work (`exchanges`, `turn_basis`, `agent_runs`, `agent_seconds`, `agent_peak`, `interrupts`, `steers`), when it began and ended and your computer's offset from UTC (`started_at`, `at`, `utc_offset`), what the CLI derived from the stretch as kinds and counts (`evidence`: checks run and failed, delivery steps and whether a check passed first, recoveries, delegation, context, routing, steering, refusals, planning, files and test files changed, the complexity class; never a word, a command or a path; since 0.8.3), a hash of the session and the day (`stretch_ref`), `steered_from` when it ran over SSH, and, so the work can be matched to GitHub, the repository as its remote's owner/name (`repo`, only for a repository on GitHub, GitLab or Bitbucket, the hosts WorkTrust can couple; never one on your own or a client's server) and the hashes of the commits made in the stretch (`commits`, never their messages). |
|
|
263
264
|
| **Never sends** | Prompts, answers, code, file names or paths, commit messages, branch names, or the name of your local folder (a repository is named only as its remote's owner/name, above). The door has no field for them and refuses a submission that carries them. |
|
|
264
265
|
|
|
265
266
|
## Why you can check it
|
package/log-session.mjs
CHANGED
|
@@ -135,6 +135,8 @@ const DAY_SECONDS = 86400;
|
|
|
135
135
|
const TOOL_CAP = 1800;
|
|
136
136
|
/** Tools whose result waits for the person: the gap to it is the person's time. Names only; nothing of the call is read. */
|
|
137
137
|
const WAITS_FOR_PERSON = new Set(["AskUserQuestion", "ExitPlanMode"]);
|
|
138
|
+
/** An AI named as a commit's co-author by the tools that sign their work that way (read locally; the name never leaves). */
|
|
139
|
+
const AI_COAUTHOR = /\b(claude|anthropic|codex|openai|chatgpt|copilot|cursor|devin|gemini|jules|aider|windsurf|cline)\b/i;
|
|
138
140
|
/** A path that holds tests, by the conventions of the common test runners (read here; the path never leaves). */
|
|
139
141
|
const TEST_PATH = /(^|[\\/])(__tests__|tests?|spec|e2e)[\\/]|\.(test|spec)\.[cm]?[jt]sx?$|_test\.(go|py|rb)$|(^|[\\/])test_[^\\/]+\.py$|Tests?\.(java|kt|cs|swift)$/;
|
|
140
142
|
/** The derived record of a stretch, kept for the local archive only (see payloadFor). */
|
|
@@ -303,9 +305,15 @@ const layersOf = (paths) => {
|
|
|
303
305
|
};
|
|
304
306
|
|
|
305
307
|
const git = (cwd, ...argv) => { try { return execFileSync("git", ["-C", cwd, ...argv], { encoding: "utf8", stdio: ["ignore", "pipe", "ignore"] }).trim(); } catch { return ""; } };
|
|
306
|
-
/**
|
|
308
|
+
/**
|
|
309
|
+
* owner/name of a remote WorkTrust can couple, or null (0.8.6, privacy by design). Only a repository on GitHub, GitLab or
|
|
310
|
+
* Bitbucket can be joined to its commits, CI and deploys; the name of one on the person's own or a client's server
|
|
311
|
+
* proves nothing and could name the client, so it is never sent. Nothing else of the remote is read.
|
|
312
|
+
*/
|
|
313
|
+
const CODE_HOSTS = /^(?:[a-z+]+:\/\/)?(?:[^@/]+@)?(github\.com|gitlab\.com|bitbucket\.org)[:/]/i;
|
|
307
314
|
const repoOf = (cwd) => {
|
|
308
315
|
const remote = git(cwd, "remote", "get-url", "origin");
|
|
316
|
+
if (!CODE_HOSTS.test(remote)) return null;
|
|
309
317
|
const match = /[:/]([^/:]+)\/([^/]+?)(?:\.git)?$/.exec(remote);
|
|
310
318
|
return match ? `${match[1]}/${match[2]}` : null;
|
|
311
319
|
};
|
|
@@ -391,6 +399,8 @@ const messageOf = (line, at) => {
|
|
|
391
399
|
// out, the hook counted it as a turn and the gap before it as the person's time. It is the loop's own, like a meta line.
|
|
392
400
|
at, type: line.type, usage: line.message?.usage ?? null, model: line.message?.model ?? null, messageId: typeof line.message?.id === "string" ? line.message.id : null, meta: line.isMeta === true || line.isCompactSummary === true, content,
|
|
393
401
|
compacted: line.isCompactSummary === true,
|
|
402
|
+
// 0.8.7: the permission mode the client recorded for this turn (Claude Code's own word; a reader's for Codex), never more.
|
|
403
|
+
permissionMode: typeof line.permissionMode === "string" ? line.permissionMode : null,
|
|
394
404
|
// A tool's answer comes back on the user role; a line a reader marks `agentic` is an agent run (Copilot's asked → completed).
|
|
395
405
|
kind: line.type === "assistant" ? "assistant" : blocks.some((block) => block && block.type === "tool_result") ? "tool_result" : "user",
|
|
396
406
|
toolUse: line.type === "assistant" && blocks.some((block) => block && block.type === "tool_use"),
|
|
@@ -596,7 +606,9 @@ function payloadFor(stretch) {
|
|
|
596
606
|
// 0.8.1: what the stretch changed, as counts of files (never their paths): how many, and how many of them tests.
|
|
597
607
|
// 0.7.6: and the stretch's complexity class, read from what the hook knows here (layers, subagents, duration) and the record.
|
|
598
608
|
const changed = [...new Set(touched.paths)], tests = changed.filter((path) => TEST_PATH.test(path)).length;
|
|
599
|
-
|
|
609
|
+
// 0.8.7: of the stretch's commits, how many name an AI as co-author (the Co-authored-by trailer, read here, never kept).
|
|
610
|
+
const coauthored = cwd && touched.shas.length > 0 ? git(cwd, "log", "--no-walk", "--format=%(trailers:key=Co-authored-by,valueonly,separator=%x01)%x00", ...touched.shas.slice(0, 50)).split("\0").filter((entry, index) => index < Math.min(50, touched.shas.length) && AI_COAUTHOR.test(entry)).length : 0;
|
|
611
|
+
const derived = stretch.derived ? { ...stretch.derived, ...(changed.length > 0 ? { changes: { files: changed.length, test_files: tests } } : {}), ...(touched.shas.length > 0 ? { authorship: { commits: Math.min(50, touched.shas.length), ai_coauthored: coauthored } } : {}), ...(complexityOf ? { complexity: complexityOf(stretch.derived, { layers: Math.max(layers.length, layer ? 1 : 0), agentRuns: stretch.agents?.runs ?? 0, agentPeak: stretch.agents?.peak ?? 0, seconds: stretch.seconds }) } : {}) } : null;
|
|
600
612
|
const payload = {
|
|
601
613
|
title: layer ? `AI-assisted work · ${layer}` : "AI-assisted work",
|
|
602
614
|
kind,
|
package/package.json
CHANGED
package/preserve-lines.mjs
CHANGED
|
@@ -4,7 +4,7 @@
|
|
|
4
4
|
* tranches (docs/CAPABILITY-EVIDENCE-ROADMAP.md) add their fields here, each with its own check.
|
|
5
5
|
*/
|
|
6
6
|
import { spawnSync } from "node:child_process";
|
|
7
|
-
import { createHash } from "node:crypto";
|
|
7
|
+
import { createHash, createHmac } from "node:crypto";
|
|
8
8
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
9
9
|
import { join } from "node:path";
|
|
10
10
|
|
|
@@ -32,7 +32,7 @@ export function stretchSignals(here, say) {
|
|
|
32
32
|
return new Map(rows.slice(0, -1).filter(Boolean).map((row) => [row.stretch_ref, row]));
|
|
33
33
|
}
|
|
34
34
|
const SIGNAL = /^[a-zA-Z][A-Za-z0-9._-]{1,63}$/;
|
|
35
|
-
export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {}) {
|
|
35
|
+
export function archiveLine(entry, behaviour = null, { ids = {}, collector, salt = null } = {}) {
|
|
36
36
|
if (typeof entry?.client !== "string" || !/^[a-z][a-z0-9-]{0,31}$/.test(entry.client) || !ISO.test(entry.started_at ?? "") || !ISO.test(entry.at ?? "") || !/^[A-Za-z0-9_-]{16,128}$/.test(entry.stretch_ref ?? "")) return null;
|
|
37
37
|
const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
|
|
38
38
|
for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
|
|
@@ -46,7 +46,9 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
|
|
|
46
46
|
if (Array.isArray(entry.commits) && entry.commits.length > 0 && entry.commits.every((sha) => typeof sha === "string" && /^[0-9a-f]{7,40}$/.test(sha))) line.commits = entry.commits.slice(0, 50);
|
|
47
47
|
// The project as a one-way hash of its name (owner/name, else the folder's own name): which days belong together, never which project.
|
|
48
48
|
const name = entry.repo ?? entry.folder;
|
|
49
|
-
|
|
49
|
+
// KEYED (0.8.6, audit S6): with this computer's own secret (~/.worktrust/archive-salt, never in the archive) a guessed
|
|
50
|
+
// repository name can no longer be tested against the archive; without one the plain hash, as before.
|
|
51
|
+
if (typeof name === "string" && name) line.project = salt ? createHmac("sha256", salt).update(`worktrust-project|${name}`).digest("hex") : sha256(`worktrust-project|${name}`);
|
|
50
52
|
// VERIFICATION AND DELIVERY (0.7.1): per kind of check [ran, failed], per delivery step a count, and whether a check had
|
|
51
53
|
// passed before the first; known kinds and whole numbers only, a failure count never above its runs.
|
|
52
54
|
const whole = (value) => Number.isInteger(value) && value >= 0 && value <= 100000;
|
|
@@ -96,6 +98,10 @@ export function archiveLine(entry, behaviour = null, { ids = {}, collector } = {
|
|
|
96
98
|
if (planning && planning.lists > 0 && planning.done_last <= planning.items_last && planning.items_last <= planning.items_max) line.planning = planning;
|
|
97
99
|
const changes = pick(entry.changes, ["files", "test_files"]);
|
|
98
100
|
if (changes && changes.test_files <= changes.files) line.changes = changes;
|
|
101
|
+
// AUTONOMY AND AUTHORSHIP (0.8.7): the permission mode on one scale; commits and those naming an AI as co-author.
|
|
102
|
+
if (entry.autonomy && ["ask", "edits", "plan", "auto", "full"].includes(entry.autonomy.mode) && whole(entry.autonomy.switches) && whole(entry.autonomy.plan_turns)) line.autonomy = { mode: entry.autonomy.mode, switches: entry.autonomy.switches, plan_turns: entry.autonomy.plan_turns };
|
|
103
|
+
const authorship = pick(entry.authorship, ["commits", "ai_coauthored"]);
|
|
104
|
+
if (authorship && authorship.commits > 0 && authorship.ai_coauthored <= authorship.commits) line.authorship = authorship;
|
|
99
105
|
// The behaviour signals of this stretch (0.7.0): keys of the counter's rubric and whole counts, never a word of a turn.
|
|
100
106
|
if (behaviour && behaviour.signals && typeof behaviour.analyzer_version === "string" && /^counter@\d+\.\d+\.\d+$/.test(behaviour.analyzer_version)) {
|
|
101
107
|
const kept = Object.entries(behaviour.signals).filter(([key, count]) => SIGNAL.test(key) && Number.isInteger(count) && count > 0).sort();
|
|
@@ -143,7 +149,7 @@ export function archiveEntries(dir, ownKey) {
|
|
|
143
149
|
* a line of their own that names the stretch it adds to (`supplements`) and carries only what no earlier line of that
|
|
144
150
|
* stretch carries, in the same chain, under the same day's root. Null when there is nothing new.
|
|
145
151
|
*/
|
|
146
|
-
export const DERIVED_KEYS = ["signals", "analyzer_version", "verification", "unconfirmed", "delivery", "recovery", "delegation", "context", "routing", "steering", "tools", "complexity", "oversight", "planning", "changes", "interrupts", "steers", "utc_offset"];
|
|
152
|
+
export const DERIVED_KEYS = ["signals", "analyzer_version", "verification", "unconfirmed", "delivery", "recovery", "delegation", "context", "routing", "steering", "tools", "complexity", "oversight", "planning", "changes", "autonomy", "authorship", "interrupts", "steers", "utc_offset"];
|
|
147
153
|
export function supplementFor(line, earlier) {
|
|
148
154
|
const missing = DERIVED_KEYS.filter((key) => line[key] !== undefined && !earlier.some((old) => old[key] !== undefined));
|
|
149
155
|
if (missing.length === 0) return null;
|
package/preserve.mjs
CHANGED
|
@@ -24,7 +24,7 @@
|
|
|
24
24
|
* (~/.worktrust/key.json) when there is one. Today waits for the next run. Nothing leaves this computer.
|
|
25
25
|
*/
|
|
26
26
|
import { execFileSync, spawnSync } from "node:child_process";
|
|
27
|
-
import { createHash, createPrivateKey, createPublicKey, sign, verify } from "node:crypto";
|
|
27
|
+
import { createHash, createPrivateKey, createPublicKey, randomBytes, sign, verify } from "node:crypto";
|
|
28
28
|
import { appendFileSync, copyFileSync, existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync } from "node:fs";
|
|
29
29
|
import { homedir, platform } from "node:os";
|
|
30
30
|
import { dirname, join, relative } from "node:path";
|
|
@@ -182,6 +182,12 @@ function computerIds() {
|
|
|
182
182
|
const hash = (text) => createHash("sha256").update(text).digest("base64url");
|
|
183
183
|
return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
|
|
184
184
|
}
|
|
185
|
+
/** This computer's own secret for the archive's project hashes (0.8.6): made once, readable by its owner alone, never archived. */
|
|
186
|
+
function archiveSalt() {
|
|
187
|
+
const file = home(".worktrust", "archive-salt");
|
|
188
|
+
try { const kept = readFileSync(file, "utf8").trim(); if (/^[0-9a-f]{64}$/.test(kept)) return kept; } catch { /* first run */ }
|
|
189
|
+
try { mkdirSync(home(".worktrust"), { recursive: true, mode: 0o700 }); const made = randomBytes(32).toString("hex"); writeFileSync(file, `${made}\n`, { mode: 0o600, flag: "wx" }); return made; } catch { try { return readFileSync(file, "utf8").trim(); } catch { return null; } }
|
|
190
|
+
}
|
|
185
191
|
/** The device key, when this computer is coupled: its private half signs, its public half goes into the proof. */
|
|
186
192
|
function deviceKey() {
|
|
187
193
|
try {
|
|
@@ -259,14 +265,14 @@ function archive(dir) {
|
|
|
259
265
|
// A WHOLE ANSWER OR NOTHING: the hook ends with its own count; one line missing or cut is a read to refuse, never part of an archive.
|
|
260
266
|
const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
|
|
261
267
|
if (!Number.isInteger(end) || end !== rows.length - 1) { say(` The session hook's answer came back incomplete (${rows.length - 1} of ${end ?? "an unknown number of"} lines), so nothing was added. Run it again.`); process.exit(1); }
|
|
262
|
-
const signals = stretchSignals(HERE, say);
|
|
268
|
+
const signals = stretchSignals(HERE, say), salt = archiveSalt();
|
|
263
269
|
const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set(), additions = [];
|
|
264
270
|
const byRef = new Map(); // 0.8.2: every earlier line of a stretch, its supplements included
|
|
265
271
|
for (const line of existing.lines) { const ref = line.supplements ?? line.stretch_ref; byRef.set(ref, [...(byRef.get(ref) ?? []), line]); }
|
|
266
272
|
let waiting = 0;
|
|
267
273
|
for (const text of rows.slice(0, -1)) {
|
|
268
274
|
let entry; try { entry = JSON.parse(text); } catch { continue; }
|
|
269
|
-
const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR });
|
|
275
|
+
const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null, { ids: IDS, collector: COLLECTOR, salt });
|
|
270
276
|
if (line && line.day < today()) measured.add(line.stretch_ref);
|
|
271
277
|
if (line && byRef.has(line.stretch_ref)) { const extra = supplementFor(line, byRef.get(line.stretch_ref)); if (extra) { additions.push(extra); byRef.set(line.stretch_ref, [...byRef.get(line.stretch_ref), extra]); } continue; }
|
|
272
278
|
if (!line || known.has(line.stretch_ref)) continue; // a file present twice is one stretch
|
package/stretch-evidence.mjs
CHANGED
|
@@ -237,9 +237,28 @@ function planningOf(messages) {
|
|
|
237
237
|
return { lists: lists.length, items_max: Math.max(...lists.map((list) => list.items)), items_last: last.items, done_last: last.done };
|
|
238
238
|
}
|
|
239
239
|
|
|
240
|
+
/**
|
|
241
|
+
* THE AUTONOMY THE PERSON GAVE (0.8.7; framework L5, "autonomy calibration"): the permission mode the client ran in, on
|
|
242
|
+
* one common scale (ask, edits, plan, auto, full), as the mode most turns ran in, how often it changed, and how many
|
|
243
|
+
* turns ran in plan mode. Claude Code records its own word per turn; the Codex reader maps its approval policy and
|
|
244
|
+
* sandbox. Null where no client recorded one.
|
|
245
|
+
*/
|
|
246
|
+
const MODE_SCALE = { default: "ask", ask: "ask", acceptEdits: "edits", edits: "edits", plan: "plan", auto: "auto", bypassPermissions: "full", dontAsk: "full", full: "full" };
|
|
247
|
+
function autonomyOf(messages) {
|
|
248
|
+
const modes = [];
|
|
249
|
+
for (const message of messages) if (!message.bridge && message.permissionMode && MODE_SCALE[message.permissionMode]) modes.push(MODE_SCALE[message.permissionMode]);
|
|
250
|
+
if (modes.length === 0) return null;
|
|
251
|
+
const tally = {};
|
|
252
|
+
for (const mode of modes) tally[mode] = (tally[mode] ?? 0) + 1;
|
|
253
|
+
const mode = Object.entries(tally).sort((a, b) => b[1] - a[1])[0][0];
|
|
254
|
+
let switches = 0;
|
|
255
|
+
for (let i = 1; i < modes.length; i += 1) if (modes[i] !== modes[i - 1]) switches += 1;
|
|
256
|
+
return { mode, switches, plan_turns: tally.plan ?? 0 };
|
|
257
|
+
}
|
|
258
|
+
|
|
240
259
|
/** The stretch's whole derived record; `helpers` are the hook's own readers of a human turn, so both read it the same way. */
|
|
241
260
|
export function deriveStretch(messages, helpers) {
|
|
242
261
|
const context = contextOf(messages, helpers), routing = routingOf(messages), steering = steeringOf(messages, helpers);
|
|
243
|
-
const tools = toolsOf(messages), oversight = oversightOf(messages), planning = planningOf(messages);
|
|
244
|
-
return { ...(oversight ? { oversight } : {}), ...(planning ? { planning } : {}), ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}) };
|
|
262
|
+
const tools = toolsOf(messages), oversight = oversightOf(messages), planning = planningOf(messages), autonomy = autonomyOf(messages);
|
|
263
|
+
return { ...(oversight ? { oversight } : {}), ...(planning ? { planning } : {}), ...(autonomy ? { autonomy } : {}), ...verificationOf(messages, helpers), ...(context ? { context } : {}), ...(routing ? { routing } : {}), ...(steering ? { steering } : {}), ...(tools ? { tools } : {}) };
|
|
245
264
|
}
|
package/transcript-readers.mjs
CHANGED
|
@@ -56,7 +56,7 @@ export const codexToolInput = (name, raw) => {
|
|
|
56
56
|
export const codexOutputText = (output) => (typeof output === "string" ? output : Array.isArray(output) ? output.map((part) => (typeof part === "string" ? part : part?.text ?? "")).join("\n") : "");
|
|
57
57
|
/** The rollout's raw JSON lines → transcript-shaped line objects, in file order. */
|
|
58
58
|
export function* codexLines(records) {
|
|
59
|
-
let cwd = null, model = null;
|
|
59
|
+
let cwd = null, model = null, mode = null;
|
|
60
60
|
const seenResponses = new Set();
|
|
61
61
|
for (const raw of records) {
|
|
62
62
|
if (!raw || !String(raw).trim()) continue;
|
|
@@ -64,7 +64,13 @@ export function* codexLines(records) {
|
|
|
64
64
|
const timestamp = typeof record?.timestamp === "string" ? record.timestamp : null;
|
|
65
65
|
const payload = record?.payload && typeof record.payload === "object" ? record.payload : {};
|
|
66
66
|
if (record.type === "session_meta") { if (typeof payload.cwd === "string") cwd = payload.cwd; continue; }
|
|
67
|
-
if (record.type === "turn_context") {
|
|
67
|
+
if (record.type === "turn_context") {
|
|
68
|
+
if (typeof payload.cwd === "string") cwd = payload.cwd; if (typeof payload.model === "string") model = payload.model;
|
|
69
|
+
// 0.8.7: Codex's approval policy and sandbox, as one word on the common scale (never asks and full access is "full").
|
|
70
|
+
const approval = String(payload.approval_policy ?? ""), sandbox = String(payload.sandbox_policy?.type ?? payload.sandbox_policy ?? payload.sandbox_mode ?? "");
|
|
71
|
+
mode = approval === "never" ? (sandbox === "danger-full-access" ? "full" : "auto") : approval === "on-failure" ? "edits" : approval ? "ask" : mode;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
68
74
|
if (record.type === "token_usage_record") {
|
|
69
75
|
const id = String(payload.response_id ?? "");
|
|
70
76
|
const usage = payload.usage;
|
|
@@ -80,7 +86,7 @@ export function* codexLines(records) {
|
|
|
80
86
|
if (payload.role !== "user" && payload.role !== "assistant") continue;
|
|
81
87
|
const text = Array.isArray(payload.content) ? payload.content.filter((part) => typeof part?.text === "string").map((part) => part.text).join("\n") : typeof payload.content === "string" ? payload.content : "";
|
|
82
88
|
if (payload.role === "user" && CODEX_HARNESS_TURN.test(text)) continue;
|
|
83
|
-
yield { type: payload.role, timestamp, cwd, uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
|
|
89
|
+
yield { type: payload.role, timestamp, cwd, ...(mode ? { permissionMode: mode } : {}), uuid: payload.id ? `codex-${payload.id}` : undefined, message: { ...(payload.role === "assistant" && model ? { model } : {}), content: [{ type: "text", text }] } };
|
|
84
90
|
continue;
|
|
85
91
|
}
|
|
86
92
|
if (kind === "custom_tool_call" || kind === "function_call") {
|
package/worktrust.mjs
CHANGED
|
@@ -73,7 +73,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
|
|
|
73
73
|
const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
|
|
74
74
|
const has = (name) => args.includes(`--${name}`);
|
|
75
75
|
/** This CLI's version, said to the door so the app can tell which computer runs an old one (check-cli-package holds it equal to package.json). */
|
|
76
|
-
const CLI_VERSION = "0.8.
|
|
76
|
+
const CLI_VERSION = "0.8.7";
|
|
77
77
|
const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
|
|
78
78
|
const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
|
|
79
79
|
const HOME_DIR = join(homedir(), ".worktrust");
|