@brainervirus/workit-core 8.29.0 → 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.0",
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": [
package/src/core/heavy.ts CHANGED
@@ -446,8 +446,8 @@ export type HeavyPlan =
446
446
  | { kind: "rewrite"; command: string; reason: string }
447
447
  /** A chain with heavy parts: run each part alone (`unblock` is the first heavy one). */
448
448
  | { kind: "split"; reason: string; unblock: string | null }
449
- /** A host rule names it, or wrapping would raise it: it runs as typed. */
450
- | { kind: "passthrough"; note: string };
449
+ /** A host rule names it, or wrapping would raise it: it runs as typed, saying nothing. */
450
+ | { kind: "passthrough"; note: "" };
451
451
 
452
452
  export type PlanOptions = HeavyOptions & {
453
453
  /** The host's permission rules; null where the host has none to read (Pi). */
@@ -476,7 +476,7 @@ export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
476
476
  if (!lines) return { kind: "none" };
477
477
  const heavy = heavyLines(lines, options);
478
478
  if (!heavy.length) return { kind: "none" };
479
- const simple = simpleCommand(command, dialect);
479
+ const simple = simpleCommand(command, dialect, 0, true);
480
480
  const single = simple && !WORKIT.test(baseName(simple[0])) && heavyLines([simple], options)[0];
481
481
  const judged = single
482
482
  ? [{ words: lines.flat(), text: command }]
@@ -490,7 +490,7 @@ export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
490
490
  if (!verdict.ok)
491
491
  return {
492
492
  kind: "passthrough",
493
- note: `workit: \`${command.trim()}\` is heavy but runs outside the heavy-check lease because a host permission rule names it (or \`workit\` is allowlisted); the host decides.`,
493
+ note: "",
494
494
  };
495
495
  }
496
496
  if (single) {
@@ -502,7 +502,7 @@ export function heavyPlan(command: string, options: PlanOptions): HeavyPlan {
502
502
  const parts = heavy.map((match) => leaseCommand(match.line));
503
503
  return {
504
504
  kind: "split",
505
- reason: `heavy_command: \`${command.trim()}\` chains heavy work (${heavy.map((match) => match.reason).join("; ")}) with other commands, and a chain cannot run under the machine-wide heavy-check lease. Run each heavy part alone as \`workit check --heavy -- <segment>\` (here: ${parts.map((part) => `\`${part}\``).join("; ")}); run the other parts as their own commands.`,
505
+ reason: `heavy_command: \`${command.trim()}\` chains heavy work (${heavy.map((match) => match.reason).join("; ")}) with other commands, and a chain cannot run under the machine-wide heavy-check lease. Run the heavy part alone, now: \`${parts[0]}\`; then run the other parts as their own commands${parts.length > 1 ? ", each heavy one the same way" : ""}.`,
506
506
  unblock: parts[0] ?? null,
507
507
  };
508
508
  }
@@ -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,39 +81,43 @@ 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.`;
127
98
  const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
128
99
  if (role !== "judge" || !event.agentId) return readOnly;
129
100
  const own = subagentSession(session, event.agentId);
101
+ const cwd = input.cwd || process.cwd();
102
+ const judge = event.agentType.endsWith("reviewer") ? "reviewer" : "verifier";
103
+ const pick = judge === "verifier" ? ", --kind live when you drove the real surface" : "";
104
+ const branch = judgeBranch(cwd);
105
+ // Codex runs a subagent's shell with its own CODEX_THREAD_ID: the CLI already
106
+ // acts as that session, so the line names no --session and no --token (a
107
+ // token on a command line lands in the rollout, #427) and nothing is issued.
108
+ if (descriptor.host === "codex_cli" || descriptor.host === "codex_desktop")
109
+ return `${readOnly} Its shell already acts as its own Workit session, so the ledger tells its verdicts apart from the author's. Record the verdict with exactly this command (pick the result${pick}, name the candidate branch if it differs, say what you ran): \`${verdictCommand({ branch, judge })}\`. Add no session or token option to it, and never set WORKIT_SESSION_ID by hand.`;
130
110
  // `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it,
131
- // and only with the token it hands to this subagent alone.
111
+ // and only with the token it hands to this subagent alone. Claude Code
112
+ // subagents share the lead's environment, so no per-agent env var or file
113
+ // can carry it: the token stays on the line (docs/agents/hosts.md, #427).
132
114
  // (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
133
115
  // Issued once: a replayed SubagentStart for the same id gets no token.
134
- const cwd = input.cwd || process.cwd();
135
116
  const token = session ? recordIssuedSession(cwd, session, own) : null;
136
117
  // Already issued: without its token the id is refused (unbound_session).
137
118
  if (session && !token)
138
119
  return `${readOnly} ${own} was already issued to an earlier subagent, so no token is handed out again and a verdict under it would be refused (unbound_session). Record no verdict as ${own}: spawn a fresh verifier or reviewer, which gets its own session and token.`;
139
- const judge = event.agentType.endsWith("reviewer") ? "reviewer" : "verifier";
140
- const line = verdictCommand({ branch: judgeBranch(cwd), judge, session: own, token });
141
- const pick = judge === "verifier" ? ", --kind live when you drove the real surface" : "";
120
+ const line = verdictCommand({ branch, judge, session: own, token });
142
121
  return `${readOnly} Its own Workit session is ${own}, so the ledger tells its verdicts apart from the author's. Record the verdict with exactly this command (pick the result${pick}, name the candidate branch if it differs, say what you ran): \`${line}\`.${token ? " The token is this subagent's alone: never write it into a report, a file or a commit. --token needs a workit CLI at least as new as this plugin; if the CLI answers that --token is an unknown option, update the workit CLI (workit upgrade --cli) and record again." : ""}`;
143
122
  };
144
123
 
@@ -264,8 +243,14 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
264
243
  // An in-process plugin shows nudges on the tool result (shellNudge):
265
244
  // spending the once-per-session nudge here would lose it.
266
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;
267
252
  const nudged = withContextLine(
268
- withContextLine(canDeny ? rawGitPre(input, event.command) : NONE, aside),
253
+ withContextLine(nudge, aside),
269
254
  shipNudge(input, event.command),
270
255
  );
271
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);
@@ -727,15 +729,19 @@ export function workitCheckTail(
727
729
  : { argv: tail, cwd };
728
730
  }
729
731
 
732
+ const OUTPUT_FILTERS = new Set(["tail", "head", "grep"]);
733
+
730
734
  /**
731
735
  * The one command `command` runs after its wrappers (env prefixes, `timeout`,
732
736
  * `nice`, `flock`, `bash -c '<one command>'`), or null when it is a chain,
733
- * pipe, redirect, substitution, group or heredoc anywhere.
737
+ * pipe, redirect, substitution, group or heredoc anywhere (`shaped` allows
738
+ * redirects and one trailing `| tail|head|grep` filter).
734
739
  */
735
740
  export function simpleCommand(
736
741
  command: string,
737
742
  dialect: ShellDialect = "posix",
738
743
  depth = 0,
744
+ shaped = false,
739
745
  ): string[] | null {
740
746
  let segments: Segment[];
741
747
  try {
@@ -743,13 +749,27 @@ export function simpleCommand(
743
749
  } catch {
744
750
  return null;
745
751
  }
746
- if (segments.length !== 1) return null;
752
+ // `shaped`: output shaping around the one command is not a chain: any
753
+ // redirects, and at most one trailing `| tail|head|grep …` filter.
754
+ const filter = shaped && segments.length === 2 ? segments[1] : null;
755
+ if (filter) {
756
+ if (
757
+ !segments[0].pipe ||
758
+ !OUTPUT_FILTERS.has(commandName(filter.words[0])) ||
759
+ filter.group ||
760
+ filter.pipe ||
761
+ filter.unanalyzable ||
762
+ filter.subs?.length ||
763
+ filter.stdin?.length
764
+ )
765
+ return null;
766
+ } else if (segments.length !== 1) return null;
747
767
  const [segment] = segments;
748
768
  if (
749
769
  segment.group ||
750
- segment.pipe ||
770
+ (segment.pipe && !filter) ||
751
771
  segment.unanalyzable ||
752
- segment.redirects.length ||
772
+ (segment.redirects.length && !shaped) ||
753
773
  segment.subs?.length ||
754
774
  segment.stdin?.length
755
775
  )
@@ -1679,8 +1699,8 @@ const nudgeFor = (action: RawAction, host: HookInput["host"], session: string):
1679
1699
  : `workit: prefer ${quote("workit git push")}: it refuses protected branches and verifies the remote tip.`;
1680
1700
  case "pr-create":
1681
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).
1682
1703
  case "pr-read":
1683
- return `workit: ${quote("workit pr status")} shows the PR, its checks and the verdict state; ${quote("workit ci wait")} waits for checks.`;
1684
1704
  case "merge":
1685
1705
  return null;
1686
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
+ };
@@ -381,6 +381,20 @@ export type SessionClaimRefusal = { error: string; unblock: string };
381
381
  * Anything else is a forged id: a verdict recorded under it would make author
382
382
  * is not verifier a naming convention.
383
383
  */
384
+ const isOwnShellClaim = (real: RealSession, claimed: string): boolean =>
385
+ (claimed === real.session && !(real.kind === "declared" && claimed.includes(":"))) ||
386
+ (real.kind === "derived" && claimed.endsWith(`:${real.session}`));
387
+
388
+ /**
389
+ * `claimed` is the shell's own derived session (OPENCODE_SESSION_ID,
390
+ * PI_SESSION_ID, CODEX_THREAD_ID), alone or as `<lead>:<it>`: a `--session`
391
+ * naming it adds nothing, so the CLI acts as the shell's session instead.
392
+ */
393
+ export const namesOwnDerivedSession = (env: NodeJS.ProcessEnv, claimed: string): boolean => {
394
+ const real = realHostSession(env);
395
+ return real.kind === "derived" && isOwnShellClaim(real, claimed);
396
+ };
397
+
384
398
  export function sessionClaimRefusal(
385
399
  env: NodeJS.ProcessEnv,
386
400
  claimed: string,
@@ -397,16 +411,17 @@ export function sessionClaimRefusal(
397
411
  unblock: `record the verdict from the ${real.host} session's own shell, where ${HOST_SET_VARIABLE[host]} is set`,
398
412
  };
399
413
  if (real.kind === "none" || !real.session) return null;
400
- const ownShell =
401
- (claimed === real.session && !(real.kind === "declared" && claimed.includes(":"))) ||
402
- (real.kind === "derived" && claimed.endsWith(`:${real.session}`));
403
- if (ownShell)
404
- return options.flag
405
- ? {
406
- error: `session_flag: --session ${claimed} names this shell's own ${real.host} session; on a host only an issued subagent id is passed as --session`,
407
- unblock: "drop --session: the shell's own session is already the acting one",
408
- }
409
- : null;
414
+ const ownShell = isOwnShellClaim(real, claimed);
415
+ if (ownShell) {
416
+ // A derived session (the host's own shell variable) cannot be replaced by
417
+ // a flag, so naming it is a no-op, not an error: a printed line that
418
+ // carries it still records. Declared sessions keep refusing the flag.
419
+ if (!options.flag || real.kind === "derived") return null;
420
+ return {
421
+ error: `session_flag: --session ${claimed} names this shell's own ${real.host} session; on a host only an issued subagent id is passed as --session`,
422
+ unblock: "drop --session: the shell's own session is already the acting one",
423
+ };
424
+ }
410
425
  const issuedClaim = claimed === real.session || claimed.startsWith(`${real.session}:`);
411
426
  if (issuedClaim) {
412
427
  const binding = issuedBinding(cwd, claimed);