@brainervirus/workit-core 8.9.1 → 8.11.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 +1 -1
- package/skills/workit-fanout/references/brief.md +1 -1
- package/skills/workit-fanout/references/orchestration.md +16 -6
- package/skills/workit-implement/SKILL.md +3 -2
- package/skills/workit-review/SKILL.md +4 -2
- package/src/autonomy.ts +6 -2
- package/src/core/branch-policy.ts +8 -1
- package/src/core/branch.ts +270 -3
- package/src/core/methods.ts +2 -1
- package/src/core/route-intent.ts +155 -27
- package/src/forge/github.ts +13 -2
- package/src/forge/gitlab.ts +9 -1
- package/src/forge/pr-ops.ts +26 -8
- package/src/forge/types.ts +5 -0
- package/src/git/ops.ts +259 -4
- package/src/hooks/handle.ts +9 -3
- package/src/hooks/hosts/codex.ts +1 -1
- package/src/hooks/policy.ts +11 -5
- package/src/hooks/raw-git.ts +138 -10
- package/src/host-session.ts +189 -0
- package/src/ledger.ts +26 -4
package/src/hooks/raw-git.ts
CHANGED
|
@@ -24,7 +24,7 @@ import { createHash } from "node:crypto";
|
|
|
24
24
|
import fs from "node:fs";
|
|
25
25
|
import os from "node:os";
|
|
26
26
|
import path from "node:path";
|
|
27
|
-
import { resolveBranchPolicyFor } from "../core/branch";
|
|
27
|
+
import { releaseBranch, resolveBranchPolicyFor, validateBranchNameFor } from "../core/branch";
|
|
28
28
|
import { resolveRuntimeWorkspaceVcs } from "../core/workspaces";
|
|
29
29
|
import { pushRemoteName } from "../git/rev";
|
|
30
30
|
import { appendHookObserved, readLedger } from "../ledger";
|
|
@@ -230,6 +230,18 @@ export type RawAction =
|
|
|
230
230
|
blindForce: boolean;
|
|
231
231
|
/** The remote named on the command line; null pushes to the default one. */
|
|
232
232
|
remote: string | null;
|
|
233
|
+
/** Tags named as `refs/tags/<t>` destinations (bare names are told apart later). */
|
|
234
|
+
tags?: string[];
|
|
235
|
+
/** `--tags`: every tag goes along. */
|
|
236
|
+
allTags?: true;
|
|
237
|
+
/** `--delete`/`-d`: the refspecs are deleted, not pushed. */
|
|
238
|
+
deletes?: true;
|
|
239
|
+
/** Names deleted by a colon refspec (`:chore/old`). */
|
|
240
|
+
deleted?: string[];
|
|
241
|
+
/** `--all`/`--branches`: every local branch. */
|
|
242
|
+
allBranches?: true;
|
|
243
|
+
/** `--mirror`: every ref, deletions included. */
|
|
244
|
+
mirror?: true;
|
|
233
245
|
}
|
|
234
246
|
| { kind: "merge"; forge: "gh" | "glab" }
|
|
235
247
|
| { kind: "pr-create"; forge: "gh" | "glab" }
|
|
@@ -242,6 +254,10 @@ const pushAction = (args: string[]): RawAction | null => {
|
|
|
242
254
|
let force = false;
|
|
243
255
|
let lease = false;
|
|
244
256
|
let wholesale = false;
|
|
257
|
+
let allTags = false;
|
|
258
|
+
let deletes = false;
|
|
259
|
+
let allBranches = false;
|
|
260
|
+
let mirror = false;
|
|
245
261
|
let remote: string | null = null;
|
|
246
262
|
const positionals: string[] = [];
|
|
247
263
|
for (let index = 1; index < args.length; index++) {
|
|
@@ -256,26 +272,51 @@ const pushAction = (args: string[]): RawAction | null => {
|
|
|
256
272
|
else if (PUSH_VALUE_OPTIONS.has(arg)) index++;
|
|
257
273
|
else if (arg === "--force" || /^-[a-zA-Z]*f[a-zA-Z]*$/.test(arg)) force = true;
|
|
258
274
|
else if (arg.startsWith("--force-with-lease") || arg === "--force-if-includes") lease = true;
|
|
259
|
-
else if (
|
|
275
|
+
else if (arg === "--delete" || arg === "-d") deletes = true;
|
|
276
|
+
else if (arg === "--tags") allTags = wholesale = true;
|
|
277
|
+
else if (arg === "--all" || arg === "--branches") allBranches = wholesale = true;
|
|
278
|
+
else if (arg === "--mirror") mirror = wholesale = true;
|
|
260
279
|
else if (!arg.startsWith("-")) positionals.push(arg);
|
|
261
280
|
}
|
|
262
281
|
// `--repo` names the remote; positionals are then all refspecs.
|
|
263
282
|
const specs = remote === null ? positionals.slice(1) : positionals;
|
|
264
283
|
remote ??= positionals[0] ?? null;
|
|
265
284
|
const targets: Array<string | null> = [];
|
|
285
|
+
const tags: string[] = [];
|
|
286
|
+
const deleted: string[] = [];
|
|
266
287
|
for (const raw of specs) {
|
|
267
288
|
if (raw.startsWith("+")) force = true;
|
|
268
289
|
const spec = raw.replace(/^\+/, "");
|
|
269
290
|
const colon = spec.indexOf(":");
|
|
270
291
|
const destination = colon >= 0 ? spec.slice(colon + 1) : spec;
|
|
271
292
|
if (!destination) continue;
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
293
|
+
const name =
|
|
294
|
+
destination === "HEAD"
|
|
295
|
+
? null
|
|
296
|
+
: destination.startsWith("refs/heads/")
|
|
297
|
+
? destination.slice(11)
|
|
298
|
+
: destination.startsWith("refs/")
|
|
299
|
+
? undefined
|
|
300
|
+
: destination;
|
|
301
|
+
if (destination.startsWith("refs/tags/")) tags.push(destination.slice(10));
|
|
302
|
+
if (name === undefined) continue;
|
|
303
|
+
targets.push(name);
|
|
304
|
+
if (colon === 0 && name !== null) deleted.push(name);
|
|
275
305
|
}
|
|
276
306
|
// No refspec pushes the current branch (push.default simple/current).
|
|
277
307
|
if (specs.length === 0 && !wholesale) targets.push(null);
|
|
278
|
-
return {
|
|
308
|
+
return {
|
|
309
|
+
kind: "push",
|
|
310
|
+
targets,
|
|
311
|
+
blindForce: force && !lease,
|
|
312
|
+
remote,
|
|
313
|
+
...(tags.length ? { tags } : {}),
|
|
314
|
+
...(allTags ? { allTags: true as const } : {}),
|
|
315
|
+
...(deletes ? { deletes: true as const } : {}),
|
|
316
|
+
...(deleted.length ? { deleted } : {}),
|
|
317
|
+
...(allBranches ? { allBranches: true as const } : {}),
|
|
318
|
+
...(mirror ? { mirror: true as const } : {}),
|
|
319
|
+
};
|
|
279
320
|
};
|
|
280
321
|
|
|
281
322
|
/** Options whose next word is their value (never an option or subcommand). */
|
|
@@ -432,12 +473,88 @@ const headSha = (repo: Repo): string | null => {
|
|
|
432
473
|
|
|
433
474
|
const quote = (text: string) => `\`${text}\``;
|
|
434
475
|
|
|
435
|
-
const refusal = (action: RawAction, branch: string | null): string => {
|
|
476
|
+
const refusal = (action: RawAction, branch: string | null, dir?: string): string => {
|
|
436
477
|
if (action.kind === "merge")
|
|
437
478
|
return `workit: raw ${quote(action.forge === "gh" ? "gh pr merge" : "glab mr merge")} bypasses the merge grant and the verdict gate. Run ${quote("workit pr merge")} instead (it checks both; ${quote("workit help pr")}).`;
|
|
479
|
+
// Finishing a release (git-flow main/develop, the github-flow trunk, a
|
|
480
|
+
// custom protected branch): the push has its own granted verb.
|
|
481
|
+
const plainPush = action.kind === "push" && !action.blindForce && !action.deletes;
|
|
482
|
+
if (dir && branch && plainPush && releaseBranch(dir, branch))
|
|
483
|
+
return `workit: raw ${quote("git push")} onto ${branch}, a protected release branch, bypasses workit's protected-branch check. To finish a release or hotfix, push it with ${quote(`workit git push --release ${branch} [--release v<version>]`)}: it pushes ${branch} (fast-forward only) and the release tag, verifies the remote and records it, under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}). Feature work goes through ${quote("workit git push")} and ${quote("workit pr merge")}.`;
|
|
438
484
|
return `workit: raw ${quote("git push")}${action.kind === "push" && action.blindForce ? " --force" : ""} onto ${branch}, a protected branch or the default target, bypasses workit's protected-branch check. Push your feature branch with ${quote("workit git push")} (${quote("workit git push --force-with-lease")} to rewrite it) and land it with ${quote("workit pr merge")}.`;
|
|
439
485
|
};
|
|
440
486
|
|
|
487
|
+
type PushAction = Extract<RawAction, { kind: "push" }>;
|
|
488
|
+
|
|
489
|
+
/** The local branches and tags, and the remote's branches as last fetched: one git call. */
|
|
490
|
+
type RefFacts = { heads: Set<string>; tags: Set<string>; remoteHeads: Set<string> };
|
|
491
|
+
|
|
492
|
+
const refFacts = (repo: Repo, remote: string): RefFacts => {
|
|
493
|
+
const facts: RefFacts = { heads: new Set(), tags: new Set(), remoteHeads: new Set() };
|
|
494
|
+
const listed = spawnSync(
|
|
495
|
+
"git",
|
|
496
|
+
["for-each-ref", "--format=%(refname)", "refs/heads", "refs/tags", `refs/remotes/${remote}`],
|
|
497
|
+
{ cwd: repo.dir, encoding: "utf8", timeout: 5_000 },
|
|
498
|
+
);
|
|
499
|
+
const remotePrefix = `refs/remotes/${remote}/`;
|
|
500
|
+
for (const ref of (listed.stdout ?? "").split("\n")) {
|
|
501
|
+
if (ref.startsWith("refs/heads/")) facts.heads.add(ref.slice(11));
|
|
502
|
+
else if (ref.startsWith("refs/tags/")) facts.tags.add(ref.slice(10));
|
|
503
|
+
else if (ref.startsWith(remotePrefix)) facts.remoteHeads.add(ref.slice(remotePrefix.length));
|
|
504
|
+
}
|
|
505
|
+
return facts;
|
|
506
|
+
};
|
|
507
|
+
|
|
508
|
+
/** A bare refspec naming a local tag and no local branch pushes the tag. */
|
|
509
|
+
const isTag = (facts: RefFacts, name: string): boolean =>
|
|
510
|
+
facts.tags.has(name) && !facts.heads.has(name);
|
|
511
|
+
|
|
512
|
+
/**
|
|
513
|
+
* A raw tag push (`git push origin v1.0`, `refs/tags/v1.0`, `--tags`) skips
|
|
514
|
+
* the release grant: denied with the workit command. `--follow-tags` only
|
|
515
|
+
* nudges (it pushes annotated tags the pushed commits already reach).
|
|
516
|
+
*/
|
|
517
|
+
const tagRefusal = (action: PushAction, facts: RefFacts): string | null => {
|
|
518
|
+
const tags = [
|
|
519
|
+
...(action.tags ?? []),
|
|
520
|
+
...action.targets.filter((name): name is string => name !== null && isTag(facts, name)),
|
|
521
|
+
];
|
|
522
|
+
if (!tags.length && !action.allTags) return null;
|
|
523
|
+
const named = tags.length ? tags.join(" --release ") : "v<version>";
|
|
524
|
+
return `workit: raw ${quote(`git push${action.allTags ? " --tags" : ""}`)} of ${tags.length ? `tag ${tags.join(", ")}` : "tags"} bypasses the release grant. Push a release tag with ${quote(`workit git push --release ${named}`)} (v<version> tags whose commit the production branch already has; a tag never moves), under the ${quote("release")} grant (if refused, ask the user to run ${quote("workit grant set <workspace> release=true")}).`;
|
|
525
|
+
};
|
|
526
|
+
|
|
527
|
+
/** The remote already has the branch (ls-remote, bounded; unreachable answers yes: fail open). */
|
|
528
|
+
const remoteHas = (repo: Repo, remote: string, branch: string): boolean => {
|
|
529
|
+
const listed = spawnSync("git", ["ls-remote", "--heads", remote, `refs/heads/${branch}`], {
|
|
530
|
+
cwd: repo.dir,
|
|
531
|
+
encoding: "utf8",
|
|
532
|
+
timeout: 3_000,
|
|
533
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0" },
|
|
534
|
+
});
|
|
535
|
+
return listed.status !== 0 || (listed.stdout ?? "").trim() !== "";
|
|
536
|
+
};
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* A branch name that would be new on the remote and that the workspace policy
|
|
540
|
+
* does not allow, with the closest allowed name. An existing remote branch
|
|
541
|
+
* (`dependabot/…`, a legacy name) is never judged: pushing it is not a new
|
|
542
|
+
* name. `--all`/`--branches` expand to every local branch.
|
|
543
|
+
*/
|
|
544
|
+
const offPolicyPush = (action: PushAction, repo: Repo, facts: RefFacts, remote: string) => {
|
|
545
|
+
const names = action.allBranches
|
|
546
|
+
? [...facts.heads]
|
|
547
|
+
: action.targets.map((target) => target ?? repo.branch);
|
|
548
|
+
for (const branch of names) {
|
|
549
|
+
if (!branch || action.deleted?.includes(branch) || isTag(facts, branch)) continue;
|
|
550
|
+
if (facts.remoteHeads.has(branch)) continue;
|
|
551
|
+
const check = validateBranchNameFor(repo.dir, branch);
|
|
552
|
+
if (check.ok || check.rule !== "allowed_pattern" || remoteHas(repo, remote, branch)) continue;
|
|
553
|
+
return `workit: raw ${quote("git push")} onto ${branch}: ${check.error}`;
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
};
|
|
557
|
+
|
|
441
558
|
const nudgeFor = (action: RawAction, host: HookInput["host"], session: string): string | null => {
|
|
442
559
|
switch (action.kind) {
|
|
443
560
|
case "commit": {
|
|
@@ -550,12 +667,23 @@ export function rawGitPre(
|
|
|
550
667
|
for (const { action, repo } of actionsIn(input.cwd, command, dialect)) {
|
|
551
668
|
store ??= repo.location.dir;
|
|
552
669
|
if (action.kind === "merge") return deny(refusal(action, null));
|
|
553
|
-
if (action.kind === "push")
|
|
670
|
+
if (action.kind === "push" && toPushRemote(repo, action.remote)) {
|
|
671
|
+
if (action.mirror)
|
|
672
|
+
return deny(
|
|
673
|
+
`workit: raw ${quote("git push --mirror")} rewrites and deletes every remote ref, protected branches and tags included. Push one branch with ${quote("workit git push")}.`,
|
|
674
|
+
);
|
|
554
675
|
for (const target of action.targets) {
|
|
555
676
|
const branch = target ?? repo.branch;
|
|
556
|
-
if (branch && guardedBranch(repo.dir, branch)
|
|
557
|
-
return deny(refusal(action, branch));
|
|
677
|
+
if (branch && guardedBranch(repo.dir, branch))
|
|
678
|
+
return deny(refusal(action, branch, repo.dir));
|
|
558
679
|
}
|
|
680
|
+
const remote = action.remote ?? pushRemoteName(repo.dir, repo.branch) ?? "origin";
|
|
681
|
+
const facts = refFacts(repo, remote);
|
|
682
|
+
const tagDeny = tagRefusal(action, facts);
|
|
683
|
+
if (tagDeny) return deny(tagDeny);
|
|
684
|
+
const nameDeny = action.deletes ? null : offPolicyPush(action, repo, facts, remote);
|
|
685
|
+
if (nameDeny) return deny(nameDeny);
|
|
686
|
+
}
|
|
559
687
|
const nudge = nudgeFor(action, input.host, input.session.id);
|
|
560
688
|
if (nudge && !nudges.has(nudgeKind(action))) nudges.set(nudgeKind(action), nudge);
|
|
561
689
|
}
|
package/src/host-session.ts
CHANGED
|
@@ -7,6 +7,10 @@
|
|
|
7
7
|
// - Pi: PI_SESSION_ID (the bash tool exposes the Pi session id);
|
|
8
8
|
// - Codex CLI and Desktop: CODEX_THREAD_ID (the thread id hooks get as session_id).
|
|
9
9
|
// Cursor puts no conversation id in its shell; its hook names the id to use.
|
|
10
|
+
import { createHash } from "node:crypto";
|
|
11
|
+
import fs from "node:fs";
|
|
12
|
+
import path from "node:path";
|
|
13
|
+
import { resolveStore } from "./store/paths";
|
|
10
14
|
|
|
11
15
|
export type HostSession = { host: string | null; session: string | null };
|
|
12
16
|
|
|
@@ -51,3 +55,188 @@ export function hostSessionFromEnv(env: NodeJS.ProcessEnv): HostSession {
|
|
|
51
55
|
}
|
|
52
56
|
return inner ? { host: inner.host, session: inner.session } : { host: declared, session: null };
|
|
53
57
|
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* Where this shell's session comes from:
|
|
61
|
+
* - `derived`: the host's own shell variable (OPENCODE_SESSION_ID,
|
|
62
|
+
* PI_SESSION_ID, CODEX_THREAD_ID), which a caller cannot replace by
|
|
63
|
+
* exporting WORKIT_SESSION_ID;
|
|
64
|
+
* - `declared`: a host that puts no id in the shell but names itself
|
|
65
|
+
* (WORKIT_HOST, e.g. Claude Code's SessionStart export) declares it in
|
|
66
|
+
* WORKIT_SESSION_ID;
|
|
67
|
+
* - `none`: no host at all (CI, a plain terminal, Cursor's prefixed
|
|
68
|
+
* commands): whatever session the caller names is only declared.
|
|
69
|
+
*/
|
|
70
|
+
export type RealSession = HostSession & { kind: "derived" | "declared" | "none" };
|
|
71
|
+
|
|
72
|
+
export function realHostSession(env: NodeJS.ProcessEnv): RealSession {
|
|
73
|
+
for (const [key, host] of HOST_SESSION_VARS) {
|
|
74
|
+
const session = env[key]?.trim();
|
|
75
|
+
if (session) return { host: host(env), session, kind: "derived" };
|
|
76
|
+
}
|
|
77
|
+
const declared = hostSessionFromEnv(env);
|
|
78
|
+
const hosted = declared.host !== null && declared.host !== "cli";
|
|
79
|
+
return { ...declared, kind: hosted ? "declared" : "none" };
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Ids a SubagentStart hook gave a lead's subagents (`<lead>:<agent id>`): one
|
|
83
|
+
// empty file per id under `<store>/hooks/issued/<lead hash>/`, so concurrent
|
|
84
|
+
// hooks never lose each other's write, bounded per lead.
|
|
85
|
+
const ISSUED_PER_LEAD = 64;
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Written into each issued-id file. A hook bundle that contains this string
|
|
89
|
+
* records issued ids; `workit doctor` looks for it in the installed Claude
|
|
90
|
+
* Code and Codex hooks to catch a plugin older than the CLI.
|
|
91
|
+
*/
|
|
92
|
+
export const ISSUED_SESSIONS_MARKER = "workit-issued-sessions/1";
|
|
93
|
+
|
|
94
|
+
const hash = (value: string): string =>
|
|
95
|
+
createHash("sha256").update(value).digest("hex").slice(0, 32);
|
|
96
|
+
|
|
97
|
+
const issuedRoot = (cwd: string): string | null => {
|
|
98
|
+
const location = resolveStore(cwd);
|
|
99
|
+
return location instanceof Error ? null : path.join(location.dir, "hooks", "issued");
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
const issuedDir = (cwd: string, lead: string): string | null => {
|
|
103
|
+
const root = issuedRoot(cwd);
|
|
104
|
+
return root === null ? null : path.join(root, hash(lead));
|
|
105
|
+
};
|
|
106
|
+
|
|
107
|
+
/** No hook in this workspace has ever recorded an issued id (an older plugin hook, most likely). */
|
|
108
|
+
const noIssuedRecord = (cwd: string | undefined): boolean => {
|
|
109
|
+
if (cwd === undefined) return false;
|
|
110
|
+
try {
|
|
111
|
+
const root = issuedRoot(cwd);
|
|
112
|
+
return root !== null && !fs.existsSync(root);
|
|
113
|
+
} catch {
|
|
114
|
+
return false;
|
|
115
|
+
}
|
|
116
|
+
};
|
|
117
|
+
|
|
118
|
+
/** Per host: how to update the plugin whose SubagentStart hook issues ids. */
|
|
119
|
+
const PLUGIN_UPDATE: Readonly<Record<string, string>> = {
|
|
120
|
+
claude_code: "claude plugin marketplace update workit && claude plugin update workit@workit",
|
|
121
|
+
codex: "codex plugin remove workit@workit && codex plugin add workit@workit",
|
|
122
|
+
};
|
|
123
|
+
|
|
124
|
+
/** Remember that a SubagentStart hook issued `issued` to a subagent of `lead`. Fails open. */
|
|
125
|
+
export function recordIssuedSession(cwd: string, lead: string, issued: string): void {
|
|
126
|
+
try {
|
|
127
|
+
const dir = issuedDir(cwd, lead);
|
|
128
|
+
if (!dir) return;
|
|
129
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
130
|
+
const own = hash(issued);
|
|
131
|
+
fs.writeFileSync(path.join(dir, own), ISSUED_SESSIONS_MARKER);
|
|
132
|
+
const entries = fs.readdirSync(dir);
|
|
133
|
+
if (entries.length <= ISSUED_PER_LEAD) return;
|
|
134
|
+
// Oldest first; the id just issued is never the one pruned.
|
|
135
|
+
const byAge = entries
|
|
136
|
+
.filter((name) => name !== own)
|
|
137
|
+
.map((name) => ({ name, at: fs.statSync(path.join(dir, name)).mtimeMs }))
|
|
138
|
+
.toSorted((a, b) => a.at - b.at);
|
|
139
|
+
for (const { name } of byAge.slice(0, entries.length - ISSUED_PER_LEAD))
|
|
140
|
+
fs.rmSync(path.join(dir, name), { force: true });
|
|
141
|
+
} catch {
|
|
142
|
+
// Fail open: an unrecorded id is refused later, never a broken hook.
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
const wasIssued = (cwd: string | undefined, lead: string, claimed: string): boolean => {
|
|
147
|
+
if (cwd === undefined) return false;
|
|
148
|
+
try {
|
|
149
|
+
const dir = issuedDir(cwd, lead);
|
|
150
|
+
return dir !== null && fs.existsSync(path.join(dir, hash(claimed)));
|
|
151
|
+
} catch {
|
|
152
|
+
return false;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const leadOf = (session: string): string => session.slice(0, session.lastIndexOf(":"));
|
|
157
|
+
|
|
158
|
+
/**
|
|
159
|
+
* Hosts whose child sessions run their own shell with their own host session
|
|
160
|
+
* id: a child proves itself from its own shell, so a lead never mints a child id.
|
|
161
|
+
*/
|
|
162
|
+
const OWN_CHILD_SHELL = new Set(["opencode", "pi"]);
|
|
163
|
+
|
|
164
|
+
const SESSION_UNSAFE = /[^A-Za-z0-9_.:@/+-]/g;
|
|
165
|
+
|
|
166
|
+
/**
|
|
167
|
+
* The host-derived identity of a child session: `<lead>:<child>`, the shape
|
|
168
|
+
* Claude Code's SubagentStart hook gives a subagent. The child's own id is
|
|
169
|
+
* kept whole (it is what the host vouches for); the lead part is trimmed to
|
|
170
|
+
* fit the ledger's 128-character session grammar.
|
|
171
|
+
*/
|
|
172
|
+
export function childSessionId(lead: string, child: string): string {
|
|
173
|
+
const leaf = child.replace(SESSION_UNSAFE, "-").slice(-120);
|
|
174
|
+
const prefix = lead.replace(SESSION_UNSAFE, "-").slice(0, 127 - leaf.length);
|
|
175
|
+
return prefix ? `${prefix}:${leaf}` : leaf;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
export type SessionClaimRefusal = { error: string; unblock: string };
|
|
179
|
+
|
|
180
|
+
/**
|
|
181
|
+
* Whether `claimed` may act as this shell's session (`cwd` locates the
|
|
182
|
+
* workspace's record of hook-issued ids):
|
|
183
|
+
* - no host session (CI, a plain terminal): any id; `--session X` and
|
|
184
|
+
* WORKIT_SESSION_ID=X are the same declaration;
|
|
185
|
+
* - the shell's own session (a declared `<lead>:<x>` on Claude Code only
|
|
186
|
+
* when its SubagentStart hook issued it);
|
|
187
|
+
* - on a host that sets the id, `<lead>:<this session>`: a child naming its
|
|
188
|
+
* own host session;
|
|
189
|
+
* - `<this session>:<agent id>` only when this lead's SubagentStart hook
|
|
190
|
+
* issued it (Claude Code, Codex).
|
|
191
|
+
* Anything else is a forged id: a verdict recorded under it would make author
|
|
192
|
+
* is not verifier a naming convention.
|
|
193
|
+
*/
|
|
194
|
+
export function sessionClaimRefusal(
|
|
195
|
+
env: NodeJS.ProcessEnv,
|
|
196
|
+
claimed: string,
|
|
197
|
+
cwd?: string,
|
|
198
|
+
): SessionClaimRefusal | null {
|
|
199
|
+
const real = realHostSession(env);
|
|
200
|
+
const host = family(real.host);
|
|
201
|
+
// A host that always sets its own variable, named without it: the variable
|
|
202
|
+
// was removed (`env -u OPENCODE_SESSION_ID`), so nothing vouches for the id.
|
|
203
|
+
if (real.kind !== "derived" && host !== null && HOST_SET_VARIABLE[host])
|
|
204
|
+
return {
|
|
205
|
+
error: `forged_session: WORKIT_HOST names ${real.host}, but its own ${HOST_SET_VARIABLE[host]} is not in this shell, so ${claimed} cannot be checked`,
|
|
206
|
+
unblock: `record the verdict from the ${real.host} session's own shell, where ${HOST_SET_VARIABLE[host]} is set`,
|
|
207
|
+
};
|
|
208
|
+
if (real.kind === "none" || !real.session) return null;
|
|
209
|
+
let issuedClaim = false;
|
|
210
|
+
if (claimed === real.session) {
|
|
211
|
+
const declaredChild =
|
|
212
|
+
real.kind === "declared" && host === "claude_code" && claimed.includes(":");
|
|
213
|
+
if (!declaredChild || wasIssued(cwd, leadOf(claimed), claimed)) return null;
|
|
214
|
+
issuedClaim = true;
|
|
215
|
+
} else if (real.kind === "derived" && claimed.endsWith(`:${real.session}`)) return null;
|
|
216
|
+
else if (claimed.startsWith(`${real.session}:`)) {
|
|
217
|
+
if (wasIssued(cwd, real.session, claimed)) return null;
|
|
218
|
+
issuedClaim = true;
|
|
219
|
+
}
|
|
220
|
+
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
|
+
// A well-formed subagent id, but no hook here ever recorded one: the hook
|
|
222
|
+
// most likely predates this CLI (CLI and plugin update separately).
|
|
223
|
+
if (issuedClaim && noIssuedRecord(cwd) && host !== null && PLUGIN_UPDATE[host])
|
|
224
|
+
return {
|
|
225
|
+
error: `${error}; no SubagentStart hook in this workspace has recorded an issued id`,
|
|
226
|
+
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
|
+
};
|
|
228
|
+
const ownChildShell = host !== null && OWN_CHILD_SHELL.has(host);
|
|
229
|
+
return {
|
|
230
|
+
error,
|
|
231
|
+
unblock: ownChildShell
|
|
232
|
+
? `record the verdict from the verifier's own ${host === "pi" ? "Pi worker" : "OpenCode child session"} shell, where WORKIT_SESSION_ID is already set; never set WORKIT_SESSION_ID or --session by hand`
|
|
233
|
+
: "never name a session yourself: spawn a verifier or reviewer subagent and pass exactly the --session its SubagentStart context names, or pass --as verifier",
|
|
234
|
+
};
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Hosts that always put their own session variable in the agent's shell. */
|
|
238
|
+
const HOST_SET_VARIABLE: Readonly<Record<string, string>> = {
|
|
239
|
+
opencode: "OPENCODE_SESSION_ID",
|
|
240
|
+
pi: "PI_SESSION_ID",
|
|
241
|
+
codex: "CODEX_THREAD_ID",
|
|
242
|
+
};
|
package/src/ledger.ts
CHANGED
|
@@ -1140,10 +1140,31 @@ export function authorSessions(
|
|
|
1140
1140
|
return sessions;
|
|
1141
1141
|
}
|
|
1142
1142
|
|
|
1143
|
+
/** Where each host's verifier session comes from, for refusal hints. */
|
|
1144
|
+
const HOST_DERIVED_SESSIONS =
|
|
1145
|
+
"Claude Code, Codex and Cursor: `--session <lead>:<agent id>` from the SubagentStart hook; OpenCode child sessions and Pi workers: WORKIT_SESSION_ID=<lead>:<child> already set in their shell";
|
|
1146
|
+
|
|
1147
|
+
/** Whether `derived` is a host-derived `<lead>:<bare>` id for the bare host session `bare`. */
|
|
1148
|
+
const derivesFrom = (derived: string, bare: string): boolean =>
|
|
1149
|
+
!bare.includes(":") && derived.endsWith(`:${bare}`);
|
|
1150
|
+
|
|
1151
|
+
/**
|
|
1152
|
+
* Whether `session` is one of the authors: the same id, or one is a bare host
|
|
1153
|
+
* session and the other its derived `<lead>:<session>` form (an OpenCode
|
|
1154
|
+
* child that committed as `<lead>:<child>` and judges as `<child>` is still
|
|
1155
|
+
* the author). Two derived ids never match on a shared last segment.
|
|
1156
|
+
*/
|
|
1157
|
+
const authoredBy = (authors: Set<string>, session: string): boolean => {
|
|
1158
|
+
if (authors.has(session)) return true;
|
|
1159
|
+
for (const author of authors)
|
|
1160
|
+
if (derivesFrom(author, session) || derivesFrom(session, author)) return true;
|
|
1161
|
+
return false;
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1143
1164
|
const independenceReasons = (row: ReadRow, authors: Set<string>): RejectReason[] => {
|
|
1144
1165
|
const reasons: RejectReason[] = [];
|
|
1145
1166
|
if (!row.actor.session) reasons.push("no_session");
|
|
1146
|
-
else if (authors
|
|
1167
|
+
else if (authoredBy(authors, row.actor.session)) reasons.push("author_session");
|
|
1147
1168
|
if (row.self === true && !reasons.includes("no_session")) reasons.push("self");
|
|
1148
1169
|
return reasons;
|
|
1149
1170
|
};
|
|
@@ -1687,15 +1708,16 @@ export function recordVerdict(
|
|
|
1687
1708
|
head: key.head,
|
|
1688
1709
|
});
|
|
1689
1710
|
const authoring = [session, input.derivedFrom].find(
|
|
1690
|
-
(candidate): candidate is string =>
|
|
1711
|
+
(candidate): candidate is string =>
|
|
1712
|
+
typeof candidate === "string" && authoredBy(authors, candidate),
|
|
1691
1713
|
);
|
|
1692
1714
|
if (!self && authoring)
|
|
1693
1715
|
return err(
|
|
1694
1716
|
"blocked",
|
|
1695
1717
|
`author_verdict: session ${authoring} authored ${branch}; a verdict must come from a different session`,
|
|
1696
1718
|
input.derivedFrom === undefined
|
|
1697
|
-
?
|
|
1698
|
-
:
|
|
1719
|
+
? `have a non-author session record the verdict: spawn a verifier subagent, which gets its own host-derived session (${HOST_DERIVED_SESSIONS}); or, as the author, pass --self (self-reviewed, never accepted)`
|
|
1720
|
+
: `run the verifier as a separate session: spawn a verifier subagent, which gets its own host-derived session (${HOST_DERIVED_SESSIONS})`,
|
|
1699
1721
|
);
|
|
1700
1722
|
const link = checkSupersede(context, "verdict", { self, kind, branch });
|
|
1701
1723
|
if (!link.ok) return link;
|