@brainervirus/workit-core 8.1.0 → 8.3.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.
@@ -0,0 +1,128 @@
1
+ // A small shell tokenizer shared by the hooks: quoted words, operators,
2
+ // redirects, heredoc bodies and `( … )` group markers. Pure string parsing,
3
+ // no I/O. Two dialects:
4
+ // - posix (Bash, sh, zsh): `\` escapes, '…' is literal, "…" honors `\"`;
5
+ // - powershell (Claude Code's PowerShell tool): `\` is a path separator,
6
+ // the backtick escapes, '…' is literal with '' for a quote, "…" honors `".
7
+ export type Segment = {
8
+ words: string[];
9
+ redirects: string[];
10
+ /** A `(`/`$(` group starts or ends here; the segment carries no words. */
11
+ group?: "open" | "close";
12
+ };
13
+
14
+ export type ShellDialect = "posix" | "powershell";
15
+
16
+ /** The index of the quote closing the one at `start`, or -1. */
17
+ const closingQuote = (command: string, start: number, dialect: ShellDialect): number => {
18
+ const quote = command[start];
19
+ for (let index = start + 1; index < command.length; index++) {
20
+ const char = command[index];
21
+ if (quote === "'") {
22
+ if (char !== "'") continue;
23
+ // PowerShell: '' inside single quotes is one quote.
24
+ if (dialect === "powershell" && command[index + 1] === "'") {
25
+ index++;
26
+ continue;
27
+ }
28
+ return index;
29
+ }
30
+ if ((dialect === "posix" && char === "\\") || (dialect === "powershell" && char === "`")) {
31
+ index++;
32
+ continue;
33
+ }
34
+ if (char === '"') return index;
35
+ }
36
+ return -1;
37
+ };
38
+
39
+ /** The text of a quoted span without its quotes, escapes resolved. */
40
+ const unquote = (body: string, quote: string, dialect: ShellDialect): string => {
41
+ if (quote === "'") return dialect === "powershell" ? body.replaceAll("''", "'") : body;
42
+ return dialect === "posix" ? body.replace(/\\(["\\$`])/g, "$1") : body.replace(/`(.)/g, "$1");
43
+ };
44
+
45
+ /** Tokenize `command` into simple-command segments. */
46
+ export const segmentsOf = (command: string, dialect: ShellDialect = "posix"): Segment[] => {
47
+ const segments: Segment[] = [];
48
+ let current: Segment = { words: [], redirects: [] };
49
+ let word = "";
50
+ let inWord = false;
51
+ let pending: "redirect" | "input" | "heredoc" | null = null;
52
+ const heredocs: string[] = [];
53
+ const escape = dialect === "posix" ? "\\" : "`";
54
+ const flushWord = () => {
55
+ if (!inWord) return;
56
+ if (pending === "redirect") {
57
+ if (!word.startsWith("&")) current.redirects.push(word);
58
+ } else if (pending === "heredoc") heredocs.push(word.replace(/^-/, ""));
59
+ else if (pending !== "input") current.words.push(word);
60
+ pending = null;
61
+ word = "";
62
+ inWord = false;
63
+ };
64
+ const flushSegment = () => {
65
+ flushWord();
66
+ if (current.words.length || current.redirects.length) segments.push(current);
67
+ current = { words: [], redirects: [] };
68
+ };
69
+ for (let index = 0; index < command.length; index++) {
70
+ const char = command[index];
71
+ if (char === "'" || char === '"') {
72
+ const end = closingQuote(command, index, dialect);
73
+ word += unquote(command.slice(index + 1, end < 0 ? undefined : end), char, dialect);
74
+ inWord = true;
75
+ index = end < 0 ? command.length : end;
76
+ } else if (char === escape && index + 1 < command.length) {
77
+ word += command[++index];
78
+ inWord = true;
79
+ } else if (char === "\n") {
80
+ flushSegment();
81
+ // Skip heredoc bodies up to each delimiter line.
82
+ while (heredocs.length) {
83
+ const delimiter = heredocs.shift()!;
84
+ const lines = command.slice(index + 1).split("\n");
85
+ const at = lines.findIndex((line) => line.trim() === delimiter);
86
+ const skipped = (at < 0 ? lines : lines.slice(0, at + 1)).join("\n").length + 1;
87
+ index += skipped;
88
+ }
89
+ } else if (/\s/.test(char)) flushWord();
90
+ else if (char === "(" || char === ")") {
91
+ // `$(` opens a command substitution: the `$` is not a word.
92
+ if (char === "(" && inWord && word.endsWith("$")) {
93
+ word = word.slice(0, -1);
94
+ inWord = word !== "";
95
+ }
96
+ flushSegment();
97
+ segments.push({ words: [], redirects: [], group: char === "(" ? "open" : "close" });
98
+ } else if (";&|".includes(char)) {
99
+ if (char === "&" && command[index + 1] === ">") continue;
100
+ flushSegment();
101
+ } else if (char === ">") {
102
+ // `2>`, `&>`: the fd digit is not a word.
103
+ if (inWord && /^\d$/.test(word)) {
104
+ word = "";
105
+ inWord = false;
106
+ } else flushWord();
107
+ if (command[index + 1] === ">") index++;
108
+ if (command[index + 1] === "&") {
109
+ index++;
110
+ word = "&";
111
+ inWord = true;
112
+ }
113
+ pending = "redirect";
114
+ } else if (char === "<" && dialect === "posix") {
115
+ flushWord();
116
+ if (command[index + 1] === "<") {
117
+ index++;
118
+ if (command[index + 1] === "<") index++;
119
+ pending = "heredoc";
120
+ } else pending = "input";
121
+ } else {
122
+ word += char;
123
+ inWord = true;
124
+ }
125
+ }
126
+ flushSegment();
127
+ return segments;
128
+ };
@@ -11,6 +11,7 @@ import { latestJudgment, writeBlockers, type WriteBlocker } from "../core/policy
11
11
  import { applicableDecision } from "../core/task-evaluation";
12
12
  import { ledgerPath } from "../ledger";
13
13
  import type { HookDecision } from "./protocol";
14
+ import { segmentsOf } from "./shell-words";
14
15
 
15
16
  const NONE: HookDecision = { kind: "none" };
16
17
 
@@ -51,84 +52,6 @@ const exempt = (cwd: string, root: string, file: string, plans: ReadonlySet<stri
51
52
  );
52
53
  };
53
54
 
54
- type Segment = { words: string[]; redirects: string[] };
55
-
56
- /** A small shell tokenizer: quoted words, operators, redirects and heredoc bodies. */
57
- const segmentsOf = (command: string): Segment[] => {
58
- const segments: Segment[] = [];
59
- let current: Segment = { words: [], redirects: [] };
60
- let word = "";
61
- let inWord = false;
62
- let pending: "redirect" | "input" | "heredoc" | null = null;
63
- const heredocs: string[] = [];
64
- const flushWord = () => {
65
- if (!inWord) return;
66
- if (pending === "redirect") {
67
- if (!word.startsWith("&")) current.redirects.push(word);
68
- } else if (pending === "heredoc") heredocs.push(word.replace(/^-/, ""));
69
- else if (pending !== "input") current.words.push(word);
70
- pending = null;
71
- word = "";
72
- inWord = false;
73
- };
74
- const flushSegment = () => {
75
- flushWord();
76
- if (current.words.length || current.redirects.length) segments.push(current);
77
- current = { words: [], redirects: [] };
78
- };
79
- for (let index = 0; index < command.length; index++) {
80
- const char = command[index];
81
- if (char === "'" || char === '"') {
82
- const end = command.indexOf(char, index + 1);
83
- word += command.slice(index + 1, end < 0 ? undefined : end);
84
- inWord = true;
85
- index = end < 0 ? command.length : end;
86
- } else if (char === "\\" && index + 1 < command.length) {
87
- word += command[++index];
88
- inWord = true;
89
- } else if (char === "\n") {
90
- flushSegment();
91
- // Skip heredoc bodies up to each delimiter line.
92
- while (heredocs.length) {
93
- const delimiter = heredocs.shift()!;
94
- const lines = command.slice(index + 1).split("\n");
95
- const at = lines.findIndex((line) => line.trim() === delimiter);
96
- const skipped = (at < 0 ? lines : lines.slice(0, at + 1)).join("\n").length + 1;
97
- index += skipped;
98
- }
99
- } else if (/\s/.test(char)) flushWord();
100
- else if (";&|()".includes(char)) {
101
- if (char === "&" && command[index + 1] === ">") continue;
102
- flushSegment();
103
- } else if (char === ">") {
104
- // `2>`, `&>`: the fd digit is not a word.
105
- if (inWord && /^\d$/.test(word)) {
106
- word = "";
107
- inWord = false;
108
- } else flushWord();
109
- if (command[index + 1] === ">") index++;
110
- if (command[index + 1] === "&") {
111
- index++;
112
- word = "&";
113
- inWord = true;
114
- }
115
- pending = "redirect";
116
- } else if (char === "<") {
117
- flushWord();
118
- if (command[index + 1] === "<") {
119
- index++;
120
- if (command[index + 1] === "<") index++;
121
- pending = "heredoc";
122
- } else pending = "input";
123
- } else {
124
- word += char;
125
- inWord = true;
126
- }
127
- }
128
- flushSegment();
129
- return segments;
130
- };
131
-
132
55
  const PREFIXES = new Set(["sudo", "command", "env", "nohup", "time", "exec", "xargs"]);
133
56
  const operands = (words: string[]) => words.filter((item) => !item.startsWith("-"));
134
57
 
@@ -0,0 +1,53 @@
1
+ // The acting host session for a `workit` call made from an agent's shell.
2
+ // WORKIT_SESSION_ID, when set, wins (Claude Code exports it from SessionStart; a lead
3
+ // assigns one to a verifier), unless WORKIT_HOST shows it came from another host. Otherwise the id each host itself puts in its
4
+ // shell tool's environment is used, so commits and verdicts made on those
5
+ // hosts carry the same session id their hooks see (M5):
6
+ // - OpenCode V2: OPENCODE_SESSION_ID (the shell tool's session id);
7
+ // - Pi: PI_SESSION_ID (the bash tool exposes the Pi session id);
8
+ // - Codex CLI and Desktop: CODEX_THREAD_ID (the thread id hooks get as session_id).
9
+ // Cursor puts no conversation id in its shell; its hook names the id to use.
10
+
11
+ export type HostSession = { host: string | null; session: string | null };
12
+
13
+ const HOST_SESSION_VARS: ReadonlyArray<readonly [string, (env: NodeJS.ProcessEnv) => string]> = [
14
+ ["OPENCODE_SESSION_ID", () => "opencode"],
15
+ ["PI_SESSION_ID", () => "pi"],
16
+ [
17
+ "CODEX_THREAD_ID",
18
+ (env) =>
19
+ env.CODEX_INTERNAL_ORIGINATOR_OVERRIDE === "Codex Desktop" ||
20
+ env.CODEX_ELECTRON_RESOURCES_PATH
21
+ ? "codex_desktop"
22
+ : "codex_cli",
23
+ ],
24
+ ];
25
+
26
+ const family = (host: string | null): string | null =>
27
+ host?.replace(/_(?:cli|desktop)$/, "") ?? null;
28
+
29
+ /**
30
+ * WORKIT_HOST/WORKIT_SESSION_ID, else the host's own shell session variable.
31
+ * A WORKIT_SESSION_ID whose WORKIT_HOST names another host was inherited from
32
+ * an outer session (Codex started from Claude Code's Bash): the inner host's
33
+ * own id wins, so the CLI acts as the session that host's hooks see.
34
+ */
35
+ export function hostSessionFromEnv(env: NodeJS.ProcessEnv): HostSession {
36
+ const value = (key: string): string | null => env[key]?.trim() || null;
37
+ let inner: HostSession | null = null;
38
+ for (const [key, host] of HOST_SESSION_VARS) {
39
+ const session = value(key);
40
+ if (session) {
41
+ inner = { host: host(env), session };
42
+ break;
43
+ }
44
+ }
45
+ const declared = value("WORKIT_HOST");
46
+ // Set, even empty, it is the answer: `WORKIT_SESSION_ID=` opts out of the fallback.
47
+ if (env.WORKIT_SESSION_ID !== undefined) {
48
+ const inherited =
49
+ inner !== null && declared !== null && family(declared) !== family(inner.host);
50
+ if (!inherited) return { host: declared, session: value("WORKIT_SESSION_ID") };
51
+ }
52
+ return inner ? { host: inner.host, session: inner.session } : { host: declared, session: null };
53
+ }
package/src/ledger.ts CHANGED
@@ -40,6 +40,7 @@ import { createHash, randomBytes } from "node:crypto";
40
40
  import fs from "node:fs";
41
41
  import path from "node:path";
42
42
  import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
43
+ import { hostSessionFromEnv } from "./host-session";
43
44
  import { resolveStore } from "./store/paths";
44
45
 
45
46
  export const LEDGER_VERSION = 1;
@@ -304,15 +305,16 @@ export function ledgerPath(cwd: string): LedgerResult<string> {
304
305
  // ---------------------------------------------------------------------------
305
306
  // append
306
307
 
307
- /** The acting identity from the environment. */
308
+ /** The acting identity from the environment (host-session.ts: WORKIT_SESSION_ID, else the host's own shell session id). */
308
309
  export function actorFromEnv(env: NodeJS.ProcessEnv): LedgerActor {
309
310
  const value = (key: string): string | null => {
310
311
  const raw = env[key]?.trim();
311
312
  return raw ? raw : null;
312
313
  };
314
+ const acting = hostSessionFromEnv(env);
313
315
  return {
314
- host: value("WORKIT_HOST") ?? "cli",
315
- session: value("WORKIT_SESSION_ID"),
316
+ host: acting.host ?? "cli",
317
+ session: acting.session,
316
318
  agentId: value("WORKIT_AGENT_ID"),
317
319
  };
318
320
  }
@@ -551,6 +553,18 @@ export function appendObserved(
551
553
  return appendRaw(cwd, { ...row, observer: "workit_cli" }, options);
552
554
  }
553
555
 
556
+ /**
557
+ * A row a host hook observed (a raw shell `git commit`), stamped
558
+ * `observer:"host_hook"`: weaker than the CLI's own observation, but enough
559
+ * to name the session that authored a commit (D18 honest-agent model).
560
+ */
561
+ export function appendHookObserved(
562
+ cwd: string,
563
+ row: Record<string, unknown> & { type: string; actor: LedgerActor },
564
+ ): LedgerResult<Record<string, unknown>> {
565
+ return appendRaw(cwd, { ...row, observer: "host_hook" }, {});
566
+ }
567
+
554
568
  // ---------------------------------------------------------------------------
555
569
  // read (D17: tolerant)
556
570