@brainervirus/workit-core 8.21.2 → 8.22.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.21.2",
3
+ "version": "8.22.0",
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": [
@@ -125,6 +125,9 @@ Autonomy contract:
125
125
  CLI first: if a step has one right answer, use the \`workit\` verb instead of
126
126
  hand-running git, gh or glab (\`workit git|pr|ci|check|help\`;
127
127
  else \`npx -y @brainervirus/workit-cli\`).
128
+ Other checkout: \`workit --cwd <dir> <verb>\` (Codex: \`<verb> --cwd <dir>\`); one workit
129
+ verb per shell call, never \`cd <dir> && workit\`. zsh-safe: no \`echo =====\`
130
+ (\`printf '%s\\n' ---\`), quote globs and URLs, no variable named \`status\` or \`path\`.
128
131
  Add Co-Authored-By via \`workit git commit --trailer "K: v" -- <paths>\`, never
129
132
  an amend.
130
133
  The task families (task, policy, evidence, finding, decision, worker, state)
@@ -0,0 +1,45 @@
1
+ // `cd <dir> && workit <args>` → `workit --cwd <dir> <args>` (issue #388).
2
+ // On Claude Code the compound does not match a `Bash(workit:*)` allow rule
3
+ // (every subcommand must match, and a `cd` outside the working dirs breaks
4
+ // it), so the auto-mode classifier judged it. The rewrite is exact and
5
+ // conservative: one literal directory, one workit call, and only read-only
6
+ // filters after it. Anything else is left alone. Pure string work.
7
+ import { segmentsOf } from "./shell-words";
8
+
9
+ // A directory spelled as one literal word: bare, '…' or "…" with no expansion.
10
+ const COMPOUND =
11
+ /^\s*cd\s+([A-Za-z0-9_./@%+:,=-]+|'[^'$`\\\n]*'|"[^"$`\\!\n]*")\s*&&\s*(workit(?:\s[\s\S]*)?)$/u;
12
+ const FILTERS = new Set(["tail", "head", "grep"]);
13
+
14
+ // A filter that reads only stdin, so moving the cwd cannot change what it
15
+ // reads: flags and counts for tail/head, one pattern for grep (no -r/-R/-f).
16
+ const stdinOnly = (words: readonly string[]): boolean => {
17
+ const [name, ...args] = words;
18
+ const operands = args.filter((w) => !w.startsWith("-"));
19
+ if (name === "grep")
20
+ return operands.length <= 1 && !args.some((w) => /^-[^-]*[rRf]|^--(recursive|file)/u.test(w));
21
+ return operands.every((w) => /^\d+$/u.test(w)) && !args.some((w) => w.startsWith("--output"));
22
+ };
23
+
24
+ /**
25
+ * The command rewritten to `workit --cwd <dir> …`, or null when it is not
26
+ * exactly `cd <literal dir> && workit … [2>&1] [| tail|head|grep …]*`.
27
+ */
28
+ export const cwdRewrite = (command: string): string | null => {
29
+ const match = COMPOUND.exec(command);
30
+ const dir = match?.[1];
31
+ const rest = match?.[2];
32
+ if (!dir || !rest || dir.startsWith("-")) return null;
33
+ // `2>&1` is the only redirect, `|` the only operator: no `&`, `;`, `<`, `>`, backtick or newline.
34
+ if (/[&;<>`\n]/u.test(rest.replaceAll("2>&1", ""))) return null;
35
+ const segments = segmentsOf(rest);
36
+ const [head, ...filters] = segments;
37
+ if (!head || head.words[0] !== "workit") return null;
38
+ if (head.words.some((w) => w === "--cwd" || w.startsWith("--cwd="))) return null;
39
+ const clean = (s: (typeof segments)[number]) =>
40
+ !s.unanalyzable && !s.group && !s.subs?.length && !s.stdin?.length && !s.redirects.length;
41
+ if (!segments.every(clean)) return null;
42
+ if (!segments.slice(0, -1).every((s) => s.pipe)) return null;
43
+ if (!filters.every((s) => FILTERS.has(s.words[0] ?? "") && stdinOnly(s.words))) return null;
44
+ return `workit --cwd ${dir} ${rest.slice("workit".length).trimStart()}`.trimEnd();
45
+ };
@@ -9,6 +9,7 @@ import type { HostDescriptor, Support } from "./descriptor";
9
9
  import { branchGuidance } from "../core/branch";
10
10
  import { workspaceTrailers } from "../core/workspaces";
11
11
  import { judgeBranch, recordIssuedSession, verdictCommand } from "../host-session";
12
+ import { cwdRewrite } from "./cwd-normalize";
12
13
  import { heavyDenial, heavyGate, rewriteNote, splitDenial } from "./heavy-gate";
13
14
  import { shellPolicy } from "./policy";
14
15
  import {
@@ -204,6 +205,10 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
204
205
  const rewrites = usable(descriptor.shellPolicy.rewrite);
205
206
  if (plan.kind === "rewrite" && !rewrites && canDeny) return heavyDenial(event.command, plan);
206
207
  const lease = plan.kind === "rewrite" && rewrites ? plan : null;
208
+ // `cd <dir> && workit …` becomes `workit --cwd <dir> …` (issue #388). A
209
+ // workit command is never heavy itself, so the lease plan wins when set.
210
+ const cwdCommand =
211
+ !lease && rewrites && input.host === "claude_code" ? cwdRewrite(event.command) : null;
207
212
  const aside = plan.kind === "passthrough" ? plan.note : null;
208
213
  // The command will run: note HEAD so the post-tool hook (or the next
209
214
  // command, without one) can tell whether a raw commit moved it.
@@ -212,12 +217,13 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
212
217
  event.command,
213
218
  postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
214
219
  );
220
+ const rewrittenTo = lease?.command ?? cwdCommand;
215
221
  const rewritten = (text: string | null): HookDecision =>
216
- lease
222
+ rewrittenTo
217
223
  ? {
218
224
  kind: "rewrite",
219
- command: lease.command,
220
- input: event.toolInput ? { ...event.toolInput, command: lease.command } : null,
225
+ command: rewrittenTo,
226
+ input: event.toolInput ? { ...event.toolInput, command: rewrittenTo } : null,
221
227
  text,
222
228
  }
223
229
  : NONE;
@@ -228,7 +234,11 @@ function decide(input: HookInput, deps: HookDeps): HookDecision {
228
234
  withContextLine(canDeny ? rawGitPre(input, event.command) : NONE, aside),
229
235
  shipNudge(input, event.command),
230
236
  );
231
- if (!lease) return nudged;
237
+ // A deny always wins over the cwd rewrite.
238
+ if (!lease)
239
+ return cwdCommand && nudged.kind !== "deny"
240
+ ? rewritten(nudged.kind === "context" ? nudged.text : null)
241
+ : nudged;
232
242
  return rewritten(
233
243
  [rewriteNote(lease), nudged.kind === "context" ? nudged.text : null]
234
244
  .filter(Boolean)
package/src/stack.ts CHANGED
@@ -1494,7 +1494,7 @@ export function syncStack(
1494
1494
  return stackFail(
1495
1495
  "blocked",
1496
1496
  `a rebase is in progress in ${owner}`,
1497
- `cd ${owner} && git rebase --continue # or --abort; then workit stack sync`,
1497
+ `git -C ${owner} rebase --continue # or --abort; then workit --cwd ${owner} stack sync`,
1498
1498
  { conflict: entry.branch, worktree: owner, progress: outcome },
1499
1499
  );
1500
1500
  if (owner && trackedDirt(owner))