@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.29.1",
3
+ "version": "8.29.2",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -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\` opens
106
- the PR; \`green\` babysits it without asking (workit-ship) to merge-ready,
107
- never merging; \`merged\` also lands it with \`workit pr merge\`.
108
- Merge and release need a workspace grant only the user raises from their
109
- own terminal (a blocked verb names how); never edit workspaces.json or route
110
- around grant_required. Release: \`workit release start <v>\`, commits,
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
- CLI first: if a step has one right answer, use the \`workit\` verb instead of
126
- hand-running git, gh or glab (\`workit git|pr|ci|check|help\`;
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\`.
@@ -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 ? `<workit-task-context>${text}</workit-task-context>` : null;
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
  }
@@ -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, hostIsolated } from "./worktree-confine";
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 = workitRole(descriptor, event.agentType);
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 (role === "implementer" && isolated)
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 (role === "implementer")
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(canDeny ? rawGitPre(input, event.command) : NONE, aside),
253
+ withContextLine(nudge, aside),
277
254
  shipNudge(input, event.command),
278
255
  );
279
256
  // A deny always wins over the cwd rewrite.
@@ -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|view|checks`, `glab mr create|view`) with a short nudge,
10
- // once per kind per session, and none on a command that runs workit.
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
+ };