pi-goal-list-loop-audit 0.15.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.
@@ -0,0 +1,230 @@
1
+ /**
2
+ * pi-goal-list-loop-audit — v0.3.0
3
+ * extensions/goal-loop-forever.ts
4
+ *
5
+ * Loop 3 core: metric parsing, improvement comparison, plateau detection.
6
+ * Pure + dependency-free so unit tests can exercise it under plain node.
7
+ *
8
+ * Design rule (the anti-doorknob law): the loop only believes a number.
9
+ * The orchestrator runs the user's measure command; the agent never
10
+ * self-reports progress.
11
+ */
12
+
13
+ export type LoopDirection = "min" | "max";
14
+
15
+ export interface LoopMeasure {
16
+ iteration: number;
17
+ value: number | null;
18
+ improved: boolean;
19
+ at: string;
20
+ }
21
+
22
+ export interface LoopRefinement {
23
+ at: string;
24
+ iteration: number;
25
+ oldTarget: string;
26
+ newTarget: string;
27
+ oldMeasureCmd: string;
28
+ newMeasureCmd: string;
29
+ }
30
+
31
+ export interface LoopState {
32
+ target: string;
33
+ measureCmd: string;
34
+ direction: LoopDirection;
35
+ iteration: number;
36
+ maxIterations: number;
37
+ plateauWindow: number;
38
+ stallCount: number;
39
+ bestValue: number | null;
40
+ lastValue: number | null;
41
+ active: boolean;
42
+ stopReason?: string;
43
+ history: LoopMeasure[];
44
+ startedAt: string;
45
+ /** v0.15.0: arbitrary bounds (never "completion") — stop after this many hours. */
46
+ timeLimitHours?: number;
47
+ /** v0.15.0: arbitrary bounds — stop after this many tokens (input+output). */
48
+ tokenBudget?: number;
49
+ /** v0.15.0: accumulated loop tokens (input+output), orchestrator-counted. */
50
+ tokensUsed?: number;
51
+ /** v0.15.0: living spec — user-confirmed target/measure refinements. */
52
+ refinements?: LoopRefinement[];
53
+ /** branch=1 mode: scratch branch holding the loop's commits. */
54
+ branchName?: string;
55
+ /** branch=1 mode: the branch to return to on stop. */
56
+ originalBranch?: string;
57
+ }
58
+
59
+ /** Scratch-branch name for branch=1 mode. Format pinned by tests. */
60
+ export function loopBranchName(startedAtIso: string, target: string): string {
61
+ const stamp = startedAtIso.replace(/[^0-9]/g, "").slice(0, 14);
62
+ const slug = target.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 30) || "loop";
63
+ return `pi-gla-loop/${stamp}-${slug}`;
64
+ }
65
+
66
+ export const LOOP_DEFAULTS = {
67
+ maxIterations: 50,
68
+ plateauWindow: 5,
69
+ };
70
+
71
+ /**
72
+ * Apply a user-confirmed spec refinement (v0.15.0, propose_loop_refine).
73
+ * The loop is a process against a LIVING spec: target/measure may be
74
+ * sharpened mid-run. History keeps both eras via `refinements`. When the
75
+ * measure changes, the old best/last values are a different scale — the
76
+ * caller re-baselines with a fresh measurement and stall state resets.
77
+ */
78
+ export function applyRefinement(
79
+ loop: LoopState,
80
+ refinement: LoopRefinement,
81
+ newBaseline: number | null,
82
+ ): void {
83
+ loop.refinements = loop.refinements ?? [];
84
+ loop.refinements.push(refinement);
85
+ loop.target = refinement.newTarget;
86
+ const measureChanged = refinement.newMeasureCmd !== refinement.oldMeasureCmd;
87
+ loop.measureCmd = refinement.newMeasureCmd;
88
+ if (measureChanged) {
89
+ loop.bestValue = newBaseline;
90
+ loop.lastValue = newBaseline;
91
+ loop.stallCount = 0;
92
+ }
93
+ }
94
+
95
+ /**
96
+ * Parse the first number in measure-command output. Accepts integers,
97
+ * decimals, negatives, and scientific notation; ignores surrounding text
98
+ * (e.g. "score: 42" → 42). Returns null when no number is present — a
99
+ * broken measure is a stall, never a crash.
100
+ */
101
+ export function parseMetric(output: string): number | null {
102
+ const m = output.match(/-?\d+(?:\.\d+)?(?:[eE][+-]?\d+)?/);
103
+ if (!m) return null;
104
+ const n = Number.parseFloat(m[0]!);
105
+ return Number.isFinite(n) ? n : null;
106
+ }
107
+
108
+ /** Did `value` improve on `best` for this direction? First value is always a baseline. */
109
+ export function isImprovement(direction: LoopDirection, value: number, best: number | null): boolean {
110
+ if (best === null) return true;
111
+ return direction === "min" ? value < best : value > best;
112
+ }
113
+
114
+ export type LoopTickOutcome =
115
+ | { kind: "continue"; improved: boolean; value: number | null }
116
+ | { kind: "stop"; reason: string };
117
+
118
+ /**
119
+ * Apply one measurement to the loop state (mutates + returns the outcome).
120
+ * v0.15.0: a loop NEVER checks for completion — there is no done=. Stop
121
+ * rules, in order: time bound, token bound, plateau (stall >= window),
122
+ * iteration cap. All four are arbitrary ends; the metric only judges
123
+ * movement, never arrival.
124
+ */
125
+ export function applyMeasurement(loop: LoopState, value: number | null, at: string): LoopTickOutcome {
126
+ loop.iteration++;
127
+ const improved = value !== null && isImprovement(loop.direction, value, loop.bestValue);
128
+ if (value === null) {
129
+ loop.stallCount++;
130
+ } else if (improved) {
131
+ loop.bestValue = value;
132
+ loop.stallCount = 0;
133
+ } else {
134
+ loop.stallCount++;
135
+ }
136
+ loop.lastValue = value;
137
+ loop.history.push({ iteration: loop.iteration, value, improved, at });
138
+ if (loop.history.length > 200) loop.history.splice(0, loop.history.length - 200);
139
+
140
+ if (loop.timeLimitHours !== undefined) {
141
+ const elapsedH = (Date.parse(at) - Date.parse(loop.startedAt)) / 3_600_000;
142
+ if (Number.isFinite(elapsedH) && elapsedH >= loop.timeLimitHours) {
143
+ loop.active = false;
144
+ loop.stopReason = `time bound reached (${loop.timeLimitHours}h); best: ${loop.bestValue ?? "n/a"}`;
145
+ return { kind: "stop", reason: loop.stopReason };
146
+ }
147
+ }
148
+ if (loop.tokenBudget !== undefined && (loop.tokensUsed ?? 0) >= loop.tokenBudget) {
149
+ loop.active = false;
150
+ loop.stopReason = `token budget exhausted (${(loop.tokensUsed ?? 0).toLocaleString()} >= ${loop.tokenBudget.toLocaleString()}); best: ${loop.bestValue ?? "n/a"}`;
151
+ return { kind: "stop", reason: loop.stopReason };
152
+ }
153
+ if (loop.stallCount >= loop.plateauWindow) {
154
+ loop.active = false;
155
+ loop.stopReason = `plateau — no improvement in ${loop.plateauWindow} consecutive iterations (best: ${loop.bestValue ?? "n/a"})`;
156
+ return { kind: "stop", reason: loop.stopReason };
157
+ }
158
+ if (loop.iteration >= loop.maxIterations) {
159
+ loop.active = false;
160
+ loop.stopReason = `max iterations reached (${loop.maxIterations}); best: ${loop.bestValue ?? "n/a"}`;
161
+ return { kind: "stop", reason: loop.stopReason };
162
+ }
163
+ return { kind: "continue", improved, value };
164
+ }
165
+
166
+ /** Parse `/loop start` args into a config. Throws on missing pieces. */
167
+ export function parseLoopStartArgs(raw: string): {
168
+ target: string;
169
+ measureCmd: string;
170
+ direction: LoopDirection;
171
+ plateauWindow: number;
172
+ maxIterations: number;
173
+ branch: boolean;
174
+ force: boolean;
175
+ timeLimitHours?: number;
176
+ tokenBudget?: number;
177
+ } {
178
+ // Key=value pairs first (measure= and direction= may hold quoted values),
179
+ // the remaining text is the target.
180
+ let rest = raw.trim();
181
+ const kv = new Map<string, string>();
182
+ const kvRe = /(\w+)=(?:"([^"]*)"|'([^']*)'|(\S+))/g;
183
+ let m: RegExpExecArray | null;
184
+ const spans: Array<[number, number]> = [];
185
+ while ((m = kvRe.exec(rest)) !== null) {
186
+ kv.set(m[1]!.toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
187
+ spans.push([m.index, m.index + m[0].length]);
188
+ }
189
+ // Remove kv spans from the target text.
190
+ let target = "";
191
+ let cursor = 0;
192
+ for (const [s, e] of spans) {
193
+ target += rest.slice(cursor, s);
194
+ cursor = e;
195
+ }
196
+ target += rest.slice(cursor);
197
+ target = target.trim().replace(/^["']|["']$/g, "").trim();
198
+
199
+ const measureCmd = kv.get("measure") ?? "";
200
+ if (!measureCmd) throw new Error('missing measure="<shell command that prints a number>"');
201
+ const dirRaw = (kv.get("direction") ?? "").toLowerCase();
202
+ if (dirRaw !== "min" && dirRaw !== "max") throw new Error("missing direction=min|max");
203
+ if (!target) throw new Error("missing target (what to improve), e.g. /loop start \"reduce test failures\" measure=\"...\" direction=min");
204
+
205
+ const window = Number.parseInt(kv.get("window") ?? "", 10);
206
+ const max = Number.parseInt(kv.get("max") ?? "", 10);
207
+ const branchRaw = (kv.get("branch") ?? "").toLowerCase();
208
+ const forceRaw = (kv.get("force") ?? "").toLowerCase();
209
+ // v0.15.0: done= is removed — a loop never checks for completion. Teach.
210
+ if (kv.has("done")) {
211
+ throw new Error(
212
+ 'done= was removed in v0.15.0 — "improve until X" is a GOAL, not a loop. ' +
213
+ 'Use /goal "<target>. Done when: <checkable criterion>" (the auditor verifies it). ' +
214
+ "A loop is a process: it runs until /loop stop, plateau, max= iterations, time= hours, or tokens= budget.",
215
+ );
216
+ }
217
+ const timeRaw = Number.parseFloat(kv.get("time") ?? "");
218
+ const tokensRaw = Number.parseInt(kv.get("tokens") ?? "", 10);
219
+ return {
220
+ target,
221
+ measureCmd,
222
+ direction: dirRaw,
223
+ plateauWindow: Number.isFinite(window) && window > 0 ? window : LOOP_DEFAULTS.plateauWindow,
224
+ maxIterations: Number.isFinite(max) && max > 0 ? max : LOOP_DEFAULTS.maxIterations,
225
+ branch: branchRaw === "1" || branchRaw === "true" || branchRaw === "yes",
226
+ force: forceRaw === "1" || forceRaw === "true" || forceRaw === "yes",
227
+ timeLimitHours: Number.isFinite(timeRaw) && timeRaw > 0 ? timeRaw : undefined,
228
+ tokenBudget: Number.isFinite(tokensRaw) && tokensRaw > 0 ? tokensRaw : undefined,
229
+ };
230
+ }
@@ -0,0 +1,59 @@
1
+ /**
2
+ * pi-goal-list-loop-audit — v0.2.0
3
+ * extensions/goal-loop-shield.ts
4
+ *
5
+ * regression_shield — pure, dependency-free enforcement logic.
6
+ *
7
+ * When a goal has a verification contract, an <approved/> verdict is only
8
+ * accepted if the auditor's report carries an <evidence> section that
9
+ * references every contract item. This kills the "auditor ran bash true and
10
+ * approved" class of bamboozle that pi-goal-x's author explicitly documented
11
+ * as a known hole.
12
+ *
13
+ * Kept free of pi imports so unit tests can exercise it under plain node.
14
+ */
15
+
16
+ /** Split a verification contract into its individual checkable items. */
17
+ export function contractItems(contract: string): string[] {
18
+ return contract
19
+ .split("\n")
20
+ .map((l) => l.trim())
21
+ .map((l) => l.replace(/^(?:done when|verify|verified when|verification|done)\s*:\s*/i, ""))
22
+ .map((l) => l.replace(/^[-*•]\s+/, "").replace(/^\d+[.)]\s+/, ""))
23
+ .filter((l) => l.length > 0);
24
+ }
25
+
26
+ export interface RegressionShieldResult {
27
+ passed: boolean;
28
+ missingItems: string[];
29
+ hasEvidenceBlock: boolean;
30
+ }
31
+
32
+ /**
33
+ * Check an approved auditor report against the verification contract.
34
+ * Rules (deliberately simple + auditable):
35
+ * 1. The report must contain an <evidence> ... </evidence> block.
36
+ * 2. Every contract item must be referenced inside the report by a
37
+ * distinctive token (the item's longest word >= 5 chars, or the full
38
+ * item if shorter) — a cheap, honest proxy for "the auditor addressed
39
+ * this item".
40
+ */
41
+ export function checkRegressionShield(report: string, contract: string): RegressionShieldResult {
42
+ const hasEvidenceBlock = /<evidence>[\t\n\r ]*[\s\S]*?<\/evidence>/i.test(report);
43
+ const items = contractItems(contract);
44
+ const missingItems: string[] = [];
45
+ const reportLower = report.toLowerCase();
46
+ for (const item of items) {
47
+ // Distinctive token: longest word >= 5 chars in the item; fall back to the
48
+ // whole item (short items like "npm test" are matched whole).
49
+ const words = item.split(/[^A-Za-z0-9_.\-/]+/).filter(Boolean);
50
+ const distinctive = words.reduce((a, b) => (b.length >= 5 && b.length > a.length ? b : a), "");
51
+ const needle = (distinctive || item).toLowerCase();
52
+ if (!reportLower.includes(needle)) missingItems.push(item);
53
+ }
54
+ return {
55
+ passed: hasEvidenceBlock && missingItems.length === 0,
56
+ missingItems,
57
+ hasEvidenceBlock,
58
+ };
59
+ }