@brainervirus/workit-core 8.7.0 → 8.8.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/forge/report.ts +52 -2
- package/src/hooks/handle.ts +37 -15
- package/src/hooks/hosts/claude-code.ts +6 -4
- package/src/hooks/hosts/codex.ts +26 -6
- package/src/hooks/hosts/cursor.ts +78 -4
- package/src/hooks/hosts/opencode.ts +5 -2
- package/src/hooks/hosts/pi.ts +5 -2
- package/src/hooks/index.ts +1 -0
- package/src/hooks/raw-git.ts +91 -8
- package/src/hooks/session-marker.ts +53 -0
- package/src/hooks/skill-nudge.ts +27 -70
- package/src/hooks/stop.ts +308 -0
- package/src/ledger.ts +3 -0
package/package.json
CHANGED
package/src/forge/report.ts
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
// forge-neutral status document, the `next` priority, the CI-wait verdict,
|
|
3
3
|
// and rerun planning with the rerun-once rule.
|
|
4
4
|
import { aheadBehind, currentBranch, fetchRefs, hasCommit, headSha, resolveRef } from "../git/rev";
|
|
5
|
+
import { appendObserved, readLedger, type LedgerActor } from "../ledger";
|
|
5
6
|
import type { ResolvedForge } from "./resolve";
|
|
6
7
|
import { rerunCounts, recordReruns, withRerunLock, type RerunRow } from "./reruns";
|
|
7
8
|
import {
|
|
@@ -425,11 +426,59 @@ export function buildStatusDoc(
|
|
|
425
426
|
});
|
|
426
427
|
}
|
|
427
428
|
|
|
428
|
-
/**
|
|
429
|
+
/**
|
|
430
|
+
* Record what the forge said about a PR as a CLI-observed `pr.status` row
|
|
431
|
+
* (pr, branch, head, state: open|merged|closed, checks), only when it
|
|
432
|
+
* differs from the newest row for that PR, so polling never floods the
|
|
433
|
+
* ledger. Hooks read it without calling the forge: an open PR's checks
|
|
434
|
+
* (passing|failing|pending; one without gating checks is not recorded), and
|
|
435
|
+
* a PR merged or closed outside workit (the forge UI). Never fails the verb:
|
|
436
|
+
* a ledger error only loses the row.
|
|
437
|
+
*/
|
|
438
|
+
export function recordPrStatus(cwd: string, doc: PrStatusDoc, actor: LedgerActor): void {
|
|
439
|
+
const checks = doc.checks.state;
|
|
440
|
+
const state = doc.state;
|
|
441
|
+
if (state !== "open" && state !== "merged" && state !== "closed") return;
|
|
442
|
+
if (state === "open" && checks !== "passing" && checks !== "failing" && checks !== "pending")
|
|
443
|
+
return;
|
|
444
|
+
try {
|
|
445
|
+
const ledger = readLedger(cwd);
|
|
446
|
+
if (!ledger.ok) return;
|
|
447
|
+
const last = ledger.value.rows.findLast(
|
|
448
|
+
(row) =>
|
|
449
|
+
row.type === "pr.status" &&
|
|
450
|
+
row.observer === "workit_cli" &&
|
|
451
|
+
row.pr === doc.number &&
|
|
452
|
+
row.repo === doc.repo,
|
|
453
|
+
);
|
|
454
|
+
if (
|
|
455
|
+
last &&
|
|
456
|
+
last.head === doc.head.sha &&
|
|
457
|
+
last.checks === checks &&
|
|
458
|
+
(last.state ?? "open") === state
|
|
459
|
+
)
|
|
460
|
+
return;
|
|
461
|
+
appendObserved(cwd, {
|
|
462
|
+
type: "pr.status",
|
|
463
|
+
actor,
|
|
464
|
+
branch: doc.head.branch,
|
|
465
|
+
head: doc.head.sha,
|
|
466
|
+
pr: doc.number,
|
|
467
|
+
repo: doc.repo,
|
|
468
|
+
forge: doc.forge,
|
|
469
|
+
state,
|
|
470
|
+
checks,
|
|
471
|
+
});
|
|
472
|
+
} catch {
|
|
473
|
+
// The row is an observation for hooks; the verb's answer stands without it.
|
|
474
|
+
}
|
|
475
|
+
}
|
|
476
|
+
|
|
477
|
+
/** Select, read and document one PR/MR (recorded as a `pr.status` row when `actor` is given). */
|
|
429
478
|
export function prStatusReport(
|
|
430
479
|
cwd: string,
|
|
431
480
|
resolved: ResolvedForge,
|
|
432
|
-
options: ReportOptions & { identity?: PrStatusDoc["identity"] } = {},
|
|
481
|
+
options: ReportOptions & { identity?: PrStatusDoc["identity"]; actor?: LedgerActor } = {},
|
|
433
482
|
): ForgeResult<{ doc: PrStatusDoc; raw: ForgePrStatus }> {
|
|
434
483
|
const number = selectPr(cwd, resolved, options);
|
|
435
484
|
if (!number.ok) return number;
|
|
@@ -441,6 +490,7 @@ export function prStatusReport(
|
|
|
441
490
|
identity: options.identity,
|
|
442
491
|
});
|
|
443
492
|
if (!doc.ok) return doc;
|
|
493
|
+
if (options.actor) recordPrStatus(cwd, doc.data, options.actor);
|
|
444
494
|
return success({ doc: doc.data, raw: status.data });
|
|
445
495
|
}
|
|
446
496
|
|
package/src/hooks/handle.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
skillReadIn,
|
|
23
23
|
withContextLine,
|
|
24
24
|
} from "./skill-nudge";
|
|
25
|
+
import { stopDecision } from "./stop";
|
|
25
26
|
import { shellWrites, writeGate } from "./write-gate";
|
|
26
27
|
import type { HookDecision, HookEventKind, HookInput, HostAdapter, RenderedHook } from "./protocol";
|
|
27
28
|
|
|
@@ -67,21 +68,35 @@ const subagentSession = (session: string | null, agentId: string): string =>
|
|
|
67
68
|
.replace(/[^A-Za-z0-9_.:@/+-]/g, "-")
|
|
68
69
|
.slice(0, 128);
|
|
69
70
|
|
|
71
|
+
/** A Claude Code worktree path: `<repo>/.claude/worktrees/<name>`. */
|
|
72
|
+
const CLAUDE_WORKTREE = /[\\/]\.claude[\\/]worktrees[\\/]/;
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Claude Code's worktree isolation refuses any command that wraps git
|
|
76
|
+
* (`workit git …` included) and cannot be turned off, so an isolated agent
|
|
77
|
+
* runs git itself and workit for everything else.
|
|
78
|
+
*/
|
|
79
|
+
const ISOLATED_GIT =
|
|
80
|
+
'Run git as plain, separate commands inside your worktree (`git switch -c <branch> <base>`, `git commit --trailer "Workit-Session=<your session>"`, `git push`; `workit doctor --fix` can install a commit-msg hook that adds the trailer), never `workit git …`: Claude Code\'s worktree isolation refuses a command that wraps git and cannot be turned off (https://code.claude.com/docs/en/worktrees#how-claude-code-enforces-isolation). Never dodge that check by renaming or re-wrapping git. Use workit for the non-git verbs (`workit check`, `workit ledger`, `workit pr`).';
|
|
81
|
+
|
|
70
82
|
const subagentStartText = (
|
|
71
|
-
|
|
83
|
+
input: HookInput,
|
|
72
84
|
descriptor: HostDescriptor,
|
|
73
85
|
event: Extract<HookInput["event"], { kind: "subagent.start" }>,
|
|
74
86
|
): string => {
|
|
87
|
+
const session = input.session.id ?? null;
|
|
75
88
|
const role = workitRole(descriptor, event.agentType);
|
|
76
89
|
const who = `${descriptor.label} subagent ${event.agentId} (${event.agentType})`;
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
return
|
|
84
|
-
|
|
90
|
+
const isolated =
|
|
91
|
+
descriptor.subagents.worktreeIsolation === "native" &&
|
|
92
|
+
(role === "implementer" || CLAUDE_WORKTREE.test(input.cwd));
|
|
93
|
+
if (role === "implementer" && isolated)
|
|
94
|
+
return `Workit observed ${who} working in its own git worktree: it may edit and commit there, within its brief's scope. ${ISOLATED_GIT} Use a policy-compliant branch (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
95
|
+
if (isolated)
|
|
96
|
+
return `${ISOLATED_GIT}\n${subagentStartText({ ...input, cwd: "" }, descriptor, event)}`;
|
|
97
|
+
if (role === "implementer")
|
|
98
|
+
// No host isolation: the lead makes the worktree, and workit git works there.
|
|
99
|
+
return `Workit observed ${who} as an implementer: it edits and commits only in its own git worktree (the lead makes one with \`workit fanout worktree create <slice>\`), never in the lead's checkout, within its brief's scope. Before the first commit, switch to a policy-compliant branch with \`workit git branch <branch> --base <base>\` (e.g. feature/<slug>); branch policy hooks still deny protected or non-compliant branches. Never record a verdict on your own work. Never push, open a PR, or merge unless the brief asks for it.`;
|
|
85
100
|
const readOnly = `Workit observed ${who} as read-only/agent-guided.`;
|
|
86
101
|
if (role !== "judge" || !event.agentId) return readOnly;
|
|
87
102
|
const own = subagentSession(session, event.agentId);
|
|
@@ -125,7 +140,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
125
140
|
// Without a post-tool event, the previous raw commit is recorded now.
|
|
126
141
|
const postTool = usable(descriptor.events["shell.post"].support);
|
|
127
142
|
if (!postTool) settlePendingCommit(input);
|
|
128
|
-
const raw = canDeny ? rawGitPre(input, event.command) : NONE;
|
|
143
|
+
const raw = canDeny ? rawGitPre(input, event.command, { nudge: false }) : NONE;
|
|
129
144
|
if (raw.kind === "deny") return raw;
|
|
130
145
|
const writes = usable(descriptor.events["write.pre"].support)
|
|
131
146
|
? shellWrites(event.command)
|
|
@@ -139,7 +154,13 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
139
154
|
event.command,
|
|
140
155
|
postTool ? callKey(event.toolUseId, event.command) : NEXT_COMMAND,
|
|
141
156
|
);
|
|
142
|
-
|
|
157
|
+
// An in-process plugin shows nudges on the tool result (shellNudge):
|
|
158
|
+
// spending the once-per-session nudge here would lose it.
|
|
159
|
+
if (descriptor.transport !== "hook-process") return NONE;
|
|
160
|
+
return withContextLine(
|
|
161
|
+
canDeny ? rawGitPre(input, event.command) : NONE,
|
|
162
|
+
shipNudge(input, event.command),
|
|
163
|
+
);
|
|
143
164
|
}
|
|
144
165
|
case "shell.post":
|
|
145
166
|
if (usable(descriptor.events["shell.post"].support))
|
|
@@ -152,7 +173,7 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
152
173
|
case "subagent.start":
|
|
153
174
|
return {
|
|
154
175
|
kind: "context",
|
|
155
|
-
text: subagentStartText(input
|
|
176
|
+
text: subagentStartText(input, descriptor, event),
|
|
156
177
|
};
|
|
157
178
|
case "compact.pre":
|
|
158
179
|
return {
|
|
@@ -164,11 +185,12 @@ export function handleHook(input: HookInput, deps: HookDeps): HookDecision {
|
|
|
164
185
|
// Host permission policy owns other tools; a skill load is recorded.
|
|
165
186
|
if (event.skill) recordSkillLoad(input, event.skill, "tool");
|
|
166
187
|
return NONE;
|
|
167
|
-
|
|
168
|
-
|
|
188
|
+
case "stop":
|
|
189
|
+
return usable(descriptor.events.stop.support) ? stopDecision(input, descriptor) : NONE;
|
|
190
|
+
// A subagent's stop is never blocked; attestation and prompt control
|
|
191
|
+
// arrive with the CLI-observed evidence model.
|
|
169
192
|
case "subagent.stop":
|
|
170
193
|
case "prompt.submit":
|
|
171
|
-
case "stop":
|
|
172
194
|
return NONE;
|
|
173
195
|
}
|
|
174
196
|
}
|
|
@@ -44,13 +44,15 @@ export const CLAUDE_CODE_DESCRIPTOR: HostDescriptor = {
|
|
|
44
44
|
// SubagentStart output is additionalContext only: it cannot block or bind.
|
|
45
45
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
46
46
|
"prompt.submit": { support: "native", native: "UserPromptSubmit" },
|
|
47
|
-
// Claude Code has SubagentStop
|
|
48
|
-
//
|
|
49
|
-
//
|
|
47
|
+
// Claude Code has SubagentStop and PreCompact, but the plugin registers
|
|
48
|
+
// neither: events list what workit registers (docs/agents/hosts.md, Host
|
|
49
|
+
// parity). A subagent's stop is never blocked; compaction restore runs on
|
|
50
50
|
// SessionStart source=compact.
|
|
51
51
|
"subagent.stop": { support: "none", native: null },
|
|
52
52
|
"compact.pre": { support: "none", native: null },
|
|
53
|
-
|
|
53
|
+
// `{"decision":"block","reason"}` continues the turn; the input carries
|
|
54
|
+
// `stop_hook_active` and `last_assistant_message` (claude 2.1.295).
|
|
55
|
+
stop: { support: "native", native: "Stop" },
|
|
54
56
|
},
|
|
55
57
|
shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
|
|
56
58
|
context: {
|
package/src/hooks/hosts/codex.ts
CHANGED
|
@@ -18,7 +18,8 @@ export type CodexHookEvent =
|
|
|
18
18
|
| "PreToolUse"
|
|
19
19
|
| "PostToolUse"
|
|
20
20
|
| "SubagentStart"
|
|
21
|
-
| "SubagentStop"
|
|
21
|
+
| "SubagentStop"
|
|
22
|
+
| "Stop";
|
|
22
23
|
// The session-start.command.input schema in codex-cli 0.160.1 lists fork as a source.
|
|
23
24
|
type SessionSource = "startup" | "resume" | "clear" | "compact" | "fork";
|
|
24
25
|
|
|
@@ -80,10 +81,12 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
80
81
|
"subagent.start": { support: "native", native: "SubagentStart" },
|
|
81
82
|
"subagent.stop": { support: "native", native: "SubagentStop" },
|
|
82
83
|
"prompt.submit": { support: "native", native: "UserPromptSubmit" },
|
|
83
|
-
// Codex has PreCompact
|
|
84
|
-
//
|
|
84
|
+
// Codex has PreCompact, but workit does not register it: events list what
|
|
85
|
+
// workit registers (docs/agents/hosts.md, Host parity).
|
|
85
86
|
"compact.pre": { support: "none", native: null },
|
|
86
|
-
stop
|
|
87
|
+
// stop.command.input (codex-cli 0.160.1): stop_hook_active,
|
|
88
|
+
// last_assistant_message, turn_id; output `{"decision":"block","reason"}`.
|
|
89
|
+
stop: { support: "native", native: "Stop" },
|
|
87
90
|
},
|
|
88
91
|
shellPolicy: { deny: "native", channel: "permissionDecision", failClosed: false },
|
|
89
92
|
context: {
|
|
@@ -104,7 +107,7 @@ export const CODEX_DESCRIPTOR: HostDescriptor = {
|
|
|
104
107
|
},
|
|
105
108
|
provenance: { sessionId: "native", agentIdOnTool: "partial", postToolObserve: "native" },
|
|
106
109
|
interaction: { questions: "none", writeBoundary: "partial" },
|
|
107
|
-
stopControl: "
|
|
110
|
+
stopControl: "native",
|
|
108
111
|
shellAvailable: "native",
|
|
109
112
|
perEventCost: "low",
|
|
110
113
|
capabilities: [
|
|
@@ -178,6 +181,7 @@ const EVENTS: Record<CodexHookEvent, HookEventKind> = {
|
|
|
178
181
|
PostToolUse: "shell.post",
|
|
179
182
|
SubagentStart: "subagent.start",
|
|
180
183
|
SubagentStop: "subagent.stop",
|
|
184
|
+
Stop: "stop",
|
|
181
185
|
};
|
|
182
186
|
const SHELL_TOOLS = new Set(["bash", "unified-exec"]);
|
|
183
187
|
const isShellTool = (name: unknown) => SHELL_TOOLS.has(String(name).toLowerCase());
|
|
@@ -227,6 +231,12 @@ export const parseCodexHookInput = (value: unknown): CodexParseResult => {
|
|
|
227
231
|
...(nonEmpty(value.tool_use_id) ? { tool_use_id: value.tool_use_id } : {}),
|
|
228
232
|
...(nonEmpty(value.agent_id) ? { agent_id: value.agent_id } : {}),
|
|
229
233
|
...(nonEmpty(value.agent_type) ? { agent_type: value.agent_type } : {}),
|
|
234
|
+
...(event === "Stop"
|
|
235
|
+
? {
|
|
236
|
+
last_assistant_message: optionalText(value.last_assistant_message),
|
|
237
|
+
stop_hook_active: value.stop_hook_active === true,
|
|
238
|
+
}
|
|
239
|
+
: {}),
|
|
230
240
|
...(event === "SubagentStop"
|
|
231
241
|
? {
|
|
232
242
|
agent_transcript_path: optionalText(value.agent_transcript_path),
|
|
@@ -287,6 +297,12 @@ const protocolEvent = (input: CodexHookInput): HookEvent => {
|
|
|
287
297
|
lastMessage: input.last_assistant_message ?? null,
|
|
288
298
|
stopHookActive: input.stop_hook_active ?? false,
|
|
289
299
|
};
|
|
300
|
+
case "Stop":
|
|
301
|
+
return {
|
|
302
|
+
kind: "stop",
|
|
303
|
+
lastMessage: input.last_assistant_message ?? null,
|
|
304
|
+
stopHookActive: input.stop_hook_active ?? false,
|
|
305
|
+
};
|
|
290
306
|
}
|
|
291
307
|
};
|
|
292
308
|
|
|
@@ -295,7 +311,11 @@ const output = (event: string, extra: Record<string, unknown> = {}) => ({
|
|
|
295
311
|
});
|
|
296
312
|
|
|
297
313
|
const render = (decision: HookDecision, native: string | null) => {
|
|
298
|
-
|
|
314
|
+
// stop.command.output takes no hookSpecificOutput: a block with a reason continues.
|
|
315
|
+
if (native === "Stop" && decision.kind === "continue")
|
|
316
|
+
return { json: { decision: "block", reason: decision.reason }, exitCode: 0 };
|
|
317
|
+
if (native === "SubagentStop" || native === "Stop" || native === null)
|
|
318
|
+
return { json: {}, exitCode: 0 };
|
|
299
319
|
if (decision.kind === "deny")
|
|
300
320
|
return {
|
|
301
321
|
json: output(native, {
|
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
// Cursor: command hooks (hooks/hooks-cursor.json) mapped onto the protocol.
|
|
2
|
+
import fs from "node:fs";
|
|
2
3
|
import path from "node:path";
|
|
3
4
|
import type { HostDescriptor } from "../descriptor";
|
|
4
5
|
import type { HookDecision, HookEvent, HookEventKind, HostAdapter } from "../protocol";
|
|
@@ -10,7 +11,8 @@ export type CursorHookEvent =
|
|
|
10
11
|
| "beforeShellExecution"
|
|
11
12
|
| "subagentStart"
|
|
12
13
|
| "subagentStop"
|
|
13
|
-
| "preCompact"
|
|
14
|
+
| "preCompact"
|
|
15
|
+
| "stop";
|
|
14
16
|
|
|
15
17
|
export type CursorHookInput = {
|
|
16
18
|
hook_event_name: CursorHookEvent;
|
|
@@ -26,6 +28,8 @@ export type CursorHookInput = {
|
|
|
26
28
|
parent_conversation_id?: string;
|
|
27
29
|
task?: string;
|
|
28
30
|
status?: "completed" | "error" | "aborted";
|
|
31
|
+
loop_count?: number;
|
|
32
|
+
transcript_path?: string;
|
|
29
33
|
};
|
|
30
34
|
|
|
31
35
|
export type CursorParseResult = { ok: true; data: CursorHookInput } | { ok: false; error: string };
|
|
@@ -53,8 +57,11 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
53
57
|
"prompt.submit": undocumented,
|
|
54
58
|
// preCompact can only show a user message.
|
|
55
59
|
"compact.pre": { support: "partial", native: "preCompact" },
|
|
56
|
-
//
|
|
57
|
-
|
|
60
|
+
// stop (cursor-agent 2026.09.26): `{"followup_message"}` queues one more
|
|
61
|
+
// user turn; the input carries loop_count and transcript_path, not the
|
|
62
|
+
// last message (read from transcript_path). Registered with
|
|
63
|
+
// `loop_limit: 1`, so the continuation's own stop never runs the hook.
|
|
64
|
+
stop: { support: "native", native: "stop" },
|
|
58
65
|
},
|
|
59
66
|
shellPolicy: { deny: "native", channel: "exit2+json", failClosed: true },
|
|
60
67
|
context: {
|
|
@@ -78,7 +85,7 @@ export const CURSOR_DESCRIPTOR: HostDescriptor = {
|
|
|
78
85
|
postToolObserve: "undocumented",
|
|
79
86
|
},
|
|
80
87
|
interaction: { questions: "none", writeBoundary: "partial" },
|
|
81
|
-
stopControl: "
|
|
88
|
+
stopControl: "native",
|
|
82
89
|
shellAvailable: "native",
|
|
83
90
|
// Hooks run the plugin's launcher against a local runtime (bundled dist or a
|
|
84
91
|
// global bin); only an install with neither falls back to a pinned,
|
|
@@ -155,6 +162,7 @@ const EVENTS: Record<CursorHookEvent, HookEventKind> = {
|
|
|
155
162
|
subagentStart: "subagent.start",
|
|
156
163
|
subagentStop: "subagent.stop",
|
|
157
164
|
preCompact: "compact.pre",
|
|
165
|
+
stop: "stop",
|
|
158
166
|
};
|
|
159
167
|
|
|
160
168
|
/** Validate the keys each Cursor event needs; unknown keys are ignored. */
|
|
@@ -205,6 +213,15 @@ export const parseCursorHookInput = (value: unknown): CursorParseResult => {
|
|
|
205
213
|
? { parent_conversation_id: value.parent_conversation_id }
|
|
206
214
|
: {}),
|
|
207
215
|
...(event === "subagentStart" && nonEmpty(value.task) ? { task: value.task } : {}),
|
|
216
|
+
...(event === "stop"
|
|
217
|
+
? {
|
|
218
|
+
...(nonEmpty(value.status)
|
|
219
|
+
? { status: value.status as CursorHookInput["status"] }
|
|
220
|
+
: {}),
|
|
221
|
+
...(typeof value.loop_count === "number" ? { loop_count: value.loop_count } : {}),
|
|
222
|
+
...(nonEmpty(value.transcript_path) ? { transcript_path: value.transcript_path } : {}),
|
|
223
|
+
}
|
|
224
|
+
: {}),
|
|
208
225
|
},
|
|
209
226
|
};
|
|
210
227
|
};
|
|
@@ -244,6 +261,61 @@ const protocolEvent = (input: CursorHookInput): HookEvent => {
|
|
|
244
261
|
};
|
|
245
262
|
case "preCompact":
|
|
246
263
|
return { kind: "compact.pre", trigger: "auto" };
|
|
264
|
+
case "stop":
|
|
265
|
+
return {
|
|
266
|
+
kind: "stop",
|
|
267
|
+
// Only a completed turn is continued; an aborted or failed one stops.
|
|
268
|
+
lastMessage:
|
|
269
|
+
(input.status ?? "completed") === "completed"
|
|
270
|
+
? lastAssistantText(input.transcript_path)
|
|
271
|
+
: null,
|
|
272
|
+
// A followup_message turn reports loop_count > 0: one continue per turn.
|
|
273
|
+
stopHookActive: (input.loop_count ?? 0) > 0,
|
|
274
|
+
};
|
|
275
|
+
}
|
|
276
|
+
};
|
|
277
|
+
|
|
278
|
+
/** The transcript tail read for the last assistant message. */
|
|
279
|
+
const TRANSCRIPT_TAIL_BYTES = 256 * 1024;
|
|
280
|
+
|
|
281
|
+
/**
|
|
282
|
+
* The text of the last assistant entry in a Cursor agent transcript
|
|
283
|
+
* (`{"role":"assistant","message":{"content":[{"type":"text","text":…}]}}`
|
|
284
|
+
* per line), or null when it cannot be read.
|
|
285
|
+
*/
|
|
286
|
+
export const lastAssistantText = (file: string | null | undefined): string | null => {
|
|
287
|
+
if (!file || !path.isAbsolute(file)) return null;
|
|
288
|
+
try {
|
|
289
|
+
const size = fs.statSync(file).size;
|
|
290
|
+
const start = Math.max(0, size - TRANSCRIPT_TAIL_BYTES);
|
|
291
|
+
const buffer = Buffer.alloc(size - start);
|
|
292
|
+
const fd = fs.openSync(file, "r");
|
|
293
|
+
try {
|
|
294
|
+
fs.readSync(fd, buffer, 0, buffer.length, start);
|
|
295
|
+
} finally {
|
|
296
|
+
fs.closeSync(fd);
|
|
297
|
+
}
|
|
298
|
+
const lines = buffer.toString("utf8").split("\n");
|
|
299
|
+
for (let index = lines.length - 1; index >= 0; index--) {
|
|
300
|
+
let entry: unknown;
|
|
301
|
+
try {
|
|
302
|
+
entry = JSON.parse(lines[index]);
|
|
303
|
+
} catch {
|
|
304
|
+
continue;
|
|
305
|
+
}
|
|
306
|
+
if (!isRecord(entry) || entry.role !== "assistant") continue;
|
|
307
|
+
const content = isRecord(entry.message) ? entry.message.content : entry.content;
|
|
308
|
+
if (typeof content === "string") return content;
|
|
309
|
+
if (!Array.isArray(content)) return null;
|
|
310
|
+
const text = content
|
|
311
|
+
.filter((part) => isRecord(part) && part.type === "text" && typeof part.text === "string")
|
|
312
|
+
.map((part) => (part as { text: string }).text)
|
|
313
|
+
.join("\n");
|
|
314
|
+
return text || null;
|
|
315
|
+
}
|
|
316
|
+
return null;
|
|
317
|
+
} catch {
|
|
318
|
+
return null;
|
|
247
319
|
}
|
|
248
320
|
};
|
|
249
321
|
|
|
@@ -291,6 +363,8 @@ const render = (decision: HookDecision, native: string | null) => {
|
|
|
291
363
|
return { json: { additional_context: decision.text }, exitCode: 0 };
|
|
292
364
|
if (decision.kind === "notice")
|
|
293
365
|
return { json: { user_message: decision.userMessage }, exitCode: 0 };
|
|
366
|
+
if (decision.kind === "continue" && native === "stop")
|
|
367
|
+
return { json: { followup_message: decision.reason }, exitCode: 0 };
|
|
294
368
|
return {
|
|
295
369
|
json: native !== null && BLOCKING.has(native) ? { permission: "allow" } : {},
|
|
296
370
|
exitCode: 0,
|
|
@@ -19,8 +19,11 @@ export const OPENCODE_DESCRIPTOR: HostDescriptor = {
|
|
|
19
19
|
"subagent.stop": { support: "native", native: 'tool.hook("execute.after") subagent' },
|
|
20
20
|
"prompt.submit": { support: "undocumented", native: null },
|
|
21
21
|
"compact.pre": { support: "native", native: 'session.hook("compaction")' },
|
|
22
|
-
//
|
|
23
|
-
|
|
22
|
+
// No stop hook: the plugin's event subscription sees session.idle and
|
|
23
|
+
// resumes the session with `ctx.session.synthetic({resume: true})`
|
|
24
|
+
// (@opencode/client 2.0.18). Partial: the continuation lands after the
|
|
25
|
+
// session went idle, and resume semantics are inferred from the schema.
|
|
26
|
+
stop: { support: "partial", native: 'event("session.idle")' },
|
|
24
27
|
},
|
|
25
28
|
shellPolicy: { deny: "native", channel: "effect", failClosed: false },
|
|
26
29
|
context: {
|
package/src/hooks/hosts/pi.ts
CHANGED
|
@@ -19,7 +19,10 @@ export const PI_DESCRIPTOR: HostDescriptor = {
|
|
|
19
19
|
"subagent.stop": { support: "none", native: null },
|
|
20
20
|
"prompt.submit": { support: "undocumented", native: null },
|
|
21
21
|
"compact.pre": { support: "native", native: "session_before_compact" },
|
|
22
|
-
|
|
22
|
+
// agent_end carries the run's messages; pi.sendMessage(…, {triggerTurn})
|
|
23
|
+
// starts one more turn (pi-coding-agent 0.85.1 types.d.ts). The host has
|
|
24
|
+
// no loop guard: the extension continues at most once per run.
|
|
25
|
+
stop: { support: "native", native: "agent_end" },
|
|
23
26
|
},
|
|
24
27
|
shellPolicy: { deny: "native", channel: "block", failClosed: false },
|
|
25
28
|
context: {
|
|
@@ -41,7 +44,7 @@ export const PI_DESCRIPTOR: HostDescriptor = {
|
|
|
41
44
|
},
|
|
42
45
|
provenance: { sessionId: "native", agentIdOnTool: "none", postToolObserve: "native" },
|
|
43
46
|
interaction: { questions: "native", writeBoundary: "native" },
|
|
44
|
-
stopControl: "
|
|
47
|
+
stopControl: "native",
|
|
45
48
|
shellAvailable: "native",
|
|
46
49
|
perEventCost: "low",
|
|
47
50
|
capabilities: [
|
package/src/hooks/index.ts
CHANGED
|
@@ -20,6 +20,7 @@ export {
|
|
|
20
20
|
} from "./context";
|
|
21
21
|
export { shellPolicy } from "./policy";
|
|
22
22
|
export { shellWrites, writeGate } from "./write-gate";
|
|
23
|
+
export { asksUser, stopDecision, stopObligation, type StopObligation } from "./stop";
|
|
23
24
|
export { callKey, noteRawCommit, rawGitPost, rawGitPre } from "./raw-git";
|
|
24
25
|
export { promptNudge, recordSkillLoad, shellNudge, skillFileIn } from "./skill-nudge";
|
|
25
26
|
export { isWriteTool, writePaths } from "./hosts/fields";
|
package/src/hooks/raw-git.ts
CHANGED
|
@@ -6,7 +6,8 @@
|
|
|
6
6
|
// remote onto a protected branch or the default target, naming the
|
|
7
7
|
// workit command;
|
|
8
8
|
// - lets routine raw commands run (`git commit`, a feature-branch push,
|
|
9
|
-
// `gh pr create|view|checks`, `glab mr create|view`) with a short nudge
|
|
9
|
+
// `gh pr create|view|checks`, `glab mr create|view`) with a short nudge,
|
|
10
|
+
// once per kind per session, and none on a command that runs workit.
|
|
10
11
|
// A raw `git commit` is recorded as the session's `commit.recorded` row so
|
|
11
12
|
// the author's own verdict never reads as independent. The pre-tool hook
|
|
12
13
|
// notes HEAD before the command (a marker in the commit's own repository);
|
|
@@ -29,6 +30,7 @@ import { pushRemoteName } from "../git/rev";
|
|
|
29
30
|
import { appendHookObserved, readLedger } from "../ledger";
|
|
30
31
|
import { resolveStore, resolveTaskKey, type StoreLocation } from "../store/paths";
|
|
31
32
|
import type { HookDecision, HookInput } from "./protocol";
|
|
33
|
+
import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
|
|
32
34
|
import { segmentsOf, type ShellDialect } from "./shell-words";
|
|
33
35
|
|
|
34
36
|
const NONE: HookDecision = { kind: "none" };
|
|
@@ -176,6 +178,47 @@ export function rawInvocations(
|
|
|
176
178
|
return found;
|
|
177
179
|
}
|
|
178
180
|
|
|
181
|
+
const LAUNCHERS = new Set([
|
|
182
|
+
"npx",
|
|
183
|
+
"bunx",
|
|
184
|
+
"pnpx",
|
|
185
|
+
"-y",
|
|
186
|
+
"--yes",
|
|
187
|
+
"pnpm",
|
|
188
|
+
"dlx",
|
|
189
|
+
"exec",
|
|
190
|
+
"env",
|
|
191
|
+
"sudo",
|
|
192
|
+
"command",
|
|
193
|
+
]);
|
|
194
|
+
const WORKIT_BIN = /(?:^|[\\/])workit(?:\.exe)?$|^@brainervirus\/workit-cli$/;
|
|
195
|
+
|
|
196
|
+
/**
|
|
197
|
+
* The arguments of every workit CLI invocation in a shell command: past env
|
|
198
|
+
* assignments and package runners (`npx -y @brainervirus/workit-cli …`), and
|
|
199
|
+
* inside `bash -c '…'` scripts.
|
|
200
|
+
*/
|
|
201
|
+
export function workitInvocations(
|
|
202
|
+
command: string,
|
|
203
|
+
dialect: ShellDialect = "posix",
|
|
204
|
+
depth = 0,
|
|
205
|
+
): string[][] {
|
|
206
|
+
const found: string[][] = [];
|
|
207
|
+
for (const { words } of segmentsOf(command, dialect)) {
|
|
208
|
+
const at = words.findIndex((word) => !/^\w+=/.test(word) && !LAUNCHERS.has(word));
|
|
209
|
+
if (at < 0) continue;
|
|
210
|
+
if (WORKIT_BIN.test(words[at])) {
|
|
211
|
+
found.push(words.slice(at + 1));
|
|
212
|
+
continue;
|
|
213
|
+
}
|
|
214
|
+
const shell = SHELLS.has(commandName(words[at]));
|
|
215
|
+
const script = shell && words[at + 1] === "-c" ? words[at + 2] : undefined;
|
|
216
|
+
if (depth < 3 && script !== undefined)
|
|
217
|
+
found.push(...workitInvocations(script, "posix", depth + 1));
|
|
218
|
+
}
|
|
219
|
+
return found;
|
|
220
|
+
}
|
|
221
|
+
|
|
179
222
|
/** What one raw invocation means for workit. */
|
|
180
223
|
export type RawAction =
|
|
181
224
|
| { kind: "commit"; amend: boolean }
|
|
@@ -336,7 +379,7 @@ const isWorkitWorkspace = (repo: Repo): boolean => {
|
|
|
336
379
|
};
|
|
337
380
|
|
|
338
381
|
/** Protected by the branch policy, or the workspace's default target. */
|
|
339
|
-
const guardedBranch = (dir: string, branch: string): boolean => {
|
|
382
|
+
export const guardedBranch = (dir: string, branch: string): boolean => {
|
|
340
383
|
try {
|
|
341
384
|
const policy = resolveBranchPolicyFor(dir);
|
|
342
385
|
return (
|
|
@@ -459,16 +502,53 @@ const dialectOf = (input: HookInput): ShellDialect =>
|
|
|
459
502
|
? input.event.dialect
|
|
460
503
|
: undefined) ?? "posix";
|
|
461
504
|
|
|
505
|
+
/** The nudge kind an action shows: each is shown once per session (agent). */
|
|
506
|
+
const nudgeKind = (action: RawAction): string =>
|
|
507
|
+
action.kind === "commit" && action.amend
|
|
508
|
+
? "commit-amend"
|
|
509
|
+
: action.kind === "push" && action.blindForce
|
|
510
|
+
? "push-force"
|
|
511
|
+
: action.kind;
|
|
512
|
+
|
|
513
|
+
/**
|
|
514
|
+
* The nudges a session (or one of its subagents) has not been shown yet,
|
|
515
|
+
* recorded as shown in the per-session marker. Without a session or a
|
|
516
|
+
* marker, every nudge shows: the throttle fails open to repeating.
|
|
517
|
+
*/
|
|
518
|
+
const unseenNudges = (input: HookInput, nudges: Map<string, string>, store: string): string[] => {
|
|
519
|
+
const session = input.session.id;
|
|
520
|
+
const file = session ? sessionMarkerFile(input, session, store) : null;
|
|
521
|
+
if (!file) return [...nudges.values()];
|
|
522
|
+
const marker = readSessionMarker(file);
|
|
523
|
+
const agent = input.session.agentId ? `${input.session.agentId}:` : "";
|
|
524
|
+
const fresh = [...nudges.keys()].filter((kind) => !marker.raw.includes(agent + kind));
|
|
525
|
+
if (fresh.length)
|
|
526
|
+
writeSessionMarker(file, {
|
|
527
|
+
...marker,
|
|
528
|
+
raw: [...marker.raw, ...fresh.map((kind) => agent + kind)],
|
|
529
|
+
});
|
|
530
|
+
return fresh.map((kind) => nudges.get(kind) as string);
|
|
531
|
+
};
|
|
532
|
+
|
|
462
533
|
/**
|
|
463
534
|
* The pre-tool decision for a shell command: deny a gate bypass inside a
|
|
464
535
|
* Workit workspace, else a nudge (as context) for routine raw delivery
|
|
465
|
-
* commands, else nothing.
|
|
536
|
+
* commands, else nothing. Each nudge kind is shown once per session, and a
|
|
537
|
+
* command that runs a workit verb itself gets none (denies still apply).
|
|
538
|
+
* `nudge: false` asks for the deny alone and spends no nudge. Never throws.
|
|
466
539
|
*/
|
|
467
|
-
export function rawGitPre(
|
|
540
|
+
export function rawGitPre(
|
|
541
|
+
input: HookInput,
|
|
542
|
+
command: string,
|
|
543
|
+
options: { nudge?: boolean } = {},
|
|
544
|
+
): HookDecision {
|
|
468
545
|
try {
|
|
469
546
|
if (!mentionsRawTool(command)) return NONE;
|
|
470
|
-
const
|
|
471
|
-
|
|
547
|
+
const dialect = dialectOf(input);
|
|
548
|
+
const nudges = new Map<string, string>();
|
|
549
|
+
let store: string | null = null;
|
|
550
|
+
for (const { action, repo } of actionsIn(input.cwd, command, dialect)) {
|
|
551
|
+
store ??= repo.location.dir;
|
|
472
552
|
if (action.kind === "merge") return deny(refusal(action, null));
|
|
473
553
|
if (action.kind === "push")
|
|
474
554
|
for (const target of action.targets) {
|
|
@@ -477,9 +557,12 @@ export function rawGitPre(input: HookInput, command: string): HookDecision {
|
|
|
477
557
|
return deny(refusal(action, branch));
|
|
478
558
|
}
|
|
479
559
|
const nudge = nudgeFor(action, input.host, input.session.id);
|
|
480
|
-
if (nudge && !nudges.
|
|
560
|
+
if (nudge && !nudges.has(nudgeKind(action))) nudges.set(nudgeKind(action), nudge);
|
|
481
561
|
}
|
|
482
|
-
|
|
562
|
+
if (options.nudge === false || !store || nudges.size === 0) return NONE;
|
|
563
|
+
if (workitInvocations(command, dialect).length > 0) return NONE;
|
|
564
|
+
const shown = unseenNudges(input, nudges, store);
|
|
565
|
+
return shown.length ? { kind: "context", text: shown.join("\n") } : NONE;
|
|
483
566
|
} catch {
|
|
484
567
|
return NONE;
|
|
485
568
|
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
// The per-session nudge marker in the workspace store
|
|
2
|
+
// (`<store>/hooks/skills-<session hash>.json`): which skills a session was
|
|
3
|
+
// nudged about or loaded, and which raw-git nudge kinds it was shown, so each
|
|
4
|
+
// nudge fires once per session. Each hook is a fresh process, so the marker
|
|
5
|
+
// lives on disk. Every failure reads as an empty marker: hooks fail open.
|
|
6
|
+
import { createHash } from "node:crypto";
|
|
7
|
+
import fs from "node:fs";
|
|
8
|
+
import path from "node:path";
|
|
9
|
+
import { resolveStore } from "../store/paths";
|
|
10
|
+
import type { HookInput } from "./protocol";
|
|
11
|
+
|
|
12
|
+
export type SessionMarker = { nudged: string[]; loaded: string[]; raw: string[] };
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* The marker in the workspace's store (null outside one: nothing is nudged or
|
|
16
|
+
* recorded there). `store` names the store directly when the command acts in
|
|
17
|
+
* another checkout than the hook's cwd.
|
|
18
|
+
*/
|
|
19
|
+
export const sessionMarkerFile = (
|
|
20
|
+
input: HookInput,
|
|
21
|
+
session: string,
|
|
22
|
+
store?: string,
|
|
23
|
+
): string | null => {
|
|
24
|
+
let dir = store;
|
|
25
|
+
if (dir === undefined) {
|
|
26
|
+
const location = resolveStore(input.cwd);
|
|
27
|
+
if (location instanceof Error || !location.shared) return null;
|
|
28
|
+
dir = location.dir;
|
|
29
|
+
}
|
|
30
|
+
const name = createHash("sha256").update(`${input.host}\0${session}`).digest("hex").slice(0, 32);
|
|
31
|
+
return path.join(dir, "hooks", `skills-${name}.json`);
|
|
32
|
+
};
|
|
33
|
+
|
|
34
|
+
const list = (value: unknown): string[] =>
|
|
35
|
+
Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
36
|
+
|
|
37
|
+
export const readSessionMarker = (file: string): SessionMarker => {
|
|
38
|
+
try {
|
|
39
|
+
const value = JSON.parse(fs.readFileSync(file, "utf8")) as Record<string, unknown>;
|
|
40
|
+
return { nudged: list(value.nudged), loaded: list(value.loaded), raw: list(value.raw) };
|
|
41
|
+
} catch {
|
|
42
|
+
return { nudged: [], loaded: [], raw: [] };
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
export const writeSessionMarker = (file: string, marker: SessionMarker): void => {
|
|
47
|
+
try {
|
|
48
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
49
|
+
fs.writeFileSync(file, JSON.stringify(marker));
|
|
50
|
+
} catch {
|
|
51
|
+
// Fail open: without a marker a later nudge repeats.
|
|
52
|
+
}
|
|
53
|
+
};
|
package/src/hooks/skill-nudge.ts
CHANGED
|
@@ -11,9 +11,6 @@
|
|
|
11
11
|
// A session is nudged about one skill at most once (a marker per session
|
|
12
12
|
// in the workspace store), never in a subagent, never once it loaded the skill.
|
|
13
13
|
// Every failure answers "no nudge": hooks fail open.
|
|
14
|
-
import { createHash } from "node:crypto";
|
|
15
|
-
import fs from "node:fs";
|
|
16
|
-
import path from "node:path";
|
|
17
14
|
import {
|
|
18
15
|
skillLoadWording,
|
|
19
16
|
WORKIT_METHOD_SKILLS,
|
|
@@ -22,9 +19,9 @@ import {
|
|
|
22
19
|
type WorkitSkill,
|
|
23
20
|
} from "../core/skill-manifests";
|
|
24
21
|
import { appendHookObserved, readLedger } from "../ledger";
|
|
25
|
-
import { resolveStore } from "../store/paths";
|
|
26
22
|
import type { HookDecision, HookInput, HostId } from "./protocol";
|
|
27
|
-
import { inWorkitWorkspace, rawDelivery, rawGitPre } from "./raw-git";
|
|
23
|
+
import { inWorkitWorkspace, rawDelivery, rawGitPre, workitInvocations } from "./raw-git";
|
|
24
|
+
import { readSessionMarker, sessionMarkerFile, writeSessionMarker } from "./session-marker";
|
|
28
25
|
import { segmentsOf, type ShellDialect } from "./shell-words";
|
|
29
26
|
|
|
30
27
|
/** Skills the user starts; a nudge says to offer them, never to load them. */
|
|
@@ -105,35 +102,6 @@ export const promptTrigger = (prompt: string): { skill: WorkitSkill; trigger: st
|
|
|
105
102
|
return null;
|
|
106
103
|
};
|
|
107
104
|
|
|
108
|
-
/** Per-session nudge state: skills nudged or loaded, so each nudges once. */
|
|
109
|
-
type Marker = { nudged: string[]; loaded: string[] };
|
|
110
|
-
|
|
111
|
-
/** The marker in the workspace's store (null outside one: nothing is nudged or recorded there). */
|
|
112
|
-
const markerFile = (input: HookInput, session: string): string | null => {
|
|
113
|
-
const location = resolveStore(input.cwd);
|
|
114
|
-
if (location instanceof Error || !location.shared) return null;
|
|
115
|
-
const name = createHash("sha256").update(`${input.host}\0${session}`).digest("hex").slice(0, 32);
|
|
116
|
-
return path.join(location.dir, "hooks", `skills-${name}.json`);
|
|
117
|
-
};
|
|
118
|
-
|
|
119
|
-
const readMarker = (file: string): Marker => {
|
|
120
|
-
try {
|
|
121
|
-
const value = JSON.parse(fs.readFileSync(file, "utf8")) as Partial<Marker>;
|
|
122
|
-
return { nudged: value.nudged ?? [], loaded: value.loaded ?? [] };
|
|
123
|
-
} catch {
|
|
124
|
-
return { nudged: [], loaded: [] };
|
|
125
|
-
}
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
const writeMarker = (file: string, marker: Marker): void => {
|
|
129
|
-
try {
|
|
130
|
-
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
131
|
-
fs.writeFileSync(file, JSON.stringify(marker));
|
|
132
|
-
} catch {
|
|
133
|
-
// Fail open: without a marker a later nudge repeats.
|
|
134
|
-
}
|
|
135
|
-
};
|
|
136
|
-
|
|
137
105
|
/** Skills this session already loaded, from its `skill.loaded` rows. */
|
|
138
106
|
const loadedSkills = (cwd: string, session: string): Set<string> => {
|
|
139
107
|
const ledger = readLedger(cwd);
|
|
@@ -194,11 +162,11 @@ export function recordSkillLoad(
|
|
|
194
162
|
const session = input.session.id;
|
|
195
163
|
if (!skill || !session) return;
|
|
196
164
|
if (!inWorkitWorkspace(input.cwd)) return;
|
|
197
|
-
const file =
|
|
165
|
+
const file = sessionMarkerFile(input, session);
|
|
198
166
|
if (!file) return;
|
|
199
|
-
const marker =
|
|
167
|
+
const marker = readSessionMarker(file);
|
|
200
168
|
if (marker.loaded.includes(skill)) return;
|
|
201
|
-
|
|
169
|
+
writeSessionMarker(file, { ...marker, loaded: [...marker.loaded, skill] });
|
|
202
170
|
if (loadedSkills(input.cwd, session).has(skill)) return;
|
|
203
171
|
appendHookObserved(input.cwd, {
|
|
204
172
|
type: "skill.loaded",
|
|
@@ -218,12 +186,12 @@ const nudgeOnce = (
|
|
|
218
186
|
skill: WorkitSkill,
|
|
219
187
|
line: string,
|
|
220
188
|
): string | null => {
|
|
221
|
-
const file =
|
|
189
|
+
const file = sessionMarkerFile(input, session);
|
|
222
190
|
if (!file) return null;
|
|
223
|
-
const marker =
|
|
191
|
+
const marker = readSessionMarker(file);
|
|
224
192
|
if (marker.nudged.includes(skill) || marker.loaded.includes(skill)) return null;
|
|
225
193
|
if (loadedSkills(input.cwd, session).has(skill)) return null;
|
|
226
|
-
|
|
194
|
+
writeSessionMarker(file, { ...marker, nudged: [...marker.nudged, skill] });
|
|
227
195
|
return line;
|
|
228
196
|
};
|
|
229
197
|
|
|
@@ -240,11 +208,22 @@ const promptLoads = (prompt: string): string | null =>
|
|
|
240
208
|
/^\s*Load the (workit-[a-z-]+) skill with the skill tool\b/.exec(prompt)?.[1] ??
|
|
241
209
|
null;
|
|
242
210
|
|
|
211
|
+
/**
|
|
212
|
+
* A prompt that relays another agent's words, not the user's: Claude Code
|
|
213
|
+
* delivers subagent hand-backs, teammate messages and background-task
|
|
214
|
+
* notifications through UserPromptSubmit. A reviewer's report that says
|
|
215
|
+
* "regressed" is not a request for workit-debug.
|
|
216
|
+
*/
|
|
217
|
+
export const relayedMessage = (prompt: string): boolean =>
|
|
218
|
+
/^\s*(?:Another Claude session sent a message\b|<agent-message\s+from=|\[SYSTEM NOTIFICATION\b|<task-notification>)/.test(
|
|
219
|
+
prompt,
|
|
220
|
+
);
|
|
221
|
+
|
|
243
222
|
/** A main session's prompt in a Workit workspace: one advisory line naming the skill it asks for. */
|
|
244
223
|
export function promptNudge(input: HookInput, prompt: string | null | undefined): string | null {
|
|
245
224
|
try {
|
|
246
225
|
const session = input.session.id;
|
|
247
|
-
if (!prompt || !session || input.session.agentId) return null;
|
|
226
|
+
if (!prompt || !session || input.session.agentId || relayedMessage(prompt)) return null;
|
|
248
227
|
const loads = promptLoads(prompt);
|
|
249
228
|
if (loads) {
|
|
250
229
|
// The prompt already loads its skill; record it, never nudge.
|
|
@@ -265,35 +244,13 @@ export function promptNudge(input: HookInput, prompt: string | null | undefined)
|
|
|
265
244
|
}
|
|
266
245
|
|
|
267
246
|
const WORKIT_DELIVERY = /^(?:pr (?:create|merge)|ci wait|git push)\b/;
|
|
268
|
-
|
|
269
|
-
const
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
"pnpm",
|
|
276
|
-
"dlx",
|
|
277
|
-
"exec",
|
|
278
|
-
"env",
|
|
279
|
-
"sudo",
|
|
280
|
-
"command",
|
|
281
|
-
]);
|
|
282
|
-
|
|
283
|
-
/** Does a segment list run a workit delivery verb (also inside `bash -c '…'`)? */
|
|
284
|
-
const runsWorkitDelivery = (command: string, dialect: ShellDialect, depth = 0): boolean =>
|
|
285
|
-
segmentsOf(command, dialect).some(({ words }) => {
|
|
286
|
-
// The command word, past env assignments and package runners (`npx -y`).
|
|
287
|
-
const at = words.findIndex((word) => !/^\w+=/.test(word) && !LAUNCHERS.has(word));
|
|
288
|
-
if (at >= 0 && /(?:^|[\\/])workit(?:\.exe)?$|^@brainervirus\/workit-cli$/.test(words[at])) {
|
|
289
|
-
const args = words.slice(at + 1);
|
|
290
|
-
if (args.some((arg) => arg === "--help" || arg === "-h" || arg === "--dry-run")) return false;
|
|
291
|
-
return WORKIT_DELIVERY.test(args.join(" "));
|
|
292
|
-
}
|
|
293
|
-
const shell = SHELLS.has(words[at]?.split(/[\\/]/).at(-1) ?? "");
|
|
294
|
-
const script = shell && words[at + 1] === "-c" ? words[at + 2] : undefined;
|
|
295
|
-
return depth < 3 && script !== undefined && runsWorkitDelivery(script, "posix", depth + 1);
|
|
296
|
-
});
|
|
247
|
+
/** Does the command run a workit delivery verb (also inside `bash -c '…'`), never help or a dry run? */
|
|
248
|
+
const runsWorkitDelivery = (command: string, dialect: ShellDialect): boolean =>
|
|
249
|
+
workitInvocations(command, dialect).some(
|
|
250
|
+
(args) =>
|
|
251
|
+
!args.some((arg) => arg === "--help" || arg === "-h" || arg === "--dry-run") &&
|
|
252
|
+
WORKIT_DELIVERY.test(args.join(" ")),
|
|
253
|
+
);
|
|
297
254
|
|
|
298
255
|
/**
|
|
299
256
|
* Does the command deliver in a Workit workspace: a push, a PR/MR create or
|
|
@@ -0,0 +1,308 @@
|
|
|
1
|
+
// Stop control (ledger decision "stop control: option A, nudge once"): when
|
|
2
|
+
// the main agent ends its turn with an obligation the ledger or git can
|
|
3
|
+
// prove unmet, the host continues it once with a reason naming the
|
|
4
|
+
// obligation and the workit command that clears it. The obligations, for the
|
|
5
|
+
// session's branch (the checked-out branch, on which this session recorded a
|
|
6
|
+
// commit or opened a PR):
|
|
7
|
+
// (a) unpushed: a commit this session recorded, not on the push remote,
|
|
8
|
+
// while the effective endpoint is pr, green or merged;
|
|
9
|
+
// (b) checks: the branch's open PR has failing or pending checks recorded
|
|
10
|
+
// for its current head (`pr.status` rows) while the endpoint is green
|
|
11
|
+
// or merged;
|
|
12
|
+
// (c) verdict: the session opened the PR (or verified delivery) but the
|
|
13
|
+
// head carries no accepted non-author verdict.
|
|
14
|
+
// A branch that landed owes nothing: a recorded merge (`workit pr merge`, or
|
|
15
|
+
// `pr status`/`ci wait` seeing the forge report it merged) of HEAD or of a
|
|
16
|
+
// head that contains this session's commits, or, with no merge recorded, an
|
|
17
|
+
// upstream that is gone. Its remote branch may be deleted, and an
|
|
18
|
+
// `--unverified` merge was the user's call.
|
|
19
|
+
// A stop is never blocked: in a subagent, outside a Workit workspace, when
|
|
20
|
+
// the host says this stop already follows a continuation (one continue per
|
|
21
|
+
// turn), or when the agent's last message asks the user a question. Reads
|
|
22
|
+
// the ledger and local git only (no forge call), and every failure allows
|
|
23
|
+
// the stop.
|
|
24
|
+
import { spawnSync } from "node:child_process";
|
|
25
|
+
import { resolveAutonomy } from "../autonomy";
|
|
26
|
+
import { currentBranch, pushRemoteName } from "../git/rev";
|
|
27
|
+
import { checkVerdicts, readLedger, type ReadRow } from "../ledger";
|
|
28
|
+
import type { HostDescriptor } from "./descriptor";
|
|
29
|
+
import type { HookDecision, HookInput } from "./protocol";
|
|
30
|
+
import { guardedBranch, inWorkitWorkspace } from "./raw-git";
|
|
31
|
+
|
|
32
|
+
const NONE: HookDecision = { kind: "none" };
|
|
33
|
+
|
|
34
|
+
export type StopObligation = {
|
|
35
|
+
kind: "unpushed" | "checks" | "verdict";
|
|
36
|
+
/** What is left, with the workit command that clears it. */
|
|
37
|
+
reason: string;
|
|
38
|
+
};
|
|
39
|
+
|
|
40
|
+
/** A question mark (Latin, full-width, Arabic) ending a sentence, or Spanish's opening one. */
|
|
41
|
+
const QUESTION = /[??؟](?=[\s*_`"')\]>]*(?:\s|$))|¿/;
|
|
42
|
+
/** An offer or a request for permission. */
|
|
43
|
+
const OFFER =
|
|
44
|
+
/\b(?:let me know|want me to|should I|shall I|would you like|do you want|if you(?:'d| would) like|may I|can I go ahead)\b/i;
|
|
45
|
+
/** A `[y/N]`, `(y/n)` or `[Y/n]` prompt. */
|
|
46
|
+
const YES_NO = /[[(]\s*y(?:es)?\s*\/\s*n(?:o)?\s*[\])]/i;
|
|
47
|
+
/** A list item: `- a`, `* a`, `1. a`, `2) a`, `a) a`. */
|
|
48
|
+
const LIST_ITEM = /^\s*(?:[-*+•]|\d+[.)]|[a-z][.)])\s+/i;
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Does the message end by asking the user something? The last two
|
|
52
|
+
* paragraphs (past code blocks and a trailing option list) are read for a
|
|
53
|
+
* question mark, an offer ("want me to", "shall I", "let me know") or a
|
|
54
|
+
* yes/no prompt: a stop that waits on the user is never blocked, so this
|
|
55
|
+
* errs towards "yes".
|
|
56
|
+
*/
|
|
57
|
+
export const asksUser = (message: string | null | undefined): boolean => {
|
|
58
|
+
if (!message) return false;
|
|
59
|
+
const paragraphs = message
|
|
60
|
+
.replace(/```[\s\S]*?(?:```|$)/g, "\n\n")
|
|
61
|
+
.split(/\n\s*\n/)
|
|
62
|
+
.map((paragraph) => paragraph.trim())
|
|
63
|
+
.filter(Boolean);
|
|
64
|
+
// A trailing option list belongs to the question before it.
|
|
65
|
+
while (
|
|
66
|
+
paragraphs.length > 1 &&
|
|
67
|
+
paragraphs
|
|
68
|
+
.at(-1)!
|
|
69
|
+
.split("\n")
|
|
70
|
+
.every((line) => LIST_ITEM.test(line))
|
|
71
|
+
)
|
|
72
|
+
paragraphs.pop();
|
|
73
|
+
const tail = paragraphs.slice(-2).join("\n");
|
|
74
|
+
return QUESTION.test(tail) || OFFER.test(tail) || YES_NO.test(tail);
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const short = (sha: string | null | undefined) => (sha ?? "?").slice(0, 12);
|
|
78
|
+
|
|
79
|
+
/** A git command's output lines, or null when it fails. */
|
|
80
|
+
const gitList = (cwd: string, args: string[]): string[] | null => {
|
|
81
|
+
const run = spawnSync("git", args, {
|
|
82
|
+
cwd,
|
|
83
|
+
encoding: "utf8",
|
|
84
|
+
timeout: 3_000,
|
|
85
|
+
windowsHide: true,
|
|
86
|
+
env: { ...process.env, GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0", LC_ALL: "C" },
|
|
87
|
+
});
|
|
88
|
+
return run.status === 0 ? run.stdout.split("\n").filter(Boolean) : null;
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
const gitLine = (cwd: string, args: string[]): string | null => gitList(cwd, args)?.[0] ?? null;
|
|
92
|
+
|
|
93
|
+
const sessionOf = (row: ReadRow): string | null =>
|
|
94
|
+
(typeof row.session === "string" ? row.session : null) ?? row.actor.session;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* The branch's open PR: the newest one the CLI opened or observed
|
|
98
|
+
* (`pr.created`, `pr.status`), unless it was merged since.
|
|
99
|
+
*/
|
|
100
|
+
/** A `pr.status` row's PR state; rows written before it was recorded are open. */
|
|
101
|
+
const prState = (row: ReadRow): string => (typeof row.state === "string" ? row.state : "open");
|
|
102
|
+
|
|
103
|
+
/** A CLI row saying the PR ended: `workit pr merge`, or the forge reporting it merged or closed. */
|
|
104
|
+
const ended = (row: ReadRow): boolean =>
|
|
105
|
+
row.observer === "workit_cli" &&
|
|
106
|
+
(row.type === "pr.merged" || (row.type === "pr.status" && prState(row) !== "open"));
|
|
107
|
+
|
|
108
|
+
const openPr = (rows: readonly ReadRow[]): number | null => {
|
|
109
|
+
const seen = rows.findLast(
|
|
110
|
+
(row) =>
|
|
111
|
+
(row.type === "pr.created" || row.type === "pr.status") &&
|
|
112
|
+
row.observer === "workit_cli" &&
|
|
113
|
+
typeof row.pr === "number",
|
|
114
|
+
);
|
|
115
|
+
if (!seen || ended(seen)) return null;
|
|
116
|
+
const closed = rows.some((row) => ended(row) && row.pr === seen.pr && row.seq > seen.seq);
|
|
117
|
+
return closed ? null : (seen.pr as number);
|
|
118
|
+
};
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* The heads the ledger records as merged: `workit pr merge` (`pr.merged`), or
|
|
122
|
+
* the forge reporting the PR merged (`pr.status` state merged, as `workit pr
|
|
123
|
+
* status` and `workit ci wait` record a merge done in the forge UI). A PR
|
|
124
|
+
* closed without merging is not one. Newest last, at most three.
|
|
125
|
+
*/
|
|
126
|
+
const mergedHeads = (rows: readonly ReadRow[]): string[] =>
|
|
127
|
+
[
|
|
128
|
+
...new Set(
|
|
129
|
+
rows
|
|
130
|
+
.filter(
|
|
131
|
+
(row) =>
|
|
132
|
+
row.observer === "workit_cli" &&
|
|
133
|
+
typeof row.head === "string" &&
|
|
134
|
+
(row.type === "pr.merged" || (row.type === "pr.status" && prState(row) === "merged")),
|
|
135
|
+
)
|
|
136
|
+
.map((row) => row.head as string),
|
|
137
|
+
),
|
|
138
|
+
].slice(-3);
|
|
139
|
+
|
|
140
|
+
/** Is `sha` a commit in the local object store? */
|
|
141
|
+
const localCommit = (cwd: string, sha: string): boolean =>
|
|
142
|
+
/^[0-9a-f]{7,64}$/.test(sha) && gitList(cwd, ["cat-file", "-e", `${sha}^{commit}`]) !== null;
|
|
143
|
+
|
|
144
|
+
/** The branch's configured upstream was deleted on the remote and pruned. */
|
|
145
|
+
const upstreamGone = (cwd: string, branch: string): boolean =>
|
|
146
|
+
gitLine(cwd, ["for-each-ref", "--format=%(upstream:track)", `refs/heads/${branch}`]) === "[gone]";
|
|
147
|
+
|
|
148
|
+
/**
|
|
149
|
+
* Has the branch landed? A recorded merge (`mergedHeads`) of the current
|
|
150
|
+
* HEAD, or of a head that already contains every commit of this session on
|
|
151
|
+
* HEAD: the merge record and the branch both say done, so nothing is left to
|
|
152
|
+
* push (the remote branch may be deleted) or to verify (an `--unverified`
|
|
153
|
+
* merge was the user's call). With no merge recorded, an upstream that is
|
|
154
|
+
* gone (the forge deleted the merged branch and a fetch pruned it) says the
|
|
155
|
+
* same. New commits of this session after a recorded merge are not landed.
|
|
156
|
+
*/
|
|
157
|
+
const landed = (
|
|
158
|
+
cwd: string,
|
|
159
|
+
branch: string,
|
|
160
|
+
rows: readonly ReadRow[],
|
|
161
|
+
head: string | null,
|
|
162
|
+
mine: ReadonlySet<string>,
|
|
163
|
+
): boolean => {
|
|
164
|
+
if (!head) return false;
|
|
165
|
+
const merged = mergedHeads(rows);
|
|
166
|
+
if (merged.length === 0) return upstreamGone(cwd, branch);
|
|
167
|
+
if (merged.includes(head)) return true;
|
|
168
|
+
if (mine.size === 0) return false;
|
|
169
|
+
return merged.some((at) => {
|
|
170
|
+
// Commits on HEAD the merged head lacks; null when it is not a local commit.
|
|
171
|
+
const after = gitList(cwd, ["rev-list", `--max-count=${UNPUSHED_SCAN}`, "HEAD", "--not", at]);
|
|
172
|
+
return after !== null && !after.some((sha) => mine.has(sha));
|
|
173
|
+
});
|
|
174
|
+
};
|
|
175
|
+
|
|
176
|
+
/** How far back (a) looks for unpushed commits. */
|
|
177
|
+
const UNPUSHED_SCAN = 500;
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* (a) This session's recorded commits on HEAD that neither the push remote
|
|
181
|
+
* nor a recorded merge holds. Another session's or the user's commits never
|
|
182
|
+
* count, nor do commits already merged before the branch was reused.
|
|
183
|
+
*/
|
|
184
|
+
const unpushed = (
|
|
185
|
+
cwd: string,
|
|
186
|
+
branch: string,
|
|
187
|
+
endpoint: string,
|
|
188
|
+
mine: ReadonlySet<string>,
|
|
189
|
+
merged: readonly string[],
|
|
190
|
+
): StopObligation | null => {
|
|
191
|
+
const remote = pushRemoteName(cwd, branch);
|
|
192
|
+
if (!remote || mine.size === 0) return null;
|
|
193
|
+
const listed = gitList(cwd, [
|
|
194
|
+
"rev-list",
|
|
195
|
+
`--max-count=${UNPUSHED_SCAN}`,
|
|
196
|
+
"HEAD",
|
|
197
|
+
"--not",
|
|
198
|
+
`--remotes=${remote}`,
|
|
199
|
+
...merged.filter((sha) => localCommit(cwd, sha)),
|
|
200
|
+
]);
|
|
201
|
+
const count = (listed ?? []).filter((sha) => mine.has(sha)).length;
|
|
202
|
+
if (count === 0) return null;
|
|
203
|
+
return {
|
|
204
|
+
kind: "unpushed",
|
|
205
|
+
reason: `${branch} has ${count} commit(s) of this session not on ${remote}, and this workspace's endpoint is \`${endpoint}\` (\`workit grant show\`): push them with \`workit git push\`${endpoint === "pr" ? ", then open or update the PR (`workit pr create --fill`)" : ""}.`,
|
|
206
|
+
};
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
/** (b) The open PR's checks, as last recorded for the current head. */
|
|
210
|
+
const redChecks = (
|
|
211
|
+
rows: readonly ReadRow[],
|
|
212
|
+
pr: number,
|
|
213
|
+
head: string,
|
|
214
|
+
endpoint: string,
|
|
215
|
+
): StopObligation | null => {
|
|
216
|
+
const status = rows.findLast(
|
|
217
|
+
(row) => row.type === "pr.status" && row.observer === "workit_cli" && row.pr === pr,
|
|
218
|
+
);
|
|
219
|
+
const state = status && status.head === head && prState(status) === "open" ? status.checks : null;
|
|
220
|
+
if (state !== "failing" && state !== "pending") return null;
|
|
221
|
+
return {
|
|
222
|
+
kind: "checks",
|
|
223
|
+
reason: `PR #${pr}'s checks were last seen ${state} at ${short(head)}, and this workspace's endpoint is \`${endpoint}\`: run \`workit ci wait --pr ${pr}\`${state === "failing" ? " after fixing the failures `workit pr status` lists (then push)" : ""}.`,
|
|
224
|
+
};
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
/** (c) Delivered by this session, yet no accepted non-author verdict on the head. */
|
|
228
|
+
const unverified = (
|
|
229
|
+
cwd: string,
|
|
230
|
+
branch: string,
|
|
231
|
+
all: readonly ReadRow[],
|
|
232
|
+
descriptor: HostDescriptor,
|
|
233
|
+
): StopObligation | null => {
|
|
234
|
+
const check = checkVerdicts(cwd, branch, all);
|
|
235
|
+
if (check.accepted.accepted || check.accepted.reasons.includes("failing_verdict")) return null;
|
|
236
|
+
const has = check.review === "self-reviewed" ? "only a self verdict" : "no current verdict";
|
|
237
|
+
const verifier = `${descriptor.subagents.agentPrefix}verifier`;
|
|
238
|
+
return {
|
|
239
|
+
kind: "verdict",
|
|
240
|
+
reason: `the head ${short(check.head)} of ${branch}, which this session delivered, has ${has}: if no verifier is already running on it, hand it to a session that did not author it (the ${verifier} agent records \`workit ledger verdict <result> --branch ${branch} --how "<evidence>"\`), or tell the user it is unverified. Never record a verdict on your own work.`,
|
|
241
|
+
};
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/** The first unmet obligation for the session's branch, or null. Never throws. */
|
|
245
|
+
export function stopObligation(
|
|
246
|
+
input: HookInput,
|
|
247
|
+
descriptor: HostDescriptor,
|
|
248
|
+
): StopObligation | null {
|
|
249
|
+
try {
|
|
250
|
+
const session = input.session.id;
|
|
251
|
+
// Outside a Workit workspace there is no ledger either; the check only
|
|
252
|
+
// spares every other repository's Stop the git and ledger reads below.
|
|
253
|
+
if (!session || input.session.agentId || !inWorkitWorkspace(input.cwd)) return null;
|
|
254
|
+
const branch = currentBranch(input.cwd);
|
|
255
|
+
if (!branch || guardedBranch(input.cwd, branch)) return null;
|
|
256
|
+
const ledger = readLedger(input.cwd);
|
|
257
|
+
if (!ledger.ok) return null;
|
|
258
|
+
const all = ledger.value.rows;
|
|
259
|
+
const rows = all.filter((row) => row.branch === branch);
|
|
260
|
+
const mine = rows.filter((row) => sessionOf(row) === session);
|
|
261
|
+
const commits = new Set(
|
|
262
|
+
mine
|
|
263
|
+
.filter((row) => row.type === "commit.recorded")
|
|
264
|
+
.map((row) => (typeof row.sha === "string" ? row.sha : row.head))
|
|
265
|
+
.filter((sha): sha is string => typeof sha === "string"),
|
|
266
|
+
);
|
|
267
|
+
const delivered = mine.some(
|
|
268
|
+
(row) =>
|
|
269
|
+
row.observer === "workit_cli" &&
|
|
270
|
+
(row.type === "pr.created" || row.type === "delivery.verified"),
|
|
271
|
+
);
|
|
272
|
+
if (commits.size === 0 && !delivered) return null;
|
|
273
|
+
const head = gitLine(input.cwd, ["rev-parse", "HEAD"]);
|
|
274
|
+
if (landed(input.cwd, branch, rows, head, commits)) return null;
|
|
275
|
+
const endpoint = resolveAutonomy(input.cwd).effectiveEndpoint;
|
|
276
|
+
if (endpoint !== "commit") {
|
|
277
|
+
const left = unpushed(input.cwd, branch, endpoint, commits, mergedHeads(rows));
|
|
278
|
+
if (left) return left;
|
|
279
|
+
}
|
|
280
|
+
const pr = openPr(rows);
|
|
281
|
+
if ((endpoint === "green" || endpoint === "merged") && pr !== null && head) {
|
|
282
|
+
const left = redChecks(rows, pr, head, endpoint);
|
|
283
|
+
if (left) return left;
|
|
284
|
+
}
|
|
285
|
+
return delivered ? unverified(input.cwd, branch, all, descriptor) : null;
|
|
286
|
+
} catch {
|
|
287
|
+
return null;
|
|
288
|
+
}
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
/**
|
|
292
|
+
* The stop decision: continue once with the obligation, or allow. A stop
|
|
293
|
+
* that follows a continuation (`stopHookActive`), a subagent's stop, and a
|
|
294
|
+
* stop whose last message asks the user something are always allowed. So is
|
|
295
|
+
* a stop whose last message is unknown: it may be a question.
|
|
296
|
+
*/
|
|
297
|
+
export function stopDecision(input: HookInput, descriptor: HostDescriptor): HookDecision {
|
|
298
|
+
const event = input.event;
|
|
299
|
+
if (event.kind !== "stop" || event.stopHookActive) return NONE;
|
|
300
|
+
if (!event.lastMessage?.trim() || asksUser(event.lastMessage)) return NONE;
|
|
301
|
+
const left = stopObligation(input, descriptor);
|
|
302
|
+
return left
|
|
303
|
+
? {
|
|
304
|
+
kind: "continue",
|
|
305
|
+
reason: `Workit: before stopping, ${left.reason} If this cannot be done now, say so to the user and stop.`,
|
|
306
|
+
}
|
|
307
|
+
: NONE;
|
|
308
|
+
}
|
package/src/ledger.ts
CHANGED
|
@@ -1801,6 +1801,9 @@ export function summarizeRow(row: ReadRow): RowSummary {
|
|
|
1801
1801
|
case "pr.created":
|
|
1802
1802
|
summary = `#${row.pr ?? "?"} -> ${text("base")} at ${(row.head ?? "?").slice(0, 12)}${row.created === false ? " (existing)" : ""}`;
|
|
1803
1803
|
break;
|
|
1804
|
+
case "pr.status":
|
|
1805
|
+
summary = `#${row.pr ?? "?"} ${text("state") && text("state") !== "open" ? `${text("state")} ` : ""}checks ${text("checks") || "?"} at ${(row.head ?? "?").slice(0, 12)}`;
|
|
1806
|
+
break;
|
|
1804
1807
|
case "pr.merged":
|
|
1805
1808
|
summary = `#${row.pr ?? "?"} ${text("method")} at ${(row.head ?? "?").slice(0, 12)}${text("mergeSha") ? ` -> ${text("mergeSha").slice(0, 12)}` : ""}`;
|
|
1806
1809
|
break;
|