@brainervirus/workit-core 8.1.1 → 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.
- package/package.json +1 -1
- package/skills/workit-ship/SKILL.md +14 -14
- package/src/core/task-store.ts +4 -2
- package/src/forge/github.ts +143 -0
- package/src/forge/gitlab.ts +167 -0
- package/src/forge/pr-edit.ts +291 -0
- package/src/forge/pr-ops.ts +103 -17
- package/src/forge/types.ts +32 -0
- package/src/hooks/handle.ts +33 -7
- package/src/hooks/hosts/claude-code.ts +12 -2
- package/src/hooks/hosts/codex.ts +33 -8
- package/src/hooks/hosts/cursor.ts +10 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/protocol.ts +8 -1
- package/src/hooks/raw-git.ts +612 -0
- package/src/hooks/shell-words.ts +128 -0
- package/src/hooks/write-gate.ts +1 -78
- package/src/host-session.ts +53 -0
- package/src/ledger.ts +17 -3
|
@@ -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
|
+
};
|
package/src/hooks/write-gate.ts
CHANGED
|
@@ -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:
|
|
315
|
-
session:
|
|
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
|
|