@brainervirus/workit-core 8.20.0 → 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 +1 -1
- package/src/hooks/handle.ts +24 -0
- package/src/hooks/index.ts +1 -0
- package/src/hooks/standing-signal.ts +99 -0
- package/src/hooks/standing.ts +196 -0
- package/src/ledger.ts +70 -13
package/package.json
CHANGED
package/src/hooks/handle.ts
CHANGED
|
@@ -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),
|
package/src/hooks/index.ts
CHANGED
|
@@ -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
|
-
/**
|
|
169
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
1913
|
+
return signalStanding(
|
|
1887
1914
|
context.cwd,
|
|
1888
|
-
|
|
1889
|
-
|
|
1890
|
-
|
|
1891
|
-
|
|
1892
|
-
|
|
1893
|
-
|
|
1894
|
-
|
|
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.)
|