@hank-warren/pi-loop 0.9.0 → 1.0.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 +12 -0
- package/README.md +35 -32
- package/package.json +1 -1
- package/skills/pi-loop/SKILL.md +64 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/index.ts +70 -79
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +2 -1
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +22 -22
- package/src/manager.ts +118 -104
- package/src/messages.ts +1 -1
- package/src/objective.ts +25 -0
- package/src/planning.ts +67 -23
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +29 -13
- package/src/settings.ts +27 -22
- package/src/state.ts +28 -0
- package/src/wait-tool.ts +1 -1
- package/src/widget.ts +7 -3
- package/src/inline-command.ts +0 -159
- package/src/inline-invocation.ts +0 -109
- package/src/start-tool.ts +0 -199
package/src/messages.ts
CHANGED
|
@@ -20,7 +20,7 @@ export type ContinuationKind = "kickoff" | "continue" | "reanchor";
|
|
|
20
20
|
* counter against the delivered-wake cap; that cap is gone, collapsed into
|
|
21
21
|
* the single loop-turn cap, and pairing a wake number with a turn cap would
|
|
22
22
|
* have been a number that reads as a budget and is not one. The cap is shown
|
|
23
|
-
* to the *user*, in the widget and
|
|
23
|
+
* to the *user*, in the widget and the /loop status screen, which is who it is for.
|
|
24
24
|
*/
|
|
25
25
|
function formatWakeOrdinal(loop: LoopState): string {
|
|
26
26
|
return `${loop.iteration + 1}`;
|
package/src/objective.ts
CHANGED
|
@@ -32,6 +32,7 @@ export function buildLoopObjectivePrompt(
|
|
|
32
32
|
"<loop_objective>",
|
|
33
33
|
escapeXmlText(loop.objective),
|
|
34
34
|
"</loop_objective>",
|
|
35
|
+
...groundRuleLines(loop),
|
|
35
36
|
`<loop_id>\n${escapeXmlText(loop.id)}\n</loop_id>`,
|
|
36
37
|
"This loop_id is only the loop_complete tool's stale-loop guard, not part of the objective.",
|
|
37
38
|
"",
|
|
@@ -73,6 +74,30 @@ export function buildLoopObjectivePrompt(
|
|
|
73
74
|
.trimEnd();
|
|
74
75
|
}
|
|
75
76
|
|
|
77
|
+
/**
|
|
78
|
+
* The approved ground rules, as a block the model cannot mistake for the
|
|
79
|
+
* objective.
|
|
80
|
+
*
|
|
81
|
+
* They sit next to the objective rather than inside it because they are a
|
|
82
|
+
* different kind of thing: the objective is what the loop is trying to reach
|
|
83
|
+
* and what `loop_complete` answers for, while these bound how it may get
|
|
84
|
+
* there. Folding them into the objective would make them criteria, and a
|
|
85
|
+
* constraint that has to be "met" is a constraint nobody can satisfy.
|
|
86
|
+
*
|
|
87
|
+
* Approved by the user on the card, so unlike the objective they are not
|
|
88
|
+
* merely task data to consider — they outrank the loop's own judgement about
|
|
89
|
+
* what is expedient at 3am on turn 200.
|
|
90
|
+
*/
|
|
91
|
+
function groundRuleLines(loop: LoopState): string[] {
|
|
92
|
+
if (!loop.groundRules || loop.groundRules.length === 0) return [];
|
|
93
|
+
return [
|
|
94
|
+
"",
|
|
95
|
+
"Ground rules (hard constraints, never violate):",
|
|
96
|
+
...loop.groundRules.map((rule) => `- ${escapeXmlText(rule)}`),
|
|
97
|
+
"The user approved these with the objective. They bound how the work may be done, they are never satisfied or completed, and no amount of progress justifies breaking one. If the only way forward violates a ground rule, stop and call loop_wait.",
|
|
98
|
+
];
|
|
99
|
+
}
|
|
100
|
+
|
|
76
101
|
/**
|
|
77
102
|
* The ledger contract. Stable per loop (the path is derived from the loop
|
|
78
103
|
* id), so it keeps the append byte-identical across turns.
|
package/src/planning.ts
CHANGED
|
@@ -7,26 +7,38 @@
|
|
|
7
7
|
* it becomes the acceptance gate — and the moment it is decided is a
|
|
8
8
|
* conversation, not a typed command.
|
|
9
9
|
*
|
|
10
|
-
* So `/loop`
|
|
11
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
* optional-interval, `every`-prefix, adverb or dry-run machinery needs to
|
|
16
|
-
* exist. A concept removed rather than a knob added.
|
|
10
|
+
* So `/loop` opens a menu whose first item is a drafting conversation, and the
|
|
11
|
+
* loop starts from an approval card that shows the exact criteria the split
|
|
12
|
+
* will produce. The card is the design language: because the cadence, the
|
|
13
|
+
* caps and the ground rules are on it and editable there, no command grammar
|
|
14
|
+
* has to carry them. A concept removed rather than a knob added.
|
|
17
15
|
*
|
|
18
|
-
*
|
|
19
|
-
*
|
|
16
|
+
* Planning is now the only door. The typed start and the inline token are
|
|
17
|
+
* gone: both authored an acceptance gate in one line, unreviewed.
|
|
20
18
|
*/
|
|
21
19
|
|
|
22
20
|
import { deriveCriteria, type LoopCriterion } from "./ledger.js";
|
|
23
21
|
import { formatDuration } from "./interval.js";
|
|
24
22
|
|
|
23
|
+
/** Bounds on drafted ground rules: enough for real constraints, not a manifesto. */
|
|
24
|
+
export const MAX_GROUND_RULES = 10;
|
|
25
|
+
export const MAX_GROUND_RULE_LENGTH = 500;
|
|
26
|
+
|
|
25
27
|
/** A drafted loop, put up for approval and not yet started. */
|
|
26
28
|
export interface LoopProposal {
|
|
27
29
|
objective: string;
|
|
28
30
|
/** Exactly what `deriveCriteria` will produce, computed here so the card cannot lie. */
|
|
29
31
|
criteria: LoopCriterion[];
|
|
32
|
+
/**
|
|
33
|
+
* Hard constraints the loop must never violate.
|
|
34
|
+
*
|
|
35
|
+
* Constraints, not criteria: they never enter `criteria.json` and never
|
|
36
|
+
* gate completion. A loop is unattended, so the useful thing to fix in
|
|
37
|
+
* advance is not only what done looks like but what it must not do on the
|
|
38
|
+
* way there — don't touch prod, don't force-push, don't rewrite the fixture
|
|
39
|
+
* to make the test pass.
|
|
40
|
+
*/
|
|
41
|
+
groundRules?: string[];
|
|
30
42
|
intervalMs: number;
|
|
31
43
|
maxTurns: number | null;
|
|
32
44
|
expiresInMs: number;
|
|
@@ -50,17 +62,26 @@ export interface LoopPlanningState {
|
|
|
50
62
|
cardShownAt?: number;
|
|
51
63
|
}
|
|
52
64
|
|
|
65
|
+
export interface LoopProposalOverrides {
|
|
66
|
+
intervalMs?: number;
|
|
67
|
+
maxTurns?: number | null;
|
|
68
|
+
expiresInMs?: number;
|
|
69
|
+
groundRules?: string[];
|
|
70
|
+
}
|
|
71
|
+
|
|
53
72
|
export function buildProposal(
|
|
54
73
|
objective: string,
|
|
55
74
|
defaults: { intervalMs: number; maxTurns: number | null; expiresInMs: number },
|
|
56
75
|
now: number,
|
|
57
|
-
overrides:
|
|
76
|
+
overrides: LoopProposalOverrides = {},
|
|
58
77
|
): LoopProposal {
|
|
78
|
+
const groundRules = normalizeGroundRules(overrides.groundRules);
|
|
59
79
|
return {
|
|
60
80
|
objective: objective.trim(),
|
|
61
81
|
// Derived, never authored: the card has to show the criteria the engine
|
|
62
82
|
// will actually freeze, or approving it means approving something else.
|
|
63
83
|
criteria: deriveCriteria(objective),
|
|
84
|
+
...(groundRules ? { groundRules } : {}),
|
|
64
85
|
intervalMs: overrides.intervalMs ?? defaults.intervalMs,
|
|
65
86
|
maxTurns: overrides.maxTurns === undefined ? defaults.maxTurns : overrides.maxTurns,
|
|
66
87
|
expiresInMs: overrides.expiresInMs ?? defaults.expiresInMs,
|
|
@@ -68,6 +89,21 @@ export function buildProposal(
|
|
|
68
89
|
};
|
|
69
90
|
}
|
|
70
91
|
|
|
92
|
+
/**
|
|
93
|
+
* Trim, drop the empties, and bound a drafted ground-rule list. Returns
|
|
94
|
+
* undefined when nothing survives, so an empty array never becomes an empty
|
|
95
|
+
* section on the card or an empty block in the system append.
|
|
96
|
+
*/
|
|
97
|
+
export function normalizeGroundRules(rules: readonly string[] | undefined): string[] | undefined {
|
|
98
|
+
if (!rules) return undefined;
|
|
99
|
+
const cleaned = rules
|
|
100
|
+
.map((rule) => rule.trim())
|
|
101
|
+
.filter((rule) => rule.length > 0)
|
|
102
|
+
.slice(0, MAX_GROUND_RULES)
|
|
103
|
+
.map((rule) => (rule.length > MAX_GROUND_RULE_LENGTH ? rule.slice(0, MAX_GROUND_RULE_LENGTH) : rule));
|
|
104
|
+
return cleaned.length > 0 ? cleaned : undefined;
|
|
105
|
+
}
|
|
106
|
+
|
|
71
107
|
/** The approval card, as transcript lines. */
|
|
72
108
|
export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
73
109
|
return [
|
|
@@ -79,6 +115,13 @@ export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
|
79
115
|
`**Criteria the gate will hold you to** (${proposal.criteria.length})`,
|
|
80
116
|
...proposal.criteria.map((criterion) => `- \`${criterion.id}\` ${criterion.description}`),
|
|
81
117
|
"",
|
|
118
|
+
...(proposal.groundRules
|
|
119
|
+
? [
|
|
120
|
+
`**Ground rules the loop must never violate** (${proposal.groundRules.length})`,
|
|
121
|
+
...proposal.groundRules.map((rule) => `- ${rule}`),
|
|
122
|
+
"",
|
|
123
|
+
]
|
|
124
|
+
: []),
|
|
82
125
|
`**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
|
|
83
126
|
`**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
|
|
84
127
|
"",
|
|
@@ -88,19 +131,20 @@ export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
|
88
131
|
|
|
89
132
|
export const LOOP_PLANNING_HINT = [
|
|
90
133
|
"<system-reminder>",
|
|
91
|
-
"The user opened loop planning. You are drafting a loop
|
|
92
|
-
"
|
|
93
|
-
"
|
|
94
|
-
"- Name the check in the requirement itself ('…, verified by npm test passing')
|
|
95
|
-
"- The
|
|
96
|
-
"
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
//
|
|
100
|
-
// request for a loop pattern-matches
|
|
101
|
-
// answers by telling the user to type
|
|
102
|
-
// planning exists to remove. Observed live in a
|
|
103
|
-
|
|
134
|
+
"The user opened loop planning. You are drafting a loop with them; no loop is running and none starts until they approve one on the card.",
|
|
135
|
+
"Read the pi-loop skill before drafting if it is available: it carries the objective, criteria, cadence and evidence craft in depth.",
|
|
136
|
+
"Cover three things in the conversation, then call loop_propose:",
|
|
137
|
+
"- The objective, written as an acceptance test. One requirement per bullet; a conjunction inside a sentence does not split, so 'fix the flaky test and update the docs' becomes one criterion whose evidence must cover both halves. Name the check in the requirement itself ('…, verified by npm test passing'). The two questions that fix most objectives: how will we know it is done, and what command proves it?",
|
|
138
|
+
"- The cadence: how long the loop may run before it expires, and the fallback heartbeat for a session that goes quiet. The loop advances whenever the session settles, so the heartbeat only matters when it is waiting on something.",
|
|
139
|
+
"- The ground rules: hard constraints it must never violate while unattended, such as which systems are off limits, what must never be force-pushed or deleted, and which files may not be edited to make a check pass. Ask for them; a loop runs with nobody watching, so an unstated constraint is one nobody enforces.",
|
|
140
|
+
"Ground rules are constraints, not criteria. They never gate completion — they bound how the work may be done.",
|
|
141
|
+
"When the draft is ready, call loop_propose with the objective and any ground rules. That renders an approval card showing the exact criteria the split will produce; the user approves, edits, or cancels.",
|
|
142
|
+
// Without this the model reaches for a prohibition instead. A conversational
|
|
143
|
+
// request for a loop pattern-matches onto 'do not start loops on your own',
|
|
144
|
+
// and the model answers by telling the user to type a command, which is
|
|
145
|
+
// precisely the dead end planning exists to remove. Observed live in a
|
|
146
|
+
// canary session.
|
|
147
|
+
"loop_propose starts nothing, so no rule against starting a loop on your own applies to it: while planning is open, a conversational request for a loop is exactly when to call loop_propose. Do not refuse and tell the user to run a command instead — drafting a proposal for them is the whole point of this mode.",
|
|
104
148
|
"The user has already opened planning, so their intent to consider a loop is established. What still requires their explicit approval is starting one, and the card is where they give it.",
|
|
105
149
|
"Never restate the objective as a tidier version of what they meant. If they decline to name checks, say plainly what the gate will and will not catch, and let them decide.",
|
|
106
150
|
"If the work is a bad fit for a loop at all — a recurring cadence, open-ended investigation with no end state, or something that finishes this turn — say so in one line and offer the alternative instead of drafting one anyway.",
|
package/src/progress-tool.ts
CHANGED
|
@@ -87,7 +87,7 @@ export function registerLoopProgressTool(pi: ExtensionAPI, controller: LoopContr
|
|
|
87
87
|
const loop = controller.state;
|
|
88
88
|
if (!loop || loop.objective === undefined) {
|
|
89
89
|
return failure(
|
|
90
|
-
"No /loop with an objective is active, so there is no ledger to write.
|
|
90
|
+
"No /loop with an objective is active, so there is no ledger to write. Run /loop to plan and approve one.",
|
|
91
91
|
);
|
|
92
92
|
}
|
|
93
93
|
const paths = controller.ledger;
|
package/src/propose-tool.ts
CHANGED
|
@@ -2,11 +2,11 @@
|
|
|
2
2
|
* `loop_propose`: put a drafted loop up for the user's approval.
|
|
3
3
|
*
|
|
4
4
|
* It starts nothing. That separation is the point — the model drafts, the user
|
|
5
|
-
* approves, and the approval is what
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
5
|
+
* approves, and the approval is what starts the loop. A loop is
|
|
6
|
+
* self-continuing and must never begin on model initiative, so this is the
|
|
7
|
+
* only way a model can put one in front of a user: a card showing the
|
|
8
|
+
* objective, the criteria, the ground rules, the cadence and the caps, with
|
|
9
|
+
* the start reserved to the human reading it.
|
|
10
10
|
*
|
|
11
11
|
* Registered unconditionally, like the other loop tools: the tool set is part
|
|
12
12
|
* of the cached request prefix, so it never changes with loop state.
|
|
@@ -16,7 +16,7 @@ import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
|
16
16
|
import { Type } from "typebox";
|
|
17
17
|
import { formatDuration, MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
18
18
|
import type { LoopController } from "./loop.js";
|
|
19
|
-
import {
|
|
19
|
+
import { MAX_GROUND_RULE_LENGTH, MAX_GROUND_RULES } from "./planning.js";
|
|
20
20
|
|
|
21
21
|
export const LOOP_PROPOSE_TOOL = "loop_propose";
|
|
22
22
|
|
|
@@ -26,13 +26,13 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
26
26
|
name: LOOP_PROPOSE_TOOL,
|
|
27
27
|
label: "Loop Propose",
|
|
28
28
|
description:
|
|
29
|
-
"Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce. Starts nothing: the user approves, edits, or cancels.",
|
|
29
|
+
"Put a drafted loop objective up for the user's approval during loop planning. Renders an approval card showing the exact completion criteria the objective will produce, plus any ground rules. Starts nothing: the user approves, edits, or cancels.",
|
|
30
30
|
promptSnippet: "Propose a drafted loop objective for approval",
|
|
31
31
|
promptGuidelines: [
|
|
32
32
|
"Call loop_propose only while loop planning is open, and only once the objective reads as an acceptance test: one requirement per bullet, each naming the check that proves it.",
|
|
33
33
|
"Pass the objective you and the user agreed on, not a tidier version of it. The criteria are derived from this text and frozen when the loop starts.",
|
|
34
|
-
"
|
|
35
|
-
"loop_propose starts nothing, so
|
|
34
|
+
"Pass ground_rules for the hard constraints the loop must never violate while unattended. They bound how the work may be done and never gate completion, so they belong there rather than in the objective.",
|
|
35
|
+
"loop_propose starts nothing, so no rule against starting loops on your own applies to it. While planning is open, a conversational request for a loop is the signal to draft one and propose it — never to refuse and ask the user to run a command instead.",
|
|
36
36
|
],
|
|
37
37
|
parameters: Type.Object({
|
|
38
38
|
objective: Type.String({
|
|
@@ -58,6 +58,13 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
58
58
|
description: "Loop lifetime as <number><unit>, e.g. 3d. Omit for the default.",
|
|
59
59
|
}),
|
|
60
60
|
),
|
|
61
|
+
ground_rules: Type.Optional(
|
|
62
|
+
Type.Array(Type.String({ minLength: 1, maxLength: MAX_GROUND_RULE_LENGTH }), {
|
|
63
|
+
maxItems: MAX_GROUND_RULES,
|
|
64
|
+
description:
|
|
65
|
+
"Hard constraints the loop must never violate, one per entry (e.g. 'never touch production', 'never force-push', 'never edit a test to make it pass'). Constraints, not criteria: they never gate completion.",
|
|
66
|
+
}),
|
|
67
|
+
),
|
|
61
68
|
}),
|
|
62
69
|
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
63
70
|
if (!controller.planning.active) {
|
|
@@ -67,14 +74,22 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
67
74
|
}
|
|
68
75
|
if (controller.state && controller.state.status !== "stopped") {
|
|
69
76
|
return failure(
|
|
70
|
-
"A loop is already running in this session. Stop it
|
|
77
|
+
"A loop is already running in this session. Stop it from the /loop menu before planning another.",
|
|
71
78
|
);
|
|
72
79
|
}
|
|
73
80
|
const objective = params.objective.trim();
|
|
74
81
|
if (!objective) return failure("A proposal needs an objective.");
|
|
75
82
|
|
|
76
|
-
const overrides: {
|
|
77
|
-
|
|
83
|
+
const overrides: {
|
|
84
|
+
intervalMs?: number;
|
|
85
|
+
maxTurns?: number | null;
|
|
86
|
+
expiresInMs?: number;
|
|
87
|
+
groundRules?: string[];
|
|
88
|
+
} = {};
|
|
89
|
+
// Bounded and trimmed in buildProposal, so an over-long or empty entry
|
|
90
|
+
// is normalized rather than refused: a rejected proposal costs the whole
|
|
91
|
+
// draft, and the card is where the user reviews them anyway.
|
|
92
|
+
if (params.ground_rules !== undefined) overrides.groundRules = params.ground_rules;
|
|
78
93
|
if (params.interval !== undefined) {
|
|
79
94
|
const parsed = parseDuration(params.interval);
|
|
80
95
|
if (parsed === undefined) {
|
|
@@ -111,13 +126,14 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
111
126
|
content: [
|
|
112
127
|
{
|
|
113
128
|
type: "text" as const,
|
|
114
|
-
text: `Approval card rendered: ${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}, waking every ${formatDuration(proposal.intervalMs)}, cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns}, expires in ${formatDuration(proposal.expiresInMs)}. The user starts it from /loop; nothing is running yet.`,
|
|
129
|
+
text: `Approval card rendered: ${proposal.criteria.length} ${proposal.criteria.length === 1 ? "criterion" : "criteria"}${proposal.groundRules ? `, ${proposal.groundRules.length} ground rule${proposal.groundRules.length === 1 ? "" : "s"}` : ""}, waking every ${formatDuration(proposal.intervalMs)}, cap ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns}, expires in ${formatDuration(proposal.expiresInMs)}. The user starts it from /loop; nothing is running yet.`,
|
|
115
130
|
},
|
|
116
131
|
],
|
|
117
132
|
details: {
|
|
118
133
|
criteria: proposal.criteria.length,
|
|
119
134
|
intervalMs: proposal.intervalMs,
|
|
120
135
|
maxTurns: proposal.maxTurns,
|
|
136
|
+
groundRules: proposal.groundRules?.length ?? 0,
|
|
121
137
|
},
|
|
122
138
|
};
|
|
123
139
|
},
|
package/src/settings.ts
CHANGED
|
@@ -29,12 +29,28 @@ export interface LoopCompactionSettings {
|
|
|
29
29
|
*/
|
|
30
30
|
const LEGACY_CAP_KEYS = ["maxIterations", "automaticTurns"] as const;
|
|
31
31
|
|
|
32
|
+
/**
|
|
33
|
+
* Settings that no longer exist. They are tolerated on read (an unknown field
|
|
34
|
+
* is ignored, never a reason to reject the file) and dropped on the next save,
|
|
35
|
+
* so a settings file written by an older version keeps working and quietly
|
|
36
|
+
* stops advertising a switch that controls nothing.
|
|
37
|
+
*
|
|
38
|
+
* `inlineInvocation` toggled mid-prompt `/loop` detection, which was removed
|
|
39
|
+
* along with the `loop_start` tool it pointed at.
|
|
40
|
+
*/
|
|
41
|
+
const REMOVED_KEYS = ["inlineInvocation"] as const;
|
|
42
|
+
|
|
32
43
|
export interface LoopSettings {
|
|
33
44
|
/**
|
|
34
45
|
* Cap on the turns the loop itself causes (settle continuations plus
|
|
35
|
-
* fallback pokes); null means unlimited
|
|
36
|
-
* there is: one wake can yield many turns, so counting turns is
|
|
37
|
-
* actually bounds a loop.
|
|
46
|
+
* fallback pokes); null means unlimited, and unlimited is the default. The
|
|
47
|
+
* only cap there is: one wake can yield many turns, so counting turns is
|
|
48
|
+
* what actually bounds a loop.
|
|
49
|
+
*
|
|
50
|
+
* A turn budget is a proxy for cost, not for progress, and a loop that hits
|
|
51
|
+
* one stops in the middle of the work with nothing decided. The real bounds
|
|
52
|
+
* are the expiry and the no-progress breaker, which stop a loop for reasons
|
|
53
|
+
* a user can act on. Set a number here to opt back into a budget.
|
|
38
54
|
*/
|
|
39
55
|
maxTurns: number | null;
|
|
40
56
|
/**
|
|
@@ -45,26 +61,19 @@ export interface LoopSettings {
|
|
|
45
61
|
/** Wall-clock expiry for a loop, e.g. "7d" (research: bound forgotten loops). */
|
|
46
62
|
maxLoopDuration: string;
|
|
47
63
|
/**
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
|
|
52
|
-
inlineInvocation: boolean;
|
|
53
|
-
/**
|
|
54
|
-
* Fallback heartbeat used by an inline invocation that names no interval.
|
|
55
|
-
* In a settle-paced loop the interval is only a fallback — the settle
|
|
56
|
-
* boundary is the pacemaker — so this value is far less consequential than
|
|
57
|
-
* it looks; it is still clamped to MIN_INTERVAL_MS.
|
|
64
|
+
* Fallback heartbeat used by a proposal that names no interval. In a
|
|
65
|
+
* settle-paced loop the interval is only a fallback — the settle boundary is
|
|
66
|
+
* the pacemaker — so this value is far less consequential than it looks; it
|
|
67
|
+
* is still clamped to MIN_INTERVAL_MS.
|
|
58
68
|
*/
|
|
59
69
|
defaultInterval: string;
|
|
60
70
|
compaction: LoopCompactionSettings;
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
export const DEFAULT_LOOP_SETTINGS: LoopSettings = {
|
|
64
|
-
maxTurns:
|
|
74
|
+
maxTurns: null,
|
|
65
75
|
noProgressTurns: 3,
|
|
66
76
|
maxLoopDuration: "7d",
|
|
67
|
-
inlineInvocation: true,
|
|
68
77
|
defaultInterval: "10m",
|
|
69
78
|
compaction: {
|
|
70
79
|
enabled: true,
|
|
@@ -98,11 +107,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
98
107
|
return undefined;
|
|
99
108
|
}
|
|
100
109
|
|
|
101
|
-
const inlineInvocation = Object.hasOwn(record, "inlineInvocation")
|
|
102
|
-
? record.inlineInvocation
|
|
103
|
-
: DEFAULT_LOOP_SETTINGS.inlineInvocation;
|
|
104
|
-
if (typeof inlineInvocation !== "boolean") return undefined;
|
|
105
|
-
|
|
106
110
|
const defaultInterval = Object.hasOwn(record, "defaultInterval")
|
|
107
111
|
? record.defaultInterval
|
|
108
112
|
: DEFAULT_LOOP_SETTINGS.defaultInterval;
|
|
@@ -140,7 +144,6 @@ export function normalizeLoopSettings(value: unknown): LoopSettings | undefined
|
|
|
140
144
|
maxTurns,
|
|
141
145
|
noProgressTurns,
|
|
142
146
|
maxLoopDuration,
|
|
143
|
-
inlineInvocation,
|
|
144
147
|
defaultInterval,
|
|
145
148
|
compaction: { enabled, threshold, instructions },
|
|
146
149
|
};
|
|
@@ -253,13 +256,15 @@ export function saveLoopSettings(settings: LoopSettings, settingsPath = loopSett
|
|
|
253
256
|
// unknown: leaving them next to a cap that supersedes them would show the
|
|
254
257
|
// user two numbers where only one applies.
|
|
255
258
|
for (const key of LEGACY_CAP_KEYS) delete raw[key];
|
|
259
|
+
// Removed settings are dropped rather than preserved: keeping a switch that
|
|
260
|
+
// controls nothing is worse than losing it.
|
|
261
|
+
for (const key of REMOVED_KEYS) delete raw[key];
|
|
256
262
|
const document = `${JSON.stringify(
|
|
257
263
|
{
|
|
258
264
|
...raw,
|
|
259
265
|
maxTurns: normalized.maxTurns,
|
|
260
266
|
noProgressTurns: normalized.noProgressTurns,
|
|
261
267
|
maxLoopDuration: normalized.maxLoopDuration,
|
|
262
|
-
inlineInvocation: normalized.inlineInvocation,
|
|
263
268
|
defaultInterval: normalized.defaultInterval,
|
|
264
269
|
compaction: { ...compaction, ...normalized.compaction },
|
|
265
270
|
},
|
package/src/state.ts
CHANGED
|
@@ -29,6 +29,12 @@ export interface LoopState {
|
|
|
29
29
|
* before 0.6.0 may predate it — every loop started now has one.
|
|
30
30
|
*/
|
|
31
31
|
objective?: string;
|
|
32
|
+
/**
|
|
33
|
+
* Hard constraints, approved with the objective and injected alongside it on
|
|
34
|
+
* every active turn. Optional: a loop started before ground rules existed,
|
|
35
|
+
* or approved without any, simply has none.
|
|
36
|
+
*/
|
|
37
|
+
groundRules?: string[];
|
|
32
38
|
intervalMs: number;
|
|
33
39
|
/**
|
|
34
40
|
* Cap on the turns this loop causes (continuations plus pokes); null means
|
|
@@ -100,6 +106,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
100
106
|
objective = record.objective.trim();
|
|
101
107
|
if (!objective || objective.length > MAX_PROMPT_LENGTH) return undefined;
|
|
102
108
|
}
|
|
109
|
+
const groundRules = normalizeGroundRuleList(record.groundRules);
|
|
110
|
+
if (groundRules === false) return undefined;
|
|
103
111
|
const intervalMs = record.intervalMs;
|
|
104
112
|
if (!isPositiveSafeInteger(intervalMs)) return undefined;
|
|
105
113
|
const maxTurns = readTurnCap(record);
|
|
@@ -154,6 +162,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
154
162
|
status: status as LoopStatus,
|
|
155
163
|
...(prompt === undefined ? {} : { prompt }),
|
|
156
164
|
...(objective === undefined ? {} : { objective }),
|
|
165
|
+
...(groundRules === undefined ? {} : { groundRules }),
|
|
157
166
|
intervalMs,
|
|
158
167
|
maxTurns,
|
|
159
168
|
compactAt: compactAt as number | null,
|
|
@@ -235,6 +244,25 @@ function ownRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
235
244
|
: undefined;
|
|
236
245
|
}
|
|
237
246
|
|
|
247
|
+
/**
|
|
248
|
+
* A present-but-optional ground-rule list: the list, undefined when absent,
|
|
249
|
+
* false when invalid. Empty survives as undefined so an approved loop with no
|
|
250
|
+
* rules and a restored one are the same state.
|
|
251
|
+
*/
|
|
252
|
+
function normalizeGroundRuleList(value: unknown): string[] | undefined | false {
|
|
253
|
+
if (value === undefined) return undefined;
|
|
254
|
+
if (!Array.isArray(value)) return false;
|
|
255
|
+
const rules: string[] = [];
|
|
256
|
+
for (const entry of value) {
|
|
257
|
+
if (typeof entry !== "string") return false;
|
|
258
|
+
const trimmed = entry.trim();
|
|
259
|
+
if (!trimmed) continue;
|
|
260
|
+
if (trimmed.length > MAX_PROMPT_LENGTH) return false;
|
|
261
|
+
rules.push(trimmed);
|
|
262
|
+
}
|
|
263
|
+
return rules.length > 0 ? rules : undefined;
|
|
264
|
+
}
|
|
265
|
+
|
|
238
266
|
/** A present-but-optional string: the value, undefined when absent, false when invalid. */
|
|
239
267
|
function optionalText(value: unknown): string | undefined | false {
|
|
240
268
|
if (value === undefined) return undefined;
|
package/src/wait-tool.ts
CHANGED
|
@@ -59,7 +59,7 @@ export function registerLoopWaitTool(pi: ExtensionAPI, controller: LoopControlle
|
|
|
59
59
|
if (!loop || loop.objective === undefined) {
|
|
60
60
|
return {
|
|
61
61
|
content: toolContent(
|
|
62
|
-
"No /loop with an objective is active, so there is nothing to wait on.
|
|
62
|
+
"No /loop with an objective is active, so there is nothing to wait on. Run /loop to plan and approve one.",
|
|
63
63
|
),
|
|
64
64
|
details: {},
|
|
65
65
|
isError: true,
|
package/src/widget.ts
CHANGED
|
@@ -21,6 +21,11 @@
|
|
|
21
21
|
* order is the whole reason to glance at the line: paused and blocked and
|
|
22
22
|
* expiring come before the ordinary running line.
|
|
23
23
|
*
|
|
24
|
+
* The glyph vocabulary is shared with pi-plan-mode by convention, not by
|
|
25
|
+
* import — `◆` planning or ready, `▶` implementing, `⟳` running, `⏸` paused,
|
|
26
|
+
* `⏳` waiting, `⚠` attention. Six characters are not worth a package; a user
|
|
27
|
+
* reading a footer is worth the consistency.
|
|
28
|
+
*
|
|
24
29
|
* Presentation only: every entry point tolerates a host without setWidget
|
|
25
30
|
* (test fixtures, print mode) and swallows render-side failures, because a
|
|
26
31
|
* widget must never interrupt loop state transitions.
|
|
@@ -122,9 +127,8 @@ export function widgetTone(view: LoopWidgetView): Tone {
|
|
|
122
127
|
|
|
123
128
|
export function loopWidgetLine(view: LoopWidgetView): string {
|
|
124
129
|
if (view.kind === "planning") {
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
: `◆ loop planning · ${view.proposedCriteria} criteria proposed · approve to start`;
|
|
130
|
+
if (view.proposedCriteria === undefined) return "◆ loop · drafting objective";
|
|
131
|
+
return `◆ loop · ${view.proposedCriteria} ${view.proposedCriteria === 1 ? "criterion" : "criteria"} proposed · approve to start`;
|
|
128
132
|
}
|
|
129
133
|
const loop = view.loop;
|
|
130
134
|
|