pi-goal-list-loop-audit 0.25.6 → 0.26.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/extensions/goal-settings.ts +3 -0
- package/extensions/loops/goal.ts +151 -1
- package/extensions/reviewer.ts +276 -0
- package/package.json +1 -1
|
@@ -60,6 +60,9 @@ export interface Settings {
|
|
|
60
60
|
* override without the model pin so subagents share the session model and
|
|
61
61
|
* its quota; "agent-default" restores upstream behavior. Applies to NEW
|
|
62
62
|
* sessions (pi-subagents registers agents at session start). */
|
|
63
|
+
/** v0.26.0: reviewer (post-completion follow-up enqueuer) config —
|
|
64
|
+
* project-scoped; see extensions/reviewer.ts DEFAULT_REVIEWER_CONFIG. */
|
|
65
|
+
reviewer?: Record<string, unknown>;
|
|
63
66
|
subagentModelStrategy?: SubagentModelStrategy;
|
|
64
67
|
/** v0.24.6: per-agent-type model pin, e.g. { "Explore": "minimax/MiniMax-M3" }.
|
|
65
68
|
* Always wins over subagentModelStrategy — the managed override is written
|
package/extensions/loops/goal.ts
CHANGED
|
@@ -102,6 +102,13 @@ import {
|
|
|
102
102
|
settingsProvenance,
|
|
103
103
|
type Settings,
|
|
104
104
|
} from "../goal-settings.js";
|
|
105
|
+
import {
|
|
106
|
+
DEFAULT_REVIEWER_CONFIG,
|
|
107
|
+
resolveReviewerConfig,
|
|
108
|
+
reviewerMenuOptions,
|
|
109
|
+
runReviewer,
|
|
110
|
+
type ReviewerConfig,
|
|
111
|
+
} from "../reviewer.js";
|
|
105
112
|
import {
|
|
106
113
|
discoverGllaProjects,
|
|
107
114
|
parseLedgerEntries,
|
|
@@ -517,7 +524,73 @@ function archiveCurrentGoal(ctx: ExtensionContext, status: Status, stopReason?:
|
|
|
517
524
|
// (v0.2.0 bug: bare /list next silently consumed TWO items, found by the
|
|
518
525
|
// pick-any-item verification in v0.10.0).
|
|
519
526
|
if (goal.policy === "list" && status === "complete") {
|
|
520
|
-
activateNextListItem(ctx);
|
|
527
|
+
const advanced = activateNextListItem(ctx);
|
|
528
|
+
// v0.26.0: the queue just EMPTIED on a completion → list-complete.
|
|
529
|
+
if (!advanced) fireReviewer(ctx, { kind: "list", goalId: goal.id, objective: goal.objective, terminal: "goal-complete" });
|
|
530
|
+
return;
|
|
531
|
+
}
|
|
532
|
+
// v0.26.0: a /goal (non-list) reached a terminal state → maybe fire.
|
|
533
|
+
if (goal.policy !== "list") {
|
|
534
|
+
fireReviewer(ctx, { kind: "goal", goalId: goal.id, objective: goal.objective, terminal: status === "complete" ? "goal-complete" : status === "aborted" ? "goal-aborted" : "goal-paused" });
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
/**
|
|
539
|
+
* v0.26.0: bind the reviewer to the live session. Sources for finding
|
|
540
|
+
* extraction: the archived goal markdown + its audit reports + the
|
|
541
|
+
* durable audit log entries for this goal. List items are enqueued via
|
|
542
|
+
* the ONE enqueue path; /goal proposals go through the agent (which
|
|
543
|
+
* calls propose_goal_draft → the user's Confirm dialog).
|
|
544
|
+
*/
|
|
545
|
+
function fireReviewer(
|
|
546
|
+
ctx: ExtensionContext,
|
|
547
|
+
source: { kind: "goal" | "list"; goalId: string; objective: string; terminal: string },
|
|
548
|
+
opts: { manual?: boolean } = {},
|
|
549
|
+
): void {
|
|
550
|
+
try {
|
|
551
|
+
const settings = loadSettings(ctx.cwd);
|
|
552
|
+
const config = resolveReviewerConfig(settings.reviewer as Partial<ReviewerConfig> | undefined);
|
|
553
|
+
const sources: Array<{ name: string; text: string }> = [];
|
|
554
|
+
try {
|
|
555
|
+
sources.push({ name: "archive", text: fs.readFileSync(archivedGoalPath(ctx.cwd, source.goalId), "utf-8") });
|
|
556
|
+
} catch {
|
|
557
|
+
/* archive md may not exist for manual review of a live goal */
|
|
558
|
+
}
|
|
559
|
+
const auditTexts = readAuditLog(ctx.cwd)
|
|
560
|
+
.filter((e) => e.goalId === source.goalId)
|
|
561
|
+
.map((e) => e.report);
|
|
562
|
+
for (const t of auditTexts) sources.push({ name: "audit", text: t });
|
|
563
|
+
let ledgerEntries: Array<{ type: string; at?: string; value?: any }> = [];
|
|
564
|
+
try {
|
|
565
|
+
ledgerEntries = parseLedgerEntries(fs.readFileSync(ledgerPath(ctx.cwd), "utf-8"));
|
|
566
|
+
} catch {
|
|
567
|
+
/* no ledger yet */
|
|
568
|
+
}
|
|
569
|
+
const outcome = runReviewer(config, source, {
|
|
570
|
+
cwd: ctx.cwd,
|
|
571
|
+
nowMs: Date.now(),
|
|
572
|
+
manual: opts.manual,
|
|
573
|
+
ledgerEntries,
|
|
574
|
+
sources,
|
|
575
|
+
enqueueListItems: (objectives) => enqueueItems(ctx, objectives, "reviewer"),
|
|
576
|
+
proposeGoal: (objective, reason) => {
|
|
577
|
+
try {
|
|
578
|
+
extensionApi?.sendUserMessage(
|
|
579
|
+
`[REVIEWER FOLLOW-UP — ${reason}. Propose this as a /goal via propose_goal_draft (the user Confirms or rejects): ${objective}]`,
|
|
580
|
+
{ deliverAs: ctx.isIdle() ? "followUp" : "steer" },
|
|
581
|
+
);
|
|
582
|
+
} catch {
|
|
583
|
+
/* proposal best-effort */
|
|
584
|
+
}
|
|
585
|
+
},
|
|
586
|
+
notify: (message, level) => ctx.ui.notify(message, level),
|
|
587
|
+
ledger: (type, value) => appendLedger(ctx.cwd, type, value),
|
|
588
|
+
});
|
|
589
|
+
if (!outcome.fired && outcome.suppressedReason && opts.manual) {
|
|
590
|
+
ctx.ui.notify(`Reviewer suppressed: ${outcome.suppressedReason}`, "info");
|
|
591
|
+
}
|
|
592
|
+
} catch (err) {
|
|
593
|
+
ctx.ui.notify(`Reviewer failed (non-fatal): ${err instanceof Error ? err.message : String(err)}`, "warning");
|
|
521
594
|
}
|
|
522
595
|
}
|
|
523
596
|
|
|
@@ -2766,6 +2839,73 @@ async function openSettingsUI(ctx: ExtensionContext): Promise<void> {
|
|
|
2766
2839
|
}
|
|
2767
2840
|
}
|
|
2768
2841
|
|
|
2842
|
+
/** v0.26.0: /review <archived-goal-id> — manual reviewer invocation. */
|
|
2843
|
+
async function cmdReview(args: string, ctx: ExtensionContext): Promise<void> {
|
|
2844
|
+
const id = args.trim();
|
|
2845
|
+
if (!id) {
|
|
2846
|
+
ctx.ui.notify("Usage: /review <goal-id> — see /goal archive for ids.", "info");
|
|
2847
|
+
return;
|
|
2848
|
+
}
|
|
2849
|
+
// Resolve the id against the archive (suffix match allowed).
|
|
2850
|
+
let goalId = id;
|
|
2851
|
+
let objective = "(archived goal)";
|
|
2852
|
+
try {
|
|
2853
|
+
const files = fs.readdirSync(archiveDir(ctx.cwd)).filter((f) => f.endsWith(".md"));
|
|
2854
|
+
const match = files.find((f) => f === `${id}.md`) ?? files.find((f) => f.includes(id));
|
|
2855
|
+
if (!match) {
|
|
2856
|
+
ctx.ui.notify(`No archived goal matching "${id}". /goal archive lists them.`, "warning");
|
|
2857
|
+
return;
|
|
2858
|
+
}
|
|
2859
|
+
goalId = match.replace(/\.md$/, "");
|
|
2860
|
+
const md = fs.readFileSync(path.join(archiveDir(ctx.cwd), match), "utf-8");
|
|
2861
|
+
const objMatch = md.match(/## Objective\n\n> ([\s\S]*?)(?:\n\n|$)/);
|
|
2862
|
+
if (objMatch) objective = objMatch[1]!.replace(/\n/g, " ").slice(0, 300);
|
|
2863
|
+
} catch {
|
|
2864
|
+
ctx.ui.notify(`No archive found for ${id}.`, "warning");
|
|
2865
|
+
return;
|
|
2866
|
+
}
|
|
2867
|
+
fireReviewer(ctx, { kind: "goal", goalId, objective, terminal: "goal-complete" }, { manual: true });
|
|
2868
|
+
}
|
|
2869
|
+
|
|
2870
|
+
/** v0.26.0: /glla reviewer — the reviewer config menu (project-scoped). */
|
|
2871
|
+
async function cmdReviewerSettings(ctx: ExtensionContext): Promise<void> {
|
|
2872
|
+
if (!ctx.hasUI) {
|
|
2873
|
+
const cfg = resolveReviewerConfig(loadSettings(ctx.cwd).reviewer as Partial<ReviewerConfig> | undefined);
|
|
2874
|
+
ctx.ui.notify(`reviewer (project): ${JSON.stringify(cfg, null, 2)}`, "info");
|
|
2875
|
+
return;
|
|
2876
|
+
}
|
|
2877
|
+
const load = () => resolveReviewerConfig(loadSettings(ctx.cwd).reviewer as Partial<ReviewerConfig> | undefined);
|
|
2878
|
+
const save = (patch: Partial<ReviewerConfig>) => saveSettings("project", ctx.cwd, { reviewer: { ...load(), ...patch } as Record<string, unknown> });
|
|
2879
|
+
for (;;) {
|
|
2880
|
+
const cfg = load();
|
|
2881
|
+
let choice: string | undefined;
|
|
2882
|
+
try {
|
|
2883
|
+
choice = await ctx.ui.select("Reviewer — post-completion follow-up enqueuer (project settings)", reviewerMenuOptions(cfg));
|
|
2884
|
+
} catch {
|
|
2885
|
+
return;
|
|
2886
|
+
}
|
|
2887
|
+
if (!choice || choice === "Done") return;
|
|
2888
|
+
try {
|
|
2889
|
+
if (choice.startsWith("Enabled")) save({ enabled: !cfg.enabled });
|
|
2890
|
+
else if (choice.startsWith("Leverage mode")) save({ leverageMode: cfg.leverageMode === "fix-without-confirm" ? "confirm-all" : "fix-without-confirm" });
|
|
2891
|
+
else if (choice.startsWith("Fire on goal-complete")) save({ fireOn: cfg.fireOn.includes("goal-complete") ? cfg.fireOn.filter((e) => e !== "goal-complete") : [...cfg.fireOn, "goal-complete"] });
|
|
2892
|
+
else if (choice.startsWith("Fire on list-complete")) save({ fireOn: cfg.fireOn.includes("list-complete") ? cfg.fireOn.filter((e) => e !== "list-complete") : [...cfg.fireOn, "list-complete"] });
|
|
2893
|
+
else if (choice.startsWith("Cascade: audit-on-clean")) save({ cascade: cfg.cascade.includes("fire-audit-on-clean") ? cfg.cascade.filter((c) => c !== "fire-audit-on-clean") : [...cfg.cascade, "fire-audit-on-clean"] });
|
|
2894
|
+
else if (choice.startsWith("Max findings")) {
|
|
2895
|
+
const v = await ctx.ui.input("Max findings per review", "1-50");
|
|
2896
|
+
const n = Number(v?.trim());
|
|
2897
|
+
if (Number.isSafeInteger(n) && n >= 1 && n <= 50) save({ maxFindingsPerReview: n });
|
|
2898
|
+
} else if (choice.startsWith("Max reviews")) {
|
|
2899
|
+
const v = await ctx.ui.input("Max reviewer fires per day", "1-100");
|
|
2900
|
+
const n = Number(v?.trim());
|
|
2901
|
+
if (Number.isSafeInteger(n) && n >= 1 && n <= 100) save({ maxReviewsPerDay: n });
|
|
2902
|
+
}
|
|
2903
|
+
} catch {
|
|
2904
|
+
/* individual save failures are non-fatal */
|
|
2905
|
+
}
|
|
2906
|
+
}
|
|
2907
|
+
}
|
|
2908
|
+
|
|
2769
2909
|
/**
|
|
2770
2910
|
* v0.25.2: /glla stats — one command, every project's rollup. Args:
|
|
2771
2911
|
* (none) markdown table, all discovered projects
|
|
@@ -2858,6 +2998,10 @@ async function cmdSettings(args: string, ctx: ExtensionContext): Promise<void> {
|
|
|
2858
2998
|
cmdAudits(trimmed.slice("audits".length).trim(), ctx);
|
|
2859
2999
|
return;
|
|
2860
3000
|
}
|
|
3001
|
+
if (/^reviewer\b/.test(trimmed)) {
|
|
3002
|
+
await cmdReviewerSettings(ctx);
|
|
3003
|
+
return;
|
|
3004
|
+
}
|
|
2861
3005
|
if (!trimmed) {
|
|
2862
3006
|
if (ctx.hasUI) {
|
|
2863
3007
|
await openSettingsUI(ctx);
|
|
@@ -3161,10 +3305,15 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3161
3305
|
["stats", "per-project ledger rollups: /glla stats [json|premature|project=<path>]"],
|
|
3162
3306
|
["audits", "audit-log browser: /glla audits [N|full] — recent verdicts from .pi-glla/audits.jsonl"],
|
|
3163
3307
|
["autoaccept=", "on: drafts activate without the Confirm dialog (unattended rigs)"],
|
|
3308
|
+
["reviewer", "reviewer config menu (post-completion follow-up enqueuer)"],
|
|
3164
3309
|
["project", "write a project override: /glla project key=value"],
|
|
3165
3310
|
]),
|
|
3166
3311
|
handler: settingsHandler,
|
|
3167
3312
|
});
|
|
3313
|
+
pi.registerCommand("review", {
|
|
3314
|
+
description: "Manually run the reviewer on an archived goal: /review <goal-id> — extracts findings, writes a report to .pi-glla/reviews/, enqueues bug-class fixes to /list, proposes architectural items as /goal. Bypasses the trigger gates (explicit user request).",
|
|
3315
|
+
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdReview(args, ctx); },
|
|
3316
|
+
});
|
|
3168
3317
|
pi.registerCommand("list", {
|
|
3169
3318
|
description: "Loop 2: the list of audited goals — order is the default, not the law. /list <describe tasks or name a plan file> (dumps get shaped into items, files import, 'Done when:' adds directly) | /list show | /list resume | /list next [n] | /list remove <n> | /list clear | /list cancel",
|
|
3170
3319
|
getArgumentCompletions: completions([
|
|
@@ -3173,6 +3322,7 @@ export default function (pi: ExtensionAPI): void {
|
|
|
3173
3322
|
["next", "activate the next item (or /list next <n> for position n)"],
|
|
3174
3323
|
["remove", "remove an item: /list remove <n>"],
|
|
3175
3324
|
["clear", "empty the list"],
|
|
3325
|
+
["depth", "queue depth, oldest item age, average item duration"],
|
|
3176
3326
|
["cancel", "stop the whole list: abort the active item + drop all waiting"],
|
|
3177
3327
|
]),
|
|
3178
3328
|
handler: (args: string, ctx: ExtensionContext) => { rememberCtx(ctx); return cmdList(args, ctx); },
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
// pi-goal-list-loop-audit — v0.26.0
|
|
2
|
+
// extensions/reviewer.ts
|
|
3
|
+
//
|
|
4
|
+
// The Reviewer: post-completion follow-up enqueuer. Fires after a /goal
|
|
5
|
+
// completes or a /list queue empties, extracts findings from the archive
|
|
6
|
+
// + ledger, classifies them by leverage, writes a review report, and
|
|
7
|
+
// cascades: bug/refactor findings → /list items (no Confirm, per the
|
|
8
|
+
// leverage principle), architectural findings → /goal proposal (Confirm),
|
|
9
|
+
// clean completions → audit /goal proposal, strategic-only → notify+idle.
|
|
10
|
+
//
|
|
11
|
+
// Deterministic by design (REVIEWER-DESIGN-2026-07-24: "makes NO new tool
|
|
12
|
+
// calls — purely analytical"). All side effects are injected so tests
|
|
13
|
+
// drive it without a pi host. /loop completions never trigger it.
|
|
14
|
+
|
|
15
|
+
import * as fs from "node:fs";
|
|
16
|
+
import * as path from "node:path";
|
|
17
|
+
|
|
18
|
+
export interface ReviewerConfig {
|
|
19
|
+
enabled: boolean;
|
|
20
|
+
fireOn: Array<"goal-complete" | "list-complete">;
|
|
21
|
+
doNotFireOn: string[];
|
|
22
|
+
cascade: Array<"convert-findings-to-list" | "queue-leftovers" | "fire-audit-on-clean" | "notify-and-idle">;
|
|
23
|
+
auditCadence: string;
|
|
24
|
+
auditScope: string;
|
|
25
|
+
leverageMode: "fix-without-confirm" | "confirm-all";
|
|
26
|
+
confirmOn: string[];
|
|
27
|
+
maxFindingsPerReview: number;
|
|
28
|
+
maxReviewsPerDay: number;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export const DEFAULT_REVIEWER_CONFIG: ReviewerConfig = {
|
|
32
|
+
enabled: true,
|
|
33
|
+
fireOn: ["goal-complete", "list-complete"],
|
|
34
|
+
doNotFireOn: ["goal-aborted", "goal-paused"],
|
|
35
|
+
cascade: ["convert-findings-to-list", "queue-leftovers", "fire-audit-on-clean", "notify-and-idle"],
|
|
36
|
+
auditCadence: "every-clean-completion",
|
|
37
|
+
auditScope: "regression-scan",
|
|
38
|
+
leverageMode: "fix-without-confirm",
|
|
39
|
+
confirmOn: ["architectural-decision", "new-dependency", "schema-change"],
|
|
40
|
+
maxFindingsPerReview: 10,
|
|
41
|
+
maxReviewsPerDay: 20,
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
/** Merge a partial project-settings block over the defaults. */
|
|
45
|
+
export function resolveReviewerConfig(block?: Partial<ReviewerConfig>): ReviewerConfig {
|
|
46
|
+
return { ...DEFAULT_REVIEWER_CONFIG, ...(block ?? {}) };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export type FindingClass = "bug" | "refactor" | "architectural" | "strategic";
|
|
50
|
+
|
|
51
|
+
export interface Finding {
|
|
52
|
+
text: string;
|
|
53
|
+
source: string;
|
|
54
|
+
class: FindingClass;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
/** Leverage classification (contract item 5). Order matters: strategic
|
|
58
|
+
* and architectural win over bug/refactor — "should we rewrite this
|
|
59
|
+
* broken schema" is a decision, not a fix. */
|
|
60
|
+
const CLASS_PATTERNS: Array<{ class: FindingClass; re: RegExp }> = [
|
|
61
|
+
{ class: "strategic", re: /\bshould we\b|\bdeprecat|ship this\??|strategic/i },
|
|
62
|
+
{ class: "architectural", re: /\brewrite\b|new dependency|schema change|architectural|redesign/i },
|
|
63
|
+
{ class: "bug", re: /\bTODO\b|\bFIXME\b|\bbug\b|\bissue\b|regression|broken|\bfixme\b/i },
|
|
64
|
+
{ class: "refactor", re: /could be cleaner|consider refactoring|duplicat|refactor|left ?out|follow[\s-]?up|deferred/i },
|
|
65
|
+
];
|
|
66
|
+
|
|
67
|
+
export function classifyFindingText(line: string): FindingClass | undefined {
|
|
68
|
+
const t = line.trim();
|
|
69
|
+
if (t.length < 8) return undefined;
|
|
70
|
+
for (const { class: cls, re } of CLASS_PATTERNS) {
|
|
71
|
+
if (re.test(t)) return cls;
|
|
72
|
+
}
|
|
73
|
+
return undefined;
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** Scan source texts line-by-line for finding-shaped content. */
|
|
77
|
+
export function extractFindings(sources: Array<{ name: string; text: string }>, max: number): Finding[] {
|
|
78
|
+
const out: Finding[] = [];
|
|
79
|
+
const seen = new Set<string>();
|
|
80
|
+
for (const { name, text } of sources) {
|
|
81
|
+
for (const line of text.split("\n")) {
|
|
82
|
+
const cls = classifyFindingText(line);
|
|
83
|
+
if (!cls) continue;
|
|
84
|
+
const clean = line.trim().replace(/^[-*>\s\[\]x]+/, "").slice(0, 200);
|
|
85
|
+
if (clean.length < 8 || seen.has(clean)) continue;
|
|
86
|
+
seen.add(clean);
|
|
87
|
+
out.push({ text: clean, source: name, class: cls });
|
|
88
|
+
if (out.length >= max) return out;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
return out;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Runaway prevention (contract item 6/9): a reviewer fire in the last
|
|
95
|
+
* `windowMs` suppresses re-firing — reviewer-created work completing
|
|
96
|
+
* immediately must not recursively fire the reviewer. */
|
|
97
|
+
export function reviewerFiredRecently(entries: Array<{ type: string; at?: string }>, windowMs: number, nowMs: number): boolean {
|
|
98
|
+
return entries.some((e) => e.type === "reviewer_fired" && e.at !== undefined && nowMs - Date.parse(e.at) < windowMs);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Per-day cap (contract item 10). */
|
|
102
|
+
export function reviewsToday(entries: Array<{ type: string; at?: string }>, nowMs: number): number {
|
|
103
|
+
const day = new Date(nowMs).toISOString().slice(0, 10);
|
|
104
|
+
return entries.filter((e) => e.type === "reviewer_fired" && e.at?.startsWith(day)).length;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
export interface ReviewReport {
|
|
108
|
+
goalId: string;
|
|
109
|
+
kind: "goal" | "list";
|
|
110
|
+
objective: string;
|
|
111
|
+
findings: Finding[];
|
|
112
|
+
cascadeStep: string;
|
|
113
|
+
at: string;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
export function formatReviewReport(r: ReviewReport): string {
|
|
117
|
+
const byClass = (c: FindingClass) => r.findings.filter((f) => f.class === c);
|
|
118
|
+
const section = (title: string, items: Finding[]) =>
|
|
119
|
+
items.length === 0 ? "" : `\n## ${title}\n\n${items.map((f) => `- ${f.text} _(${f.source})_`).join("\n")}\n`;
|
|
120
|
+
return [
|
|
121
|
+
`# Review — ${r.goalId}`,
|
|
122
|
+
"",
|
|
123
|
+
`**Kind**: ${r.kind} · **At**: ${r.at}`,
|
|
124
|
+
"",
|
|
125
|
+
"## Summary",
|
|
126
|
+
"",
|
|
127
|
+
`Completed: ${r.objective.slice(0, 300)}`,
|
|
128
|
+
"",
|
|
129
|
+
`**Cascade step**: ${r.cascadeStep}`,
|
|
130
|
+
"",
|
|
131
|
+
`## Findings (${r.findings.length})`,
|
|
132
|
+
section("Bug-class (enqueued to /list, no Confirm)", byClass("bug")),
|
|
133
|
+
section("Refactor-class (enqueued to /list, no Confirm)", byClass("refactor")),
|
|
134
|
+
section("Architectural-class (proposed as /goal, Confirm required)", byClass("architectural")),
|
|
135
|
+
section("Strategic-class (notify only)", byClass("strategic")),
|
|
136
|
+
r.findings.length === 0 ? "\n(none — completion looks clean)\n" : "",
|
|
137
|
+
].join("\n");
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export function writeReviewReport(cwd: string, report: ReviewReport): string {
|
|
141
|
+
const dir = path.join(cwd, ".pi-glla", "reviews");
|
|
142
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
143
|
+
const ts = report.at.replace(/[:.]/g, "-");
|
|
144
|
+
const file = path.join(dir, `${report.goalId}-${ts}.md`);
|
|
145
|
+
fs.writeFileSync(file, formatReviewReport(report));
|
|
146
|
+
return file;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Injectable side effects — goal.ts binds these to the live session. */
|
|
150
|
+
export interface ReviewerDeps {
|
|
151
|
+
cwd: string;
|
|
152
|
+
nowMs: number;
|
|
153
|
+
/** /review <id> manual invocation — bypasses fireOn/doNotFireOn gates,
|
|
154
|
+
* the refire window, and the day cap (the user asked explicitly). */
|
|
155
|
+
manual?: boolean;
|
|
156
|
+
ledgerEntries: Array<{ type: string; at?: string; value?: any }>;
|
|
157
|
+
/** Source texts for finding extraction (archive md, audit reports). */
|
|
158
|
+
sources: Array<{ name: string; text: string }>;
|
|
159
|
+
enqueueListItems: (objectives: string[]) => void;
|
|
160
|
+
proposeGoal: (objective: string, reason: string) => void;
|
|
161
|
+
notify: (message: string, level: "info" | "warning") => void;
|
|
162
|
+
ledger: (type: string, value: Record<string, unknown>) => void;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
export interface ReviewerOutcome {
|
|
166
|
+
fired: boolean;
|
|
167
|
+
suppressedReason?: string;
|
|
168
|
+
report?: ReviewReport;
|
|
169
|
+
reportPath?: string;
|
|
170
|
+
enqueued: number;
|
|
171
|
+
proposed: number;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export const REVIEWER_REFIRE_WINDOW_MS = 5 * 60_000;
|
|
175
|
+
|
|
176
|
+
/** The reviewer lifecycle (contract item 1). */
|
|
177
|
+
export function runReviewer(
|
|
178
|
+
config: ReviewerConfig,
|
|
179
|
+
source: { kind: "goal" | "list"; goalId: string; objective: string; terminal: string },
|
|
180
|
+
deps: ReviewerDeps,
|
|
181
|
+
): ReviewerOutcome {
|
|
182
|
+
const none = (suppressedReason: string): ReviewerOutcome => ({ fired: false, suppressedReason, enqueued: 0, proposed: 0 });
|
|
183
|
+
if (!config.enabled && !deps.manual) return none("reviewer disabled");
|
|
184
|
+
const event = source.kind === "goal" ? `${source.terminal}` : "list-complete";
|
|
185
|
+
if (!deps.manual) {
|
|
186
|
+
if (config.doNotFireOn.includes(event)) return none(`doNotFireOn: ${event}`);
|
|
187
|
+
if (source.kind === "goal" && source.terminal !== "goal-complete") return none(`not a completion: ${source.terminal}`);
|
|
188
|
+
if (!config.fireOn.includes(source.kind === "goal" ? "goal-complete" : "list-complete")) return none("fireOn excludes this event");
|
|
189
|
+
if (reviewerFiredRecently(deps.ledgerEntries, REVIEWER_REFIRE_WINDOW_MS, deps.nowMs)) {
|
|
190
|
+
deps.ledger("reviewer_suppressed", { reason: "refire-window", goalId: source.goalId });
|
|
191
|
+
return none("reviewer fired within the last 5 minutes (runaway prevention)");
|
|
192
|
+
}
|
|
193
|
+
const today = reviewsToday(deps.ledgerEntries, deps.nowMs);
|
|
194
|
+
if (today >= config.maxReviewsPerDay) {
|
|
195
|
+
deps.ledger("reviewer_suppressed", { reason: "day-cap", count: today, cap: config.maxReviewsPerDay, goalId: source.goalId });
|
|
196
|
+
return none(`day cap reached (${today}/${config.maxReviewsPerDay})`);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
const findings = extractFindings(deps.sources, config.maxFindingsPerReview);
|
|
201
|
+
const bugs = findings.filter((f) => f.class === "bug" || f.class === "refactor");
|
|
202
|
+
const architectural = findings.filter((f) => f.class === "architectural");
|
|
203
|
+
const strategic = findings.filter((f) => f.class === "strategic");
|
|
204
|
+
|
|
205
|
+
let enqueued = 0;
|
|
206
|
+
let proposed = 0;
|
|
207
|
+
let cascadeStep = "notify-and-idle";
|
|
208
|
+
|
|
209
|
+
// Cascade: findings → list items (leverage: fix-without-confirm).
|
|
210
|
+
const convertStep = source.kind === "goal" ? "convert-findings-to-list" : "queue-leftovers";
|
|
211
|
+
if (bugs.length > 0 && config.cascade.includes(convertStep)) {
|
|
212
|
+
deps.enqueueListItems(bugs.map((f) => f.text));
|
|
213
|
+
enqueued = bugs.length;
|
|
214
|
+
cascadeStep = convertStep;
|
|
215
|
+
}
|
|
216
|
+
// Architectural findings → /goal proposal WITH Confirm.
|
|
217
|
+
if (architectural.length > 0) {
|
|
218
|
+
deps.proposeGoal(
|
|
219
|
+
architectural.map((f) => f.text).join("; "),
|
|
220
|
+
`reviewer found ${architectural.length} architectural-class finding(s) — needs your Confirm`,
|
|
221
|
+
);
|
|
222
|
+
proposed += architectural.length;
|
|
223
|
+
cascadeStep = "propose-goal";
|
|
224
|
+
}
|
|
225
|
+
// Clean completion → audit /goal (opt-in cascade step).
|
|
226
|
+
if (findings.length === 0 && config.cascade.includes("fire-audit-on-clean")) {
|
|
227
|
+
deps.proposeGoal(
|
|
228
|
+
`Post-completion regression scan after ${source.goalId} (${config.auditScope})`,
|
|
229
|
+
"reviewer: completion looks clean — firing the audit step",
|
|
230
|
+
);
|
|
231
|
+
proposed++;
|
|
232
|
+
cascadeStep = "fire-audit-on-clean";
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
const report: ReviewReport = {
|
|
236
|
+
goalId: source.goalId,
|
|
237
|
+
kind: source.kind,
|
|
238
|
+
objective: source.objective,
|
|
239
|
+
findings,
|
|
240
|
+
cascadeStep,
|
|
241
|
+
at: new Date(deps.nowMs).toISOString(),
|
|
242
|
+
};
|
|
243
|
+
const reportPath = writeReviewReport(deps.cwd, report);
|
|
244
|
+
deps.ledger("reviewer_fired", {
|
|
245
|
+
goalId: source.goalId,
|
|
246
|
+
kind: source.kind,
|
|
247
|
+
findings: findings.length,
|
|
248
|
+
enqueued,
|
|
249
|
+
proposed,
|
|
250
|
+
cascadeStep,
|
|
251
|
+
report: path.relative(deps.cwd, reportPath),
|
|
252
|
+
});
|
|
253
|
+
deps.notify(
|
|
254
|
+
`Reviewer: ${findings.length} finding(s) — ${enqueued} enqueued to /list, ${proposed} proposed as /goal (${cascadeStep}). Report: ${path.relative(deps.cwd, reportPath)}`,
|
|
255
|
+
"info",
|
|
256
|
+
);
|
|
257
|
+
if (strategic.length > 0) {
|
|
258
|
+
deps.notify(`Reviewer: ${strategic.length} strategic finding(s) need YOUR call — see the report's Strategic section.`, "warning");
|
|
259
|
+
}
|
|
260
|
+
return { fired: true, report, reportPath, enqueued, proposed };
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** /glla reviewer menu options, derived from the config — extracted so
|
|
264
|
+
* the menu surface is unit-testable without a pi host. */
|
|
265
|
+
export function reviewerMenuOptions(cfg: ReviewerConfig): string[] {
|
|
266
|
+
return [
|
|
267
|
+
`Enabled — ${cfg.enabled ? "ON" : "OFF"}`,
|
|
268
|
+
`Leverage mode — ${cfg.leverageMode} (bug/refactor findings)`,
|
|
269
|
+
`Fire on goal-complete — ${cfg.fireOn.includes("goal-complete") ? "ON" : "OFF"}`,
|
|
270
|
+
`Fire on list-complete — ${cfg.fireOn.includes("list-complete") ? "ON" : "OFF"}`,
|
|
271
|
+
`Cascade: audit-on-clean — ${cfg.cascade.includes("fire-audit-on-clean") ? "ON" : "OFF"}`,
|
|
272
|
+
`Max findings per review — ${cfg.maxFindingsPerReview}`,
|
|
273
|
+
`Max reviews per day — ${cfg.maxReviewsPerDay}`,
|
|
274
|
+
"Done",
|
|
275
|
+
];
|
|
276
|
+
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-goal-list-loop-audit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.26.0",
|
|
4
4
|
"description": "Goal. Loop. Audit. Done. — a pi-coding-agent extension that supervises long-running work, with isolated auditor on each completion. Beat bamboozling by design: the auditor runs in a fresh session with no extensions, no skills, no editor — only the read tools needed to verify your goal.",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"author": "dracon",
|