worktrust 0.6.18 → 0.7.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/README.md CHANGED
@@ -158,6 +158,7 @@ nothing it does not:
158
158
  | `project` | a one-way hash of the project's name: which days belong together, never which project |
159
159
  | `stretch_ref` | a hash of the session's id and the day, so a stretch is archived once |
160
160
  | `device_id`, `profile_id` | which computer and which user profile on it, as one-way hashes (the machine's own id never appears) |
161
+ | `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) |
161
162
  | `collector_version` | the CLI version that measured it |
162
163
  | `seq`, `prev`, `hash` | the line's place, the hash of the line before it, and its own hash |
163
164
 
@@ -788,6 +788,18 @@ export const isoWeek = (day) => {
788
788
  return `${thursday.getUTCFullYear()}-W${String(week).padStart(2, "0")}`;
789
789
  };
790
790
  const byWeek = new Map(); // "month|week" → Map(signal → count)
791
+ /**
792
+ * THE WORK STRETCH (0.7.0, the owner's capability framework v4 §13: "work_stretch_id, the stable unit joining AI
793
+ * activity, verification, failures and delivery"). The same observation is tallied a third time, per stretch: one
794
+ * session's UTC day, named by the hash the session hook files the stretch under (`stretch_ref` in log-session.mjs), so a
795
+ * signal and the stretch's clocks, tokens and commits meet on one key. Only sessions read from a file carry it (Claude
796
+ * Code, Codex, Antigravity); the session's id is hashed with the day and never leaves the computer. Read by `preserve`
797
+ * through --stretch-signals; nothing of it is sent yet.
798
+ */
799
+ const byStretch = new Map(); // stretch_ref → { day, signals: { signal: count } }
800
+ let currentStretchBase = null; // the session's own id, as the session hook hashes it — local only
801
+ const stretchBaseOf = (session) => !session.path ? null : /(^|[\\/])brain[\\/][^\\/]+[\\/]\.system_generated[\\/]logs[\\/]transcript\.jsonl$/.test(session.path) ? String(session.path).split(/[\\/]/).at(-4) : String(session.path).split(/[\\/]/).pop().replace(/\.jsonl?$/, "");
802
+ const stretchRefOf = (base, day) => createHash("sha256").update(`worktrust-stretch|${base}|${day}`).digest("base64url");
791
803
  const bump = (month, signal, n = 1) => {
792
804
  {
793
805
  const key = `${month}|${currentDay && currentDay.startsWith(month) ? isoWeek(currentDay) : "U"}`;
@@ -795,6 +807,11 @@ const bump = (month, signal, n = 1) => {
795
807
  week.set(signal, (week.get(signal) ?? 0) + n);
796
808
  byWeek.set(key, week);
797
809
  }
810
+ if (currentStretchBase && currentDay) {
811
+ const ref = stretchRefOf(currentStretchBase, currentDay), entry = byStretch.get(ref) ?? { day: currentDay, signals: {} };
812
+ entry.signals[signal] = (entry.signals[signal] ?? 0) + n;
813
+ byStretch.set(ref, entry);
814
+ }
798
815
  if (currentUnit) {
799
816
  const bag = unitsByMonth.get(month) ?? new Map();
800
817
  bag.set(`${currentUnit}|${signal}`, { unit: currentUnit, signal, induced: currentInduced, day: currentDay, context: currentContext });
@@ -827,10 +844,10 @@ const seenLines = new Set(); // v0.10: line identities already counted (a uuid o
827
844
  const manifest = [];
828
845
  for(const session of sessions())manifest.push(sessionDigest(session));
829
846
  exportConversations = 0; copilotSessionCount = 0; codexRolloutCount = 0; antigravityCount = 0; skippedDirs.length = 0; countedDirs.length = 0; skippedConversations.length = 0;
830
- const checkpoint = flag("no-checkpoint") || LABEL_OUT ? null : checkpointStore(
847
+ const checkpoint = flag("no-checkpoint") || flag("stretch-signals") || LABEL_OUT ? null : checkpointStore(
831
848
  value("checkpoint") ?? join(homedir(), ".worktrust", "counter-checkpoint.json"),
832
849
  fingerprint({code:readFileSync(new URL(import.meta.url)),version:ANALYZER_VERSION,months:MONTHS_BACK,thisMonth,exclude:EXCLUDE,leaveOut:LEAVE_OUT,exports:EXPORTS}),manifest);
833
- const checkpointMaps = {byMonth,byWeek,unitsByMonth,contextNames,contextBasenames,byMonthContext,monthToolNames,modelFirstSeen,monthMcpServers,rolePatterns,contextCwds,projectFirstSeen,sessionsByMonth};
850
+ const checkpointMaps = {byMonth,byWeek,byStretch,unitsByMonth,contextNames,contextBasenames,byMonthContext,monthToolNames,modelFirstSeen,monthMcpServers,rolePatterns,contextCwds,projectFirstSeen,sessionsByMonth};
834
851
  const checkpointSets = {openingHashes,toolFirstSeen,seenLines};
835
852
  if (checkpoint?.saved) {
836
853
  for(const [key,map] of Object.entries(checkpointMaps)) for(const [k,v] of checkpoint.saved.state.maps[key] ?? []) map.set(k,v);
@@ -849,6 +866,7 @@ for (const session of sessions()) {
849
866
  if(sessionIndex++<resumeAt)continue;
850
867
  const streamHash = session.path ? createHash("sha256").update(`file:${session.path}\n`) : null;
851
868
  const file = session.key;
869
+ currentStretchBase = stretchBaseOf(session);
852
870
  files += 1;
853
871
  // One file is one session: order preserved, so the STRUCTURAL half reads the shape.
854
872
  let sessionMonth = null;
@@ -1152,7 +1170,7 @@ for (const session of sessions()) {
1152
1170
  checkpoint?.close();process.exit(0);
1153
1171
  }
1154
1172
  }
1155
- currentUnit = null; currentDay = null; // month-level aggregates carry no unit and no day
1173
+ currentUnit = null; currentDay = null; currentStretchBase = null; // month-level aggregates carry no unit, no day and no stretch
1156
1174
  for (const [month, set] of monthToolNames) bump(month, "toolBreadth", set.size);
1157
1175
  for (const [month, set] of monthMcpServers) if (set.size > 0) bump(month, "mcpServersDistinct", set.size);
1158
1176
  for (const [month, byPattern] of rolePatterns) for (const sessions of byPattern.values()) if (sessions.size >= RECURRENCE_FLOOR) bump(month, "recurringPatternCandidate");
@@ -1190,6 +1208,14 @@ for (let i = 0; i < sessionSpans.length; i += 1) {
1190
1208
  if (overlapping.has(i)) { currentUnit = a.unit; currentDay = a.day ?? null; currentContext = a.context ?? null; bump(a.month, "parallelSessions"); currentUnit = null; currentDay = null; currentContext = null; }
1191
1209
  }
1192
1210
 
1211
+ // THE STRETCHES, FOR PRESERVE (0.7.0): one JSON line per stretch that carries a signal, the analyzer's version on each,
1212
+ // and a closing count so the reader can tell a whole answer from a cut one. Counts and keys only; nothing is sent.
1213
+ if (flag("stretch-signals")) {
1214
+ const rows = [...byStretch.entries()].sort(([, a], [, b]) => a.day.localeCompare(b.day)).map(([stretch_ref, entry]) => JSON.stringify({ stretch_ref, day: entry.day, analyzer_version: ANALYZER_VERSION, signals: Object.fromEntries(Object.entries(entry.signals).sort().map(([signal, count]) => [signal, Math.min(COUNT_CEILING, count)])) }));
1215
+ await new Promise((resolve) => process.stdout.write(`${[...rows, JSON.stringify({ stretch_signals_end: rows.length })].join("\n")}\n`, resolve));
1216
+ process.exit(0);
1217
+ }
1218
+
1193
1219
  const months = [...byMonth.keys()].sort();
1194
1220
  console.log(`${ANALYZER_VERSION} · ${files} transcript file(s), ${messages} own messages, ${months.length} month(s) with counts`);
1195
1221
  console.log(` projects counted: ${countedDirs.length}${countedDirs.length ? ` (${countedDirs.join(", ")})` : ""}`);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "worktrust",
3
- "version": "0.6.18",
3
+ "version": "0.7.0",
4
4
  "description": "Couple this computer to WorkTrust: approve a code in your browser, and every AI app here reports what you built. Metadata only, no dependencies.",
5
5
  "type": "module",
6
6
  "bin": {
package/preserve.mjs CHANGED
@@ -187,7 +187,22 @@ function computerIds() {
187
187
  const hash = (text) => createHash("sha256").update(text).digest("base64url");
188
188
  return { device_id: hash(`worktrust-machine|${machine.toLowerCase()}`), profile_id: hash(`worktrust-profile|${machine.toLowerCase()}|${homedir()}`) };
189
189
  }
190
- function archiveLine(entry) {
190
+ /**
191
+ * THE STRETCH'S BEHAVIOUR SIGNALS (0.7.0): the local counter's keys and counts per stretch, joined on `stretch_ref`,
192
+ * with the rubric's version. Read on this computer by count-behaviour.mjs --stretch-signals, which sends nothing; a
193
+ * counter that is missing, fails or answers cut leaves the archive without signals and says so, never half of them.
194
+ */
195
+ function stretchSignals() {
196
+ const counter = join(HERE, "count-behaviour.mjs");
197
+ if (!existsSync(counter)) return null;
198
+ const run = spawnSync(process.execPath, [counter, "--stretch-signals", "--months", "120"], { encoding: "utf8", maxBuffer: 1 << 29, env: { ...process.env, WORKTRUST_MCP_URL: "", WORKTRUST_MCP_TOKEN: "" } });
199
+ const rows = (run.stdout ?? "").split("\n").filter((text) => text.startsWith("{")).map((text) => { try { return JSON.parse(text); } catch { return null; } });
200
+ const end = rows.at(-1)?.stretch_signals_end;
201
+ if (run.status !== 0 || !Number.isInteger(end) || end !== rows.length - 1) { say(" The behaviour counter did not answer whole, so these lines carry no signals; the next run adds them to new lines."); return null; }
202
+ return new Map(rows.slice(0, -1).filter(Boolean).map((row) => [row.stretch_ref, row]));
203
+ }
204
+ const SIGNAL = /^[a-zA-Z][A-Za-z0-9._-]{1,63}$/;
205
+ function archiveLine(entry, behaviour = null) {
191
206
  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;
192
207
  const line = { client: entry.client, day: entry.started_at.slice(0, 10), started_at: entry.started_at, ended_at: entry.at };
193
208
  for (const key of COUNTS) if (typeof entry[key] === "number" && Number.isFinite(entry[key]) && entry[key] >= 0) line[key] = entry[key];
@@ -202,6 +217,11 @@ function archiveLine(entry) {
202
217
  // 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.
203
218
  const name = entry.repo ?? entry.folder;
204
219
  if (typeof name === "string" && name) line.project = sha256(`worktrust-project|${name}`);
220
+ // The behaviour signals of this stretch (0.7.0): keys of the counter's rubric and whole counts, never a word of a turn.
221
+ if (behaviour && behaviour.signals && typeof behaviour.analyzer_version === "string" && /^counter@\d+\.\d+\.\d+$/.test(behaviour.analyzer_version)) {
222
+ const kept = Object.entries(behaviour.signals).filter(([key, count]) => SIGNAL.test(key) && Number.isInteger(count) && count > 0).sort();
223
+ if (kept.length > 0) { line.signals = Object.fromEntries(kept); line.analyzer_version = behaviour.analyzer_version; }
224
+ }
205
225
  return { ...line, stretch_ref: entry.stretch_ref, ...IDS, collector_version: COLLECTOR };
206
226
  }
207
227
 
@@ -282,11 +302,12 @@ function archive(dir) {
282
302
  // 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.
283
303
  const rows = hook.stdout.split("\n").filter(Boolean), end = (() => { try { return JSON.parse(rows.at(-1) ?? "").archive_end; } catch { return undefined; } })();
284
304
  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); }
305
+ const signals = stretchSignals();
285
306
  const known = new Set(existing.lines.map((line) => line.stretch_ref)), fresh = [], measured = new Set();
286
307
  let waiting = 0;
287
308
  for (const text of rows.slice(0, -1)) {
288
309
  let entry; try { entry = JSON.parse(text); } catch { continue; }
289
- const line = archiveLine(entry);
310
+ const line = archiveLine(entry, signals?.get(entry.stretch_ref) ?? null);
290
311
  if (line && line.day < today()) measured.add(line.stretch_ref); if (!line || known.has(line.stretch_ref)) continue; // a file present twice is one stretch
291
312
  if (line.day >= today()) { waiting += 1; continue; }
292
313
  known.add(line.stretch_ref);
package/worktrust.mjs CHANGED
@@ -71,7 +71,7 @@ const command = args.find((arg, at) => !arg.startsWith("--") && !(at > 0 && VALU
71
71
  const flag = (name) => { const at = args.indexOf(`--${name}`); return at >= 0 ? args[at + 1] : undefined; };
72
72
  const has = (name) => args.includes(`--${name}`);
73
73
  /** 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). */
74
- const CLI_VERSION = "0.6.18";
74
+ const CLI_VERSION = "0.7.0";
75
75
  const ORIGIN = (flag("origin") ?? process.env.WORKTRUST_ORIGIN ?? "https://app.worktrust.io").replace(/\/$/, "");
76
76
  const MCP = flag("url") ?? process.env.WORKTRUST_MCP_URL ?? `${ORIGIN}/api/mcp`;
77
77
  const HOME_DIR = join(homedir(), ".worktrust");