@hank-warren/pi-loop 0.8.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 +32 -0
- package/README.md +52 -32
- package/package.json +4 -1
- package/skills/pi-loop/SKILL.md +73 -45
- package/src/command.ts +33 -187
- package/src/complete-tool.ts +1 -1
- package/src/fresh-launch.ts +128 -0
- package/src/index.ts +70 -79
- package/src/ledger.ts +2 -2
- package/src/loop-action-menus.ts +130 -0
- package/src/loop-env.ts +50 -0
- package/src/loop-launch-menu.ts +158 -0
- package/src/loop-manager-menu.ts +191 -0
- package/src/loop.ts +156 -29
- package/src/manager.ts +213 -147
- package/src/messages.ts +1 -1
- package/src/objective.ts +38 -1
- package/src/planning.ts +78 -24
- package/src/presentation.ts +50 -0
- package/src/progress-tool.ts +1 -1
- package/src/propose-tool.ts +42 -15
- package/src/settings.ts +27 -22
- package/src/state.ts +43 -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/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;
|
|
@@ -38,19 +50,38 @@ export interface LoopPlanningState {
|
|
|
38
50
|
active: boolean;
|
|
39
51
|
/** The current draft awaiting approval, when one has been proposed. */
|
|
40
52
|
proposal?: LoopProposal;
|
|
53
|
+
/**
|
|
54
|
+
* `proposedAt` of the draft whose card has already been rendered.
|
|
55
|
+
*
|
|
56
|
+
* The card is an artifact in the transcript, not a status line, so re-running
|
|
57
|
+
* `/loop` to reopen the menu must not emit a second copy of the same card.
|
|
58
|
+
* A new draft — a reworded objective, a changed cadence — has a new
|
|
59
|
+
* `proposedAt` and does get its own card, because it is a different thing to
|
|
60
|
+
* approve.
|
|
61
|
+
*/
|
|
62
|
+
cardShownAt?: number;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export interface LoopProposalOverrides {
|
|
66
|
+
intervalMs?: number;
|
|
67
|
+
maxTurns?: number | null;
|
|
68
|
+
expiresInMs?: number;
|
|
69
|
+
groundRules?: string[];
|
|
41
70
|
}
|
|
42
71
|
|
|
43
72
|
export function buildProposal(
|
|
44
73
|
objective: string,
|
|
45
74
|
defaults: { intervalMs: number; maxTurns: number | null; expiresInMs: number },
|
|
46
75
|
now: number,
|
|
47
|
-
overrides:
|
|
76
|
+
overrides: LoopProposalOverrides = {},
|
|
48
77
|
): LoopProposal {
|
|
78
|
+
const groundRules = normalizeGroundRules(overrides.groundRules);
|
|
49
79
|
return {
|
|
50
80
|
objective: objective.trim(),
|
|
51
81
|
// Derived, never authored: the card has to show the criteria the engine
|
|
52
82
|
// will actually freeze, or approving it means approving something else.
|
|
53
83
|
criteria: deriveCriteria(objective),
|
|
84
|
+
...(groundRules ? { groundRules } : {}),
|
|
54
85
|
intervalMs: overrides.intervalMs ?? defaults.intervalMs,
|
|
55
86
|
maxTurns: overrides.maxTurns === undefined ? defaults.maxTurns : overrides.maxTurns,
|
|
56
87
|
expiresInMs: overrides.expiresInMs ?? defaults.expiresInMs,
|
|
@@ -58,6 +89,21 @@ export function buildProposal(
|
|
|
58
89
|
};
|
|
59
90
|
}
|
|
60
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
|
+
|
|
61
107
|
/** The approval card, as transcript lines. */
|
|
62
108
|
export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
63
109
|
return [
|
|
@@ -69,28 +115,36 @@ export function renderProposalCard(proposal: LoopProposal): string[] {
|
|
|
69
115
|
`**Criteria the gate will hold you to** (${proposal.criteria.length})`,
|
|
70
116
|
...proposal.criteria.map((criterion) => `- \`${criterion.id}\` ${criterion.description}`),
|
|
71
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
|
+
: []),
|
|
72
125
|
`**Cadence** every ${formatDuration(proposal.intervalMs)} — a fallback heartbeat; the loop advances whenever the session settles.`,
|
|
73
126
|
`**Turn cap** ${proposal.maxTurns === null ? "unlimited" : proposal.maxTurns} · **Expires** ${formatDuration(proposal.expiresInMs)}`,
|
|
74
127
|
"",
|
|
75
|
-
"Run `/loop`
|
|
128
|
+
"Run `/loop` for the actions: start here, start in a fresh session, change the cadence, keep editing, or cancel.",
|
|
76
129
|
];
|
|
77
130
|
}
|
|
78
131
|
|
|
79
132
|
export const LOOP_PLANNING_HINT = [
|
|
80
133
|
"<system-reminder>",
|
|
81
|
-
"The user opened loop planning. You are drafting a loop
|
|
82
|
-
"
|
|
83
|
-
"
|
|
84
|
-
"- Name the check in the requirement itself ('…, verified by npm test passing')
|
|
85
|
-
"- The
|
|
86
|
-
"
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
//
|
|
90
|
-
// request for a loop pattern-matches
|
|
91
|
-
// answers by telling the user to type
|
|
92
|
-
// planning exists to remove. Observed live in a
|
|
93
|
-
|
|
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.",
|
|
94
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.",
|
|
95
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.",
|
|
96
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.",
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The approval card, as a framed transcript block.
|
|
3
|
+
*
|
|
4
|
+
* It used to go out twice and neither copy was a card: `loop_propose`
|
|
5
|
+
* returned it as tool-result text, and `/loop` re-printed it through
|
|
6
|
+
* `ctx.ui.notify`. Tool text is rendered as a wall of markdown inside a tool
|
|
7
|
+
* result, and a toast is a transient line that scrolls away — so the one
|
|
8
|
+
* artifact the whole planning flow exists to produce was the least legible
|
|
9
|
+
* thing on the screen, and duplicated.
|
|
10
|
+
*
|
|
11
|
+
* A custom-type message with `display: true` is what Pi frames, and
|
|
12
|
+
* `triggerTurn: false` is what keeps it an artifact rather than a prompt: the
|
|
13
|
+
* card appears, the model is not asked to respond to it, and the user's
|
|
14
|
+
* approval remains the only thing that starts a loop. This is exactly how
|
|
15
|
+
* pi-plan-mode renders a proposed plan (`packages/pi-plan-mode/src/
|
|
16
|
+
* presentation.ts`), for the same reason.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
20
|
+
import { type LoopProposal, renderProposalCard } from "./planning.js";
|
|
21
|
+
|
|
22
|
+
export const LOOP_PROPOSAL_MESSAGE_TYPE = "loop-proposal";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Emit the card. Returns false when Pi refused it, in which case the caller
|
|
26
|
+
* still has a working flow — the menu carries the actions, and the criteria
|
|
27
|
+
* are on disk the moment the loop starts.
|
|
28
|
+
*/
|
|
29
|
+
export function showLoopProposalCard(
|
|
30
|
+
pi: ExtensionAPI,
|
|
31
|
+
ctx: ExtensionContext,
|
|
32
|
+
proposal: LoopProposal,
|
|
33
|
+
): boolean {
|
|
34
|
+
try {
|
|
35
|
+
pi.sendMessage(
|
|
36
|
+
{
|
|
37
|
+
customType: LOOP_PROPOSAL_MESSAGE_TYPE,
|
|
38
|
+
content: renderProposalCard(proposal).join("\n"),
|
|
39
|
+
display: true,
|
|
40
|
+
details: { criteria: proposal.criteria.length, proposedAt: proposal.proposedAt },
|
|
41
|
+
},
|
|
42
|
+
{ triggerTurn: false },
|
|
43
|
+
);
|
|
44
|
+
return true;
|
|
45
|
+
} catch (error) {
|
|
46
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
47
|
+
ctx.ui.notify(`Unable to show the loop proposal: ${detail}`, "error");
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
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.
|
|
@@ -14,9 +14,9 @@
|
|
|
14
14
|
|
|
15
15
|
import { defineTool, type ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
16
16
|
import { Type } from "typebox";
|
|
17
|
-
import { MAX_INTERVAL_MS, parseDuration } from "./interval.js";
|
|
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,8 +58,15 @@ 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
|
-
async execute(_toolCallId, params, _signal, _onUpdate,
|
|
69
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
63
70
|
if (!controller.planning.active) {
|
|
64
71
|
return failure(
|
|
65
72
|
"Loop planning is not open, so there is nothing to propose. The user opens it by running /loop with no loop running.",
|
|
@@ -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) {
|
|
@@ -101,12 +116,24 @@ export function registerLoopProposeTool(pi: ExtensionAPI, controller: LoopContro
|
|
|
101
116
|
}
|
|
102
117
|
|
|
103
118
|
const proposal = controller.propose(objective, overrides);
|
|
119
|
+
// The card goes to the transcript as a framed block, not back through
|
|
120
|
+
// this tool result. Returning it here too would render the same
|
|
121
|
+
// artifact twice, once framed and once as a wall of markdown, and
|
|
122
|
+
// spend the objective's tokens a second time in the model's own
|
|
123
|
+
// context for no reader that does not already have it.
|
|
124
|
+
controller.showProposalCard(ctx);
|
|
104
125
|
return {
|
|
105
|
-
content: [
|
|
126
|
+
content: [
|
|
127
|
+
{
|
|
128
|
+
type: "text" as const,
|
|
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.`,
|
|
130
|
+
},
|
|
131
|
+
],
|
|
106
132
|
details: {
|
|
107
133
|
criteria: proposal.criteria.length,
|
|
108
134
|
intervalMs: proposal.intervalMs,
|
|
109
135
|
maxTurns: proposal.maxTurns,
|
|
136
|
+
groundRules: proposal.groundRules?.length ?? 0,
|
|
110
137
|
},
|
|
111
138
|
};
|
|
112
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
|
|
@@ -63,6 +69,19 @@ export interface LoopState {
|
|
|
63
69
|
* while it writes its state down; the next settle stops it.
|
|
64
70
|
*/
|
|
65
71
|
expiring?: true;
|
|
72
|
+
/**
|
|
73
|
+
* Set on a loop handed to a fresh session and cleared the moment that
|
|
74
|
+
* session restores it.
|
|
75
|
+
*
|
|
76
|
+
* The launching session cannot kick the loop off itself: Pi builds a new
|
|
77
|
+
* extension instance for the new session, so the controller that ran the
|
|
78
|
+
* approval menu is not the controller that ends up holding the loop —
|
|
79
|
+
* observed live, where the loop crossed correctly and then sat idle waiting
|
|
80
|
+
* for its first fallback wake. Carrying the intent in the state instead
|
|
81
|
+
* means whichever instance restores it does the kickoff, which is true for
|
|
82
|
+
* every lifecycle the host might have.
|
|
83
|
+
*/
|
|
84
|
+
handoff?: true;
|
|
66
85
|
}
|
|
67
86
|
|
|
68
87
|
const MAX_PROMPT_LENGTH = 100_000;
|
|
@@ -87,6 +106,8 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
87
106
|
objective = record.objective.trim();
|
|
88
107
|
if (!objective || objective.length > MAX_PROMPT_LENGTH) return undefined;
|
|
89
108
|
}
|
|
109
|
+
const groundRules = normalizeGroundRuleList(record.groundRules);
|
|
110
|
+
if (groundRules === false) return undefined;
|
|
90
111
|
const intervalMs = record.intervalMs;
|
|
91
112
|
if (!isPositiveSafeInteger(intervalMs)) return undefined;
|
|
92
113
|
const maxTurns = readTurnCap(record);
|
|
@@ -135,11 +156,13 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
135
156
|
const lastFingerprint = optionalText(record.lastFingerprint);
|
|
136
157
|
if (lastFingerprint === false) return undefined;
|
|
137
158
|
if (record.expiring !== undefined && record.expiring !== true) return undefined;
|
|
159
|
+
if (record.handoff !== undefined && record.handoff !== true) return undefined;
|
|
138
160
|
return {
|
|
139
161
|
id,
|
|
140
162
|
status: status as LoopStatus,
|
|
141
163
|
...(prompt === undefined ? {} : { prompt }),
|
|
142
164
|
...(objective === undefined ? {} : { objective }),
|
|
165
|
+
...(groundRules === undefined ? {} : { groundRules }),
|
|
143
166
|
intervalMs,
|
|
144
167
|
maxTurns,
|
|
145
168
|
compactAt: compactAt as number | null,
|
|
@@ -154,6 +177,7 @@ export function normalizeLoopState(value: unknown): LoopState | undefined {
|
|
|
154
177
|
...(lastFingerprint === undefined ? {} : { lastFingerprint }),
|
|
155
178
|
...(pauseCause === undefined ? {} : { pauseCause }),
|
|
156
179
|
...(record.expiring === true ? { expiring: true as const } : {}),
|
|
180
|
+
...(record.handoff === true ? { handoff: true as const } : {}),
|
|
157
181
|
};
|
|
158
182
|
}
|
|
159
183
|
|
|
@@ -220,6 +244,25 @@ function ownRecord(value: unknown): Record<string, unknown> | undefined {
|
|
|
220
244
|
: undefined;
|
|
221
245
|
}
|
|
222
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
|
+
|
|
223
266
|
/** A present-but-optional string: the value, undefined when absent, false when invalid. */
|
|
224
267
|
function optionalText(value: unknown): string | undefined | false {
|
|
225
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
|
|