@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/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 `/loop status`, which is who it is for.
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` with nothing running opens a drafting conversation instead of an
11
- * error about a missing interval, and the loop starts from an approval card
12
- * that shows the exact criteria the split will produce. The card is the design
13
- * language: because the cadence and the caps are on it and editable there, the
14
- * command grammar does not have to be natural, and none of the
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
- * The typed form (`/loop 30m <objective>`) is untouched, as is inline `loop:`
19
- * invocation. Planning is the front door, not the only door.
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: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } = {},
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 objective with them; no loop is running and none starts until they approve one.",
92
- "A loop's objective becomes its acceptance gate, so draft it as an acceptance test, not as a prompt:",
93
- "- One requirement per line, as a 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.",
94
- "- Name the check in the requirement itself ('…, verified by npm test passing'), so completion is a lookup instead of an argument.",
95
- "- The two questions that fix most objectives: how will we know it is done, and what command proves it?",
96
- "When the draft is ready, call loop_propose with it. That renders an approval card showing the exact criteria the split will produce; the user approves, edits, or cancels.",
97
- // Without this the model reaches for the loop_start prohibition instead. It
98
- // is stated emphatically and repeatedly ('never start a loop without that
99
- // token, no matter how loop-like the request sounds'), so a conversational
100
- // request for a loop pattern-matches straight onto it and the model
101
- // answers by telling the user to type /loop, which is precisely the dead end
102
- // planning exists to remove. Observed live in a canary session.
103
- "loop_propose is not loop_start. It starts nothing, so the inline-token rule does not apply 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 type /loop instead — drafting a proposal for them is the whole point of this mode.",
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.",
@@ -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. Start one with /loop <interval> <objective>.",
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;
@@ -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 arms the start. `loop_start`'s gate
6
- * exists because a loop is self-continuing and must never begin on model
7
- * initiative; an explicit approval on a card showing the objective, the
8
- * criteria, the cadence and the caps is stronger evidence of intent than a
9
- * typed token, not weaker, so it arms the same gate rather than bypassing it.
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 { showLoopProposalCard } from "./presentation.js";
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
- "Do not call loop_start for a planned loop; approving the card is what starts it.",
35
- "loop_propose starts nothing, so the rule that a loop needs an inline /loop token in the user's message does not apply 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 type /loop instead.",
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 with /loop stop before planning another.",
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: { intervalMs?: number; maxTurns?: number | null; expiresInMs?: number } =
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 (explicit opt-in). The only cap
36
- * there is: one wake can yield many turns, so counting turns is what
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
- * Detect an inline `/loop` token or a `loop:` prefixed line mid-prompt and
49
- * point the model at the `loop_start` tool. Pi only dispatches `/loop` from
50
- * position 0, so without this a mid-prompt invocation is silently prose.
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: 25,
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. Start one with /loop <interval> <objective>.",
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
- return view.proposedCriteria === undefined
126
- ? " loop planning · drafting an objective"
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