@brainervirus/workit-core 8.9.0 → 8.10.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/core/methods.ts +2 -1
- package/src/core/task-context.ts +68 -30
- package/src/core/task-engine.ts +65 -3
- package/src/core/task-store.ts +158 -3
- package/src/hooks/context.ts +45 -23
- package/src/hooks/handle.ts +4 -0
- package/src/hooks/hosts/codex.ts +1 -1
- package/src/host-session.ts +189 -0
- package/src/ledger.ts +26 -4
package/package.json
CHANGED
|
@@ -25,7 +25,7 @@ REPORT: branch, head SHA, files changed, each VERIFY command with its exit code,
|
|
|
25
25
|
each ACCEPTANCE line met / not met, rulings you made (`workit ledger ruling`),
|
|
26
26
|
anything out of scope as a follow-up, not a diff.
|
|
27
27
|
STANDING: <the standing orders, verbatim: `workit ledger standing add` records each one>
|
|
28
|
-
export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead; a verifier brief
|
|
28
|
+
export WORKIT_SESSION_ID=<lead>-w<n> (set by the lead for a worker's commits; a verifier brief sets none: the host derives it)
|
|
29
29
|
```
|
|
30
30
|
|
|
31
31
|
## Worked example
|
|
@@ -72,12 +72,22 @@ Which session id a verdict carries, per host:
|
|
|
72
72
|
|
|
73
73
|
- Claude Code: subagents inherit your `WORKIT_SESSION_ID`, so the
|
|
74
74
|
SubagentStart hook names each `verifier` and `reviewer` its own session
|
|
75
|
-
(`<lead>:<agent id>`), passed as `--session
|
|
76
|
-
|
|
77
|
-
exports for its workit commands.
|
|
78
|
-
-
|
|
79
|
-
|
|
80
|
-
|
|
75
|
+
(`<lead>:<agent id>`), passed exactly as `--session`: the hook records the
|
|
76
|
+
ids it issues, and only those are accepted. An `implementer` uses the
|
|
77
|
+
session its brief exports for its workit commands.
|
|
78
|
+
- Codex: the `workit-verifier` and `workit-reviewer` agents get the same
|
|
79
|
+
issued `--session` from SubagentStart; any other verifier passes
|
|
80
|
+
`--as verifier`.
|
|
81
|
+
- OpenCode: a child session's shell already has
|
|
82
|
+
`WORKIT_SESSION_ID=<lead>:<child>`; Pi: a supervised worker's shell has
|
|
83
|
+
`<lead>:<worker session>`. Set nothing for a verifier there.
|
|
84
|
+
- Cursor (no session id in the shell): the brief sets the verifier's
|
|
85
|
+
`WORKIT_SESSION_ID=<lead>:verifier-<random hex>`, a fresh suffix per
|
|
86
|
+
verifier. Cursor cannot check it: it is declared, not derived.
|
|
87
|
+
- A worker's commits use `export WORKIT_SESSION_ID=<lead>-w-<slice>` from
|
|
88
|
+
`workit fanout brief`. A verdict under an id you made up (`<lead>-v1`,
|
|
89
|
+
`<lead>:v1`) is refused as `forged_session` on Claude Code, Codex,
|
|
90
|
+
OpenCode and Pi.
|
|
81
91
|
|
|
82
92
|
A batch verifier takes several slices on one surface in one session: for
|
|
83
93
|
each branch it checks out the head, runs that slice's VERIFY, and records its
|
|
@@ -33,8 +33,9 @@ description: Build a requested change in small verified steps - follow local pat
|
|
|
33
33
|
`workit ledger verdict tests-verified --self --how "<what you ran>"`; it
|
|
34
34
|
reads self-reviewed, never verified. High risk, a workspace with
|
|
35
35
|
`verification: independent` (`workit grant show`) or a verified merge needs
|
|
36
|
-
a fresh verifier with its own session (
|
|
37
|
-
|
|
36
|
+
a fresh verifier with its own host-derived session (Claude Code and Codex:
|
|
37
|
+
the `verifier` agent; OpenCode: a child session; Pi: a supervised worker),
|
|
38
|
+
never a hand-minted id. Before saying done, reconcile
|
|
38
39
|
every named deliverable against the target checkout and observe it (for a push:
|
|
39
40
|
`workit verify-delivery push`).
|
|
40
41
|
|
|
@@ -7,8 +7,10 @@ description: Independent review of a diff, branch or PR - intent fidelity and st
|
|
|
7
7
|
|
|
8
8
|
Review the candidate, never the author's summary of it. A session that wrote a
|
|
9
9
|
commit on the branch cannot record a passing verdict: if that is you, hand the
|
|
10
|
-
review to a fresh agent with its own session
|
|
11
|
-
|
|
10
|
+
review to a fresh agent with its own host-derived session: Claude Code and
|
|
11
|
+
Codex, the `reviewer` or `verifier` agent (`--session` from SubagentStart);
|
|
12
|
+
OpenCode, a child session; Pi, a supervised worker. Never hand-mint one such
|
|
13
|
+
as `<lead>-v1`: it is refused as `forged_session`.
|
|
12
14
|
An author's `--self` verdict reads self-reviewed and never counts as
|
|
13
15
|
independent; `type-check-only` never proves a behavior change.
|
|
14
16
|
|
package/src/core/methods.ts
CHANGED
|
@@ -115,7 +115,8 @@ Autonomy contract:
|
|
|
115
115
|
satisfies a gate; an ad-hoc \`workit check -- <cmd>\` never does.
|
|
116
116
|
- Author is not verifier: your own verdict is \`--self\`, shown as
|
|
117
117
|
self-reviewed, never verified. High risk, \`verification: independent\` and
|
|
118
|
-
merge need a verifier with its own session (
|
|
118
|
+
merge need a verifier with its own host-derived session (a subagent's
|
|
119
|
+
\`<lead>:<child>\`, set by the host); never hand-mint one such as \`<lead>-v1\`.
|
|
119
120
|
- A local-commit endpoint does not imply PR readiness, and a local commit alone
|
|
120
121
|
is not evidence of a requested remote push. Before reporting delivery,
|
|
121
122
|
reconcile every requested item and observe it (\`workit verify-delivery\`).
|
package/src/core/task-context.ts
CHANGED
|
@@ -73,11 +73,20 @@ type CompactDecision = {
|
|
|
73
73
|
digest: string;
|
|
74
74
|
choice: string;
|
|
75
75
|
references: CompactReference[];
|
|
76
|
+
/** Where an inherited decision was recorded ("branch develop"); absent for the task's own. */
|
|
77
|
+
origin?: string;
|
|
76
78
|
};
|
|
77
79
|
type CompactMethod = Pick<SelectedMethod, "id" | "assurance"> & { reason: string };
|
|
80
|
+
/** Decisions of a task the current one inherits from (its base branch's task). */
|
|
81
|
+
export type InheritedDecisions = {
|
|
82
|
+
origin: string;
|
|
83
|
+
taskId: string;
|
|
84
|
+
decisions: TaskView["task"]["decisions"];
|
|
85
|
+
};
|
|
78
86
|
export type CompactTaskContext = {
|
|
79
87
|
objective: string;
|
|
80
|
-
|
|
88
|
+
/** Null when the checkout has no task yet and only inherited decisions apply. */
|
|
89
|
+
status: TaskView["task"]["status"] | null;
|
|
81
90
|
decisions: CompactDecision[];
|
|
82
91
|
methods: CompactMethod[];
|
|
83
92
|
gaps: string[];
|
|
@@ -128,54 +137,83 @@ const compactReference = (ref: Ref): CompactReference => {
|
|
|
128
137
|
return { kind: ref.kind };
|
|
129
138
|
};
|
|
130
139
|
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
140
|
+
type TaskDecision = TaskView["task"]["decisions"][number];
|
|
141
|
+
const newestDecisionFirst = (left: TaskDecision, right: TaskDecision): number => {
|
|
142
|
+
const leftAt = compactTimestamp(left.recordedAt);
|
|
143
|
+
const rightAt = compactTimestamp(right.recordedAt);
|
|
144
|
+
if (leftAt !== rightAt) return leftAt > rightAt ? -1 : 1;
|
|
145
|
+
return left.id < right.id ? -1 : left.id > right.id ? 1 : 0;
|
|
146
|
+
};
|
|
147
|
+
/**
|
|
148
|
+
* The task's own decisions (newest first), then the ones it inherits (origin
|
|
149
|
+
* set; the engine passes only unrevoked ones), at most COMPACT_MAX_ITEMS.
|
|
150
|
+
*/
|
|
151
|
+
const compactDecisions = (
|
|
152
|
+
sources: Array<{ origin: string | null; taskId: string; decisions: TaskDecision[] } | null>,
|
|
153
|
+
): CompactDecision[] =>
|
|
154
|
+
sources
|
|
155
|
+
.flatMap((source) =>
|
|
156
|
+
source
|
|
157
|
+
? source.decisions.toSorted(newestDecisionFirst).map((entry) => ({
|
|
158
|
+
id: entry.id,
|
|
159
|
+
purpose: entry.data.purpose,
|
|
160
|
+
status: entry.data.response,
|
|
161
|
+
digest: entry.data.digest,
|
|
162
|
+
choice:
|
|
163
|
+
compactDecisionChoice(entry) ||
|
|
164
|
+
`task.inspect taskId=${source.taskId} view=full; decision=${entry.id}`,
|
|
165
|
+
references: entry.data.binding.contentRefs.slice(0, 1).map(compactReference),
|
|
166
|
+
// Undefined (dropped from the JSON) for the task's own decisions.
|
|
167
|
+
origin: source.origin ?? undefined,
|
|
168
|
+
}))
|
|
169
|
+
: [],
|
|
170
|
+
)
|
|
171
|
+
.slice(0, COMPACT_MAX_ITEMS);
|
|
172
|
+
|
|
173
|
+
/**
|
|
174
|
+
* The compact context of `view`'s task plus the decisions it inherits; with
|
|
175
|
+
* a null `view` (the branch has no task of its own yet) only the inherited
|
|
176
|
+
* decisions. Stale evidence is one counted gap with the command that lists it.
|
|
177
|
+
*/
|
|
178
|
+
export function compactTaskContext(
|
|
179
|
+
view: TaskView | null,
|
|
180
|
+
inherited: InheritedDecisions | null = null,
|
|
181
|
+
): string {
|
|
182
|
+
const decisions = compactDecisions([
|
|
183
|
+
view && { origin: null, taskId: view.task.id, decisions: view.task.decisions },
|
|
184
|
+
inherited,
|
|
185
|
+
]);
|
|
186
|
+
const methods: CompactMethod[] = view?.task.policy
|
|
152
187
|
? selectMethods(view.task.policy, view.capabilities).map((method) => ({
|
|
153
188
|
id: method.id,
|
|
154
189
|
assurance: method.assurance,
|
|
155
190
|
reason: compactText(method.reason, COMPACT_TEXT_BYTES) ?? "",
|
|
156
191
|
}))
|
|
157
192
|
: [];
|
|
193
|
+
const stale = view?.evidence.filter((entry) => entry.status === "stale").length;
|
|
158
194
|
const gaps = Array.from(
|
|
159
195
|
new Set([
|
|
160
|
-
...
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
...view
|
|
196
|
+
...(stale
|
|
197
|
+
? [`stale evidence: ${stale}; workit task inspect --task ${view.task.id} --view full`]
|
|
198
|
+
: []),
|
|
199
|
+
...(view?.requirements ?? [])
|
|
164
200
|
.filter((entry) => entry.status !== "satisfied")
|
|
165
201
|
.map((entry) => entry.reason),
|
|
166
|
-
...view
|
|
202
|
+
...(view?.task.progress.blockers ?? []).map((entry) => entry.reason),
|
|
167
203
|
]),
|
|
168
204
|
)
|
|
169
205
|
.toSorted()
|
|
170
206
|
.slice(0, COMPACT_MAX_ITEMS)
|
|
171
207
|
.map((gap) => compactText(gap, COMPACT_TEXT_BYTES)!);
|
|
172
208
|
const context: CompactTaskContext = {
|
|
173
|
-
objective:
|
|
174
|
-
|
|
209
|
+
objective: view
|
|
210
|
+
? (compactText(view.task.intent.data.objective, COMPACT_TEXT_BYTES) ?? "")
|
|
211
|
+
: "no task on this branch yet",
|
|
212
|
+
status: view?.task.status ?? null,
|
|
175
213
|
decisions,
|
|
176
214
|
methods,
|
|
177
215
|
gaps,
|
|
178
|
-
nextAction: compactText(view
|
|
216
|
+
nextAction: compactText(view?.task.progress.nextAction ?? null, COMPACT_TEXT_BYTES),
|
|
179
217
|
};
|
|
180
218
|
let encoded = JSON.stringify(context);
|
|
181
219
|
while (
|
package/src/core/task-engine.ts
CHANGED
|
@@ -68,6 +68,7 @@ import { defaultLockTimeout } from "./store-lock";
|
|
|
68
68
|
import {
|
|
69
69
|
compactTaskContext,
|
|
70
70
|
exportDigest,
|
|
71
|
+
type InheritedDecisions,
|
|
71
72
|
reconcileResume as reconcileResumeContext,
|
|
72
73
|
type ResumeReconciliation,
|
|
73
74
|
} from "./task-context";
|
|
@@ -389,6 +390,18 @@ const TASK_SCOPED_ACTIONS: ReadonlySet<string> = new Set([
|
|
|
389
390
|
"worker.cancel",
|
|
390
391
|
"state.export",
|
|
391
392
|
]);
|
|
393
|
+
/** Worker actions that name their worker: they resolve to that worker's task. */
|
|
394
|
+
const WORKER_ADDRESSED_ACTIONS: ReadonlySet<string> = new Set(["worker.cancel", "worker.report"]);
|
|
395
|
+
/**
|
|
396
|
+
* The exact CLI command that runs worker `request` (cancel or report, with a
|
|
397
|
+
* valid workerId) on `taskId`, so a retry never depends on the branch.
|
|
398
|
+
*/
|
|
399
|
+
const workerCommand = (taskId: string, request: unknown): string => {
|
|
400
|
+
const { action, workerId, reason, report } = request as Record<string, unknown>;
|
|
401
|
+
const rest = JSON.stringify({ reason, report });
|
|
402
|
+
const payload = rest === "{}" ? "" : ` --payload '${rest.replace(/'/gu, "'\\''")}'`;
|
|
403
|
+
return `workit worker ${String(action)} ${String(workerId)} --task ${taskId}${payload}`;
|
|
404
|
+
};
|
|
392
405
|
/** Recording actions that create the implicit task on first use. */
|
|
393
406
|
const CREATING_ACTIONS: ReadonlySet<string> = new Set([
|
|
394
407
|
"task.revise",
|
|
@@ -711,6 +724,24 @@ export class WorkitCore {
|
|
|
711
724
|
if (!TASK_SCOPED_ACTIONS.has(action)) return success(null, null, request);
|
|
712
725
|
const root = this.contextRootError();
|
|
713
726
|
if (!root.ok) return root;
|
|
727
|
+
// A worker belongs to the task it was assigned on, whatever branch is
|
|
728
|
+
// checked out now (#264): worker.cancel/report follow the named worker,
|
|
729
|
+
// a helper's own calls follow its worker id.
|
|
730
|
+
const addressed = WORKER_ADDRESSED_ACTIONS.has(action);
|
|
731
|
+
// A missing or malformed id is invalid_input (from taskForWorker).
|
|
732
|
+
const workerId: unknown = addressed
|
|
733
|
+
? (value as { workerId?: unknown }).workerId
|
|
734
|
+
: (this.context.workerId ?? null);
|
|
735
|
+
if (workerId !== null) {
|
|
736
|
+
const owner = this.store.taskForWorker(workerId);
|
|
737
|
+
if (!owner.ok) return owner;
|
|
738
|
+
if (owner.data) return success(null, null, { ...request, taskId: owner.data });
|
|
739
|
+
if (addressed)
|
|
740
|
+
return failure(
|
|
741
|
+
"not_found",
|
|
742
|
+
`worker ${workerId as string} is on no open task; find its task with \`workit task status --all\`, then run \`${workerCommand("<task-id>", request)}\``,
|
|
743
|
+
);
|
|
744
|
+
}
|
|
714
745
|
const create = CREATING_ACTIONS.has(action) && (this.context.workerId ?? null) === null;
|
|
715
746
|
const found = this.store.implicitTask({
|
|
716
747
|
provenance: provenance(this.context),
|
|
@@ -1605,7 +1636,16 @@ export class WorkitCore {
|
|
|
1605
1636
|
const resolved = this.implicitTask("worker", request);
|
|
1606
1637
|
if (!resolved.ok) return resolved;
|
|
1607
1638
|
const value = resolved.data;
|
|
1608
|
-
|
|
1639
|
+
const result = this.retryOmittedRevisions(value, () => this.workerOnce(value));
|
|
1640
|
+
// A cancel/report whose task was resolved from the worker id names the
|
|
1641
|
+
// exact command for that task (every host and the CLI see the same text).
|
|
1642
|
+
const { action, taskId } = (value ?? {}) as { action?: unknown; taskId?: unknown };
|
|
1643
|
+
return result.ok ||
|
|
1644
|
+
(request as { taskId?: unknown } | null)?.taskId !== undefined ||
|
|
1645
|
+
typeof taskId !== "string" ||
|
|
1646
|
+
!WORKER_ADDRESSED_ACTIONS.has(`worker.${String(action)}`)
|
|
1647
|
+
? result
|
|
1648
|
+
: { ...result, error: `${result.error}; run \`${workerCommand(taskId, value)}\`` };
|
|
1609
1649
|
}
|
|
1610
1650
|
|
|
1611
1651
|
private workerOnce(request: unknown): Result<Entry<Worker>> {
|
|
@@ -1892,9 +1932,31 @@ export class WorkitCore {
|
|
|
1892
1932
|
* judged against the last recorded candidate, and live staleness is
|
|
1893
1933
|
* detected by evidence, close, resume, and full inspection, which capture.
|
|
1894
1934
|
*/
|
|
1895
|
-
compactContext(
|
|
1935
|
+
compactContext(
|
|
1936
|
+
taskId: string | null,
|
|
1937
|
+
freshness?: Freshness,
|
|
1938
|
+
/** The base branch's task (TaskStore.originTaskEntry), whose decisions are inherited. */
|
|
1939
|
+
origin: { origin: string; taskId: string } | null = null,
|
|
1940
|
+
): Result<string> {
|
|
1896
1941
|
const root = this.contextRootError();
|
|
1897
1942
|
if (!root.ok) return root;
|
|
1943
|
+
const base = origin && this.store.readStoreTask(origin.taskId);
|
|
1944
|
+
const inherited: InheritedDecisions | null =
|
|
1945
|
+
base?.ok && base.data
|
|
1946
|
+
? {
|
|
1947
|
+
origin: origin!.origin,
|
|
1948
|
+
taskId: base.data.id,
|
|
1949
|
+
// A revoked decision no longer applies to anything cut from the base.
|
|
1950
|
+
decisions: base.data.decisions.filter((entry) => entry.data.revoked === null),
|
|
1951
|
+
}
|
|
1952
|
+
: null;
|
|
1953
|
+
// No task of its own yet: only what the branch inherits.
|
|
1954
|
+
if (taskId === null)
|
|
1955
|
+
return success(
|
|
1956
|
+
null,
|
|
1957
|
+
null,
|
|
1958
|
+
inherited?.decisions.length ? compactTaskContext(null, inherited) : "",
|
|
1959
|
+
);
|
|
1898
1960
|
const task = this.store.readTask(taskId);
|
|
1899
1961
|
if (!task.ok) return task;
|
|
1900
1962
|
const helper = this.helperTaskGuard(task.data, true);
|
|
@@ -1902,7 +1964,7 @@ export class WorkitCore {
|
|
|
1902
1964
|
// Per-turn path: the cheap worktree signal, never the object-writing tree hash.
|
|
1903
1965
|
const view = this.view(task.data, false, freshness ?? signalFreshness(this.store.root));
|
|
1904
1966
|
if (!view.ok) return view;
|
|
1905
|
-
return success(view.revision, view.workspaceRevision, compactTaskContext(view.data));
|
|
1967
|
+
return success(view.revision, view.workspaceRevision, compactTaskContext(view.data, inherited));
|
|
1906
1968
|
}
|
|
1907
1969
|
|
|
1908
1970
|
private view(
|
package/src/core/task-store.ts
CHANGED
|
@@ -314,6 +314,70 @@ const isNewerVersion = (candidate: string, current: string): boolean => {
|
|
|
314
314
|
};
|
|
315
315
|
const validId = (value: string): boolean =>
|
|
316
316
|
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/.test(value);
|
|
317
|
+
|
|
318
|
+
const readText = (file: string): string => {
|
|
319
|
+
try {
|
|
320
|
+
return fs.readFileSync(file, "utf8");
|
|
321
|
+
} catch {
|
|
322
|
+
return "";
|
|
323
|
+
}
|
|
324
|
+
};
|
|
325
|
+
|
|
326
|
+
/** `<old> <new> <ident> <unix seconds> <tz>\t<message>`: new sha, seconds, message. */
|
|
327
|
+
const REFLOG_LINE = /^[0-9a-f]+ ([0-9a-f]+) .* (\d+) [+-]\d{4}\t(.*)$/u;
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* The branch `branch` was cut from, read from git's reflogs (no git process).
|
|
331
|
+
* Only the branch log's first line counts, and only when it is the creation
|
|
332
|
+
* entry "branch: Created from <start>": an expired, rewritten or missing log
|
|
333
|
+
* says nothing. A branch created from HEAD takes
|
|
334
|
+
* the worktree HEAD log's checkout made by that creation (same new sha, same
|
|
335
|
+
* or next second) onto the branch or one of its earlier names (`aliases`, it
|
|
336
|
+
* may have been renamed since). A remote start (`origin/develop`) is
|
|
337
|
+
* returned as written.
|
|
338
|
+
*/
|
|
339
|
+
const branchStart = (
|
|
340
|
+
location: StoreLocation,
|
|
341
|
+
branch: string,
|
|
342
|
+
aliases: readonly string[],
|
|
343
|
+
): string | null => {
|
|
344
|
+
const first = readText(
|
|
345
|
+
path.join(path.dirname(location.dir), "logs", "refs", "heads", branch),
|
|
346
|
+
).split("\n", 1)[0];
|
|
347
|
+
// A "Created from" message only ever starts a log (git deletes the log
|
|
348
|
+
// with the branch), so the first line alone decides.
|
|
349
|
+
const created = REFLOG_LINE.exec(first);
|
|
350
|
+
const start = created && /^branch: Created from (\S+)$/u.exec(created[3])?.[1];
|
|
351
|
+
if (!created || !start) return null;
|
|
352
|
+
if (start !== "HEAD") return start;
|
|
353
|
+
if (!location.git) return null;
|
|
354
|
+
for (const line of readText(path.join(location.git.gitDir, "logs", "HEAD")).split("\n")) {
|
|
355
|
+
const entry = REFLOG_LINE.exec(line);
|
|
356
|
+
const moved = entry && /^checkout: moving from (\S+) to (\S+)$/u.exec(entry[3]);
|
|
357
|
+
const seconds = entry ? Number(entry[2]) - Number(created[2]) : -1;
|
|
358
|
+
if (
|
|
359
|
+
moved &&
|
|
360
|
+
entry[1] === created[1] &&
|
|
361
|
+
seconds >= 0 &&
|
|
362
|
+
seconds <= 1 &&
|
|
363
|
+
aliases.includes(moved[2])
|
|
364
|
+
)
|
|
365
|
+
return moved[1];
|
|
366
|
+
}
|
|
367
|
+
return null;
|
|
368
|
+
};
|
|
369
|
+
|
|
370
|
+
/** Whether `ref` (`refs/remotes/origin/develop`) exists, loose or packed. */
|
|
371
|
+
const refExists = (common: string, ref: string): boolean =>
|
|
372
|
+
fs.existsSync(path.join(common, ref)) ||
|
|
373
|
+
readText(path.join(common, "packed-refs")).includes(` ${ref}\n`);
|
|
374
|
+
|
|
375
|
+
/** Resolved bases per checkout and branch, reused while the reflogs they came
|
|
376
|
+
* from are unchanged (one entry per branch a process looked at). */
|
|
377
|
+
const origins = new Map<
|
|
378
|
+
string,
|
|
379
|
+
{ signature: string; name: string | null; renames: ReturnType<typeof branchRenames> }
|
|
380
|
+
>();
|
|
317
381
|
export const sameDirectoryIdentity = (left: string, right: string): boolean => {
|
|
318
382
|
if (!path.isAbsolute(left) || !path.isAbsolute(right)) return false;
|
|
319
383
|
const normalizedLeft = path.resolve(left);
|
|
@@ -658,6 +722,94 @@ export class TaskStore {
|
|
|
658
722
|
return success(null, null, { key: key.data, bound: bound.data });
|
|
659
723
|
}
|
|
660
724
|
|
|
725
|
+
/**
|
|
726
|
+
* The open task of this checkout that worker `workerId` is assigned to,
|
|
727
|
+
* whichever branch it is bound to: worker commands follow the worker, not
|
|
728
|
+
* the branch checked out now (#264). Null when no open task has it.
|
|
729
|
+
*/
|
|
730
|
+
taskForWorker(workerId: unknown): Result<Id | null> {
|
|
731
|
+
if (typeof workerId !== "string" || !validId(workerId))
|
|
732
|
+
return failure(
|
|
733
|
+
"invalid_input",
|
|
734
|
+
"needs a worker id: `workit worker cancel|report <workerId>`",
|
|
735
|
+
);
|
|
736
|
+
const listed = this.listTaskIndex();
|
|
737
|
+
if (!listed.ok) return listed;
|
|
738
|
+
for (const entry of listed.data) {
|
|
739
|
+
if (entry.status === "closed") continue;
|
|
740
|
+
const loaded = this.load(entry.id);
|
|
741
|
+
if (!loaded.ok) return loaded;
|
|
742
|
+
if (loaded.data?.record.workers.some((worker) => worker.id === workerId))
|
|
743
|
+
return success(null, null, entry.id);
|
|
744
|
+
}
|
|
745
|
+
return success(null, null, null);
|
|
746
|
+
}
|
|
747
|
+
|
|
748
|
+
/**
|
|
749
|
+
* The open task whose decisions the current branch inherits: the task of
|
|
750
|
+
* the branch it was cut from (`origin/develop` counts as `develop`), in any
|
|
751
|
+
* checkout of this repository, labelled with its origin ("branch develop").
|
|
752
|
+
* Null off a branch, or when git's reflogs do not name a base.
|
|
753
|
+
*/
|
|
754
|
+
originTaskEntry(): Result<{ origin: string; entry: TaskIndexEntry } | null> {
|
|
755
|
+
const key = this.currentKey();
|
|
756
|
+
const location = this.location();
|
|
757
|
+
const branch = key.ok ? key.data.branch : null;
|
|
758
|
+
if (!branch || !location.ok) return success(null, null, null);
|
|
759
|
+
const common = path.dirname(location.data.dir);
|
|
760
|
+
const heads = path.join(common, "logs", "refs", "heads");
|
|
761
|
+
const headLog = location.data.git ? path.join(location.data.git.gitDir, "logs", "HEAD") : "";
|
|
762
|
+
// Per-turn path: reading a long HEAD log and every branch log costs tens
|
|
763
|
+
// of ms, so the resolution is reused until one of these files changes.
|
|
764
|
+
const signatures = [
|
|
765
|
+
path.join(heads, branch),
|
|
766
|
+
headLog,
|
|
767
|
+
heads,
|
|
768
|
+
path.join(common, "packed-refs"),
|
|
769
|
+
].map((file) => fileSignature(file) ?? "-");
|
|
770
|
+
const slot = `${location.data.dir}\0${headLog}\0${branch}`;
|
|
771
|
+
let resolved = origins.get(slot);
|
|
772
|
+
if (resolved?.signature !== signatures.join("|")) {
|
|
773
|
+
const renames = branchRenames(location.data);
|
|
774
|
+
// The branch's earlier names, newest first: a rename keeps its log.
|
|
775
|
+
const aliases = [branch];
|
|
776
|
+
for (const rename of renames.toReversed())
|
|
777
|
+
if (rename.to === aliases.at(-1)) aliases.push(rename.from);
|
|
778
|
+
const start = branchStart(location.data, branch, aliases);
|
|
779
|
+
const slash = start?.indexOf("/") ?? -1;
|
|
780
|
+
// `origin/develop` is the remote's `develop`: strip the remote only for
|
|
781
|
+
// a remote-tracking ref or a configured remote, never for a local
|
|
782
|
+
// branch or tag that has a slash in its name.
|
|
783
|
+
const remote =
|
|
784
|
+
slash > 0 &&
|
|
785
|
+
!refExists(common, `refs/heads/${start}`) &&
|
|
786
|
+
(refExists(common, `refs/remotes/${start}`) ||
|
|
787
|
+
readText(path.join(common, "config")).includes(`[remote "${start!.slice(0, slash)}"]`));
|
|
788
|
+
const name = start && (remote ? start.slice(slash + 1) : start);
|
|
789
|
+
// Logs only grow (their size is in the signature), so no racy check.
|
|
790
|
+
resolved = {
|
|
791
|
+
signature: signatures.join("|"),
|
|
792
|
+
name: name && !aliases.includes(name) ? name : null,
|
|
793
|
+
renames,
|
|
794
|
+
};
|
|
795
|
+
origins.set(slot, resolved);
|
|
796
|
+
}
|
|
797
|
+
const entry = resolved.name ? this.openTasksForKey(resolved.name, resolved.renames) : null;
|
|
798
|
+
if (entry && !entry.ok) return entry;
|
|
799
|
+
return success(
|
|
800
|
+
null,
|
|
801
|
+
null,
|
|
802
|
+
entry?.data[0] ? { origin: `branch ${resolved.name}`, entry: entry.data[0] } : null,
|
|
803
|
+
);
|
|
804
|
+
}
|
|
805
|
+
|
|
806
|
+
/** The record of an indexed task (`taskId` from the index) in any checkout of
|
|
807
|
+
* this store: read-only, no binding check. */
|
|
808
|
+
readStoreTask(taskId: Id): Result<TaskRecord | null> {
|
|
809
|
+
const loaded = this.load(taskId);
|
|
810
|
+
return loaded.ok ? success(null, null, loaded.data?.record ?? null) : loaded;
|
|
811
|
+
}
|
|
812
|
+
|
|
661
813
|
// -------------------------------------------------------------------------
|
|
662
814
|
// writes
|
|
663
815
|
|
|
@@ -1442,11 +1594,14 @@ export class TaskStore {
|
|
|
1442
1594
|
* task follows its branch through `git branch -m`: it counts for the new
|
|
1443
1595
|
* name, and a branch created later under the old name starts fresh.
|
|
1444
1596
|
*/
|
|
1445
|
-
private openTasksForKey(
|
|
1597
|
+
private openTasksForKey(
|
|
1598
|
+
key: string,
|
|
1599
|
+
known?: ReturnType<typeof branchRenames>,
|
|
1600
|
+
): Result<TaskIndexEntry[]> {
|
|
1446
1601
|
const all = this.listStoreIndex();
|
|
1447
1602
|
if (!all.ok) return all;
|
|
1448
|
-
const location = this.location();
|
|
1449
|
-
const renames = location
|
|
1603
|
+
const location = known ? null : this.location();
|
|
1604
|
+
const renames = known ?? (location?.ok ? branchRenames(location.data) : []);
|
|
1450
1605
|
return success(
|
|
1451
1606
|
null,
|
|
1452
1607
|
null,
|
package/src/hooks/context.ts
CHANGED
|
@@ -74,6 +74,13 @@ export const currentTaskEntry = (
|
|
|
74
74
|
* CLI (`workit_cli`, which creates implicit tasks) are tooling, not
|
|
75
75
|
* conversations.
|
|
76
76
|
*/
|
|
77
|
+
/** Whether a host conversation other than `session` is bound to the task. */
|
|
78
|
+
const otherConversation = (entry: TaskIndexEntry, session: SessionHandle): boolean =>
|
|
79
|
+
entry.sessions.some(
|
|
80
|
+
(item) =>
|
|
81
|
+
item.host !== "workit_cli" && !(item.host === session.host && item.handle === session.handle),
|
|
82
|
+
);
|
|
83
|
+
|
|
77
84
|
const implicitTaskEntry = (
|
|
78
85
|
store: TaskStore,
|
|
79
86
|
entries: TaskIndexEntry[],
|
|
@@ -81,13 +88,7 @@ const implicitTaskEntry = (
|
|
|
81
88
|
): TaskIndexEntry | null => {
|
|
82
89
|
const open = entries.filter(
|
|
83
90
|
(entry) =>
|
|
84
|
-
entry.key !== null &&
|
|
85
|
-
entry.status !== "closed" &&
|
|
86
|
-
!entry.sessions.some(
|
|
87
|
-
(item) =>
|
|
88
|
-
item.host !== "workit_cli" &&
|
|
89
|
-
!(item.host === session.host && item.handle === session.handle),
|
|
90
|
-
),
|
|
91
|
+
entry.key !== null && entry.status !== "closed" && !otherConversation(entry, session),
|
|
91
92
|
);
|
|
92
93
|
if (open.length === 0) return null;
|
|
93
94
|
const key = store.currentKey();
|
|
@@ -174,23 +175,37 @@ export function sessionCompactContext(
|
|
|
174
175
|
const entry =
|
|
175
176
|
currentTaskEntry(listed.data, session, selection) ??
|
|
176
177
|
implicitTaskEntry(store, listed.data, session);
|
|
177
|
-
return
|
|
178
|
+
return entryCompactContext(store, entry, session, context);
|
|
178
179
|
}
|
|
179
180
|
|
|
180
181
|
function entryCompactContext(
|
|
181
182
|
store: TaskStore,
|
|
182
|
-
entry: TaskIndexEntry,
|
|
183
|
+
entry: TaskIndexEntry | null,
|
|
183
184
|
session: SessionHandle,
|
|
184
185
|
context: OperationContext,
|
|
185
186
|
): string | null {
|
|
187
|
+
// The task of the branch this one was cut from: its decisions stay in view
|
|
188
|
+
// (labelled with their origin) instead of vanishing on a switch. Another
|
|
189
|
+
// conversation's task is never injected.
|
|
190
|
+
const found = store.originTaskEntry();
|
|
191
|
+
const origin =
|
|
192
|
+
found.ok &&
|
|
193
|
+
found.data &&
|
|
194
|
+
found.data.entry.id !== entry?.id &&
|
|
195
|
+
!otherConversation(found.data.entry, session)
|
|
196
|
+
? found.data
|
|
197
|
+
: null;
|
|
198
|
+
if (!entry && !origin) return null;
|
|
186
199
|
const workspace = store.readWorkspace();
|
|
187
|
-
if (!workspace.ok || !workspace.data) return null;
|
|
200
|
+
if (!workspace.ok || (entry && !workspace.data)) return null;
|
|
188
201
|
const slot = `${store.root}\0${session.host}\0${session.handle}`;
|
|
189
202
|
const key = canonicalJson({
|
|
190
|
-
task: entry
|
|
191
|
-
revision: entry
|
|
192
|
-
file: entry
|
|
193
|
-
|
|
203
|
+
task: entry?.id ?? null,
|
|
204
|
+
revision: entry?.revision ?? null,
|
|
205
|
+
file: entry?.file ?? null,
|
|
206
|
+
// The origin task's log signature changes with every write to it.
|
|
207
|
+
origin: origin && origin.entry.file,
|
|
208
|
+
workspace: workspace.data?.revision ?? null,
|
|
194
209
|
caller: context.caller,
|
|
195
210
|
workerId: context.workerId ?? null,
|
|
196
211
|
capabilities: context.capabilities,
|
|
@@ -205,18 +220,22 @@ function entryCompactContext(
|
|
|
205
220
|
(hit.signal === null || hit.signal === recentSignal(store.root))
|
|
206
221
|
)
|
|
207
222
|
return hit.value;
|
|
208
|
-
const task = store.readTask(entry.id);
|
|
209
|
-
if (!task.ok) return null;
|
|
223
|
+
const task = entry ? store.readTask(entry.id) : null;
|
|
224
|
+
if (task && !task.ok) return null;
|
|
210
225
|
const freshness: Freshness = { mode: "signal", current: () => recentSignal(store.root) };
|
|
211
|
-
const observed = task
|
|
212
|
-
const files = task
|
|
226
|
+
const observed = task?.data.evidence.some(isObservedCheck) ?? false;
|
|
227
|
+
const files = (task?.data.decisions ?? []).flatMap((decision) =>
|
|
213
228
|
decision.data.binding.contentRefs.flatMap((ref) =>
|
|
214
229
|
ref.kind === "file" ? [path.resolve(store.root, ref.path)] : [],
|
|
215
230
|
),
|
|
216
231
|
);
|
|
217
232
|
const observedFiles = filesKey(files);
|
|
218
|
-
const compact = new WorkitCore(store, context).compactContext(
|
|
219
|
-
|
|
233
|
+
const compact = new WorkitCore(store, context).compactContext(
|
|
234
|
+
entry?.id ?? null,
|
|
235
|
+
freshness,
|
|
236
|
+
origin && { origin: origin.origin, taskId: origin.entry.id },
|
|
237
|
+
);
|
|
238
|
+
if (!compact.ok || !compact.data) return null;
|
|
220
239
|
cache.delete(slot);
|
|
221
240
|
cache.set(slot, {
|
|
222
241
|
key,
|
|
@@ -264,9 +283,12 @@ export const sessionContextText = (
|
|
|
264
283
|
const entry =
|
|
265
284
|
currentTaskEntry(listed.data, session, descriptor.context.task) ??
|
|
266
285
|
implicitTaskEntry(store, listed.data, session);
|
|
267
|
-
const text =
|
|
268
|
-
|
|
269
|
-
|
|
286
|
+
const text = entryCompactContext(
|
|
287
|
+
store,
|
|
288
|
+
entry,
|
|
289
|
+
session,
|
|
290
|
+
hookOperationContext(input, descriptor),
|
|
291
|
+
);
|
|
270
292
|
if (text) compact = `\n<workit-task-context>${text}</workit-task-context>`;
|
|
271
293
|
if (options.offer) offer = unfinishedTaskOffer(listed.data, session, entry?.id ?? null);
|
|
272
294
|
} catch {
|
package/src/hooks/handle.ts
CHANGED
|
@@ -6,6 +6,7 @@ import {
|
|
|
6
6
|
turnContextText,
|
|
7
7
|
} from "./context";
|
|
8
8
|
import type { HostDescriptor, Support } from "./descriptor";
|
|
9
|
+
import { recordIssuedSession } from "../host-session";
|
|
9
10
|
import { shellPolicy } from "./policy";
|
|
10
11
|
import {
|
|
11
12
|
callKey,
|
|
@@ -100,6 +101,9 @@ const subagentStartText = (
|
|
|
100
101
|
const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
|
|
101
102
|
if (role !== "judge" || !event.agentId) return readOnly;
|
|
102
103
|
const own = subagentSession(session, event.agentId);
|
|
104
|
+
// `ledger verdict` accepts `<lead>:<agent id>` only when this hook issued it.
|
|
105
|
+
// (An isolated judge's text is rebuilt with cwd "": the hook's own cwd then.)
|
|
106
|
+
if (session) recordIssuedSession(input.cwd || process.cwd(), session, own);
|
|
103
107
|
return `${readOnly} Its own Workit session is ${own}: record verdicts with \`workit ledger verdict <result> --session ${own} ...\` so the ledger tells it apart from the author.`;
|
|
104
108
|
};
|
|
105
109
|
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -362,5 +362,5 @@ export const codexAdapter: HostAdapter = {
|
|
|
362
362
|
},
|
|
363
363
|
render,
|
|
364
364
|
addendum: (input) =>
|
|
365
|
-
`<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other
|
|
365
|
+
`<workit-codex-mutations>Codex MCP is read-only: unattested callers cannot mutate. Run workit verbs with the workit CLI on the shell (node_modules/.bin/workit, or npx -y @brainervirus/workit-cli): check, git branch|commit|push, pr, ci, stack, ledger, handoff; task-family mutations take --json${input.session.id ? ` --actor ${input.session.id}` : ""}. workit acts as this thread (CODEX_THREAD_ID${input.session.id ? `=${input.session.id}` : ""}) unless WORKIT_SESSION_ID is set. A verifier or reviewer records workit ledger verdict under its own session, never the author's: a workit-verifier or workit-reviewer agent uses the --session its SubagentStart context names; any other passes --as verifier (an id derived from its own thread). A hand-minted id such as <lead>-v1 is refused as forged_session. Merge and release need a workspace grant (workit grant show); a question answer is not host permission.</workit-codex-mutations>`,
|
|
366
366
|
};
|
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;
|