@brainervirus/workit-core 8.29.1 → 8.29.2
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/src/core/methods.ts +9 -10
- package/src/hooks/context.ts +38 -1
- package/src/hooks/handle.ts +15 -38
- package/src/hooks/raw-git.ts +5 -3
- package/src/hooks/steering.ts +61 -0
package/package.json
CHANGED
package/src/core/methods.ts
CHANGED
|
@@ -102,13 +102,13 @@ Autonomy contract:
|
|
|
102
102
|
or an unresolved blocker; report open gaps and uncertain workers.
|
|
103
103
|
- No endpoint named: stop at a local commit on a policy-compliant branch and
|
|
104
104
|
state the next command. If the request implies delivery (fix, implement,
|
|
105
|
-
ship), go to the effective endpoint in \`workit grant show\`: \`pr\`
|
|
106
|
-
the PR; \`green\` babysits it
|
|
107
|
-
never merging; \`merged\`
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
around grant_required. Release: \`workit release
|
|
111
|
-
\`finish\`; \`publish\` if asked.
|
|
105
|
+
ship), go to the effective endpoint in \`workit grant show\`: \`pr\` stops
|
|
106
|
+
at the PR even with merge granted; \`green\` babysits it (workit-ship) to
|
|
107
|
+
merge-ready, never merging; \`merged\` lands it with \`workit pr merge\`.
|
|
108
|
+
Only the user raises a grant or the endpoint, in a terminal outside the agent
|
|
109
|
+
host (\`workit grant set <ws> defaultEndpoint=merged\`); never edit
|
|
110
|
+
workspaces.json or route around grant_required. Release: \`workit release
|
|
111
|
+
start <v>\`, commits, \`finish\`; \`publish\` if asked.
|
|
112
112
|
- Ask only for a product or preference choice, or for authority you lack, with
|
|
113
113
|
your recommended answer. Facts are yours: read, run or prototype.
|
|
114
114
|
- Label claims measured (run and seen this session), inferred, or guess.
|
|
@@ -122,9 +122,8 @@ Autonomy contract:
|
|
|
122
122
|
is not evidence of a requested remote push. Before reporting delivery,
|
|
123
123
|
reconcile every requested item and observe it (\`workit verify-delivery\`).
|
|
124
124
|
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
else \`npx -y @brainervirus/workit-cli\`).
|
|
125
|
+
Read-only git and gh run raw; mutations go through workit verbs
|
|
126
|
+
(\`workit git|pr|ci|check|help\`; else \`npx -y @brainervirus/workit-cli\`).
|
|
128
127
|
Other checkout: \`workit <verb> --cwd <dir> …\` (verb first: host rules match it); one workit
|
|
129
128
|
verb per shell call, never \`cd <dir> && workit\`. zsh-safe: no \`echo =====\`
|
|
130
129
|
(\`printf '%s\\n' ---\`), quote globs and URLs, no variable named \`status\` or \`path\`.
|
package/src/hooks/context.ts
CHANGED
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
import { createHash } from "node:crypto";
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import { allowedKinds, branchPolicySnapshotFor } from "../core/branch";
|
|
6
7
|
import { invariantBootstrap } from "../core/methods";
|
|
7
8
|
import { resolveStore } from "../store/paths";
|
|
8
9
|
import { canonicalJson } from "../core/task-contract";
|
|
@@ -298,6 +299,40 @@ export const sessionContextText = (
|
|
|
298
299
|
};
|
|
299
300
|
|
|
300
301
|
/** Per-turn task context only (no bootstrap or offer), or null when none applies. */
|
|
302
|
+
/** The checkout holding `cwd` and its branch, read from the git dir (no spawn). */
|
|
303
|
+
const checkoutAt = (cwd: string): { root: string; branch: string | null } | null => {
|
|
304
|
+
for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
|
|
305
|
+
const dotGit = path.join(dir, ".git");
|
|
306
|
+
if (fs.existsSync(dotGit)) {
|
|
307
|
+
const admin = fs.statSync(dotGit).isFile()
|
|
308
|
+
? path.resolve(
|
|
309
|
+
dir,
|
|
310
|
+
/^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim() ?? "",
|
|
311
|
+
)
|
|
312
|
+
: dotGit;
|
|
313
|
+
const head = fs.readFileSync(path.join(admin, "HEAD"), "utf8").trim();
|
|
314
|
+
return { root: dir, branch: /^ref: refs\/heads\/(.+)$/u.exec(head)?.[1] ?? null };
|
|
315
|
+
}
|
|
316
|
+
if (path.dirname(dir) === dir) return null;
|
|
317
|
+
}
|
|
318
|
+
};
|
|
319
|
+
|
|
320
|
+
/** One short line: where the agent is and which branch kinds it may create (#431). */
|
|
321
|
+
export const whereLine = (cwd: string): string | null => {
|
|
322
|
+
try {
|
|
323
|
+
const checkout = checkoutAt(cwd);
|
|
324
|
+
if (!checkout) return null;
|
|
325
|
+
const allowed = branchPolicySnapshotFor(cwd).namePolicy.allowed;
|
|
326
|
+
const kinds = allowed.some((pattern) => pattern.source === "^.*$")
|
|
327
|
+
? "any"
|
|
328
|
+
: allowedKinds(allowed).join(", ") || "see `workit help git`";
|
|
329
|
+
const place = checkout.root === path.resolve(cwd) ? "" : ` in ${checkout.root}`;
|
|
330
|
+
return `cwd ${cwd}${place} on ${checkout.branch ?? "detached HEAD"}; branch kinds: ${kinds}`;
|
|
331
|
+
} catch {
|
|
332
|
+
return null;
|
|
333
|
+
}
|
|
334
|
+
};
|
|
335
|
+
|
|
301
336
|
export const turnContextText = (input: HookInput, descriptor: HostDescriptor): string | null => {
|
|
302
337
|
try {
|
|
303
338
|
const text = sessionCompactContext(
|
|
@@ -307,7 +342,9 @@ export const turnContextText = (input: HookInput, descriptor: HostDescriptor): s
|
|
|
307
342
|
descriptor.context.task,
|
|
308
343
|
);
|
|
309
344
|
if (text === MIGRATION_PENDING_NOTE) return text;
|
|
310
|
-
return text
|
|
345
|
+
return text
|
|
346
|
+
? `<workit-task-context>${[whereLine(input.cwd), text].filter(Boolean).join("\n")}</workit-task-context>`
|
|
347
|
+
: null;
|
|
311
348
|
} catch {
|
|
312
349
|
return null;
|
|
313
350
|
}
|
package/src/hooks/handle.ts
CHANGED
|
@@ -34,7 +34,8 @@ import { trackJudge } from "./session-marker";
|
|
|
34
34
|
import { stopDecision } from "./stop";
|
|
35
35
|
import { liveOwner } from "../core/live-session";
|
|
36
36
|
import { liveTargets, shellGate, writeGate } from "./write-gate";
|
|
37
|
-
import { confineImplementer, confineImplementerShell
|
|
37
|
+
import { confineImplementer, confineImplementerShell } from "./worktree-confine";
|
|
38
|
+
import { agentSteering, workitRole } from "./steering";
|
|
38
39
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
39
40
|
|
|
40
41
|
export type HookDeps = { descriptor: HostDescriptor; addendum: string | null };
|
|
@@ -48,29 +49,6 @@ const resendsOnChange = (descriptor: HostDescriptor): boolean =>
|
|
|
48
49
|
/** Sessions already offered unfinished tasks in this process. */
|
|
49
50
|
const offered = new Set<string>();
|
|
50
51
|
|
|
51
|
-
const ROLES: Readonly<Record<string, "implementer" | "judge">> = {
|
|
52
|
-
implementer: "implementer",
|
|
53
|
-
verifier: "judge",
|
|
54
|
-
reviewer: "judge",
|
|
55
|
-
};
|
|
56
|
-
|
|
57
|
-
/**
|
|
58
|
-
* The Workit agent role an agent type names, spelled the host's way
|
|
59
|
-
* (`descriptor.subagents.agentPrefix`): Claude Code namespaces plugin agents
|
|
60
|
-
* (`workit:implementer`, so a bare `workit-implementer` is someone else's);
|
|
61
|
-
* other hosts name custom agents freely (`workit-implementer`). The
|
|
62
|
-
* implementer is the one subagent that writes; verifier and reviewer are
|
|
63
|
-
* read-only judges that record verdicts as their own session.
|
|
64
|
-
*/
|
|
65
|
-
const workitRole = (
|
|
66
|
-
descriptor: HostDescriptor,
|
|
67
|
-
agentType: string,
|
|
68
|
-
): "implementer" | "judge" | null => {
|
|
69
|
-
const prefix = descriptor.subagents.agentPrefix;
|
|
70
|
-
const name = agentType.startsWith(prefix) ? agentType.slice(prefix.length) : "";
|
|
71
|
-
return Object.hasOwn(ROLES, name) ? ROLES[name] : null;
|
|
72
|
-
};
|
|
73
|
-
|
|
74
52
|
/** A tool call made by a Workit implementer subagent (the host names its agent type). */
|
|
75
53
|
const implementerAgent = (input: HookInput, descriptor: HostDescriptor): boolean =>
|
|
76
54
|
Boolean(input.session.agentId) &&
|
|
@@ -85,9 +63,6 @@ const subagentSession = (session: string | null, agentId: string): string =>
|
|
|
85
63
|
.replace(/[^A-Za-z0-9_.:@/+-]/g, "-")
|
|
86
64
|
.slice(0, 128);
|
|
87
65
|
|
|
88
|
-
/** A Claude Code worktree path: `<repo>/.claude/worktrees/<name>`. */
|
|
89
|
-
const CLAUDE_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]/;
|
|
90
|
-
|
|
91
66
|
/**
|
|
92
67
|
* Claude Code's worktree isolation refuses any command that wraps git
|
|
93
68
|
* (`workit git …` included) and cannot be turned off, so an isolated agent
|
|
@@ -106,21 +81,17 @@ const subagentStartText = (
|
|
|
106
81
|
event: Extract<HookInput["event"], { kind: "subagent.start" }>,
|
|
107
82
|
): string => {
|
|
108
83
|
const session = input.session.id ?? null;
|
|
109
|
-
const role =
|
|
84
|
+
const { role, isolated, git } = agentSteering(descriptor, input.cwd, {
|
|
85
|
+
type: event.agentType,
|
|
86
|
+
id: event.agentId,
|
|
87
|
+
});
|
|
110
88
|
const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
|
|
111
|
-
// An implementer is host-isolated only when the host put it in its own
|
|
112
|
-
// worktree (`.claude/worktrees/agent-<id>`); by default the lead makes one.
|
|
113
|
-
const isolated =
|
|
114
|
-
descriptor.subagents.worktreeIsolation === "native" &&
|
|
115
|
-
(role === "implementer"
|
|
116
|
-
? hostIsolated(input.cwd, event.agentId)
|
|
117
|
-
: CLAUDE_WORKTREE.test(input.cwd));
|
|
118
89
|
const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
|
|
119
|
-
if (
|
|
90
|
+
if (git === "plain")
|
|
120
91
|
return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${isolatedGit(input.cwd)} Plain git is expected here, and the branch name is still policy-checked: a raw \`git switch -c\`, \`git checkout -b\`, \`git branch\` or \`git worktree add -b\` onto a protected or non-compliant name is denied, so use a policy-compliant branch.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
121
92
|
if (isolated)
|
|
122
93
|
return `${isolatedGit(input.cwd)}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
|
|
123
|
-
if (
|
|
94
|
+
if (git === "workit")
|
|
124
95
|
// No host isolation: the lead makes the worktree, Workit's hooks confine
|
|
125
96
|
// the implementer to it (worktree-confine.ts), and workit git works there.
|
|
126
97
|
return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree, the one its brief names (the lead makes it with \`workit fanout worktree create <slice>\` before spawning), never in the lead's checkout, within its brief's scope. Workit binds it to the first Workit worktree it changes: from then on an edit or a mutating git command outside that worktree is denied, and before then so is one outside every Workit worktree. Run Workit against the worktree one verb per call, never \`cd … &&\` chains or \`$VAR\` paths: \`workit git --cwd <worktree> commit -m "<message>" -- <paths>\`, \`workit check --cwd <worktree> <name>\` (verb first, so host allow rules match). The worktree is on the brief's branch once the lead made it; otherwise switch with \`workit git --cwd <worktree> branch <branch> --base <base>\` (or \`--kind <kind> --slug <slug>\`, which picks the base); branch policy hooks still deny protected or non-compliant branches, raw git included.${guide ? ` ${guide}` : " (e.g. feature/<slug>)"} Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
@@ -272,8 +243,14 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
272
243
|
// An in-process plugin shows nudges on the tool result (shellNudge):
|
|
273
244
|
// spending the once-per-session nudge here would lose it.
|
|
274
245
|
if (descriptor.transport !== "hook-process") return rewritten(null);
|
|
246
|
+
// Only an agent no start text told how to run git is nudged (#428).
|
|
247
|
+
const { nudges } = agentSteering(descriptor, input.cwd, {
|
|
248
|
+
type: input.session.agentType,
|
|
249
|
+
id: input.session.agentId,
|
|
250
|
+
});
|
|
251
|
+
const nudge = canDeny ? rawGitPre(input, event.command, { nudge: nudges }) : NONE;
|
|
275
252
|
const nudged = withContextLine(
|
|
276
|
-
withContextLine(
|
|
253
|
+
withContextLine(nudge, aside),
|
|
277
254
|
shipNudge(input, event.command),
|
|
278
255
|
);
|
|
279
256
|
// A deny always wins over the cwd rewrite.
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -6,8 +6,10 @@
|
|
|
6
6
|
// remote onto a protected branch or the default target, naming the
|
|
7
7
|
// workit command;
|
|
8
8
|
// - lets routine raw commands run (`git commit`, a feature-branch push,
|
|
9
|
-
// `gh pr create
|
|
10
|
-
//
|
|
9
|
+
// `gh pr create`, `glab mr create`) with a short nudge, once per kind per
|
|
10
|
+
// session, only for an agent no start text told how to run git
|
|
11
|
+
// (steering.ts), and none on a command that runs workit. Read-only
|
|
12
|
+
// commands (`gh pr view|checks`, `git status`) get none.
|
|
11
13
|
// A raw `git commit` is recorded as the session's `commit.recorded` row so
|
|
12
14
|
// the author's own verdict never reads as independent. The pre-tool hook
|
|
13
15
|
// notes HEAD before the command (a marker in the commit's own repository);
|
|
@@ -1697,8 +1699,8 @@ const nudgeFor = (action: RawAction, host: HookInput["host"], session: string):
|
|
|
1697
1699
|
: `workit: prefer ${quote("workit git push")}: it refuses protected branches and verifies the remote tip.`;
|
|
1698
1700
|
case "pr-create":
|
|
1699
1701
|
return `workit: prefer ${quote("workit pr create --fill")} (or --title/--body): it records the PR the merge gate checks.`;
|
|
1702
|
+
// Read-only: raw is fine (the contract says so once).
|
|
1700
1703
|
case "pr-read":
|
|
1701
|
-
return `workit: ${quote("workit pr status")} shows the PR, its checks and the verdict state; ${quote("workit ci wait")} waits for checks.`;
|
|
1702
1704
|
case "merge":
|
|
1703
1705
|
return null;
|
|
1704
1706
|
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
// What Workit tells an agent about git, by role: one source for the
|
|
2
|
+
// SubagentStart text (handle.ts) and the raw-git nudge set (raw-git.ts), so
|
|
3
|
+
// the two cannot disagree (#428).
|
|
4
|
+
import type { HostDescriptor } from "./descriptor";
|
|
5
|
+
import { hostIsolated } from "./worktree-confine";
|
|
6
|
+
|
|
7
|
+
export type WorkitRole = "implementer" | "judge";
|
|
8
|
+
|
|
9
|
+
const ROLES: Readonly<Record<string, WorkitRole>> = {
|
|
10
|
+
implementer: "implementer",
|
|
11
|
+
verifier: "judge",
|
|
12
|
+
reviewer: "judge",
|
|
13
|
+
};
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* The Workit agent role an agent type names, spelled the host's way
|
|
17
|
+
* (`descriptor.subagents.agentPrefix`): Claude Code namespaces plugin agents
|
|
18
|
+
* (`workit:implementer`, so a bare `workit-implementer` is someone else's);
|
|
19
|
+
* other hosts name custom agents freely (`workit-implementer`).
|
|
20
|
+
*/
|
|
21
|
+
export const workitRole = (descriptor: HostDescriptor, agentType: string): WorkitRole | null => {
|
|
22
|
+
const prefix = descriptor.subagents.agentPrefix;
|
|
23
|
+
const name = agentType.startsWith(prefix) ? agentType.slice(prefix.length) : "";
|
|
24
|
+
return Object.hasOwn(ROLES, name) ? ROLES[name] : null;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/** A Claude Code worktree path: `<repo>/.claude/worktrees/<name>`. */
|
|
28
|
+
const CLAUDE_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]/;
|
|
29
|
+
|
|
30
|
+
export type RoleSteering = {
|
|
31
|
+
role: WorkitRole | null;
|
|
32
|
+
/** The host put the agent in its own worktree. */
|
|
33
|
+
isolated: boolean;
|
|
34
|
+
/** How the start text tells it to run git: `plain` (host isolation refuses a wrapper), `workit`, or not at all. */
|
|
35
|
+
git: "plain" | "workit" | null;
|
|
36
|
+
/** Raw commit/push/PR-create nudges: only for an agent no start text told how to run git. */
|
|
37
|
+
nudges: boolean;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
export const roleSteering = (role: WorkitRole | null, isolated: boolean): RoleSteering => ({
|
|
41
|
+
role,
|
|
42
|
+
isolated,
|
|
43
|
+
git: role === "implementer" ? (isolated ? "plain" : "workit") : null,
|
|
44
|
+
// An implementer has its git line already; a judge does not commit.
|
|
45
|
+
nudges: role === null,
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
/** The steering for one agent, as the host names it (no agent type: the lead). */
|
|
49
|
+
export const agentSteering = (
|
|
50
|
+
descriptor: HostDescriptor,
|
|
51
|
+
cwd: string,
|
|
52
|
+
agent: { type: string | null; id: string | null },
|
|
53
|
+
): RoleSteering => {
|
|
54
|
+
const role = agent.type ? workitRole(descriptor, agent.type) : null;
|
|
55
|
+
// An implementer is host-isolated only when the host put it in its own
|
|
56
|
+
// worktree (`.claude/worktrees/agent-<id>`); by default the lead makes one.
|
|
57
|
+
const isolated =
|
|
58
|
+
descriptor.subagents.worktreeIsolation === "native" &&
|
|
59
|
+
(role === "implementer" ? hostIsolated(cwd, agent.id) : CLAUDE_WORKTREE.test(cwd));
|
|
60
|
+
return roleSteering(role, isolated);
|
|
61
|
+
};
|