opencode-workflow-guard 1.6.0 → 1.7.1
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/README.md +9 -3
- package/bin/opencode-workflow-guard.mjs +69 -0
- package/package.json +5 -1
- package/src/lib/custom-tools.ts +2 -2
- package/src/lib/guard-dispatcher.ts +19 -12
- package/src/lib/project-memory.ts +2 -2
- package/src/lib/state.ts +11 -7
- package/src/policies/boundary.ts +1 -1
- package/src/policies/todo.ts +2 -2
- package/src/workflow-guard-ui.ts +1 -1
- package/src/workflow-guard.ts +18 -10
package/README.md
CHANGED
|
@@ -12,7 +12,13 @@ OpenCode plugin enforcing workflow discipline, agent focus, and deterministic sa
|
|
|
12
12
|
|
|
13
13
|
### 1. Installation
|
|
14
14
|
|
|
15
|
-
|
|
15
|
+
Run the setup command to register both the server guard and TUI companion globally:
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
npx opencode-workflow-guard setup
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
For server-only setup, add the package to your project's `opencode.json` or global `~/.config/opencode/opencode.json`:
|
|
16
22
|
|
|
17
23
|
```json
|
|
18
24
|
{
|
|
@@ -23,9 +29,9 @@ Add to your project's `opencode.json` or global `~/.config/opencode/opencode.jso
|
|
|
23
29
|
}
|
|
24
30
|
```
|
|
25
31
|
|
|
26
|
-
*Requires OpenCode >= 1.18.* See [docs/installation.md](docs/installation.md) for full setup options
|
|
32
|
+
*Requires OpenCode >= 1.18.* Restart OpenCode after setup. See [docs/installation.md](docs/installation.md) for full setup options.
|
|
27
33
|
|
|
28
|
-
For the optional TUI badge, configure `"opencode-workflow-guard
|
|
34
|
+
For the optional TUI badge, configure `"opencode-workflow-guard"` in `tui.json`. OpenCode resolves the package's exported `./tui` entrypoint automatically for TUI plugins. Do not place the TUI module under the server `plugins/` directory.
|
|
29
35
|
|
|
30
36
|
### 2. Verification
|
|
31
37
|
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { homedir } from "node:os";
|
|
5
|
+
import { join } from "node:path";
|
|
6
|
+
import { parse, modify, applyEdits } from "jsonc-parser";
|
|
7
|
+
|
|
8
|
+
const SERVER_SPEC = "opencode-workflow-guard";
|
|
9
|
+
const TUI_SPEC = "opencode-workflow-guard";
|
|
10
|
+
const LEGACY_TUI_SPEC = "opencode-workflow-guard/tui";
|
|
11
|
+
|
|
12
|
+
function configHome() {
|
|
13
|
+
return process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
function existingConfig(base, name) {
|
|
17
|
+
const json = join(base, name + ".json");
|
|
18
|
+
const jsonc = join(base, name + ".jsonc");
|
|
19
|
+
return existsSync(json) ? json : existsSync(jsonc) ? jsonc : json;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function preparePluginUpdate(path, schema, spec, legacySpec) {
|
|
23
|
+
const source = existsSync(path) ? readFileSync(path, "utf8") : `{
|
|
24
|
+
"$schema": "${schema}"
|
|
25
|
+
}\n`;
|
|
26
|
+
const errors = [];
|
|
27
|
+
const config = parse(source, errors, { allowTrailingComma: true, disallowComments: false });
|
|
28
|
+
if (errors.length > 0 || !config || typeof config !== "object" || Array.isArray(config)) {
|
|
29
|
+
throw new Error(`Cannot update invalid config: ${path}`);
|
|
30
|
+
}
|
|
31
|
+
if (config.plugin !== undefined && !Array.isArray(config.plugin)) {
|
|
32
|
+
throw new Error(`Cannot update ${path}: "plugin" must be an array`);
|
|
33
|
+
}
|
|
34
|
+
const plugins = config.plugin ?? [];
|
|
35
|
+
const matches = (entry, target) => entry === target || (Array.isArray(entry) && entry[0] === target);
|
|
36
|
+
if (!legacySpec) {
|
|
37
|
+
if (plugins.some((entry) => matches(entry, spec))) return undefined;
|
|
38
|
+
return applyEdits(source, modify(source, ["plugin", plugins.length], spec, {
|
|
39
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
40
|
+
}));
|
|
41
|
+
}
|
|
42
|
+
const updatedPlugins = plugins.filter((entry) => !matches(entry, legacySpec));
|
|
43
|
+
if (!updatedPlugins.some((entry) => matches(entry, spec))) updatedPlugins.push(spec);
|
|
44
|
+
if (updatedPlugins.length === plugins.length && updatedPlugins.every((entry, index) => entry === plugins[index])) return undefined;
|
|
45
|
+
return applyEdits(source, modify(source, ["plugin"], updatedPlugins, {
|
|
46
|
+
formattingOptions: { insertSpaces: true, tabSize: 2 },
|
|
47
|
+
}));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.argv[2] !== "setup" || process.argv.length > 3) {
|
|
51
|
+
console.error("Usage: opencode-workflow-guard setup");
|
|
52
|
+
process.exitCode = 1;
|
|
53
|
+
} else {
|
|
54
|
+
try {
|
|
55
|
+
const base = join(configHome(), "opencode");
|
|
56
|
+
const serverPath = existingConfig(base, "opencode");
|
|
57
|
+
const tuiPath = existingConfig(base, "tui");
|
|
58
|
+
const serverUpdate = preparePluginUpdate(serverPath, "https://opencode.ai/config.json", SERVER_SPEC);
|
|
59
|
+
const tuiUpdate = preparePluginUpdate(tuiPath, "https://opencode.ai/tui.json", TUI_SPEC, LEGACY_TUI_SPEC);
|
|
60
|
+
mkdirSync(base, { recursive: true });
|
|
61
|
+
if (serverUpdate !== undefined) writeFileSync(serverPath, serverUpdate);
|
|
62
|
+
if (tuiUpdate !== undefined) writeFileSync(tuiPath, tuiUpdate);
|
|
63
|
+
console.log(`Workflow Guard configured in ${serverPath} and ${tuiPath}.`);
|
|
64
|
+
console.log("Restart OpenCode to load both plugins.");
|
|
65
|
+
} catch (error) {
|
|
66
|
+
console.error(error instanceof Error ? error.message : String(error));
|
|
67
|
+
process.exitCode = 1;
|
|
68
|
+
}
|
|
69
|
+
}
|
package/package.json
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "opencode-workflow-guard",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.7.1",
|
|
4
4
|
"description": "OpenCode plugin enforcing workflow discipline through deterministic tool.execute.before hooks",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"main": "src/workflow-guard.ts",
|
|
8
|
+
"bin": {
|
|
9
|
+
"opencode-workflow-guard": "./bin/opencode-workflow-guard.mjs"
|
|
10
|
+
},
|
|
8
11
|
"exports": {
|
|
9
12
|
".": "./src/workflow-guard.ts",
|
|
10
13
|
"./server": "./src/workflow-guard.ts",
|
|
@@ -18,6 +21,7 @@
|
|
|
18
21
|
"url": "git+https://github.com/ultus-net/opencode-workflow-guard.git"
|
|
19
22
|
},
|
|
20
23
|
"files": [
|
|
24
|
+
"bin/",
|
|
21
25
|
"src/",
|
|
22
26
|
"README.md",
|
|
23
27
|
"LICENSE",
|
package/src/lib/custom-tools.ts
CHANGED
|
@@ -183,7 +183,7 @@ export function createCustomTools(options: {
|
|
|
183
183
|
execute: async (args, toolContext) => {
|
|
184
184
|
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree creation with no active todo item. Break the request down with todowrite first, then create worktrees.";
|
|
185
185
|
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = createGitWorktree(args.branch, args.baseBranch ?? "HEAD", toolRoot); if (!res.success) return `[workflow-guard] Failed to create worktree: ${res.error}`;
|
|
186
|
-
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID); return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
|
|
186
|
+
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree created successfully at: ${res.worktreePath}\nRun subagent tasks or pass worktree directory context to isolate file mutations.`;
|
|
187
187
|
},
|
|
188
188
|
}),
|
|
189
189
|
guard_worktree_cleanup: tool({
|
|
@@ -191,7 +191,7 @@ export function createCustomTools(options: {
|
|
|
191
191
|
execute: async (args, toolContext) => {
|
|
192
192
|
const todos = await effectiveTodos(toolContext.sessionID); if (todos !== undefined && !hasActiveTodo(todos)) return "[workflow-guard] Blocked: worktree cleanup with no active todo item. Break the request down with todowrite first, then clean up worktrees.";
|
|
193
193
|
const toolRoot = toolContext.worktree || toolContext.directory || effectiveRoot; const res = cleanupGitWorktree(args.worktreePath, toolRoot); if (!res.success) return `[workflow-guard] Failed to clean up worktree: ${res.error}`;
|
|
194
|
-
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID); return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
194
|
+
recordMutation((await effectiveTodoOwnerSessionID(toolContext.sessionID)) ?? toolContext.sessionID, toolContext.sessionID); return `[workflow-guard] Worktree at '${args.worktreePath}' cleaned up successfully.`;
|
|
195
195
|
},
|
|
196
196
|
}),
|
|
197
197
|
};
|
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
unwrapShellCommand,
|
|
25
25
|
} from "./shell.ts";
|
|
26
26
|
import { asRecord, extractTargetPath } from "./utils.ts";
|
|
27
|
+
import { getProjectMemoryIdentity } from "./project-memory.ts";
|
|
27
28
|
import {
|
|
28
29
|
detectVerifyCommand,
|
|
29
30
|
getCurrentGitCommitHash,
|
|
@@ -272,7 +273,7 @@ export async function guardToolCallImpl(
|
|
|
272
273
|
return claimReason;
|
|
273
274
|
}
|
|
274
275
|
}
|
|
275
|
-
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
276
|
+
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
276
277
|
return undefined;
|
|
277
278
|
}
|
|
278
279
|
|
|
@@ -285,6 +286,11 @@ export async function guardToolCallImpl(
|
|
|
285
286
|
}
|
|
286
287
|
}
|
|
287
288
|
if (!SHELL_TOOL_NAMES.has(toolName)) return undefined;
|
|
289
|
+
const shellWorkdir = asRecord(input)?.workdir;
|
|
290
|
+
const requestedShellRoot = typeof shellWorkdir === "string" ? resolve(currentRoot, shellWorkdir) : currentRoot;
|
|
291
|
+
const shellRoot = !isPathOutsideWorkspace(requestedShellRoot, currentRoot) || getProjectMemoryIdentity(requestedShellRoot) === getProjectMemoryIdentity(currentRoot)
|
|
292
|
+
? requestedShellRoot
|
|
293
|
+
: currentRoot;
|
|
288
294
|
|
|
289
295
|
for (const raw of extractCommands(input)) {
|
|
290
296
|
const dynamicSyntax = dynamicShellSyntaxIn(raw);
|
|
@@ -348,7 +354,7 @@ export async function guardToolCallImpl(
|
|
|
348
354
|
if (onProtectedBranch(currentRoot)) return branchGuardReason();
|
|
349
355
|
const todos = await effectiveTodos(context?.sessionID);
|
|
350
356
|
if (todos !== undefined && !hasActiveTodo(todos)) return "Blocked: inline interpreter file mutation with no active todo item.";
|
|
351
|
-
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
357
|
+
recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
352
358
|
}
|
|
353
359
|
if (!allowLive) {
|
|
354
360
|
const liveCheck = liveMutationIn(normalizeGitCommands(normalize(payload)));
|
|
@@ -402,24 +408,25 @@ export async function guardToolCallImpl(
|
|
|
402
408
|
}
|
|
403
409
|
}
|
|
404
410
|
if (hasPrCreateInvocation(raw)) {
|
|
411
|
+
const prRoot = shellRoot;
|
|
405
412
|
const isAz = /\baz\s+repos\s+pr\s+create\b/.test(normalizedCommand);
|
|
406
413
|
const prTool = isAz ? "az repos pr create" : "gh pr create";
|
|
407
414
|
const descFlag = isAz ? "--description" : "--body";
|
|
408
415
|
const preflightFailures: string[] = [];
|
|
409
|
-
const conflictCheck = checkMergeConflicts(
|
|
416
|
+
const conflictCheck = checkMergeConflicts(prRoot);
|
|
410
417
|
if (conflictCheck.hasConflicts) preflightFailures.push(conflictCheck.reason ?? "Branch has merge conflicts with its base branch.");
|
|
411
|
-
const branch = currentGitBranch(
|
|
418
|
+
const branch = currentGitBranch(prRoot);
|
|
412
419
|
if (branch) {
|
|
413
|
-
const mergedStatus = isBranchAlreadyMergedOrClosed(
|
|
420
|
+
const mergedStatus = isBranchAlreadyMergedOrClosed(prRoot, branch);
|
|
414
421
|
if (mergedStatus.merged) preflightFailures.push(mergedStatus.reason ?? "Branch is already merged or closed.");
|
|
415
422
|
}
|
|
416
|
-
if (isReviewRequired(
|
|
423
|
+
if (isReviewRequired(prRoot)) {
|
|
417
424
|
const review = context?.sessionID ? (sessionReviews.get(context.sessionID) ?? getLastReviewResult()) : getLastReviewResult();
|
|
418
|
-
const reviewMatchesContext = review?.passed === true && review.workspace === resolve(
|
|
425
|
+
const reviewMatchesContext = review?.passed === true && review.workspace === resolve(prRoot) && (!review.targetSessionID || review.targetSessionID === context?.sessionID) && review.commitHash === getCurrentGitCommitHash(prRoot) && review.gitStatus === getGitStatusSummary(prRoot) && review.worktreeFingerprint === getGitWorktreeFingerprint(prRoot);
|
|
419
426
|
if (!reviewMatchesContext) preflightFailures.push("Passing secondary review approval is required; invoke a secondary review subagent and record approval with record_review.");
|
|
420
427
|
}
|
|
421
|
-
if (isDocumentationRequired(
|
|
422
|
-
const branchChangelog = branchHasChangelogChange(
|
|
428
|
+
if (isDocumentationRequired(prRoot) && !branchHasDocumentationChange(prRoot)) preflightFailures.push("Documentation update is required (Policy 21); update README.md or relevant documentation in docs/.");
|
|
429
|
+
const branchChangelog = branchHasChangelogChange(prRoot);
|
|
423
430
|
let shellCwdKnown = true;
|
|
424
431
|
const prSegments: Array<{ segment: string; invocationRoot: string | null }> = [];
|
|
425
432
|
for (const segment of splitShellSegments(raw)) {
|
|
@@ -427,12 +434,12 @@ export async function guardToolCallImpl(
|
|
|
427
434
|
if (/(?:^|\s)(?:(?:builtin|command)\s+)?(?:cd|pushd|popd)(?:\s|$)/.test(unwrapped)) shellCwdKnown = false;
|
|
428
435
|
if (hasPrCreateInvocation(segment)) {
|
|
429
436
|
const wrapperChangesCwd = shellWrappersChangeCwd(segment);
|
|
430
|
-
prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ?
|
|
437
|
+
prSegments.push({ segment, invocationRoot: shellCwdKnown && !wrapperChangesCwd ? prRoot : null });
|
|
431
438
|
}
|
|
432
439
|
}
|
|
433
440
|
const hasChangelog = branchChangelog || prSegments.every(({ segment, invocationRoot }) => prBodyIncludesChangelog(segment, invocationRoot));
|
|
434
441
|
if (!hasChangelog) preflightFailures.push(`Release information is required; update a CHANGELOG/changeset file or include a Summary, Changes, Release notes, or Changelog section in the PR description (${descFlag}).`);
|
|
435
|
-
const lockCheck = checkLockfileSync(
|
|
442
|
+
const lockCheck = checkLockfileSync(prRoot);
|
|
436
443
|
if (lockCheck.isOutOfSync) preflightFailures.push(lockCheck.reason ?? "Dependency lockfile is out of sync with its manifest.");
|
|
437
444
|
if (preflightFailures.length > 0) {
|
|
438
445
|
const reason = `Blocked: PR preflight failed:\n${preflightFailures.map((failure) => `- ${failure}`).join("\n")}`;
|
|
@@ -465,7 +472,7 @@ export async function guardToolCallImpl(
|
|
|
465
472
|
}
|
|
466
473
|
}
|
|
467
474
|
}
|
|
468
|
-
if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID));
|
|
475
|
+
if (hasGitMutation) recordMutation(await effectiveTodoOwnerSessionID(context?.sessionID), context?.sessionID);
|
|
469
476
|
}
|
|
470
477
|
return undefined;
|
|
471
478
|
}
|
|
@@ -39,9 +39,9 @@ export function ensureProjectMemoryExcluded(root: string): boolean {
|
|
|
39
39
|
export function isProjectMemoryFresh(memory: ProjectMemoryRecord, root: string): boolean {
|
|
40
40
|
if (!memory.commit || memory.paths.length === 0) return true;
|
|
41
41
|
if (!/^[0-9a-f]{7,64}$/i.test(memory.commit)) return false;
|
|
42
|
-
const result = spawnSync("git", ["-C", root, "diff", "--quiet", memory.commit, "--", ...memory.paths], { encoding: "utf8" });
|
|
42
|
+
const result = spawnSync("git", ["-C", root, "diff", "--quiet", memory.commit, "--", ...memory.paths], { encoding: "utf8", timeout: 2_000 });
|
|
43
43
|
if (result.status !== 0) return false;
|
|
44
|
-
const untracked = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "--", ...memory.paths], { encoding: "utf8" });
|
|
44
|
+
const untracked = spawnSync("git", ["-C", root, "ls-files", "--others", "--exclude-standard", "--", ...memory.paths], { encoding: "utf8", timeout: 2_000 });
|
|
45
45
|
return untracked.status === 0 && !untracked.stdout.trim();
|
|
46
46
|
}
|
|
47
47
|
|
package/src/lib/state.ts
CHANGED
|
@@ -79,16 +79,16 @@ export const sessionVerifyResults = new Map<string, NonNullable<VerifyResult>>()
|
|
|
79
79
|
export let lastReview: ReviewResult | undefined;
|
|
80
80
|
export const sessionReviews = new Map<string, ReviewResult>();
|
|
81
81
|
|
|
82
|
-
export function recordMutation(sessionID?: string): void {
|
|
82
|
+
export function recordMutation(sessionID?: string, actorSessionID?: string): void {
|
|
83
83
|
lastMutationTimestamp = Date.now();
|
|
84
84
|
mutationCount++;
|
|
85
|
-
|
|
86
|
-
sessionMutationTimestamps.set(
|
|
87
|
-
sessionMutationCounts.set(
|
|
88
|
-
sessionVerifyResults.delete(
|
|
85
|
+
for (const id of new Set([sessionID, actorSessionID].filter((value): value is string => Boolean(value)))) {
|
|
86
|
+
sessionMutationTimestamps.set(id, lastMutationTimestamp);
|
|
87
|
+
sessionMutationCounts.set(id, (sessionMutationCounts.get(id) ?? 0) + 1);
|
|
88
|
+
sessionVerifyResults.delete(id);
|
|
89
|
+
sessionReviews.delete(id);
|
|
89
90
|
}
|
|
90
|
-
if (sessionID
|
|
91
|
-
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID) {
|
|
91
|
+
if (!lastReview?.targetSessionID || lastReview.targetSessionID === sessionID || lastReview.targetSessionID === actorSessionID) {
|
|
92
92
|
lastReview = undefined;
|
|
93
93
|
}
|
|
94
94
|
}
|
|
@@ -100,6 +100,10 @@ export function getMutationCount(sessionID?: string): number {
|
|
|
100
100
|
return mutationCount;
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
export function getSessionMutationCount(sessionID: string): number {
|
|
104
|
+
return sessionMutationCounts.get(sessionID) ?? 0;
|
|
105
|
+
}
|
|
106
|
+
|
|
103
107
|
export function getLastMutationTimestamp(): number {
|
|
104
108
|
return lastMutationTimestamp;
|
|
105
109
|
}
|
package/src/policies/boundary.ts
CHANGED
|
@@ -384,7 +384,7 @@ export async function guardShellMutation(
|
|
|
384
384
|
}
|
|
385
385
|
}
|
|
386
386
|
if (hasMutation) {
|
|
387
|
-
recordMutation(await effectiveTodoOwnerSessionID(sessionID));
|
|
387
|
+
recordMutation(await effectiveTodoOwnerSessionID(sessionID), sessionID);
|
|
388
388
|
}
|
|
389
389
|
return undefined;
|
|
390
390
|
}
|
package/src/policies/todo.ts
CHANGED
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import type { TodoItem } from "../lib/types.ts";
|
|
2
|
-
import {
|
|
2
|
+
import { getSessionMutationCount, getSdkClient, getSubagentMutationBudget } from "../lib/state.ts";
|
|
3
3
|
|
|
4
4
|
export const EDIT_TOOL_NAMES = new Set(["edit", "write", "patch", "apply_patch"]);
|
|
5
5
|
export const ACTIVE_TODO_STATUSES = new Set(["pending", "in_progress"]);
|
|
@@ -28,7 +28,7 @@ export async function subagentMutationBudgetReason(
|
|
|
28
28
|
root: string,
|
|
29
29
|
): Promise<string | undefined> {
|
|
30
30
|
if (!await fetchParentSessionID(sessionID)) return undefined;
|
|
31
|
-
const count =
|
|
31
|
+
const count = getSessionMutationCount(sessionID);
|
|
32
32
|
const budget = getSubagentMutationBudget(root);
|
|
33
33
|
if (count < budget) return undefined;
|
|
34
34
|
return `Blocked: subagent session '${sessionID}' has reached its mutation budget (${count}/${budget}). Hand work back to parent orchestrator.`;
|
package/src/workflow-guard-ui.ts
CHANGED
|
@@ -29,7 +29,7 @@ function text(props: Record<string, unknown>, children: Child[]) {
|
|
|
29
29
|
return element("text", props, children);
|
|
30
30
|
}
|
|
31
31
|
|
|
32
|
-
const BADGE_ACTIVE = "
|
|
32
|
+
const BADGE_ACTIVE = "Workflow Guard 🛡️";
|
|
33
33
|
let lastBlockedReason: string | undefined;
|
|
34
34
|
const sessionBlockedReasons = new Map<string, string>();
|
|
35
35
|
const badgeNodes = new Map<string, unknown>();
|
package/src/workflow-guard.ts
CHANGED
|
@@ -47,6 +47,7 @@ import {
|
|
|
47
47
|
lastMutationTimestamp,
|
|
48
48
|
getLastMutationTimestamp,
|
|
49
49
|
getMutationCount,
|
|
50
|
+
getSessionMutationCount,
|
|
50
51
|
lastVerify,
|
|
51
52
|
getLastVerifyResult,
|
|
52
53
|
recordMutation,
|
|
@@ -447,20 +448,24 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
447
448
|
return s === "pending" || s === "in_progress";
|
|
448
449
|
});
|
|
449
450
|
const branch = currentGitBranch(effectiveRoot) ?? "unknown";
|
|
450
|
-
const isProtected =
|
|
451
|
+
const isProtected = branch !== "unknown" && isProtectedBranchName(branch, effectiveRoot);
|
|
452
|
+
const sessionMutationCount = sessionID ? getSessionMutationCount(sessionID) : 0;
|
|
451
453
|
const lastV = sessionID
|
|
452
|
-
? (sessionVerifyResults.get(sessionID) ??
|
|
454
|
+
? (sessionVerifyResults.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionVerifyResults.get(parentID) : undefined))
|
|
453
455
|
: lastVerify;
|
|
454
|
-
const lastR =
|
|
455
|
-
|
|
456
|
+
const lastR = sessionID
|
|
457
|
+
? (sessionReviews.get(sessionID) ?? (sessionMutationCount === 0 && parentID ? sessionReviews.get(parentID) : undefined))
|
|
458
|
+
: getLastReviewResult();
|
|
459
|
+
const mutationCountVal = sessionID ? sessionMutationCount : getMutationCount();
|
|
456
460
|
|
|
457
461
|
const contextBlocks: string[] = [];
|
|
458
462
|
|
|
459
463
|
if (active && active.length > 0) {
|
|
460
|
-
const lines = active.map(
|
|
464
|
+
const lines = active.slice(0, 20).map(
|
|
461
465
|
(t) =>
|
|
462
|
-
`- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "")}`,
|
|
466
|
+
`- [${String(t.status) === "in_progress" ? "IN PROGRESS" : "PENDING"}] ${String(t.content ?? "").slice(0, 300)}`,
|
|
463
467
|
);
|
|
468
|
+
if (active.length > 20) lines.push(`- ... ${active.length - 20} more active task(s) omitted`);
|
|
464
469
|
const attribution = parentID
|
|
465
470
|
? ` (Subagent session: ${sessionID}, Parent: ${parentID})`
|
|
466
471
|
: sessionID
|
|
@@ -488,15 +493,18 @@ export const WorkflowGuard: Plugin = async (ctx) => {
|
|
|
488
493
|
`- Secondary Review: ${lastR.passed ? "APPROVED" : "CHANGES REQUESTED"} by ${lastR.reviewer}`,
|
|
489
494
|
);
|
|
490
495
|
}
|
|
491
|
-
|
|
496
|
+
let openFollowups: ReturnType<typeof listReviewFollowups> = [];
|
|
497
|
+
try { openFollowups = followupStore ? listReviewFollowups(followupStore, "open", 8) : []; } catch {}
|
|
492
498
|
if (openFollowups.length > 0) {
|
|
493
499
|
stateLines.push(`- Open Review Follow-ups: ${openFollowups.length} local P2/P3 item(s)`);
|
|
494
500
|
contextBlocks.push(`## Review Follow-ups\n${openFollowups.map((item) => `- [${item.severity}:${item.id.slice(0, 8)}] ${item.summary.slice(0, 300)}`).join("\n")}\nTreat these as durable technical debt: address relevant items when practical and resolve them explicitly after verification.`);
|
|
495
501
|
}
|
|
496
502
|
contextBlocks.push(stateLines.join("\n"));
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
503
|
+
let projectKnowledge: ReturnType<typeof getRecentProjectMemory> = [];
|
|
504
|
+
try {
|
|
505
|
+
projectKnowledge = (projectMemory ? getRecentProjectMemory(projectMemory, 8) : [])
|
|
506
|
+
.filter((memory) => memory.source !== "portable" && isProjectMemoryFresh(memory, effectiveRoot));
|
|
507
|
+
} catch {}
|
|
500
508
|
if (projectKnowledge.length > 0) {
|
|
501
509
|
const lines = projectKnowledge.map((memory) => `- [${memory.kind}:${memory.id.slice(0, 8)}] ${memory.content.slice(0, 300)}`);
|
|
502
510
|
contextBlocks.push(`## Project Memory\n${lines.join("\n")}\nTreat these as historical project knowledge; verify against current repository state when relevant files have changed.`);
|