@brainervirus/workit-core 8.19.2 → 8.21.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brainervirus/workit-core",
3
- "version": "8.19.2",
3
+ "version": "8.21.0",
4
4
  "private": false,
5
5
  "description": "Workit shared core — task, policy, evidence, review, decision, worker, and writer state for agentic coding workflows",
6
6
  "keywords": [
@@ -26,6 +26,7 @@ import {
26
26
  skillReadIn,
27
27
  withContextLine,
28
28
  } from "./skill-nudge";
29
+ import { seedStanding, standingDelta } from "./standing";
29
30
  import { stopDecision } from "./stop";
30
31
  import { liveOwner } from "../core/live-session";
31
32
  import { shellGate, writeGate } from "./write-gate";
@@ -125,7 +126,29 @@ const subagentStartText = (
125
126
  return `${readOnly} Its own Workit session is ${own}, so the ledger tells its verdicts apart from the author's. Record the verdict with exactly this command (pick the result${pick}, name the candidate branch if it differs, say what you ran): \`${line}\`.${token ? " The token is this subagent's alone: never write it into a report, a file or a commit. --token needs a workit CLI at least as new as this plugin; if the CLI answers that --token is an unknown option, update the workit CLI (workit upgrade --cli) and record again." : ""}`;
126
127
  };
127
128
 
129
+ const PRE_TOOL = new Set<HookEventKind>(["shell.pre", "write.pre", "tool.pre"]);
130
+
131
+ /**
132
+ * A running worker's standing-order delta rides any pre-tool answer that lets
133
+ * the call run. In-process hosts deliver it on their own channel (OpenCode's
134
+ * system context, Pi's tool result), so it is never spent here for them.
135
+ */
128
136
  export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
137
+ const decision = decide(input, deps);
138
+ if (
139
+ deps.descriptor.transport !== "hook-process" ||
140
+ !PRE_TOOL.has(input.event.kind) ||
141
+ decision.kind === "deny"
142
+ )
143
+ return decision;
144
+ const delta = standingDelta(input);
145
+ if (!delta) return decision;
146
+ return decision.kind === "rewrite"
147
+ ? { ...decision, text: [decision.text, delta].filter(Boolean).join("\n") }
148
+ : withContextLine(decision, delta);
149
+ }
150
+
151
+ function decide(input: HookInput, deps: HookDeps): HookDecision {
129
152
  const { descriptor } = deps;
130
153
  const event = input.event;
131
154
  switch (event.kind) {
@@ -221,6 +244,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
221
244
  ? writeGate(input.cwd, event.paths, liveOwner(input, descriptor.transport))
222
245
  : NONE;
223
246
  case "subagent.start":
247
+ seedStanding({ ...input, session: { ...input.session, agentId: event.agentId } });
224
248
  return {
225
249
  kind: "context",
226
250
  text: subagentStartText(input, descriptor, event),
@@ -26,6 +26,7 @@ export { asksUser, stopDecision, stopObligation, type StopObligation } from "./s
26
26
  export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
27
27
  export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
28
28
  export { isWriteTool, writePaths } from "./hosts/fields";
29
+ export { seedStanding, standingDelta, standingSinceStart } from "./standing";
29
30
  export { dispatchHook, failureDecision, handleHook, type HookDeps } from "./handle";
30
31
  export { HOOK_ADAPTERS, runHookProcess } from "./run";
31
32
  export { CLAUDE_CODE_DESCRIPTOR, claudeCodeAdapter } from "./hosts/claude-code";
@@ -0,0 +1,99 @@
1
+ // The standing-order revision signal (#320). Every standing add or clear
2
+ // writes the new row's id to `<store>/standing/rev`; each agent that a hook
3
+ // has answered since keeps the revision it last saw in
4
+ // `<store>/standing/seen/<key>.json`. A tool call whose agent already saw the
5
+ // current revision costs two small reads and never loads the Workit runtime.
6
+ // fs, path and crypto only: the Codex launcher imports this before the
7
+ // runtime, and the Claude Code launcher (bin/workit-hook.mjs) keeps a copy
8
+ // that test/workit-core/hooks/standing-signal.test.ts holds equal.
9
+ import { createHash } from "node:crypto";
10
+ import fs from "node:fs";
11
+ import path from "node:path";
12
+
13
+ export type StandingSeen = { rev: string | null; ids: string[] };
14
+
15
+ /** `<git common dir>/workit` by fs reads alone, or null outside a repository. */
16
+ export const gitStoreDir = (cwd: string): string | null => {
17
+ try {
18
+ for (let dir = path.resolve(cwd); ; dir = path.dirname(dir)) {
19
+ const dotGit = path.join(dir, ".git");
20
+ if (fs.existsSync(dotGit)) {
21
+ let gitDir = dotGit;
22
+ if (fs.statSync(dotGit).isFile()) {
23
+ const target = /^gitdir:\s*(.+)$/mu.exec(fs.readFileSync(dotGit, "utf8"))?.[1]?.trim();
24
+ if (!target) return null;
25
+ gitDir = path.resolve(dir, target);
26
+ const common = path.join(gitDir, "commondir");
27
+ if (fs.existsSync(common))
28
+ gitDir = path.resolve(gitDir, fs.readFileSync(common, "utf8").trim());
29
+ }
30
+ return path.join(gitDir, "workit");
31
+ }
32
+ if (path.dirname(dir) === dir) return null;
33
+ }
34
+ } catch {
35
+ return null;
36
+ }
37
+ };
38
+
39
+ export const standingRevFile = (store: string): string => path.join(store, "standing", "rev");
40
+
41
+ /** One file per agent: a host session, or one subagent of it. */
42
+ export const standingSeenFile = (store: string, session: string, agentId: string | null): string =>
43
+ path.join(
44
+ store,
45
+ "standing",
46
+ "seen",
47
+ `${createHash("sha256")
48
+ .update(`${session}\0${agentId ?? ""}`)
49
+ .digest("hex")
50
+ .slice(0, 32)}.json`,
51
+ );
52
+
53
+ export const readStandingRev = (store: string): string | null => {
54
+ try {
55
+ return fs.readFileSync(standingRevFile(store), "utf8").trim() || null;
56
+ } catch {
57
+ return null;
58
+ }
59
+ };
60
+
61
+ export const readStandingSeen = (file: string): StandingSeen => {
62
+ try {
63
+ const value = JSON.parse(fs.readFileSync(file, "utf8")) as { rev?: unknown; ids?: unknown };
64
+ return {
65
+ rev: typeof value.rev === "string" ? value.rev : null,
66
+ ids: Array.isArray(value.ids)
67
+ ? value.ids.filter((id): id is string => typeof id === "string")
68
+ : [],
69
+ };
70
+ } catch {
71
+ return { rev: null, ids: [] };
72
+ }
73
+ };
74
+
75
+ /** Fails open: without the file the agent is offered the same delta again. */
76
+ export const writeStandingSeen = (file: string, seen: StandingSeen): void => {
77
+ try {
78
+ fs.mkdirSync(path.dirname(file), { recursive: true });
79
+ fs.writeFileSync(file, JSON.stringify(seen));
80
+ } catch {
81
+ // An unwritable store only repeats the delta.
82
+ }
83
+ };
84
+
85
+ export const bumpStandingRev = (store: string, rev: string): void => {
86
+ try {
87
+ fs.mkdirSync(path.join(store, "standing"), { recursive: true });
88
+ fs.writeFileSync(standingRevFile(store), `${rev}\n`);
89
+ } catch {
90
+ // Running workers miss the delta; briefs rendered later still carry it.
91
+ }
92
+ };
93
+
94
+ /** Has this agent not yet seen the store's current standing revision? */
95
+ export const standingPending = (cwd: string, session: string, agentId: string | null): boolean => {
96
+ const store = gitStoreDir(cwd);
97
+ const rev = store ? readStandingRev(store) : null;
98
+ return rev !== null && readStandingSeen(standingSeenFile(store!, session, agentId)).rev !== rev;
99
+ };
@@ -0,0 +1,196 @@
1
+ // Standing-order deltas for running workers (#320, decision mvi7psa4). A
2
+ // worker's brief carries the orders in force when it was rendered; an order
3
+ // the lead adds or clears later reaches the worker on its next hooked tool
4
+ // call, once per revision (standing-signal.ts). A worker of a fanout is a
5
+ // subagent or child of one of its leads, a session id handed out under one
6
+ // (`<lead>:<x>`, `<lead>-w-<slice>`), or any session on one of its slice
7
+ // branches; the lead itself is never one.
8
+ import fs from "node:fs";
9
+ import path from "node:path";
10
+ import { currentBranch } from "../git/rev";
11
+ import { activeStanding, readLedger, type ReadRow } from "../ledger";
12
+ import {
13
+ gitStoreDir,
14
+ readStandingRev,
15
+ readStandingSeen,
16
+ standingSeenFile,
17
+ writeStandingSeen,
18
+ } from "./standing-signal";
19
+ import type { HookInput } from "./protocol";
20
+
21
+ type Plan = { name: string; leads: string[]; branches: string[] };
22
+
23
+ const readPlans = (store: string): Plan[] => {
24
+ const dir = path.join(store, "fanouts");
25
+ let names: string[];
26
+ try {
27
+ names = fs.readdirSync(dir).filter((name) => name.endsWith(".json"));
28
+ } catch {
29
+ return [];
30
+ }
31
+ const plans: Plan[] = [];
32
+ for (const name of names) {
33
+ try {
34
+ const raw = JSON.parse(fs.readFileSync(path.join(dir, name), "utf8")) as Record<
35
+ string,
36
+ unknown
37
+ >;
38
+ if (typeof raw.name !== "string" || !Array.isArray(raw.slices)) continue;
39
+ const texts = (value: unknown): string[] =>
40
+ Array.isArray(value)
41
+ ? value.filter((item): item is string => typeof item === "string" && item !== "")
42
+ : [];
43
+ plans.push({
44
+ name: raw.name,
45
+ leads: texts([raw.leadSession, ...texts(raw.formerLeads)]),
46
+ branches: texts(
47
+ raw.slices.map((slice) =>
48
+ slice && typeof slice === "object" ? (slice as { branch?: unknown }).branch : null,
49
+ ),
50
+ ),
51
+ });
52
+ } catch {
53
+ // An unreadable plan has no workers to tell.
54
+ }
55
+ }
56
+ return plans;
57
+ };
58
+
59
+ /** The lead a subagent or child reports to, when the host names it. */
60
+ const leadOf = (input: HookInput): string | null =>
61
+ input.session.parentId ??
62
+ (input.session.agentId ? input.session.id : null) ??
63
+ (input.session.id.includes(":") ? input.session.id.split(":")[0] : null);
64
+
65
+ const worksFor = (plan: Plan, input: HookInput, branch: () => string | null): boolean => {
66
+ const session = input.session.id;
67
+ if (plan.leads.includes(session) && !input.session.agentId) return false;
68
+ const lead = leadOf(input);
69
+ if (lead && plan.leads.includes(lead)) return true;
70
+ if (plan.leads.some((each) => session.startsWith(`${each}-w-`))) return true;
71
+ const current = branch();
72
+ return current !== null && plan.branches.includes(current);
73
+ };
74
+
75
+ type Standing = { rev: string; orders: Map<string, ReadRow>; rows: readonly ReadRow[] };
76
+
77
+ /** Orders in force now for every fanout this agent works for, keyed by row id. */
78
+ const standingFor = (input: HookInput, store: string, rev: string): Standing => {
79
+ const orders = new Map<string, ReadRow>();
80
+ const ledger = readLedger(input.cwd);
81
+ const rows = ledger.ok ? ledger.value.rows : [];
82
+ let branch: string | null | undefined;
83
+ const lazyBranch = () => (branch === undefined ? (branch = currentBranch(input.cwd)) : branch);
84
+ for (const plan of readPlans(store))
85
+ if (worksFor(plan, input, lazyBranch))
86
+ for (const row of activeStanding(rows, plan.name, plan.leads.length ? plan.leads : null))
87
+ orders.set(row.id, row);
88
+ return { rev, orders, rows };
89
+ };
90
+
91
+ /** The same store path the launchers' fast paths compute, so they read the same seen file. */
92
+ const located = (input: HookInput) => {
93
+ const store = input.session.id ? gitStoreDir(input.cwd) : null;
94
+ const rev = store ? readStandingRev(store) : null;
95
+ if (!store || rev === null) return null;
96
+ return { store, rev, file: standingSeenFile(store, input.session.id, input.session.agentId) };
97
+ };
98
+
99
+ /**
100
+ * A new subagent's brief already carries the orders in force: its baseline,
101
+ * so only later changes reach it.
102
+ */
103
+ export const seedStanding = (input: HookInput): void => {
104
+ try {
105
+ const where = located(input);
106
+ if (!where) return;
107
+ const now = standingFor(input, where.store, where.rev);
108
+ writeStandingSeen(where.file, { rev: where.rev, ids: [...now.orders.keys()] });
109
+ } catch {
110
+ // Without a baseline the first delta recaps every order in force.
111
+ }
112
+ };
113
+
114
+ const fanoutOf = (row: ReadRow): string => (typeof row.fanout === "string" ? row.fanout : "?");
115
+
116
+ /** The orders added and dropped since `ids`, as one model-visible block, or null. */
117
+ const changedSince = (now: Standing, ids: readonly string[]): string | null => {
118
+ const known = new Set(ids);
119
+ const added = [...now.orders.values()].filter((row) => !known.has(row.id));
120
+ const byId = new Map(now.rows.map((row) => [row.id, row]));
121
+ const dropped = ids
122
+ .filter((id) => !now.orders.has(id))
123
+ .map((id) => byId.get(id))
124
+ .filter((row): row is ReadRow => row !== undefined);
125
+ if (!added.length && !dropped.length) return null;
126
+ const fanouts = [...new Set([...added, ...dropped].map(fanoutOf))].join(", ");
127
+ const lines = [
128
+ `Workit: the lead changed the standing orders for fanout ${fanouts} while you run. They apply to you now and override your brief's STANDING list.`,
129
+ ...added.map((row) => ` - new: ${String(row.what)}`),
130
+ ...dropped.map(
131
+ (row) =>
132
+ ` - no longer in force: ${String(row.what)}${row.stop ? " (the stop is lifted: continue your brief)" : ""}`,
133
+ ),
134
+ ];
135
+ if (added.some((row) => row.stop === true))
136
+ lines.push(
137
+ "Stop now: make no further changes, close what you opened, and report to the lead with your branch, head SHA, what is done and what is not.",
138
+ );
139
+ return lines.join("\n");
140
+ };
141
+
142
+ /**
143
+ * What changed since this agent last saw the standing orders; null when
144
+ * nothing did or it works for no fanout. An agent seen for the first time is
145
+ * told every order in force. Marks the revision seen, so a host that keeps
146
+ * each answer in the transcript gets the block once.
147
+ */
148
+ export const standingDelta = (input: HookInput): string | null => {
149
+ try {
150
+ const where = located(input);
151
+ if (!where) return null;
152
+ const seen = readStandingSeen(where.file);
153
+ if (seen.rev === where.rev) return null;
154
+ const now = standingFor(input, where.store, where.rev);
155
+ writeStandingSeen(where.file, { rev: where.rev, ids: [...now.orders.keys()] });
156
+ return changedSince(now, seen.ids);
157
+ } catch {
158
+ return null;
159
+ }
160
+ };
161
+
162
+ const sinceStart = new Map<string, { rev: string; text: string | null }>();
163
+
164
+ /**
165
+ * For a host that rebuilds its context on every model call (OpenCode): the
166
+ * changes since this agent's first call, on every call while they stand.
167
+ * The first call records the baseline (its brief carries those orders); the
168
+ * ledger is read again only when the revision moves.
169
+ */
170
+ export const standingSinceStart = (input: HookInput): string | null => {
171
+ try {
172
+ const store = input.session.id ? gitStoreDir(input.cwd) : null;
173
+ if (!store) return null;
174
+ const rev = readStandingRev(store) ?? "";
175
+ const file = standingSeenFile(store, input.session.id, input.session.agentId);
176
+ const cached = sinceStart.get(file);
177
+ if (cached?.rev === rev) return cached.text;
178
+ let text: string | null = null;
179
+ if (rev) {
180
+ const now = standingFor(input, store, rev);
181
+ const seen = readStandingSeen(file);
182
+ let ids = seen.ids;
183
+ if (seen.rev === null) {
184
+ // Seen before any order existed: every order is new to it.
185
+ ids = cached ? [] : [...now.orders.keys()];
186
+ writeStandingSeen(file, { rev, ids });
187
+ }
188
+ text = changedSince(now, ids);
189
+ }
190
+ sinceStart.set(file, { rev, text });
191
+ if (sinceStart.size > 256) sinceStart.delete(sinceStart.keys().next().value!);
192
+ return text;
193
+ } catch {
194
+ return null;
195
+ }
196
+ };
package/src/ledger.ts CHANGED
@@ -57,6 +57,7 @@ import { hostAwareLockTimeout } from "./core/store-lock";
57
57
  import { vcsConfig } from "./core/vcs-config";
58
58
  import { GIT_TIMEOUTS, currentBranch, headSha, patchId, worktreeTree } from "./git/rev";
59
59
  import { hostSessionFromEnv, issuedBinding } from "./host-session";
60
+ import { bumpStandingRev } from "./hooks/standing-signal";
60
61
  import { resolveStore } from "./store/paths";
61
62
 
62
63
  export const LEDGER_VERSION = 1;
@@ -165,8 +166,16 @@ export type VerdictRow = RowCommon & {
165
166
  evidenceRefs: string[];
166
167
  };
167
168
  export type HandoffRow = RowCommon & { type: "handoff"; note: string | null; next: string };
168
- /** A lead's standing order for every worker of a fanout ("no new dependencies"). */
169
- export type StandingRow = RowCommon & { type: "standing"; fanout: string; what: string };
169
+ /**
170
+ * A lead's standing order for every worker of a fanout ("no new dependencies").
171
+ * `stop` marks the stop line: running workers stop and report.
172
+ */
173
+ export type StandingRow = RowCommon & {
174
+ type: "standing";
175
+ fanout: string;
176
+ what: string;
177
+ stop?: true;
178
+ };
170
179
  /** Ends one standing order (`target`), or every one recorded before it (null). */
171
180
  export type StandingClearedRow = RowCommon & {
172
181
  type: "standing.cleared";
@@ -1827,11 +1836,27 @@ function leadOnly(context: RecordContext, lead: string | null): LedgerResult<voi
1827
1836
  );
1828
1837
  }
1829
1838
 
1839
+ /** Running workers see a new revision on their next hook (hooks/standing.ts). */
1840
+ function signalStanding<T extends { id: string }>(
1841
+ cwd: string,
1842
+ result: LedgerResult<T>,
1843
+ ): LedgerResult<T> {
1844
+ const root = result.ok ? storeRoot(cwd) : null;
1845
+ if (result.ok && root?.ok && root.value.shared) bumpStandingRev(root.value.root, result.value.id);
1846
+ return result;
1847
+ }
1848
+
1849
+ /** The stop line's text, as briefs and running workers read it. */
1850
+ export const stopOrderText = (note: string | null): string =>
1851
+ `STOP: start nothing new, stop every process you started (dev servers, browsers, watchers), commit only finished work, and report to the lead now${note ? ` (${note})` : ""}`;
1852
+
1830
1853
  export function recordStanding(
1831
1854
  context: RecordContext,
1832
- input: { fanout: string; lead: string | null; what?: string },
1855
+ input: { fanout: string; lead: string | null; what?: string; stop?: boolean },
1833
1856
  ): LedgerResult<StandingRow> {
1834
- const what = required(input.what, "<order>");
1857
+ const what: LedgerResult<string> = input.stop
1858
+ ? { ok: true, value: stopOrderText(input.what?.trim() || null) }
1859
+ : required(input.what, "<order>");
1835
1860
  if (!what.ok) return what;
1836
1861
  // One order per line of every brief: a newline or control character would
1837
1862
  // let an order forge other brief fields.
@@ -1845,16 +1870,18 @@ export function recordStanding(
1845
1870
  if (!lead.ok) return lead;
1846
1871
  const link = checkSupersede(context, "standing");
1847
1872
  if (!link.ok) return link;
1848
- return appendRow<StandingRow>(
1873
+ const recorded = appendRow<StandingRow>(
1849
1874
  context.cwd,
1850
1875
  {
1851
1876
  ...common(context, noteKey(context)),
1852
1877
  type: "standing",
1853
1878
  fanout: input.fanout,
1854
1879
  what: what.value,
1880
+ ...(input.stop ? { stop: true as const } : {}),
1855
1881
  },
1856
1882
  { now: context.now },
1857
1883
  );
1884
+ return signalStanding(context.cwd, recorded);
1858
1885
  }
1859
1886
 
1860
1887
  /** Clear one standing order by id (it must be in force), or all of them; lead only. */
@@ -1883,18 +1910,48 @@ export function clearStanding(
1883
1910
  `workit ledger standing list --fanout ${input.fanout}`,
1884
1911
  );
1885
1912
  }
1886
- return appendRow<StandingClearedRow>(
1913
+ return signalStanding(
1887
1914
  context.cwd,
1888
- {
1889
- ...common(context, noteKey(context)),
1890
- type: "standing.cleared",
1891
- fanout: input.fanout,
1892
- target: input.target,
1893
- },
1894
- { now: context.now },
1915
+ appendRow<StandingClearedRow>(
1916
+ context.cwd,
1917
+ {
1918
+ ...common(context, noteKey(context)),
1919
+ type: "standing.cleared",
1920
+ fanout: input.fanout,
1921
+ target: input.target,
1922
+ },
1923
+ { now: context.now },
1924
+ ),
1895
1925
  );
1896
1926
  }
1897
1927
 
1928
+ /** Lift the stop line: clear each stop order in force and keep the others; lead only. */
1929
+ export function liftStop(
1930
+ context: RecordContext,
1931
+ input: { fanout: string; lead: string | null; leads?: readonly string[] | null },
1932
+ ): LedgerResult<StandingClearedRow[]> {
1933
+ const lead = leadOnly(context, input.lead);
1934
+ if (!lead.ok) return lead;
1935
+ const ledger = readLedger(context.cwd);
1936
+ if (!ledger.ok) return ledger;
1937
+ const stops = activeStanding(ledger.value.rows, input.fanout, input.leads ?? null).filter(
1938
+ (row) => row.stop === true,
1939
+ );
1940
+ if (!stops.length)
1941
+ return err(
1942
+ "not_found",
1943
+ `no stop line in force for fanout ${input.fanout}`,
1944
+ `workit ledger standing list --fanout ${input.fanout}`,
1945
+ );
1946
+ const cleared: StandingClearedRow[] = [];
1947
+ for (const stop of stops) {
1948
+ const row = clearStanding(context, { ...input, target: stop.id });
1949
+ if (!row.ok) return row;
1950
+ cleared.push(row.value);
1951
+ }
1952
+ return { ok: true, value: cleared };
1953
+ }
1954
+
1898
1955
  /**
1899
1956
  * Is `branch` checked out, with uncommitted changes, in another worktree of
1900
1957
  * this repository? (`codeKey` sees only the cwd's own worktree.)