@brainervirus/workit-core 8.11.0 → 8.12.1
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-fanout/references/brief.md +23 -8
- package/skills/workit-fanout/references/orchestration.md +9 -4
- package/src/check-config.ts +3 -1
- package/src/checks.ts +14 -1
- package/src/core/methods.ts +8 -6
- package/src/core/resources.ts +747 -0
- package/src/core/workspaces.ts +46 -3
- package/src/fanout-brief.ts +66 -13
- package/src/forge/exec.ts +8 -2
- package/src/forge/github.ts +14 -4
- package/src/forge/gitlab.ts +20 -4
- package/src/forge/pr-ops.ts +99 -2
- package/src/forge/types.ts +8 -1
- package/src/git/ops.ts +101 -6
- package/src/git/session-hook.ts +68 -0
- package/src/hooks/handle.ts +9 -4
- package/src/host-session.ts +69 -21
package/src/git/session-hook.ts
CHANGED
|
@@ -28,6 +28,12 @@
|
|
|
28
28
|
// it would commit a trailer-only message instead of aborting); for a
|
|
29
29
|
// template (git aborts an unedited one); for a comment-only message; and in a
|
|
30
30
|
// rebase or cherry-pick replaying commits that keep their own trailers.
|
|
31
|
+
// - A workspace with `trailers: false`: the helper adds nothing when
|
|
32
|
+
// WORKIT_TRAILERS=off (set by `workit git commit`) or `workit git trailers`
|
|
33
|
+
// prints `off` (asked with a timeout when workit is on PATH). Without
|
|
34
|
+
// workit it fails open and adds the trailer, so doctor warns about any
|
|
35
|
+
// Workit hook or helper left there and `--fix` removes them
|
|
36
|
+
// (removeSessionHook); another tool's hook and its manual line stay.
|
|
31
37
|
// - `--if-exists addIfDifferent`: the same session's trailer is never repeated;
|
|
32
38
|
// another session that amends adds its own, as `workit git commit --amend` does.
|
|
33
39
|
import { spawnSync } from "node:child_process";
|
|
@@ -80,6 +86,19 @@ fi
|
|
|
80
86
|
[ -n "$session" ] || exit 0
|
|
81
87
|
[ \${#session} -le 128 ] || exit 0
|
|
82
88
|
case "$session" in *[!A-Za-z0-9_.:@/+-]*) exit 0 ;; esac
|
|
89
|
+
# A workspace with \`trailers: false\` keeps authorship in the ledger only.
|
|
90
|
+
# \`workit git commit\` decides it already (WORKIT_TRAILERS); otherwise ask
|
|
91
|
+
# workit, bounded, and add the trailer when it cannot answer.
|
|
92
|
+
case "\${WORKIT_TRAILERS-}" in
|
|
93
|
+
off) exit 0 ;;
|
|
94
|
+
on) ;;
|
|
95
|
+
*)
|
|
96
|
+
if command -v workit >/dev/null 2>&1; then
|
|
97
|
+
t=; ! command -v timeout >/dev/null 2>&1 || t="timeout 5"
|
|
98
|
+
[ "$($t workit git trailers 2>/dev/null)" != off ] || exit 0
|
|
99
|
+
fi
|
|
100
|
+
;;
|
|
101
|
+
esac
|
|
83
102
|
add_trailer() {
|
|
84
103
|
[ -f "$1" ] && [ -r "$1" ] || return 0
|
|
85
104
|
body=$(sed '/^# -\\{24\\} >8 -\\{24\\}$/,$d' "$1" | git stripspace --strip-comments)
|
|
@@ -519,3 +538,52 @@ export function installSessionHook(
|
|
|
519
538
|
detail: `${verb} the Workit ${SESSION_HOOK_NAME} hook at ${status.hookPath}${helper}`,
|
|
520
539
|
};
|
|
521
540
|
}
|
|
541
|
+
|
|
542
|
+
export type SessionHookRemoval = {
|
|
543
|
+
/** Whether Workit's own hook or helper was deleted. */
|
|
544
|
+
action: "removed" | "unchanged" | "skipped";
|
|
545
|
+
/** The files deleted: Workit's hook (only one carrying its marker) and the helper. */
|
|
546
|
+
removed: string[];
|
|
547
|
+
status: SessionHookStatus;
|
|
548
|
+
detail: string;
|
|
549
|
+
};
|
|
550
|
+
|
|
551
|
+
/** Whether `status` finds Workit's own hook (any version) in the slot. */
|
|
552
|
+
export const workitHookIn = (status: SessionHookStatus): boolean =>
|
|
553
|
+
status.hookPath !== null && (readText(status.hookPath)?.includes(SESSION_HOOK_MARKER) ?? false);
|
|
554
|
+
|
|
555
|
+
/** Whether the helper exists for `status`, current or not. */
|
|
556
|
+
export const helperIn = (status: SessionHookStatus): boolean =>
|
|
557
|
+
status.helperPath !== null && exists(status.helperPath);
|
|
558
|
+
|
|
559
|
+
/**
|
|
560
|
+
* For a workspace with `trailers: false`: delete Workit's own hook (only a
|
|
561
|
+
* slot holding its marker) and the helper. Another tool's hook, a husky
|
|
562
|
+
* script or a lefthook job that runs the helper is never edited; without the
|
|
563
|
+
* helper its manual line runs nothing.
|
|
564
|
+
*/
|
|
565
|
+
export function removeSessionHook(
|
|
566
|
+
cwd: string,
|
|
567
|
+
env: NodeJS.ProcessEnv = process.env,
|
|
568
|
+
): SessionHookRemoval {
|
|
569
|
+
const status = inspectSessionHook(cwd, env);
|
|
570
|
+
if (status.state === "not_git")
|
|
571
|
+
return { action: "skipped", removed: [], status, detail: "not inside a git work tree" };
|
|
572
|
+
const removed: string[] = [];
|
|
573
|
+
if (workitHookIn(status)) {
|
|
574
|
+
rmSync(status.hookPath!, { force: true });
|
|
575
|
+
removed.push(status.hookPath!);
|
|
576
|
+
}
|
|
577
|
+
if (helperIn(status)) {
|
|
578
|
+
rmSync(status.helperPath!, { force: true });
|
|
579
|
+
removed.push(status.helperPath!);
|
|
580
|
+
}
|
|
581
|
+
return {
|
|
582
|
+
action: removed.length ? "removed" : "unchanged",
|
|
583
|
+
removed,
|
|
584
|
+
status: inspectSessionHook(cwd, env),
|
|
585
|
+
detail: removed.length
|
|
586
|
+
? `removed ${removed.join(" and ")}`
|
|
587
|
+
: "no Workit session hook or helper to remove",
|
|
588
|
+
};
|
|
589
|
+
}
|
package/src/hooks/handle.ts
CHANGED
|
@@ -7,6 +7,7 @@ import {
|
|
|
7
7
|
} from "./context";
|
|
8
8
|
import type { HostDescriptor, Support } from "./descriptor";
|
|
9
9
|
import { branchGuidance } from "../core/branch";
|
|
10
|
+
import { workspaceTrailers } from "../core/workspaces";
|
|
10
11
|
import { recordIssuedSession } from "../host-session";
|
|
11
12
|
import { shellPolicy } from "./policy";
|
|
12
13
|
import {
|
|
@@ -78,8 +79,12 @@ const CLAUDE_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]/;
|
|
|
78
79
|
* (`workit git …` included) and cannot be turned off, so an isolated agent
|
|
79
80
|
* runs git itself and workit for everything else.
|
|
80
81
|
*/
|
|
81
|
-
const
|
|
82
|
-
|
|
82
|
+
const isolatedGit = (cwd: string): string =>
|
|
83
|
+
`Run git as plain, separate commands inside your worktree (\`git switch -c <branch> <base>\`, ${
|
|
84
|
+
workspaceTrailers(cwd).enabled
|
|
85
|
+
? '`git commit --trailer "Workit-Session=<your session>"`, `git push`; `workit doctor --fix` can install a prepare-commit-msg hook that adds the trailer'
|
|
86
|
+
: "`git commit` (this workspace sets trailers: false: add no Workit-Session trailer), `git push`"
|
|
87
|
+
}; add Co-Authored-By with \`git commit --trailer\` in the same commit, never by amending), never \`workit git …\`: Claude Code's worktree isolation refuses a command that wraps git and cannot be turned off (https://code.claude.com/docs/en/worktrees#how-claude-code-enforces-isolation). Never dodge that check by renaming or re-wrapping git. Use workit for the non-git verbs (\`workit check\`, \`workit ledger\`, \`workit pr\`).`;
|
|
83
88
|
|
|
84
89
|
const subagentStartText = (
|
|
85
90
|
input: HookInput,
|
|
@@ -94,9 +99,9 @@ const subagentStartText = (
|
|
|
94
99
|
(role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
|
|
95
100
|
const guide = role === "implementer" ? branchGuidance(input.cwd) : null;
|
|
96
101
|
if (role === "implementer" && isolated)
|
|
97
|
-
return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${
|
|
102
|
+
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.`;
|
|
98
103
|
if (isolated)
|
|
99
|
-
return `${
|
|
104
|
+
return `${isolatedGit(input.cwd)}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
|
|
100
105
|
if (role === "implementer")
|
|
101
106
|
// No host isolation: the lead makes the worktree, and workit git works there.
|
|
102
107
|
return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git 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.`;
|
package/src/host-session.ts
CHANGED
|
@@ -10,6 +10,7 @@
|
|
|
10
10
|
import { createHash } from "node:crypto";
|
|
11
11
|
import fs from "node:fs";
|
|
12
12
|
import path from "node:path";
|
|
13
|
+
import { resolveStateDir } from "./core/logger";
|
|
13
14
|
import { resolveStore } from "./store/paths";
|
|
14
15
|
|
|
15
16
|
export type HostSession = { host: string | null; session: string | null };
|
|
@@ -80,36 +81,84 @@ export function realHostSession(env: NodeJS.ProcessEnv): RealSession {
|
|
|
80
81
|
}
|
|
81
82
|
|
|
82
83
|
// Ids a SubagentStart hook gave a lead's subagents (`<lead>:<agent id>`): one
|
|
83
|
-
// empty file per id under `<
|
|
84
|
+
// empty file per id under `<state>/issued/<lead hash>/`, so concurrent
|
|
84
85
|
// hooks never lose each other's write, bounded per lead.
|
|
85
86
|
const ISSUED_PER_LEAD = 64;
|
|
86
87
|
|
|
87
88
|
/**
|
|
88
89
|
* Written into each issued-id file. A hook bundle that contains this string
|
|
89
|
-
* records issued ids
|
|
90
|
+
* records issued ids per lead in the user's state dir (`/2`; `/1` hooks of
|
|
91
|
+
* 8.10-8.12 wrote them per repository, which breaks cross-repo verdicts); `workit doctor` looks for it in the installed Claude
|
|
90
92
|
* Code and Codex hooks to catch a plugin older than the CLI.
|
|
91
93
|
*/
|
|
92
|
-
export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/
|
|
94
|
+
export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/2";
|
|
93
95
|
|
|
94
96
|
const hash = (value: string): string =>
|
|
95
97
|
createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
96
98
|
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
99
|
+
// Issued ids live in the per-user state dir, keyed by lead session: a lead
|
|
100
|
+
// started in one repository delegates review of others, and the verifier
|
|
101
|
+
// records its verdict from whichever repository it reviews (#294).
|
|
102
|
+
const userIssuedRoot = (): string => path.join(resolveStateDir(), "issued");
|
|
103
|
+
|
|
104
|
+
/**
|
|
105
|
+
* Hooks of 8.10-8.12 wrote issued ids into `<store>/hooks/issued` of the
|
|
106
|
+
* lead's repository. Move them into the per-user registry file by file
|
|
107
|
+
* (copy, then unlink: an id an old hook writes meanwhile stays for the next
|
|
108
|
+
* read) and drop the emptied directories. Each lead migrates on its own, so
|
|
109
|
+
* one bad entry never strands the rest. Fails open.
|
|
110
|
+
*/
|
|
111
|
+
function migrateRepoIssued(cwd: string | undefined): void {
|
|
112
|
+
if (cwd === undefined) return;
|
|
113
|
+
let old: string;
|
|
114
|
+
try {
|
|
115
|
+
const location = resolveStore(cwd);
|
|
116
|
+
if (location instanceof Error) return;
|
|
117
|
+
old = path.join(location.dir, "hooks", "issued");
|
|
118
|
+
if (!fs.existsSync(old)) return;
|
|
119
|
+
} catch {
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
for (const lead of safeList(old)) {
|
|
123
|
+
try {
|
|
124
|
+
const from = path.join(old, lead);
|
|
125
|
+
if (!fs.statSync(from).isDirectory()) continue;
|
|
126
|
+
const to = path.join(userIssuedRoot(), lead);
|
|
127
|
+
fs.mkdirSync(to, { recursive: true, mode: 0o700 });
|
|
128
|
+
for (const id of fs.readdirSync(from)) {
|
|
129
|
+
fs.copyFileSync(path.join(from, id), path.join(to, id));
|
|
130
|
+
fs.rmSync(path.join(from, id), { force: true });
|
|
131
|
+
}
|
|
132
|
+
removeIfEmpty(from);
|
|
133
|
+
} catch {
|
|
134
|
+
// Fail open: this lead's ids stay for the next read.
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
removeIfEmpty(old);
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const safeList = (dir: string): string[] => {
|
|
141
|
+
try {
|
|
142
|
+
return fs.readdirSync(dir);
|
|
143
|
+
} catch {
|
|
144
|
+
return [];
|
|
145
|
+
}
|
|
100
146
|
};
|
|
101
147
|
|
|
102
|
-
const
|
|
103
|
-
|
|
104
|
-
|
|
148
|
+
const removeIfEmpty = (dir: string): void => {
|
|
149
|
+
try {
|
|
150
|
+
fs.rmdirSync(dir);
|
|
151
|
+
} catch {
|
|
152
|
+
// Not empty (a concurrent old hook wrote) or already gone.
|
|
153
|
+
}
|
|
105
154
|
};
|
|
106
155
|
|
|
107
|
-
/**
|
|
108
|
-
const noIssuedRecord = (cwd: string | undefined): boolean => {
|
|
156
|
+
/** This lead's hooks never recorded an issued id (an older plugin hook, most likely). */
|
|
157
|
+
const noIssuedRecord = (cwd: string | undefined, lead: string): boolean => {
|
|
109
158
|
if (cwd === undefined) return false;
|
|
110
159
|
try {
|
|
111
|
-
|
|
112
|
-
return
|
|
160
|
+
migrateRepoIssued(cwd);
|
|
161
|
+
return !fs.existsSync(path.join(userIssuedRoot(), hash(lead)));
|
|
113
162
|
} catch {
|
|
114
163
|
return false;
|
|
115
164
|
}
|
|
@@ -124,9 +173,9 @@ const PLUGIN_UPDATE: Readonly<Record<string, string>> = {
|
|
|
124
173
|
/** Remember that a SubagentStart hook issued `issued` to a subagent of `lead`. Fails open. */
|
|
125
174
|
export function recordIssuedSession(cwd: string, lead: string, issued: string): void {
|
|
126
175
|
try {
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
fs.mkdirSync(dir, { recursive: true });
|
|
176
|
+
migrateRepoIssued(cwd);
|
|
177
|
+
const dir = path.join(userIssuedRoot(), hash(lead));
|
|
178
|
+
fs.mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
130
179
|
const own = hash(issued);
|
|
131
180
|
fs.writeFileSync(path.join(dir, own), ISSUED_SESSIONS_MARKER);
|
|
132
181
|
const entries = fs.readdirSync(dir);
|
|
@@ -144,10 +193,9 @@ export function recordIssuedSession(cwd: string, lead: string, issued: string):
|
|
|
144
193
|
}
|
|
145
194
|
|
|
146
195
|
const wasIssued = (cwd: string | undefined, lead: string, claimed: string): boolean => {
|
|
147
|
-
|
|
196
|
+
migrateRepoIssued(cwd);
|
|
148
197
|
try {
|
|
149
|
-
|
|
150
|
-
return dir !== null && fs.existsSync(path.join(dir, hash(claimed)));
|
|
198
|
+
return fs.existsSync(path.join(userIssuedRoot(), hash(lead), hash(claimed)));
|
|
151
199
|
} catch {
|
|
152
200
|
return false;
|
|
153
201
|
}
|
|
@@ -220,9 +268,9 @@ export function sessionClaimRefusal(
|
|
|
220
268
|
const error = `forged_session: ${claimed} is neither this shell's session (${real.session}) nor an id its host issued to a child of it`;
|
|
221
269
|
// A well-formed subagent id, but no hook here ever recorded one: the hook
|
|
222
270
|
// most likely predates this CLI (CLI and plugin update separately).
|
|
223
|
-
if (issuedClaim && noIssuedRecord(cwd) && host !== null && PLUGIN_UPDATE[host])
|
|
271
|
+
if (issuedClaim && noIssuedRecord(cwd, leadOf(claimed)) && host !== null && PLUGIN_UPDATE[host])
|
|
224
272
|
return {
|
|
225
|
-
error: `${error}; no SubagentStart hook
|
|
273
|
+
error: `${error}; no SubagentStart hook has recorded an issued id for this lead`,
|
|
226
274
|
unblock: `the workit plugin hook is probably older than this CLI and does not record the ids it issues: update the plugin (${PLUGIN_UPDATE[host]}), then relaunch the verifier so its SubagentStart runs the new hook (workit doctor checks this as hook_issued_sessions)`,
|
|
227
275
|
};
|
|
228
276
|
const ownChildShell = host !== null && OWN_CHILD_SHELL.has(host);
|