@hank-warren/pi-loop 0.6.0 → 0.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/CHANGELOG.md +60 -0
- package/README.md +25 -52
- package/package.json +5 -1
- package/skills/pi-loop/SKILL.md +155 -0
- package/src/command.ts +16 -7
- package/src/complete-tool.ts +54 -13
- package/src/decide.ts +13 -16
- package/src/index.ts +38 -84
- package/src/interval.ts +25 -0
- package/src/ledger.ts +204 -10
- package/src/loop.ts +185 -55
- package/src/manager.ts +80 -11
- package/src/messages.ts +12 -5
- package/src/objective.ts +13 -2
- package/src/planning.ts +98 -0
- package/src/progress-tool.ts +162 -0
- package/src/propose-tool.ts +119 -0
- package/src/settings.ts +48 -17
- package/src/start-tool.ts +56 -2
- package/src/state.ts +40 -20
- package/src/widget.ts +103 -11
- package/src/schedule/command.ts +0 -255
- package/src/schedule/cron.ts +0 -182
- package/src/schedule/manager.ts +0 -129
- package/src/schedule/model.ts +0 -237
- package/src/schedule/runner.ts +0 -351
- package/src/schedule/store.ts +0 -183
package/src/decide.ts
CHANGED
|
@@ -9,7 +9,7 @@
|
|
|
9
9
|
* handler for a lost continuation or an external wait, not the pacemaker.
|
|
10
10
|
*
|
|
11
11
|
* Both share one precedence prefix: loop liveness → expiry → plan mode →
|
|
12
|
-
* compaction → busy → wait →
|
|
12
|
+
* compaction → busy → wait → the turn cap → act.
|
|
13
13
|
*
|
|
14
14
|
* A loop owns its own objective and reads no other extension's state: it ends
|
|
15
15
|
* only through `loop_complete`, a cap, an expiry, or the user.
|
|
@@ -49,14 +49,14 @@ export type TickDecision =
|
|
|
49
49
|
| { action: "none"; reason: "loop-not-active" }
|
|
50
50
|
| { action: "expire"; reason: ExpiryReason }
|
|
51
51
|
| { action: "skip"; reason: SkipReason }
|
|
52
|
-
| { action: "stop"; reason: "max-
|
|
52
|
+
| { action: "stop"; reason: "max-turns" }
|
|
53
53
|
| { action: "poke"; reason: "objective-stalled" | "wait-elapsed" };
|
|
54
54
|
|
|
55
55
|
export type ContinuationDecision =
|
|
56
56
|
| { action: "none"; reason: "loop-not-active" }
|
|
57
57
|
| { action: "expire"; reason: ExpiryReason }
|
|
58
58
|
| { action: "skip"; reason: SkipReason }
|
|
59
|
-
| { action: "stop"; reason: "max-
|
|
59
|
+
| { action: "stop"; reason: "max-turns" }
|
|
60
60
|
| { action: "continue"; reason: "settled-idle" };
|
|
61
61
|
|
|
62
62
|
/** Shared prefix: everything that holds or ends a loop before caps matter. */
|
|
@@ -80,17 +80,14 @@ function decideCommonPrefix(
|
|
|
80
80
|
}
|
|
81
81
|
|
|
82
82
|
/**
|
|
83
|
-
*
|
|
84
|
-
* wake cap
|
|
83
|
+
* The one cap, counting every turn the loop caused: continuations and pokes
|
|
84
|
+
* alike. A delivered-wake cap sat next to it until it was collapsed into
|
|
85
|
+
* this one — in a settle-paced loop the wake counter can stay at zero for the
|
|
86
|
+
* loop's whole life, so it was never the ceiling that held.
|
|
85
87
|
*/
|
|
86
|
-
function
|
|
87
|
-
loop
|
|
88
|
-
|
|
89
|
-
if (loop.maxIterations !== null && loop.iteration >= loop.maxIterations) {
|
|
90
|
-
return { action: "stop", reason: "max-iterations" };
|
|
91
|
-
}
|
|
92
|
-
if (loop.maxAutomaticTurns !== null && loop.automaticTurns >= loop.maxAutomaticTurns) {
|
|
93
|
-
return { action: "stop", reason: "max-automatic-turns" };
|
|
88
|
+
function decideCap(loop: LoopState): { action: "stop"; reason: "max-turns" } | undefined {
|
|
89
|
+
if (loop.maxTurns !== null && loop.automaticTurns >= loop.maxTurns) {
|
|
90
|
+
return { action: "stop", reason: "max-turns" };
|
|
94
91
|
}
|
|
95
92
|
return undefined;
|
|
96
93
|
}
|
|
@@ -103,7 +100,7 @@ function decideCaps(
|
|
|
103
100
|
export function decideContinuation(loop: LoopState, env: TickEnvironment): ContinuationDecision {
|
|
104
101
|
const prefix = decideCommonPrefix(loop, env);
|
|
105
102
|
if (prefix) return prefix;
|
|
106
|
-
const capped =
|
|
103
|
+
const capped = decideCap(loop);
|
|
107
104
|
if (capped) return capped;
|
|
108
105
|
return { action: "continue", reason: "settled-idle" };
|
|
109
106
|
}
|
|
@@ -111,10 +108,10 @@ export function decideContinuation(loop: LoopState, env: TickEnvironment): Conti
|
|
|
111
108
|
export function decideTick(loop: LoopState, env: TickEnvironment): TickDecision {
|
|
112
109
|
const prefix = decideCommonPrefix(loop, env);
|
|
113
110
|
if (prefix) return prefix;
|
|
114
|
-
const capped =
|
|
111
|
+
const capped = decideCap(loop);
|
|
115
112
|
if (capped) return capped;
|
|
116
113
|
// The prefix already let a still-waiting loop skip, so a wait surviving to
|
|
117
114
|
// here is one whose deadline has come due: this wake is the wake it asked
|
|
118
|
-
// for, and it counts against the
|
|
115
|
+
// for, and the turn it starts counts against the cap like any other.
|
|
119
116
|
return { action: "poke", reason: loop.waiting ? "wait-elapsed" : "objective-stalled" };
|
|
120
117
|
}
|
package/src/index.ts
CHANGED
|
@@ -11,23 +11,18 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
11
11
|
import { completeLoopArguments, parseLoopCommand } from "./command.js";
|
|
12
12
|
import { registerLoopCompleteTool } from "./complete-tool.js";
|
|
13
13
|
import { InlineInvocationState, registerInlineInvocation } from "./inline-invocation.js";
|
|
14
|
+
import { registerLoopProgressTool } from "./progress-tool.js";
|
|
15
|
+
import { registerLoopProposeTool } from "./propose-tool.js";
|
|
16
|
+
import { LOOP_PLANNING_HINT } from "./planning.js";
|
|
14
17
|
import { registerLoopStartTool } from "./start-tool.js";
|
|
15
18
|
import { registerLoopWaitTool } from "./wait-tool.js";
|
|
16
19
|
import { LoopController, type LoopControllerOptions } from "./loop.js";
|
|
17
|
-
import { showLoopManager, showLoopSettings } from "./manager.js";
|
|
20
|
+
import { showLoopApproval, showLoopManager, showLoopSettings } from "./manager.js";
|
|
18
21
|
import { buildLoopObjectivePrompt } from "./objective.js";
|
|
19
22
|
import { registerLoopMessageRendering } from "./render.js";
|
|
20
|
-
import { completeScheduleArguments, parseScheduleCommand } from "./schedule/command.js";
|
|
21
|
-
import { describeTask, listTasks, showScheduleManager } from "./schedule/manager.js";
|
|
22
|
-
import { describeSchedule } from "./schedule/model.js";
|
|
23
|
-
import { Scheduler } from "./schedule/runner.js";
|
|
24
23
|
|
|
25
24
|
export default function loop(pi: ExtensionAPI, options: LoopControllerOptions = {}) {
|
|
26
25
|
const controller = new LoopController(pi, options);
|
|
27
|
-
const scheduler = new Scheduler(pi, {
|
|
28
|
-
...(options.agentDir === undefined ? {} : { agentDir: options.agentDir }),
|
|
29
|
-
...(options.now === undefined ? {} : { now: options.now }),
|
|
30
|
-
});
|
|
31
26
|
// Registered unconditionally and never toggled with loop state: tools are
|
|
32
27
|
// part of the cached request prefix, so mutating the tool set mid-session
|
|
33
28
|
// would invalidate the whole conversation cache. It refuses when no loop is
|
|
@@ -36,6 +31,8 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
36
31
|
// Registered on the same terms and for the same reason: the tool set is
|
|
37
32
|
// part of the cached prefix, so it never changes with loop state.
|
|
38
33
|
registerLoopWaitTool(pi, controller);
|
|
34
|
+
registerLoopProgressTool(pi, controller);
|
|
35
|
+
registerLoopProposeTool(pi, controller);
|
|
39
36
|
// Inline invocation: an `input` handler arms a one-turn system-prompt hint
|
|
40
37
|
// for a mid-prompt `/loop` token, `before_agent_start` appends it, and
|
|
41
38
|
// loop_start is the model-invoked start it points at — refused on any turn
|
|
@@ -54,9 +51,34 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
54
51
|
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
55
52
|
const command = parseLoopCommand(args);
|
|
56
53
|
switch (command.kind) {
|
|
57
|
-
case "show":
|
|
58
|
-
|
|
54
|
+
case "show": {
|
|
55
|
+
// Bare /loop is the front door. With a loop running it is the
|
|
56
|
+
// manager, as before. With a draft awaiting approval it is the
|
|
57
|
+
// card. With neither it opens planning, which is what used to be
|
|
58
|
+
// a one-line "no loop in this session" dead end.
|
|
59
|
+
const running = controller.state && controller.state.status !== "stopped";
|
|
60
|
+
if (running) {
|
|
61
|
+
await showLoopManager(controller, ctx);
|
|
62
|
+
return;
|
|
63
|
+
}
|
|
64
|
+
if (controller.planning.proposal) {
|
|
65
|
+
await showLoopApproval(controller, ctx);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
if (controller.planning.active) {
|
|
69
|
+
ctx.ui.notify(
|
|
70
|
+
"Still planning: describe the objective, and the agent will put a loop up for approval.",
|
|
71
|
+
"info",
|
|
72
|
+
);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
controller.beginPlanning();
|
|
76
|
+
ctx.ui.notify(
|
|
77
|
+
"Loop planning. Describe what you want the loop to achieve and how you will know it is done; the agent drafts it and puts it up for approval. Nothing starts until you approve it.",
|
|
78
|
+
"info",
|
|
79
|
+
);
|
|
59
80
|
return;
|
|
81
|
+
}
|
|
60
82
|
case "status":
|
|
61
83
|
ctx.ui.notify(controller.statusLines(ctx).join("\n"), "info");
|
|
62
84
|
return;
|
|
@@ -101,83 +123,11 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
101
123
|
},
|
|
102
124
|
});
|
|
103
125
|
|
|
104
|
-
// The scheduler is user-typed only, exactly like /loop: the model gets no
|
|
105
|
-
// scheduling tools, because a model that can schedule its own future turns
|
|
106
|
-
// can schedule its way around every limit the loop imposes.
|
|
107
|
-
pi.registerCommand("schedule", {
|
|
108
|
-
description:
|
|
109
|
-
'Schedule prompts and headless runs: /schedule [every <dur>|at <time>|cron "<expr>"] [--run] <prompt>, or list/pause/resume/run/status/delete',
|
|
110
|
-
getArgumentCompletions: (prefix: string) => completeScheduleArguments(prefix),
|
|
111
|
-
handler: async (args: string, ctx: ExtensionCommandContext) => {
|
|
112
|
-
const command = parseScheduleCommand(args, { cwd: ctx.cwd });
|
|
113
|
-
switch (command.kind) {
|
|
114
|
-
case "show":
|
|
115
|
-
await showScheduleManager(scheduler, ctx);
|
|
116
|
-
return;
|
|
117
|
-
case "list":
|
|
118
|
-
ctx.ui.notify(listTasks(scheduler).join("\n"), "info");
|
|
119
|
-
return;
|
|
120
|
-
case "error":
|
|
121
|
-
ctx.ui.notify(command.message, "error");
|
|
122
|
-
return;
|
|
123
|
-
case "create": {
|
|
124
|
-
const { task, warning } = scheduler.create(command);
|
|
125
|
-
if (warning) {
|
|
126
|
-
ctx.ui.notify(`Scheduled task not persisted: ${warning}`, "warning");
|
|
127
|
-
}
|
|
128
|
-
if (command.clampedFrom !== undefined) {
|
|
129
|
-
ctx.ui.notify("Intervals below 1 minute are raised to the minimum.", "warning");
|
|
130
|
-
}
|
|
131
|
-
ctx.ui.notify(
|
|
132
|
-
[
|
|
133
|
-
`Scheduled "${task.name}" (${task.id}): ${describeSchedule(task.schedule)}.`,
|
|
134
|
-
task.task.kind === "run"
|
|
135
|
-
? `Runs headlessly in ${task.task.cwd}; wakes this session on ${task.task.wakeOn}.`
|
|
136
|
-
: "Injects a prompt into this session at an idle boundary; it dies with the session.",
|
|
137
|
-
`Runs: ${task.maxRuns === null ? "unlimited" : `at most ${task.maxRuns}`}; expires ${new Date(task.expiresAt).toLocaleDateString()}.`,
|
|
138
|
-
].join("\n"),
|
|
139
|
-
"info",
|
|
140
|
-
);
|
|
141
|
-
return;
|
|
142
|
-
}
|
|
143
|
-
default: {
|
|
144
|
-
const task = scheduler.find(command.id);
|
|
145
|
-
if (!task) {
|
|
146
|
-
ctx.ui.notify(
|
|
147
|
-
`No scheduled task matches ${command.id}. Run /schedule list to see them.`,
|
|
148
|
-
"error",
|
|
149
|
-
);
|
|
150
|
-
return;
|
|
151
|
-
}
|
|
152
|
-
if (command.kind === "status") {
|
|
153
|
-
ctx.ui.notify(describeTask(task).join("\n"), "info");
|
|
154
|
-
return;
|
|
155
|
-
}
|
|
156
|
-
if (command.kind === "pause" || command.kind === "resume") {
|
|
157
|
-
const status = command.kind === "pause" ? "paused" : "active";
|
|
158
|
-
scheduler.update({ ...task, status });
|
|
159
|
-
ctx.ui.notify(`Task "${task.name}" is now ${status}.`, "info");
|
|
160
|
-
return;
|
|
161
|
-
}
|
|
162
|
-
if (command.kind === "run") {
|
|
163
|
-
scheduler.fireNow(task);
|
|
164
|
-
ctx.ui.notify(`Running "${task.name}" now.`, "info");
|
|
165
|
-
return;
|
|
166
|
-
}
|
|
167
|
-
scheduler.remove(task.id);
|
|
168
|
-
ctx.ui.notify(`Deleted "${task.name}".`, "info");
|
|
169
|
-
}
|
|
170
|
-
}
|
|
171
|
-
},
|
|
172
|
-
});
|
|
173
|
-
|
|
174
126
|
pi.on("session_start", async (_event, ctx) => {
|
|
175
127
|
controller.onSessionStart(ctx);
|
|
176
|
-
scheduler.onSessionStart(ctx);
|
|
177
128
|
});
|
|
178
129
|
pi.on("session_shutdown", async () => {
|
|
179
130
|
controller.onSessionShutdown();
|
|
180
|
-
scheduler.onSessionShutdown();
|
|
181
131
|
});
|
|
182
132
|
// The pacemaker: agent_end records the intent to continue, agent_settled
|
|
183
133
|
// delivers it once Pi will accept a message.
|
|
@@ -189,12 +139,16 @@ export default function loop(pi: ExtensionAPI, options: LoopControllerOptions =
|
|
|
189
139
|
});
|
|
190
140
|
pi.on("agent_settled", async (_event, ctx) => {
|
|
191
141
|
controller.onAgentSettled(ctx);
|
|
192
|
-
scheduler.onAgentSettled(ctx);
|
|
193
142
|
});
|
|
194
143
|
// A loop carries its own objective and injects it as a byte-stable system
|
|
195
144
|
// append, which is what lets the poke and continuation messages stay
|
|
196
145
|
// pointer-sized.
|
|
197
146
|
pi.on("before_agent_start", (event) => {
|
|
147
|
+
// Planning precedes any loop, so its guidance is injected on the same hook
|
|
148
|
+
// and is mutually exclusive with the objective append below.
|
|
149
|
+
if (controller.planning.active) {
|
|
150
|
+
return { systemPrompt: `${event.systemPrompt}\n\n${LOOP_PLANNING_HINT}` };
|
|
151
|
+
}
|
|
198
152
|
const loop = controller.state;
|
|
199
153
|
if (!loop || loop.status !== "active") return;
|
|
200
154
|
const objectivePrompt = buildLoopObjectivePrompt(loop, controller.ledger);
|
package/src/interval.ts
CHANGED
|
@@ -54,6 +54,31 @@ export function formatDuration(ms: number): string {
|
|
|
54
54
|
return `${Math.round(ms / 1_000)}s`;
|
|
55
55
|
}
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Render an elapsed span approximately, for display only.
|
|
59
|
+
*
|
|
60
|
+
* `formatDuration` renders the *canonical token* for a configured interval and
|
|
61
|
+
* only ever emits one unit on an exact multiple, so an arbitrary elapsed span
|
|
62
|
+
* falls through it to seconds — 2h12m comes back as "7920s". An age needs the
|
|
63
|
+
* opposite trade: two units at most, truncated, never exact.
|
|
64
|
+
*/
|
|
65
|
+
export function formatElapsed(ms: number): string {
|
|
66
|
+
const clamped = Math.max(0, ms);
|
|
67
|
+
if (clamped < UNIT_MS.m) return `${Math.floor(clamped / 1_000)}s`;
|
|
68
|
+
for (const [big, small] of [
|
|
69
|
+
["d", "h"],
|
|
70
|
+
["h", "m"],
|
|
71
|
+
] as const) {
|
|
72
|
+
const bigMs = UNIT_MS[big];
|
|
73
|
+
const smallMs = UNIT_MS[small];
|
|
74
|
+
if (bigMs === undefined || smallMs === undefined || clamped < bigMs) continue;
|
|
75
|
+
const whole = Math.floor(clamped / bigMs);
|
|
76
|
+
const rest = Math.floor((clamped % bigMs) / smallMs);
|
|
77
|
+
return rest > 0 ? `${whole}${big}${rest}${small}` : `${whole}${big}`;
|
|
78
|
+
}
|
|
79
|
+
return `${Math.floor(clamped / UNIT_MS.m)}m`;
|
|
80
|
+
}
|
|
81
|
+
|
|
57
82
|
/** Render a wall-clock time as HH:MM for the status widget. */
|
|
58
83
|
export function formatClock(timestamp: number): string {
|
|
59
84
|
const date = new Date(timestamp);
|
package/src/ledger.ts
CHANGED
|
@@ -30,9 +30,9 @@ export const LEDGER_DIR_NAME = "loop";
|
|
|
30
30
|
export const CRITERIA_FILE = "criteria.json";
|
|
31
31
|
export const PROGRESS_FILE = "PROGRESS.md";
|
|
32
32
|
|
|
33
|
-
/** Cap on
|
|
34
|
-
const MAX_CRITERIA = 12;
|
|
35
|
-
const MAX_DESCRIPTION_LENGTH = 500;
|
|
33
|
+
/** Cap on a loop's criteria: an objective is a paragraph, not a backlog. */
|
|
34
|
+
export const MAX_CRITERIA = 12;
|
|
35
|
+
export const MAX_DESCRIPTION_LENGTH = 500;
|
|
36
36
|
|
|
37
37
|
export interface LoopCriterion {
|
|
38
38
|
id: string;
|
|
@@ -44,8 +44,37 @@ export interface LoopCriterion {
|
|
|
44
44
|
*/
|
|
45
45
|
check: string;
|
|
46
46
|
passes: boolean;
|
|
47
|
+
/**
|
|
48
|
+
* The citation given when `passes` was flipped, recorded by the extension
|
|
49
|
+
* at flip time. Absent on a criterion still unmet, and on one flipped by a
|
|
50
|
+
* hand-edit rather than through `loop_progress`.
|
|
51
|
+
*/
|
|
52
|
+
evidence?: string;
|
|
53
|
+
/** Epoch ms of the flip that recorded `evidence`. */
|
|
54
|
+
evidenceAt?: number;
|
|
47
55
|
}
|
|
48
56
|
|
|
57
|
+
/**
|
|
58
|
+
* The fixed headings of `PROGRESS.md`. The schema is the point: "update the
|
|
59
|
+
* ledger" means the same thing on every turn only while the sections are the
|
|
60
|
+
* same on every turn, so writes are section-scoped and a section that is not
|
|
61
|
+
* one of these is refused rather than created.
|
|
62
|
+
*/
|
|
63
|
+
export const PROGRESS_SECTIONS = [
|
|
64
|
+
"current status",
|
|
65
|
+
"completed",
|
|
66
|
+
"failed approaches and why",
|
|
67
|
+
"next actions",
|
|
68
|
+
] as const;
|
|
69
|
+
export type ProgressSection = (typeof PROGRESS_SECTIONS)[number];
|
|
70
|
+
|
|
71
|
+
/** Cap on one ledger write: a progress note is a paragraph, not a transcript. */
|
|
72
|
+
export const MAX_PROGRESS_TEXT_LENGTH = 4000;
|
|
73
|
+
export const MAX_EVIDENCE_LENGTH = 4000;
|
|
74
|
+
|
|
75
|
+
/** The template's placeholders, replaced rather than appended to on first write. */
|
|
76
|
+
const PLACEHOLDERS = new Set(["not started.", "- (nothing yet)"]);
|
|
77
|
+
|
|
49
78
|
export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string {
|
|
50
79
|
return join(agentDir, LEDGER_DIR_NAME, loopId);
|
|
51
80
|
}
|
|
@@ -61,15 +90,23 @@ export function loopLedgerDir(loopId: string, agentDir = getAgentDir()): string
|
|
|
61
90
|
export function deriveCriteria(objective: string): LoopCriterion[] {
|
|
62
91
|
const trimmed = objective.trim();
|
|
63
92
|
if (!trimmed) return [implicitCriterion(objective)];
|
|
64
|
-
const bullets = trimmed
|
|
65
|
-
.split(/\r?\n/)
|
|
66
|
-
.map((line) => line.trim())
|
|
67
|
-
.filter((line) => /^([-*+]|\d+[.)])\s+/.test(line))
|
|
68
|
-
.map((line) => line.replace(/^([-*+]|\d+[.)])\s+/, "").trim())
|
|
69
|
-
.filter(Boolean);
|
|
93
|
+
const bullets = collectBullets(trimmed);
|
|
70
94
|
const parts = bullets.length > 1 ? bullets : splitSentences(trimmed);
|
|
71
95
|
if (parts.length < 2) return [implicitCriterion(trimmed)];
|
|
72
|
-
return parts
|
|
96
|
+
return criteriaFromDescriptions(parts);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/**
|
|
100
|
+
* Number a list of descriptions into criteria.
|
|
101
|
+
*
|
|
102
|
+
* Shared by the deterministic split and by the criteria a model may propose
|
|
103
|
+
* at `loop_start`, so nothing downstream — the echo at start, the evidence
|
|
104
|
+
* gate, the immutability rule — can tell the two apart. The extension still
|
|
105
|
+
* writes every field but the description: ids are positional, `check` is
|
|
106
|
+
* empty (audit against authoritative state), and a criterion starts unmet.
|
|
107
|
+
*/
|
|
108
|
+
export function criteriaFromDescriptions(descriptions: readonly string[]): LoopCriterion[] {
|
|
109
|
+
return descriptions.slice(0, MAX_CRITERIA).map((description, index) => ({
|
|
73
110
|
id: `c${index + 1}`,
|
|
74
111
|
description: truncate(description),
|
|
75
112
|
check: "",
|
|
@@ -77,6 +114,46 @@ export function deriveCriteria(objective: string): LoopCriterion[] {
|
|
|
77
114
|
}));
|
|
78
115
|
}
|
|
79
116
|
|
|
117
|
+
const BULLET_MARKER = /^([-*+]|\d+[.)])\s+/;
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* Bullets, each folded back together with the lines it wrapped onto.
|
|
121
|
+
*
|
|
122
|
+
* A bullet longer than the terminal width is typed — or pasted — across
|
|
123
|
+
* several lines, and only the first carries the marker. Matching markers and
|
|
124
|
+
* discarding everything else silently truncated such a bullet at its first
|
|
125
|
+
* line, which is worse than mis-splitting it: the criterion still looked
|
|
126
|
+
* well-formed, so a requirement could vanish out of the gate with no signal.
|
|
127
|
+
* A non-blank line that starts no new bullet therefore continues the previous
|
|
128
|
+
* one. Text before the first bullet is still ignored (it is a preamble, not a
|
|
129
|
+
* requirement), and a blank line ends the bullet it follows so a trailing
|
|
130
|
+
* paragraph cannot be glued onto the last item.
|
|
131
|
+
*/
|
|
132
|
+
function collectBullets(text: string): string[] {
|
|
133
|
+
const bullets: string[] = [];
|
|
134
|
+
let open = false;
|
|
135
|
+
for (const raw of text.split(/\r?\n/)) {
|
|
136
|
+
const line = raw.trim();
|
|
137
|
+
if (!line) {
|
|
138
|
+
open = false;
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
if (BULLET_MARKER.test(line)) {
|
|
142
|
+
const body = line.replace(BULLET_MARKER, "").trim();
|
|
143
|
+
if (body) {
|
|
144
|
+
bullets.push(body);
|
|
145
|
+
open = true;
|
|
146
|
+
} else {
|
|
147
|
+
// A bare marker has no body to continue.
|
|
148
|
+
open = false;
|
|
149
|
+
}
|
|
150
|
+
continue;
|
|
151
|
+
}
|
|
152
|
+
if (open) bullets[bullets.length - 1] += ` ${line}`;
|
|
153
|
+
}
|
|
154
|
+
return bullets;
|
|
155
|
+
}
|
|
156
|
+
|
|
80
157
|
function implicitCriterion(objective: string): LoopCriterion {
|
|
81
158
|
return {
|
|
82
159
|
id: "c1",
|
|
@@ -184,6 +261,119 @@ export function progressTemplate(objective: string): string {
|
|
|
184
261
|
].join("\n");
|
|
185
262
|
}
|
|
186
263
|
|
|
264
|
+
/**
|
|
265
|
+
* Append to (or replace) one section of `PROGRESS.md`, leaving every other
|
|
266
|
+
* section byte-identical.
|
|
267
|
+
*
|
|
268
|
+
* This exists because the alternative the model reaches for otherwise is a
|
|
269
|
+
* whole-file overwrite, which takes out the objective line and the other
|
|
270
|
+
* three sections along with it. `createLedger` already refuses to overwrite
|
|
271
|
+
* this file for exactly that reason; the agent's write path has to honour the
|
|
272
|
+
* same rule or the protection is decorative.
|
|
273
|
+
*
|
|
274
|
+
* Returns the failure reason, or undefined on success.
|
|
275
|
+
*/
|
|
276
|
+
export function writeProgressSection(
|
|
277
|
+
paths: LedgerPaths,
|
|
278
|
+
section: ProgressSection,
|
|
279
|
+
text: string,
|
|
280
|
+
): string | undefined {
|
|
281
|
+
const entry = text.trim();
|
|
282
|
+
if (!entry) return "the text to record was empty";
|
|
283
|
+
let contents: string;
|
|
284
|
+
try {
|
|
285
|
+
contents = readFileSync(paths.progress, "utf8");
|
|
286
|
+
} catch (error) {
|
|
287
|
+
return formatError(error);
|
|
288
|
+
}
|
|
289
|
+
const lines = contents.split(/\r?\n/);
|
|
290
|
+
const start = lines.findIndex((line) => headingText(line) === section);
|
|
291
|
+
if (start === -1) {
|
|
292
|
+
return `PROGRESS.md has no "## ${section}" section (it was renamed or removed by hand)`;
|
|
293
|
+
}
|
|
294
|
+
let end = start + 1;
|
|
295
|
+
while (end < lines.length && headingText(lines[end]) === undefined) end += 1;
|
|
296
|
+
const body = lines.slice(start + 1, end);
|
|
297
|
+
while (body.length > 0 && !body[0].trim()) body.shift();
|
|
298
|
+
while (body.length > 0 && !body[body.length - 1].trim()) body.pop();
|
|
299
|
+
const placeholder =
|
|
300
|
+
body.length === 1 && PLACEHOLDERS.has(body[0].trim().toLowerCase()) ? true : body.length === 0;
|
|
301
|
+
// Whether a write replaces or extends is a property of the section, not a
|
|
302
|
+
// choice: "current status" is a single current value and the other three are
|
|
303
|
+
// running lists. Deriving it keeps the decision out of the tool schema,
|
|
304
|
+
// where the model could get it wrong on a file nothing else can repair.
|
|
305
|
+
const next =
|
|
306
|
+
section === "current status" || placeholder
|
|
307
|
+
? entry.split("\n")
|
|
308
|
+
: [...body, "", ...entry.split("\n")];
|
|
309
|
+
const rebuilt = [...lines.slice(0, start + 1), "", ...next, "", ...lines.slice(end)];
|
|
310
|
+
try {
|
|
311
|
+
writeFileSync(paths.progress, `${rebuilt.join("\n").replace(/\n{3,}$/u, "\n")}`, "utf8");
|
|
312
|
+
return undefined;
|
|
313
|
+
} catch (error) {
|
|
314
|
+
return formatError(error);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function headingText(line: string | undefined): string | undefined {
|
|
319
|
+
const match = /^##\s+(.+?)\s*$/u.exec(line ?? "");
|
|
320
|
+
return match ? match[1].toLowerCase() : undefined;
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export interface MarkCriterionResult {
|
|
324
|
+
ok: boolean;
|
|
325
|
+
message: string;
|
|
326
|
+
criteria?: LoopCriterion[];
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/**
|
|
330
|
+
* Flip one criterion's `passes` and record the citation that justified it.
|
|
331
|
+
*
|
|
332
|
+
* The only mutation `criteria.json` accepts. Descriptions, ids, checks and the
|
|
333
|
+
* set of entries are rewritten by nothing here, so "a model may not rewrite
|
|
334
|
+
* its own acceptance criteria" stops being a rule in a skill file and becomes
|
|
335
|
+
* a property of the only available write path.
|
|
336
|
+
*/
|
|
337
|
+
export function markCriterion(
|
|
338
|
+
paths: LedgerPaths,
|
|
339
|
+
id: string,
|
|
340
|
+
evidence: string,
|
|
341
|
+
passes: boolean,
|
|
342
|
+
now: number,
|
|
343
|
+
): MarkCriterionResult {
|
|
344
|
+
const criteria = readCriteria(paths);
|
|
345
|
+
if (!criteria) return { ok: false, message: "criteria.json is absent or unreadable" };
|
|
346
|
+
const target = criteria.find((criterion) => criterion.id === id);
|
|
347
|
+
if (!target) {
|
|
348
|
+
return {
|
|
349
|
+
ok: false,
|
|
350
|
+
message: `no criterion ${id}; this loop has ${criteria.map((c) => c.id).join(", ")}`,
|
|
351
|
+
};
|
|
352
|
+
}
|
|
353
|
+
const cited = evidence.trim();
|
|
354
|
+
if (passes && !cited) return { ok: false, message: "marking a criterion met requires evidence" };
|
|
355
|
+
const updated = criteria.map((criterion) =>
|
|
356
|
+
criterion.id === id
|
|
357
|
+
? {
|
|
358
|
+
...criterion,
|
|
359
|
+
passes,
|
|
360
|
+
...(passes ? { evidence: cited, evidenceAt: now } : {}),
|
|
361
|
+
}
|
|
362
|
+
: criterion,
|
|
363
|
+
);
|
|
364
|
+
try {
|
|
365
|
+
writeFileSync(paths.criteria, `${JSON.stringify(updated, null, 2)}\n`, "utf8");
|
|
366
|
+
} catch (error) {
|
|
367
|
+
return { ok: false, message: formatError(error) };
|
|
368
|
+
}
|
|
369
|
+
const met = updated.filter((criterion) => criterion.passes).length;
|
|
370
|
+
return {
|
|
371
|
+
ok: true,
|
|
372
|
+
message: `${id} marked ${passes ? "met" : "unmet"} (${met}/${updated.length} now passing)`,
|
|
373
|
+
criteria: updated,
|
|
374
|
+
};
|
|
375
|
+
}
|
|
376
|
+
|
|
187
377
|
/** Read the criteria back, fail-open: undefined when absent or unreadable. */
|
|
188
378
|
export function readCriteria(paths: LedgerPaths): LoopCriterion[] | undefined {
|
|
189
379
|
let contents: string;
|
|
@@ -213,11 +403,15 @@ function normalizeCriterion(value: unknown): LoopCriterion | undefined {
|
|
|
213
403
|
const id = typeof record.id === "string" ? record.id.trim() : "";
|
|
214
404
|
const description = typeof record.description === "string" ? record.description.trim() : "";
|
|
215
405
|
if (!id || !description) return undefined;
|
|
406
|
+
const evidence = typeof record.evidence === "string" ? record.evidence.trim() : "";
|
|
407
|
+
const evidenceAt = record.evidenceAt;
|
|
216
408
|
return {
|
|
217
409
|
id,
|
|
218
410
|
description,
|
|
219
411
|
check: typeof record.check === "string" ? record.check : "",
|
|
220
412
|
passes: record.passes === true,
|
|
413
|
+
...(evidence ? { evidence } : {}),
|
|
414
|
+
...(typeof evidenceAt === "number" && Number.isSafeInteger(evidenceAt) ? { evidenceAt } : {}),
|
|
221
415
|
};
|
|
222
416
|
}
|
|
223
417
|
|