pi-soly 2.1.1 → 2.1.2

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/README.md CHANGED
@@ -29,6 +29,7 @@ Restart pi (`/reload`), and you have:
29
29
  - **Project management** — plans, state, phases, decisions
30
30
  - **Workflow engine** — runs inline (no subagent plugin). The model proposes the next step and drives it via the `soly_workflow` tool on your plain-language intent; the verbs `soly discuss` · `plan` · `execute` · `verify` · `pause`/`resume` still work as text
31
31
  - **Self-review loop** — `soly verify` re-reviews the work until "No issues found."
32
+ - **Goal-aware verification** — at the end of every `soly execute` (task / plan / phase plan-level), the worker reads PLAN.md's `## Goal` + `## Acceptance` and judges each item against `git diff`. A `## Status` section is appended to PLAN.md with a PASS / BLOCKED verdict. On `BLOCKED` the worker halts before calling `soly done` — so you can't ship a plan whose goal isn't actually met.
32
33
  - **Visual chrome** — native footer, equalizer working spinner with live telemetry, gradient welcome banner
33
34
  - **Rules & docs modal** — `/rules` and `/docs` open a fuzzy list + preview panel (no chat dumps)
34
35
  - **Mandatory rules** — strict-mode directives injected every turn
package/commands.ts CHANGED
@@ -171,7 +171,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
171
171
  const rules = getRules();
172
172
  const overridden = getOverridden();
173
173
  if (rules.length === 0 && overridden.length === 0) {
174
- ui.notify("no rules loaded from any source", "info");
174
+
175
175
  return;
176
176
  }
177
177
  // Rich modal in the TUI; plain select elsewhere (RPC/print).
@@ -205,17 +205,11 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
205
205
  if (choice < rules.length) {
206
206
  const rel = rules[choice];
207
207
  if (rel) {
208
- ui.notify(
209
- `[${rel.sourceLabel}] ${rel.relPath}\n\n${rel.body}`,
210
- "info",
211
- );
208
+
212
209
  }
213
210
  } else {
214
211
  const idx = choice - rules.length;
215
- ui.notify(
216
- `overridden: ${overridden[idx]} (skipped — a higher-priority source defines this rule)`,
217
- "info",
218
- );
212
+
219
213
  }
220
214
  }
221
215
  return;
@@ -224,7 +218,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
224
218
  if (sub === "analytics") {
225
219
  const rules = getRules();
226
220
  const analytics = analyzeRules(rules, CONTEXT_WINDOW_TOKENS);
227
- ui.notify(formatAnalyticsFull(analytics), "info");
221
+
228
222
  return;
229
223
  }
230
224
 
@@ -234,7 +228,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
234
228
  // Surfaces context bloat and verifies rules will actually fire.
235
229
  const rules = getRules();
236
230
  const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
237
- ui.notify(formatRulesContextStats(stats), "info");
231
+
238
232
  return;
239
233
  }
240
234
 
@@ -250,13 +244,13 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
250
244
  ui.notify(`Rule not found: ${target}`, "error");
251
245
  return;
252
246
  }
253
- ui.notify(`[${rule.sourceLabel}] ${rule.relPath}\n\n${rule.body}`, "info");
247
+
254
248
  return;
255
249
  }
256
250
 
257
251
  if (sub === "reload") {
258
252
  refreshRules();
259
- ui.notify(`Reloaded ${getRules().length} rules`, "info");
253
+
260
254
  updateStatus(ui);
261
255
  return;
262
256
  }
@@ -274,7 +268,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
274
268
  return;
275
269
  }
276
270
  rule.enabled = sub === "enable";
277
- ui.notify(`${rule.relPath} ${sub}d`, "info");
271
+
278
272
  updateStatus(ui);
279
273
  return;
280
274
  }
@@ -289,10 +283,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
289
283
  count++;
290
284
  }
291
285
  }
292
- ui.notify(
293
- `${count} rule(s) ${enable ? "enabled" : "disabled"} (${rules.length} total)`,
294
- enable ? "info" : "warning",
295
- );
286
+
296
287
  updateStatus(ui);
297
288
  return;
298
289
  }
@@ -313,7 +304,7 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
313
304
  categories.map((c) => `${c.name} — ${c.description}`),
314
305
  );
315
306
  if (choice == null) {
316
- ui.notify("cancelled", "info");
307
+
317
308
  return;
318
309
  }
319
310
  const cat = categories[choice];
@@ -359,11 +350,7 @@ What must the LLM do?
359
350
  `;
360
351
  try {
361
352
  fs.writeFileSync(filePath, template, "utf-8");
362
- ui.notify(
363
- `soly: created ${path.relative(cwd, filePath)}\n\n` +
364
- `Next: edit the file (description, globs, body), then \`/rules reload\` to load it.`,
365
- "info",
366
- );
353
+
367
354
  refreshRules();
368
355
  updateStatus(ui);
369
356
  } catch (e) {
@@ -385,7 +372,7 @@ What must the LLM do?
385
372
  ui.notify(`soly: only http(s) URLs are supported (got ${parsed.protocol})`, "error");
386
373
  return;
387
374
  }
388
- ui.notify(`soly: downloading ${url}…`, "info");
375
+
389
376
  const res = await fetch(url, {
390
377
  signal: AbortSignal.timeout(10_000),
391
378
  headers: { "user-agent": "soly-extension/1.0" },
@@ -420,16 +407,13 @@ What must the LLM do?
420
407
  `${fileName} already exists. Overwrite?`,
421
408
  );
422
409
  if (!overwrite) {
423
- ui.notify("soly: add cancelled", "info");
410
+
424
411
  return;
425
412
  }
426
413
  }
427
414
  fs.writeFileSync(targetFile, text, "utf-8");
428
415
  refreshRules();
429
- ui.notify(
430
- `soly: installed ${path.relative(process.cwd(), targetFile)} (${(text.length / 1024).toFixed(1)}KB)`,
431
- "info",
432
- );
416
+
433
417
  updateStatus(ui);
434
418
  } catch (e) {
435
419
  ui.notify(`soly: download failed: ${(e as Error).message}`, "error");
@@ -466,7 +450,7 @@ What must the LLM do?
466
450
  if (sub === "list") {
467
451
  const docs = getIntentDocs();
468
452
  if (docs.length === 0) {
469
- ui.notify("no intent docs found in .agents/docs/ — drop your vision/domain docs there", "info");
453
+
470
454
  return;
471
455
  }
472
456
  if (ctx.mode === "tui") {
@@ -493,15 +477,11 @@ What must the LLM do?
493
477
  const docs = getIntentDocs();
494
478
  const inlineBodies: IntentInlineDoc[] = loadInlineIntentBodies(docs);
495
479
  const stats = buildIntentStats(docs, inlineBodies);
496
- ui.notify(formatIntentStats(stats), "info");
480
+
497
481
  return;
498
482
  }
499
483
 
500
- ui.notify(
501
- `Usage: /docs [list|stats] — open the docs panel, or show the context breakdown\n` +
502
- `Found ${getIntentDocs().length} doc(s) loaded.`,
503
- "info",
504
- );
484
+
505
485
  },
506
486
  });
507
487
 
@@ -543,7 +523,7 @@ What must the LLM do?
543
523
 
544
524
  const state = getState();
545
525
  if (!state.exists) {
546
- ui.notify("soly: no .agents/ project here — run `/soly init` to scaffold one", "info");
526
+
547
527
  return;
548
528
  }
549
529
 
@@ -557,7 +537,7 @@ What must the LLM do?
557
537
  content.length > MAX
558
538
  ? `${content.slice(0, MAX)}\n\n[...truncated, file is ${content.length} chars]`
559
539
  : content;
560
- ui.notify(`${label}\n\n${truncated}`, "info");
540
+
561
541
  };
562
542
 
563
543
  type SolySub = {
@@ -584,7 +564,7 @@ What must the LLM do?
584
564
  };
585
565
  const state = getState();
586
566
  if (!state.exists) {
587
- ui.notify("soly: no .agents/ project here — run `/soly init` to scaffold one", "info");
567
+
588
568
  return;
589
569
  }
590
570
  switch (verb) {
@@ -624,7 +604,7 @@ What must the LLM do?
624
604
  return;
625
605
  }
626
606
  default:
627
- ui.notify(`soly: workflow '${verb}' not yet wired into slash command`, "info");
607
+
628
608
  return;
629
609
  }
630
610
  };
@@ -651,7 +631,7 @@ What must the LLM do?
651
631
  out.push(` - project: edit \`${state.solyDir}/soly.json\` directly`);
652
632
  out.push(` - global: edit \`~/.agents/soly.json\``);
653
633
  out.push("After editing, run /soly reload to re-pick up changes.");
654
- ui.notify(out.join("\n"), "info");
634
+
655
635
  },
656
636
  },
657
637
  position: {
@@ -659,21 +639,9 @@ What must the LLM do?
659
639
  run: () => {
660
640
  const s = getState();
661
641
  if (s.position) {
662
- ui.notify(
663
- [
664
- `milestone: ${s.milestone}${s.milestoneName ? ` — ${s.milestoneName}` : ""}`,
665
- `phase: ${s.position.phase}`,
666
- `plan: ${s.position.plan}`,
667
- `status: ${s.position.status}`,
668
- `progress: ${buildProgressBar(s.progress.percent, 20)} ${s.progress.percent}% (${s.progress.completedPhases}/${s.progress.totalPhases} phases, ${s.progress.completedPlans}/${s.progress.totalPlans} plans)`,
669
- ].join("\n"),
670
- "info",
671
- );
642
+
672
643
  } else {
673
- ui.notify(
674
- `milestone: ${s.milestone} — no position set in STATE.md`,
675
- "info",
676
- );
644
+
677
645
  }
678
646
  },
679
647
  },
@@ -735,16 +703,7 @@ What must the LLM do?
735
703
  description: "progress bar + counts",
736
704
  run: () => {
737
705
  const s = getState();
738
- ui.notify(
739
- [
740
- `milestone: ${s.milestone}${s.milestoneName ? ` — ${s.milestoneName}` : ""}`,
741
- `status: ${s.status}`,
742
- `progress: ${buildProgressBar(s.progress.percent, 30)} ${s.progress.percent}%`,
743
- `phases: ${s.progress.completedPhases}/${s.progress.totalPhases}`,
744
- `plans: ${s.progress.completedPlans}/${s.progress.totalPlans}`,
745
- ].join("\n"),
746
- "info",
747
- );
706
+
748
707
  },
749
708
  },
750
709
  phases: {
@@ -752,7 +711,7 @@ What must the LLM do?
752
711
  run: () => {
753
712
  const phases = getState().phases;
754
713
  if (phases.length === 0) {
755
- ui.notify("soly: no phases found", "info");
714
+
756
715
  return;
757
716
  }
758
717
  const current = getState().currentPhase?.number;
@@ -761,7 +720,7 @@ What must the LLM do?
761
720
  const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
762
721
  return `${marker} ${String(p.number).padStart(2, "0")}. ${p.name} [${cr}] plans=${p.planCount}`;
763
722
  });
764
- ui.notify(`phases:\n\n${lines.join("\n")}`, "info");
723
+
765
724
  },
766
725
  },
767
726
  tasks: {
@@ -769,7 +728,7 @@ What must the LLM do?
769
728
  run: () => {
770
729
  const s = getState();
771
730
  if (s.tasks.length === 0) {
772
- ui.notify("soly: no tasks found in .agents/features/*/tasks/", "info");
731
+
773
732
  return;
774
733
  }
775
734
  const byFeature = new Map<string, typeof s.tasks>();
@@ -788,7 +747,7 @@ What must the LLM do?
788
747
  }
789
748
  out.push("");
790
749
  }
791
- ui.notify(out.join("\n"), "info");
750
+
792
751
  },
793
752
  },
794
753
  task: {
@@ -819,7 +778,7 @@ What must the LLM do?
819
778
  if (summaryBody) {
820
779
  showFile("SUMMARY.md", summaryBody);
821
780
  } else {
822
- ui.notify("SUMMARY.md: not found (task not yet executed)", "info");
781
+
823
782
  }
824
783
  },
825
784
  },
@@ -828,14 +787,14 @@ What must the LLM do?
828
787
  run: () => {
829
788
  const features = getState().features;
830
789
  if (features.length === 0) {
831
- ui.notify("soly: no features found in .agents/features/", "info");
790
+
832
791
  return;
833
792
  }
834
793
  const lines = features.map((f) => {
835
794
  const rm = f.readmeExists ? "R" : "·";
836
795
  return ` ${f.name.padEnd(28)} tasks=${f.taskCount} [${rm}]`;
837
796
  });
838
- ui.notify(`features (${features.length}):\n\n${lines.join("\n")}`, "info");
797
+
839
798
  },
840
799
  },
841
800
  milestone: {
@@ -843,7 +802,7 @@ What must the LLM do?
843
802
  run: () => {
844
803
  const s = getState();
845
804
  if (!s.milestone || s.milestone === "—") {
846
- ui.notify("soly: no milestone set in STATE.md frontmatter", "info");
805
+
847
806
  return;
848
807
  }
849
808
  const candidates = [
@@ -869,10 +828,7 @@ What must the LLM do?
869
828
  refreshState();
870
829
  updateStatus(ui);
871
830
  const s = getState();
872
- ui.notify(
873
- `soly: reloaded — ${s.milestone} · ${s.phases.length} phases`,
874
- "info",
875
- );
831
+
876
832
  },
877
833
  },
878
834
  // ------------------------------------------------------------------
package/init.ts CHANGED
@@ -223,7 +223,7 @@ export async function initSolyProject(
223
223
  ["minimal", "web-app", "library", "cli"],
224
224
  );
225
225
  if (!pick) {
226
- ui.notify("soly init: cancelled", "info");
226
+
227
227
  return { created: false, template: null, projectName };
228
228
  }
229
229
  template = pick as InitTemplate;
@@ -237,7 +237,7 @@ export async function initSolyProject(
237
237
  `Create .agents/ structure in:\n ${cwd}\n\nProject name: ${projectName}`,
238
238
  );
239
239
  if (!ok) {
240
- ui.notify("soly init: cancelled", "info");
240
+
241
241
  return { created: false, template: null, projectName };
242
242
  }
243
243
  }
@@ -288,11 +288,7 @@ export async function initSolyProject(
288
288
  for (const extra of TEMPLATE_EXTRAS[template]) {
289
289
  created.push(`.agents/${extra.file}`);
290
290
  }
291
- ui.notify(
292
- `soly init: done (${template}). Created:\n - ${created.join("\n - ")}\n\n` +
293
- `Next:\n 1. Edit \`.agents/docs/vision.md\`\n 2. \`/plan 1\` to start the first phase`,
294
- "info",
295
- );
291
+
296
292
  return { created: true, template, projectName: safeProjectName };
297
293
  }
298
294
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-soly",
3
- "version": "2.1.1",
3
+ "version": "2.1.2",
4
4
  "description": "Project management + workflow framework for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker, native footer + welcome chrome — one npm install, zero config. The LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
5
5
  "type": "module",
6
6
  "main": "index.ts",
package/workflows/done.ts CHANGED
@@ -144,7 +144,7 @@ export function buildDoneTransform(
144
144
  pushed = true;
145
145
  } catch (err) {
146
146
  const msg = err instanceof Error ? err.message : String(err);
147
- ui.notify(`soly done: push failed — ${msg}\nPlan was committed but not pushed. Push manually with \`git push -u origin ${branchName}\`.`, "warning");
147
+
148
148
  return {
149
149
  handled: true,
150
150
  transformedText: `Plan ${branchName} committed locally (${commitHash.slice(0, 7)}), but push failed.\n${msg}`,
@@ -152,7 +152,7 @@ export function buildDoneTransform(
152
152
  };
153
153
  }
154
154
  } else {
155
- ui.notify(`soly done: no 'origin' remote — committed locally only. Add a remote and \`git push\` when ready.`, "warning");
155
+
156
156
  }
157
157
 
158
158
  // 4. Draft PR via gh
@@ -174,18 +174,10 @@ export function buildDoneTransform(
174
174
  // gh failed (maybe not authenticated, or PR already exists, etc.)
175
175
  // Don't fail the whole workflow — just report.
176
176
  const msg = err instanceof Error ? err.message : String(err);
177
- ui.notify(
178
- `soly done: pushed OK, but draft PR creation failed — ${msg}\n` +
179
- `Run \`gh pr create --draft --fill\` manually.`,
180
- "warning",
181
- );
177
+
182
178
  }
183
179
  } else if (pushed) {
184
- ui.notify(
185
- `soly done: pushed OK, but \`gh\` CLI not found — draft PR not created.\n` +
186
- `Install \`gh\` (https://cli.github.com) and run \`gh pr create --draft --fill\` manually.`,
187
- "info",
188
- );
180
+
189
181
  }
190
182
 
191
183
  // 5. Done — summarize
@@ -198,7 +190,7 @@ export function buildDoneTransform(
198
190
 
199
191
  // (No registry file to update — the plan's git branch IS the source of
200
192
  // truth for "active plans". See PLAN.md / "Out of scope" for the trade-off.)
201
- ui.notify(notice, "info");
193
+
202
194
  return {
203
195
  handled: true,
204
196
  transformedText: notice,
@@ -26,6 +26,23 @@ import {
26
26
  renderPlanSummaryInline,
27
27
  writeIterationContext,
28
28
  } from "../iteration.js";
29
+ import { buildVerificationPrompt, parseGoalAndAcceptance } from "./goal-verify.ts";
30
+
31
+ /**
32
+ * Append the goal-verification step to a worker close-out block, gated on
33
+ * whether PLAN.md at `planPath` actually has Goal + Acceptance sections.
34
+ * If parsing fails (empty/missing sections), we skip the block rather than
35
+ * inject a malformed prompt — the worker has nothing meaningful to verify.
36
+ */
37
+ function withGoalCheck(planPath: string, suffix: string): string {
38
+ try {
39
+ const r = parseGoalAndAcceptance(planPath);
40
+ if (!r.ok) return suffix;
41
+ return buildVerificationPrompt(r, planPath) + suffix;
42
+ } catch {
43
+ return suffix;
44
+ }
45
+ }
29
46
 
30
47
  /** Resolve <extension>/workflows-data/<name>.md regardless of cwd. */
31
48
  function loadWorkflowMarkdown(name: string): string | null {
@@ -225,7 +242,10 @@ Hard rules:
225
242
  - Interactive-only rules are NOT in scope here: ${interactiveRules.length > 0 ? interactiveRules.join(", ") : "(none)"}.
226
243
 
227
244
  When done, confirm the SUMMARY.md was written and the task flipped to \`status: done\`. Then suggest \`soly verify\` to self-review the change with fresh eyes before calling it done.`;
228
- return { handled: true, transformedText: instruction };
245
+ return {
246
+ handled: true,
247
+ transformedText: withGoalCheck(path.join(task.dir, "PLAN.md"), instruction),
248
+ };
229
249
  }
230
250
 
231
251
  // === ALL / FEATURE (new dual-mode, sequential in v0.1) ===
@@ -348,7 +368,10 @@ Hard rules:
348
368
  - Do not commit unless the workflow tells you to; the user reviews and merges.
349
369
 
350
370
  When done, suggest \`soly verify\` to self-review with fresh eyes, then \`soly done ${target.raw}\` to open the PR.`;
351
- return { handled: true, transformedText: instruction };
371
+ return {
372
+ handled: true,
373
+ transformedText: withGoalCheck(planFile, instruction),
374
+ };
352
375
  }
353
376
 
354
377
  // === PHASE MODE ===
@@ -480,5 +503,12 @@ Hard rules:
480
503
 
481
504
  When done, confirm STATE.md was updated. Then suggest \`soly verify\` to self-review the work with fresh eyes before calling the phase done.`;
482
505
 
483
- return { handled: true, transformedText: instruction };
506
+ // Goal verification only makes sense for plan-level phase execution —
507
+ // wave-based / task-based execution iterates many plans and has no
508
+ // single PLAN.md to verify against.
509
+ const phasePlanPath = isPlanLevel && planFileResolved ? planFileResolved : null;
510
+ return {
511
+ handled: true,
512
+ transformedText: phasePlanPath ? withGoalCheck(phasePlanPath, instruction) : instruction,
513
+ };
484
514
  }
@@ -0,0 +1,297 @@
1
+ // =============================================================================
2
+ // workflows/goal-verify.ts — Goal & Acceptance verification for `soly execute`
3
+ // =============================================================================
4
+ //
5
+ // Reads PLAN.md `## Goal` + `## Acceptance`, hands the LLM a structured
6
+ // "verify against the diff" prompt at end of execute, and lets the worker
7
+ // append a `## Status` section (idempotent, fenced by marker comments).
8
+ //
9
+ // Pure LLM review — no `bun/dotnet/pytest`-style mechanical checks. This
10
+ // plugin is multi-language; mechanical checks would be unreliable. The LLM
11
+ // walks `git diff master..HEAD` itself and judges.
12
+ //
13
+ // Headless-safe by construction: this module MUST NOT import `ask_pro`,
14
+ // `decision_deck`, `@earendil-works/pi-tui`, or call `ctx.ui.custom(...)`.
15
+ // A regression test greps the source for those tokens.
16
+ //
17
+ // Three public surfaces:
18
+ // 1. `parseGoalAndAcceptance(planPath)` — read PLAN.md, return parsed
19
+ // sections or `{ ok:false, error }`.
20
+ // 2. `buildVerificationPrompt(parsed, planPath)` — instruction string for
21
+ // the execute worker.
22
+ // 3. `appendStatus(planPath, report)` — idempotent append/replace of
23
+ // the `## Status` section.
24
+ //
25
+ // Pure helpers (`buildStatusSection`, `findMarkerBlock`) are exported for tests.
26
+ // =============================================================================
27
+
28
+ import * as fs from "node:fs";
29
+
30
+ // ---------------------------------------------------------------------------
31
+ // Public types
32
+ // ---------------------------------------------------------------------------
33
+
34
+ export type GoalAcceptanceParsed = {
35
+ ok: true;
36
+ goal: string;
37
+ acceptance: string[];
38
+ };
39
+
40
+ export type GoalAcceptanceError = {
41
+ ok: false;
42
+ error: string;
43
+ };
44
+
45
+ export type GoalAcceptanceResult = GoalAcceptanceParsed | GoalAcceptanceError;
46
+
47
+ export type StatusAcceptanceItem = {
48
+ item: string;
49
+ met: boolean;
50
+ evidence: string;
51
+ };
52
+
53
+ export type StatusReport = {
54
+ goalMet: boolean;
55
+ acceptance: StatusAcceptanceItem[];
56
+ };
57
+
58
+ export const STATUS_BEGIN = "<!-- soly:status:begin -->";
59
+ export const STATUS_END = "<!-- soly:status:end -->";
60
+
61
+ // ---------------------------------------------------------------------------
62
+ // Parser
63
+ // ---------------------------------------------------------------------------
64
+
65
+ /**
66
+ * Parse a PLAN.md and extract the `## Goal` text + each `- [ ]` bullet from
67
+ * `## Acceptance`. Never throws — returns `{ ok:false, error }` so callers
68
+ * (the verifier prompt) can surface a clean gap report instead of crashing
69
+ * the execute worker.
70
+ *
71
+ * Tolerates frontmatter at the top of the file. Stops each section at the
72
+ * next `## ` heading (case-insensitive on the marker). Multiple
73
+ * `## Acceptance` sections: only the first is used.
74
+ */
75
+ export function parseGoalAndAcceptance(planPath: string): GoalAcceptanceResult {
76
+ let raw: string;
77
+ try {
78
+ raw = fs.readFileSync(planPath, "utf-8");
79
+ } catch (err) {
80
+ const msg = err instanceof Error ? err.message : String(err);
81
+ return { ok: false, error: `Cannot read PLAN.md at ${planPath}: ${msg}` };
82
+ }
83
+
84
+ // Strip optional frontmatter (--- ... --- at top of file).
85
+ const body = stripFrontmatter(raw);
86
+
87
+ const goalText = extractSectionBody(body, "Goal");
88
+ if (goalText === null) {
89
+ return { ok: false, error: "PLAN.md is missing required `## Goal` section." };
90
+ }
91
+ if (goalText.trim().length === 0) {
92
+ return { ok: false, error: "`## Goal` section is empty." };
93
+ }
94
+
95
+ const acceptanceText = extractSectionBody(body, "Acceptance");
96
+ if (acceptanceText === null) {
97
+ return { ok: false, error: "PLAN.md is missing required `## Acceptance` section." };
98
+ }
99
+
100
+ const items = extractAcceptanceItems(acceptanceText);
101
+ if (items.length === 0) {
102
+ return { ok: false, error: "`## Acceptance` section has no `- [ ]` items." };
103
+ }
104
+
105
+ return { ok: true, goal: goalText.trim(), acceptance: items };
106
+ }
107
+
108
+ /** Strip YAML frontmatter (`---\n...\n---\n`) at the top of the file. */
109
+ function stripFrontmatter(raw: string): string {
110
+ const m = raw.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?/);
111
+ return m ? raw.slice(m[0].length) : raw;
112
+ }
113
+
114
+ /**
115
+ * Extract the body of the FIRST `## <name>` section: everything from the
116
+ * heading line (exclusive) up to the next `## ` heading or end of file.
117
+ * Returns null if the section does not exist. Returns "" if the section
118
+ * exists but is empty.
119
+ */
120
+ function extractSectionBody(body: string, name: string): string | null {
121
+ const lines = body.split(/\r?\n/);
122
+ const headingRe = new RegExp(`^##\\s+${escapeRegex(name)}\\s*$`);
123
+ const nextHeadingRe = /^##\s+\S+/;
124
+ let startIdx = -1;
125
+ for (let i = 0; i < lines.length; i++) {
126
+ if (headingRe.test(lines[i] ?? "")) {
127
+ startIdx = i;
128
+ break;
129
+ }
130
+ }
131
+ if (startIdx === -1) return null;
132
+ let endIdx = lines.length;
133
+ for (let i = startIdx + 1; i < lines.length; i++) {
134
+ if (nextHeadingRe.test(lines[i] ?? "")) {
135
+ endIdx = i;
136
+ break;
137
+ }
138
+ }
139
+ return lines.slice(startIdx + 1, endIdx).join("\n");
140
+ }
141
+
142
+ /** Pull each `- [ ] <text>` bullet (allowing leading whitespace) from a section body. */
143
+ function extractAcceptanceItems(section: string): string[] {
144
+ const items: string[] = [];
145
+ for (const line of section.split(/\r?\n/)) {
146
+ // Require exactly `[ ]` (one space inside the brackets) — matches the
147
+ // standard markdown task-list convention. Lines like `- [x] foo` or
148
+ // `- [] foo` are intentionally NOT accepted: `x` is checked, and `[]`
149
+ // (no inner space) is a typo we'd rather surface than silently keep.
150
+ const m = line.match(/^\s*-\s+\[\s\]\s+(.+?)\s*$/);
151
+ if (m) items.push(m[1]);
152
+ }
153
+ return items;
154
+ }
155
+
156
+ /** Escape a literal string for safe inclusion in a RegExp constructor. */
157
+ function escapeRegex(s: string): string {
158
+ return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
159
+ }
160
+
161
+ // ---------------------------------------------------------------------------
162
+ // Prompt builder
163
+ // ---------------------------------------------------------------------------
164
+
165
+ /**
166
+ * Build the LLM instruction injected at the end of an execute worker block
167
+ * (task / plan / phase plan-level). The worker is told to read PLAN.md,
168
+ * walk `git diff master..HEAD`, judge each acceptance item, judge the
169
+ * Goal as a whole, and append/replace a `## Status` section between the
170
+ * marker comments.
171
+ *
172
+ * `planPathHint` is a textual hint about where PLAN.md lives — typically the
173
+ * absolute path. The worker resolves it via `read`.
174
+ */
175
+ export function buildVerificationPrompt(parsed: GoalAcceptanceParsed, planPathHint: string): string {
176
+ const itemsList = parsed.acceptance.map((a) => `- ${a}`).join("\n");
177
+ return `
178
+
179
+ GOAL VERIFICATION (mandatory before close-out):
180
+
181
+ Before reporting this work as done, verify that the diff actually
182
+ delivers the plan's Goal and meets every Acceptance item. This is a
183
+ hard gate — if it fails, you must NOT call \`soly done\`; you must
184
+ report the gap and stop.
185
+
186
+ PLAN.md: ${planPathHint}
187
+
188
+ Goal (verbatim from PLAN.md):
189
+
190
+ """
191
+ ${parsed.goal}
192
+ """
193
+
194
+ Acceptance items (verbatim, ${parsed.acceptance.length} total):
195
+
196
+ ${itemsList}
197
+
198
+ Steps:
199
+ 1. Read ${planPathHint} (use the read tool) to confirm the Goal and
200
+ Acceptance text.
201
+ 2. Run \`git diff master..HEAD\` (or \`git diff origin/master..HEAD\`
202
+ if no local master) to see what actually changed.
203
+ 3. For each Acceptance item above, judge met / not-met. Cite evidence
204
+ — file:line from the diff, or a short reasoning string.
205
+ 4. Judge the Goal as a whole against the actual outcome.
206
+ 5. Use the edit tool to append or replace a \`## Status\` section in
207
+ ${planPathHint}, between the marker comments:
208
+
209
+ ${STATUS_BEGIN}
210
+ ## Status
211
+
212
+ **Goal met:** YES | NO
213
+
214
+ ### Acceptance
215
+ - [x] <item text> — <evidence>
216
+ - [ ] <item text> — GAP: <what is missing>
217
+
218
+ **Verdict:** PASS | BLOCKED — gaps remain
219
+ ${STATUS_END}
220
+
221
+ If the markers already exist in the file, replace the content
222
+ between them. If they don't, append the block at end of file. Do
223
+ NOT touch any text outside the markers.
224
+ 6. If Verdict is BLOCKED: STOP. Do NOT call \`soly done\`. Report the
225
+ gap inline and list which Acceptance items remain unmet.
226
+ 7. If Verdict is PASS: proceed to the close-out steps below.
227
+
228
+ ---
229
+ `;
230
+ }
231
+
232
+ // ---------------------------------------------------------------------------
233
+ // Status section appender
234
+ // ---------------------------------------------------------------------------
235
+
236
+ /** Pure: build the Status markdown block between the marker comments. */
237
+ export function buildStatusSection(report: StatusReport): string {
238
+ const allMet = report.goalMet && report.acceptance.every((a) => a.met);
239
+ const verdict = allMet ? "PASS" : "BLOCKED — gaps remain";
240
+ const lines: string[] = [];
241
+ lines.push(STATUS_BEGIN);
242
+ lines.push("## Status");
243
+ lines.push("");
244
+ lines.push(`**Goal met:** ${report.goalMet ? "YES" : "NO"}`);
245
+ lines.push("");
246
+ lines.push("### Acceptance");
247
+ for (const a of report.acceptance) {
248
+ const box = a.met ? "[x]" : "[ ]";
249
+ const tag = a.met ? "— " : "— GAP: ";
250
+ lines.push(`- ${box} ${a.item} ${tag}${a.evidence}`);
251
+ }
252
+ lines.push("");
253
+ lines.push(`**Verdict:** ${verdict}`);
254
+ lines.push(STATUS_END);
255
+ return lines.join("\n");
256
+ }
257
+
258
+ /**
259
+ * Find the marker block in `raw`. Returns:
260
+ * { begin, end } — slice indices (begin = index of STATUS_BEGIN,
261
+ * end = index AFTER STATUS_END), or null if either
262
+ * marker is missing or the order is wrong.
263
+ */
264
+ export function findMarkerBlock(raw: string): { begin: number; end: number } | null {
265
+ const begin = raw.indexOf(STATUS_BEGIN);
266
+ const endIdx = raw.indexOf(STATUS_END);
267
+ if (begin === -1 || endIdx === -1 || endIdx < begin) return null;
268
+ return { begin, end: endIdx + STATUS_END.length };
269
+ }
270
+
271
+ /**
272
+ * Append or replace the `## Status` section in PLAN.md. Idempotent: a
273
+ * second run replaces the previous block (between marker comments)
274
+ * instead of duplicating content. Never touches text outside the
275
+ * marker block.
276
+ */
277
+ export function appendStatus(planPath: string, report: StatusReport): void {
278
+ const raw = fs.readFileSync(planPath, "utf-8");
279
+ const section = buildStatusSection(report);
280
+ const block = findMarkerBlock(raw);
281
+
282
+ let next: string;
283
+ if (block) {
284
+ // Replace between markers, preserve prefix and suffix content.
285
+ const before = raw.slice(0, block.begin).replace(/\s+$/, "");
286
+ const after = raw.slice(block.end).replace(/^\s+/, "");
287
+ const parts = [before, section];
288
+ if (after.length > 0) parts.push(after);
289
+ next = parts.join("\n\n") + "\n";
290
+ } else {
291
+ // Append at end of file.
292
+ const trimmed = raw.replace(/\s+$/, "");
293
+ next = `${trimmed}\n\n${section}\n`;
294
+ }
295
+
296
+ fs.writeFileSync(planPath, next, "utf-8");
297
+ }
@@ -248,7 +248,7 @@ export function showDoctor(_cmd: unknown, state: SolyState, ui: InspectUI, confi
248
248
  out.push("");
249
249
  out.push("Warnings present — run `/soly iterations` and `/soly config` to review.");
250
250
  }
251
- ui.notify(out.join("\n"), counts.fail > 0 ? "error" : counts.warn > 0 ? "warning" : "info");
251
+
252
252
  }
253
253
 
254
254
  // ---------------------------------------------------------------------------
@@ -287,10 +287,7 @@ export function showTodos(
287
287
  }
288
288
  const file = findTodosFile(state.solyDir);
289
289
  if (!file) {
290
- ui.notify(
291
- "soly todos: no todo file found. Install the `pi-todo` extension or write `.agents/todos.json` manually.",
292
- "info",
293
- );
290
+
294
291
  return;
295
292
  }
296
293
  let parsed: { todos?: Array<{ content?: string; status?: string; activeForm?: string }> } | null = null;
@@ -301,7 +298,7 @@ export function showTodos(
301
298
  return;
302
299
  }
303
300
  if (!parsed || !Array.isArray(parsed.todos) || parsed.todos.length === 0) {
304
- ui.notify("soly todos: list is empty. Use the LLM's `todo_update` tool to add items.", "info");
301
+
305
302
  return;
306
303
  }
307
304
  const todos = parsed.todos;
@@ -316,7 +313,7 @@ export function showTodos(
316
313
  const suffix = t.status === "in_progress" && typeof t.activeForm === "string" ? ` (${t.activeForm})` : "";
317
314
  lines.push(` ${mark} ${t.content}${suffix}`);
318
315
  }
319
- ui.notify(lines.join("\n"), "info");
316
+
320
317
  }
321
318
 
322
319
  // ---------------------------------------------------------------------------
@@ -335,7 +332,7 @@ export function showIterations(
335
332
  }
336
333
  const iterDir = path.join(state.solyDir, "iterations");
337
334
  if (!fs.existsSync(iterDir)) {
338
- ui.notify("soly iterations: no iterations yet (run soly plan or soly execute first)", "info");
335
+
339
336
  return;
340
337
  }
341
338
 
@@ -362,7 +359,7 @@ export function showIterations(
362
359
  .slice(0, limit);
363
360
 
364
361
  if (files.length === 0) {
365
- ui.notify("soly iterations: no iteration files found", "info");
362
+
366
363
  return;
367
364
  }
368
365
 
@@ -378,7 +375,7 @@ export function showIterations(
378
375
  out.push("");
379
376
  out.push("Tip: `soly iterations 20` for more, `soly diff iterations <a> <b>` to compare two.");
380
377
  }
381
- ui.notify(out.join("\n"), "info");
378
+
382
379
  }
383
380
 
384
381
  function humanizeAge(ms: number): string {
@@ -444,7 +441,7 @@ export function showDiffIterations(
444
441
  out.push(bodyB);
445
442
  out.push("--- END B ---");
446
443
  }
447
- ui.notify(out.join("\n"), "info");
444
+
448
445
  }
449
446
 
450
447
  // ---------------------------------------------------------------------------
@@ -495,5 +492,5 @@ export function showPhaseDelete(
495
492
  out.push("");
496
493
  out.push("To restore: `mv` it back to .agents/phases/");
497
494
  out.push("To permanently delete: `rm -rf " + dest + "`");
498
- ui.notify(out.join("\n"), "info");
495
+
499
496
  }
@@ -167,6 +167,6 @@ export function buildMigrateTransform(
167
167
  ? skipped.map((s) => ` ! ${s.phase}: ${s.reason}`).join("\n") + "\n"
168
168
  : "") +
169
169
  `\nPush and PR the migration branches manually: \`git push origin <branch>\`.`;
170
- ui.notify(`soly migrate: ${migrated.length} migrated, ${skipped.length} skipped`, "info");
170
+
171
171
  return { handled: true, transformedText: notice, migrated, skipped };
172
172
  }
package/workflows/new.ts CHANGED
@@ -164,7 +164,7 @@ export function buildNewTransform(
164
164
  const notice = branchExisted
165
165
  ? `Plan '${name}' reused on existing branch ${branchName}.\nPLAN.md was ${planFile}.\nNext: \`soly plan ${branchName}\``
166
166
  : `Plan '${name}' scaffolded on new branch ${branchName}.\nPLAN.md: ${planFile}\nNext: \`soly plan ${branchName}\``;
167
- ui.notify(notice, "info");
167
+
168
168
  return {
169
169
  handled: true,
170
170
  transformedText: notice,
@@ -100,7 +100,7 @@ export function showStatus(
100
100
  }
101
101
  }
102
102
 
103
- ui.notify(lines.join("\n"), "info");
103
+
104
104
  }
105
105
 
106
106
  // =============================================================================
@@ -136,7 +136,7 @@ export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
136
136
  const lines = raw.split(/\r?\n/);
137
137
  const decisionsIdx = lines.findIndex((l) => DECISIONS_HEADER.test(l));
138
138
  if (decisionsIdx === -1) {
139
- ui.notify("soly log: no Decisions table in STATE.md", "info");
139
+
140
140
  return;
141
141
  }
142
142
 
@@ -151,7 +151,7 @@ export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
151
151
  }
152
152
 
153
153
  if (rows.length === 0) {
154
- ui.notify("soly log: Decisions table is empty", "info");
154
+
155
155
  return;
156
156
  }
157
157
 
@@ -178,7 +178,7 @@ export function showLog(cmd: SolyCommand, state: SolyState, ui: QuickUI): void {
178
178
  out.push(` ${r.rationale}`);
179
179
  out.push("");
180
180
  }
181
- ui.notify(out.join("\n"), "info");
181
+
182
182
  }
183
183
 
184
184
  // =============================================================================
@@ -254,5 +254,5 @@ export async function showDiff(
254
254
  }
255
255
  }
256
256
 
257
- ui.notify(out.join("\n"), "info");
257
+
258
258
  }