pi-soly 2.1.1 → 2.1.3
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 +1 -0
- package/ask/picker.ts +129 -9
- package/ask/tests/picker.test.ts +118 -11
- package/commands.ts +34 -78
- package/init.ts +3 -7
- package/package.json +1 -1
- package/workflows/done.ts +5 -13
- package/workflows/execute.ts +33 -3
- package/workflows/goal-verify.ts +297 -0
- package/workflows/inspect.ts +9 -12
- package/workflows/migrate.ts +1 -1
- package/workflows/new.ts +1 -1
- package/workflows/quick.ts +5 -5
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/ask/picker.ts
CHANGED
|
@@ -141,6 +141,11 @@ export class AskProComponent extends Container {
|
|
|
141
141
|
private selectedIndex = 0;
|
|
142
142
|
/** answers[questionIdx] = AskAnswer (single) or AskMultiAnswer (multi). */
|
|
143
143
|
private answers = new Map<number, AskAnswer | AskMultiAnswer>();
|
|
144
|
+
/** "questions" = tabbed flow; "summary" = read-only recap before submit.
|
|
145
|
+
* After the last question is answered, the picker transitions to "summary"
|
|
146
|
+
* so the user can review all answers before committing. Enter submits,
|
|
147
|
+
* Esc cancels. */
|
|
148
|
+
private viewMode: "questions" | "summary" = "questions";
|
|
144
149
|
/** notes[questionIdx] = free-text note added by user (via `n` key). */
|
|
145
150
|
private notes = new Map<number, string>();
|
|
146
151
|
/** Set true once `done` is called — further input is ignored. */
|
|
@@ -251,8 +256,21 @@ export class AskProComponent extends Container {
|
|
|
251
256
|
|
|
252
257
|
private repaint(): void {
|
|
253
258
|
this.tabsText.setText(this.renderTabs());
|
|
254
|
-
this.
|
|
255
|
-
|
|
259
|
+
if (this.viewMode === "summary") {
|
|
260
|
+
this.renderSummaryBody();
|
|
261
|
+
this.footerText.setText(this.renderSummaryFooter());
|
|
262
|
+
} else {
|
|
263
|
+
this.renderQuestionBody();
|
|
264
|
+
this.footerText.setText(this.renderFooter());
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** Switch to the read-only recap view. Triggered when the user completes
|
|
269
|
+
* the last question and all answers are present. The actual submit() only
|
|
270
|
+
* fires when Enter is pressed in summary mode. */
|
|
271
|
+
private showSummary(): void {
|
|
272
|
+
this.viewMode = "summary";
|
|
273
|
+
this.repaint();
|
|
256
274
|
}
|
|
257
275
|
|
|
258
276
|
private renderTabs(): string {
|
|
@@ -602,6 +620,85 @@ export class AskProComponent extends Container {
|
|
|
602
620
|
return parts.join(" ");
|
|
603
621
|
}
|
|
604
622
|
|
|
623
|
+
// -------------------------------------------------------------------------
|
|
624
|
+
// Summary view rendering — read-only recap shown after the last question.
|
|
625
|
+
// One line per question: "<header>: <answer>". Skipped questions show a
|
|
626
|
+
// strike-through-like marker. Notes are shown indented below.
|
|
627
|
+
// -------------------------------------------------------------------------
|
|
628
|
+
|
|
629
|
+
/** Render the recap body into bodyContainer. Replaces renderQuestionBody
|
|
630
|
+
* while in summary mode. */
|
|
631
|
+
private renderSummaryBody(): void {
|
|
632
|
+
this.bodyContainer.clear();
|
|
633
|
+
this.bodyContainer.addChild(
|
|
634
|
+
new Text(
|
|
635
|
+
this.theme.bold("Review your answers") +
|
|
636
|
+
this.theme.fg("dim", " — press Enter to submit, Esc to cancel"),
|
|
637
|
+
1,
|
|
638
|
+
0,
|
|
639
|
+
),
|
|
640
|
+
);
|
|
641
|
+
this.bodyContainer.addChild(new Spacer(1));
|
|
642
|
+
for (let i = 0; i < this.questions.length; i++) {
|
|
643
|
+
const q = this.questions[i]!;
|
|
644
|
+
const answerText = this.formatAnswerForSummary(i);
|
|
645
|
+
const header = this.theme.bold(q.header);
|
|
646
|
+
const marker = this.theme.fg("accent", `Q${i + 1}:`);
|
|
647
|
+
this.bodyContainer.addChild(
|
|
648
|
+
new Text(` ${marker} ${header} → ${answerText}`, 1, 0),
|
|
649
|
+
);
|
|
650
|
+
const note = this.notes.get(i);
|
|
651
|
+
if (note) {
|
|
652
|
+
this.bodyContainer.addChild(
|
|
653
|
+
new Text(this.theme.fg("dim", ` note: ${note}`), 1, 0),
|
|
654
|
+
);
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
/** Format a single question's answer for the recap line. Skipped
|
|
660
|
+
* questions show a strikethrough-style marker; free-text shows the
|
|
661
|
+
* typed string (or "—" if blank); option picks show the label. */
|
|
662
|
+
private formatAnswerForSummary(qIdx: number): string {
|
|
663
|
+
if (this.skipped.has(qIdx)) {
|
|
664
|
+
return this.theme.fg("dim", "— skipped —");
|
|
665
|
+
}
|
|
666
|
+
const q = this.questions[qIdx]!;
|
|
667
|
+
if (q.freeText) {
|
|
668
|
+
const v = this.answers.get(qIdx);
|
|
669
|
+
const text = typeof v === "string" && v.length > 0 ? v : "—";
|
|
670
|
+
return this.theme.fg("text", text);
|
|
671
|
+
}
|
|
672
|
+
const ans = this.answers.get(qIdx);
|
|
673
|
+
if (ans === undefined) {
|
|
674
|
+
return this.theme.fg("dim", "— no answer —");
|
|
675
|
+
}
|
|
676
|
+
if (q.multiSelect) {
|
|
677
|
+
const arr = ans as AskMultiAnswer;
|
|
678
|
+
if (arr.length === 0) return this.theme.fg("dim", "— none —");
|
|
679
|
+
const labels = arr.map((a) => this.formatOptionLabel(q, a));
|
|
680
|
+
return this.theme.fg("text", labels.join(", "));
|
|
681
|
+
}
|
|
682
|
+
return this.theme.fg("text", this.formatOptionLabel(q, ans as AskAnswer));
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Resolve a single answer value (index or custom string) to a label. */
|
|
686
|
+
private formatOptionLabel(q: AskQuestion, ans: AskAnswer): string {
|
|
687
|
+
if (typeof ans === "string") {
|
|
688
|
+
return `Other: ${ans}`;
|
|
689
|
+
}
|
|
690
|
+
const opt = q.options[ans];
|
|
691
|
+
return opt ? opt.label : `option ${ans}`;
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
/** Footer for the summary view. */
|
|
695
|
+
private renderSummaryFooter(): string {
|
|
696
|
+
return [
|
|
697
|
+
this.theme.fg("accent", "⏎ submit"),
|
|
698
|
+
this.theme.fg("dim", "esc cancel"),
|
|
699
|
+
].join(" ");
|
|
700
|
+
}
|
|
701
|
+
|
|
605
702
|
private isAnswered(qIdx: number): boolean {
|
|
606
703
|
// Explicitly skipped questions never block submission.
|
|
607
704
|
if (this.skipped.has(qIdx)) return true;
|
|
@@ -633,6 +730,27 @@ export class AskProComponent extends Container {
|
|
|
633
730
|
handleInput(keyData: string): void {
|
|
634
731
|
if (this.completed) return;
|
|
635
732
|
|
|
733
|
+
// --- Summary view (read-only recap before submit) ------------------
|
|
734
|
+
// After the last question is answered, the picker shows a recap of
|
|
735
|
+
// all answers. Enter commits, Esc cancels. All other keys are
|
|
736
|
+
// ignored (no per-question editing in summary mode).
|
|
737
|
+
if (this.viewMode === "summary") {
|
|
738
|
+
if (keyData === KEY_ESC) {
|
|
739
|
+
this.completed = true;
|
|
740
|
+
this.done({ cancelled: true });
|
|
741
|
+
return;
|
|
742
|
+
}
|
|
743
|
+
if (
|
|
744
|
+
this.keybindings.matches(keyData, "tui.select.confirm") ||
|
|
745
|
+
keyData === KEY_ENTER ||
|
|
746
|
+
keyData === KEY_ENTER_CR
|
|
747
|
+
) {
|
|
748
|
+
this.submit();
|
|
749
|
+
return;
|
|
750
|
+
}
|
|
751
|
+
return;
|
|
752
|
+
}
|
|
753
|
+
|
|
636
754
|
// --- Inline text-input mode (note / Other…) --------------------------
|
|
637
755
|
// When active, all keys route to the embedded Input except the
|
|
638
756
|
// confirm/cancel gestures, which we intercept to commit or abort.
|
|
@@ -775,13 +893,14 @@ export class AskProComponent extends Container {
|
|
|
775
893
|
|
|
776
894
|
if (isMulti) {
|
|
777
895
|
// On the LAST question, if all questions are answered, Enter
|
|
778
|
-
//
|
|
896
|
+
// transitions to the summary view (where another Enter actually
|
|
897
|
+
// submits). Otherwise it advances (if not last) or stays put
|
|
779
898
|
// (on last + not all answered — user must finish first).
|
|
780
899
|
if (
|
|
781
900
|
this.currentIndex === this.questions.length - 1 &&
|
|
782
901
|
this.allAnswered()
|
|
783
902
|
) {
|
|
784
|
-
this.
|
|
903
|
+
this.showSummary();
|
|
785
904
|
return;
|
|
786
905
|
}
|
|
787
906
|
if (this.currentIndex < this.questions.length - 1) {
|
|
@@ -790,14 +909,15 @@ export class AskProComponent extends Container {
|
|
|
790
909
|
}
|
|
791
910
|
this.repaint();
|
|
792
911
|
} else {
|
|
793
|
-
// Single-select: set current as answer, then advance or
|
|
912
|
+
// Single-select: set current as answer, then advance or transition
|
|
913
|
+
// to summary. The final submit happens after Enter in summary mode.
|
|
794
914
|
this.answers.set(this.currentIndex, this.selectedIndex);
|
|
795
915
|
if (this.currentIndex < this.questions.length - 1) {
|
|
796
916
|
this.currentIndex++;
|
|
797
917
|
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
798
918
|
this.repaint();
|
|
799
919
|
} else if (this.allAnswered()) {
|
|
800
|
-
this.
|
|
920
|
+
this.showSummary();
|
|
801
921
|
} else {
|
|
802
922
|
this.repaint();
|
|
803
923
|
}
|
|
@@ -846,8 +966,8 @@ export class AskProComponent extends Container {
|
|
|
846
966
|
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
847
967
|
this.repaint();
|
|
848
968
|
} else if (this.allAnswered()) {
|
|
849
|
-
// Last question + all answered →
|
|
850
|
-
this.
|
|
969
|
+
// Last question + all answered → summary view (Enter there submits)
|
|
970
|
+
this.showSummary();
|
|
851
971
|
} else {
|
|
852
972
|
this.repaint();
|
|
853
973
|
}
|
|
@@ -876,7 +996,7 @@ export class AskProComponent extends Container {
|
|
|
876
996
|
this.selectedIndex = this.defaultIndexFor(this.currentIndex);
|
|
877
997
|
this.repaint();
|
|
878
998
|
} else if (this.allAnswered()) {
|
|
879
|
-
this.
|
|
999
|
+
this.showSummary();
|
|
880
1000
|
} else {
|
|
881
1001
|
this.repaint();
|
|
882
1002
|
}
|
package/ask/tests/picker.test.ts
CHANGED
|
@@ -107,10 +107,13 @@ describe("AskProComponent — single-select (number keys)", () => {
|
|
|
107
107
|
expect(picker.getCurrentIndex()).toBe(1);
|
|
108
108
|
});
|
|
109
109
|
|
|
110
|
-
test("on last question, '1' picks AND submits", () => {
|
|
110
|
+
test("on last question, '1' picks AND transitions to summary view (Enter there submits)", () => {
|
|
111
111
|
const { picker, getDone } = setup();
|
|
112
112
|
picker.handleInput("1"); // Q1 → JWT cookie (recommended), advance
|
|
113
|
-
picker.handleInput("2"); // Q2 → Bearer header,
|
|
113
|
+
picker.handleInput("2"); // Q2 → Bearer header, transitions to summary
|
|
114
|
+
// Now in summary view — Enter actually submits.
|
|
115
|
+
expect(getDone()).toBeNull();
|
|
116
|
+
picker.handleInput("\n"); // confirm summary
|
|
114
117
|
expect(getDone()).toEqual({ answers: { 0: 0, 1: 1 } });
|
|
115
118
|
});
|
|
116
119
|
});
|
|
@@ -146,14 +149,16 @@ describe("AskProComponent — single-select (arrows + enter)", () => {
|
|
|
146
149
|
expect(picker.getSelectedIndex()).toBe(2);
|
|
147
150
|
});
|
|
148
151
|
|
|
149
|
-
test("Enter confirms current selection, advances or
|
|
152
|
+
test("Enter confirms current selection, advances or transitions to summary", () => {
|
|
150
153
|
const { picker, getDone } = setup();
|
|
151
154
|
picker.handleInput("j"); // selectedIndex = 1 (JWT localStorage)
|
|
152
155
|
picker.handleInput("\n"); // confirm
|
|
153
156
|
expect(picker.getAnswers().get(0)).toBe(1);
|
|
154
157
|
expect(picker.getCurrentIndex()).toBe(1); // advanced
|
|
155
158
|
expect(getDone()).toBeNull();
|
|
156
|
-
picker.handleInput("\n"); // confirm Q2 default (index 0)
|
|
159
|
+
picker.handleInput("\n"); // confirm Q2 default (index 0) → summary
|
|
160
|
+
expect(getDone()).toBeNull();
|
|
161
|
+
picker.handleInput("\n"); // confirm summary
|
|
157
162
|
expect(getDone()).toEqual({ answers: { 0: 1, 1: 0 } });
|
|
158
163
|
});
|
|
159
164
|
});
|
|
@@ -275,17 +280,19 @@ describe("AskProComponent — multi-select", () => {
|
|
|
275
280
|
expect(picker.getAnswers().size).toBe(0); // nothing toggled
|
|
276
281
|
});
|
|
277
282
|
|
|
278
|
-
test("Submit only when all questions answered (multi on last)", () => {
|
|
283
|
+
test("Submit only when all questions answered (multi on last) — Enter there confirms summary", () => {
|
|
279
284
|
const { picker, getDone } = setup(multiQuestions);
|
|
280
285
|
picker.handleInput("1"); // Q1 multi: pick Auth
|
|
281
286
|
picker.handleInput("\t"); // → Q2
|
|
282
|
-
picker.handleInput("\n"); // Q2 single: confirm default (High)
|
|
287
|
+
picker.handleInput("\n"); // Q2 single: confirm default (High) → summary
|
|
288
|
+
expect(getDone()).toBeNull();
|
|
289
|
+
picker.handleInput("\n"); // confirm summary
|
|
283
290
|
expect(getDone()).toEqual({ answers: { 0: [0], 1: 0 } });
|
|
284
291
|
});
|
|
285
292
|
|
|
286
|
-
test("Enter on LAST multi question + all answered →
|
|
293
|
+
test("Enter on LAST multi question + all answered → summary view (Enter there confirms)", () => {
|
|
287
294
|
// Multi-select LAST question: Enter is the universal confirm gesture.
|
|
288
|
-
//
|
|
295
|
+
// It transitions to the summary view; another Enter actually submits.
|
|
289
296
|
const TWO_MULTI: AskQuestion[] = [
|
|
290
297
|
{ header: "Tasks", question: "?", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
|
|
291
298
|
{ header: "Priority", question: "?", options: [{ label: "H" }, { label: "L" }], multiSelect: true },
|
|
@@ -294,7 +301,10 @@ describe("AskProComponent — multi-select", () => {
|
|
|
294
301
|
picker.handleInput(" "); // Q1 multi: Space → toggle A
|
|
295
302
|
picker.handleInput("\t"); // → Q2
|
|
296
303
|
picker.handleInput(" "); // Q2 multi: Space → toggle H
|
|
297
|
-
// Now all answered, on last question, Enter
|
|
304
|
+
// Now all answered, on last question, Enter transitions to summary.
|
|
305
|
+
picker.handleInput("\n");
|
|
306
|
+
expect(getDone()).toBeNull();
|
|
307
|
+
// Enter in summary view actually submits.
|
|
298
308
|
picker.handleInput("\n");
|
|
299
309
|
expect(getDone()).toEqual({ answers: { 0: [0], 1: [0] } });
|
|
300
310
|
});
|
|
@@ -662,8 +672,11 @@ describe("AskProComponent — notes (n key)", () => {
|
|
|
662
672
|
picker.handleInput("n");
|
|
663
673
|
for (const ch of "rotate keys monthly") picker.handleInput(ch);
|
|
664
674
|
picker.handleInput("\n"); // commit
|
|
665
|
-
//
|
|
675
|
+
// Pick Q2 (last question, all answered) → summary view
|
|
666
676
|
picker.handleInput("1");
|
|
677
|
+
expect(doneResult).toBeNull();
|
|
678
|
+
// Enter in summary view actually submits.
|
|
679
|
+
picker.handleInput("\n");
|
|
667
680
|
expect(doneResult).not.toBeNull();
|
|
668
681
|
const result = doneResult as AskProResult | null;
|
|
669
682
|
expect(result?.notes).toBeDefined();
|
|
@@ -697,9 +710,12 @@ describe("AskProComponent — notes (n key)", () => {
|
|
|
697
710
|
picker.handleInput("n");
|
|
698
711
|
picker.handleInput("\x15"); // ^U — delete to line start
|
|
699
712
|
picker.handleInput("\n"); // commit empty → note cleared
|
|
700
|
-
// Pick Q1 → Q2 →
|
|
713
|
+
// Pick Q1 → Q2 → summary; Enter there actually submits. Result must
|
|
714
|
+
// NOT carry notes.
|
|
701
715
|
picker.handleInput("1");
|
|
702
716
|
picker.handleInput("1");
|
|
717
|
+
expect(doneResult).toBeNull();
|
|
718
|
+
picker.handleInput("\n");
|
|
703
719
|
expect(doneResult as AskProResult | null).toEqual({ answers: { 0: 0, 1: 0 } });
|
|
704
720
|
});
|
|
705
721
|
|
|
@@ -710,6 +726,97 @@ describe("AskProComponent — notes (n key)", () => {
|
|
|
710
726
|
});
|
|
711
727
|
});
|
|
712
728
|
|
|
729
|
+
// ---------------------------------------------------------------------------
|
|
730
|
+
// Summary view: read-only recap shown after the last question is answered.
|
|
731
|
+
// Enter there commits the answers; Esc cancels. Other keys are ignored.
|
|
732
|
+
// ---------------------------------------------------------------------------
|
|
733
|
+
|
|
734
|
+
describe("AskProComponent — summary view (post-last-question recap)", () => {
|
|
735
|
+
test("single-select last question transitions to summary (Enter submits)", () => {
|
|
736
|
+
const { picker, getDone } = setup();
|
|
737
|
+
picker.handleInput("1"); // Q1 → advance
|
|
738
|
+
picker.handleInput("2"); // Q2 → summary view
|
|
739
|
+
expect(getDone()).toBeNull();
|
|
740
|
+
picker.handleInput("\n"); // confirm summary
|
|
741
|
+
expect(getDone()).toEqual({ answers: { 0: 0, 1: 1 } });
|
|
742
|
+
});
|
|
743
|
+
|
|
744
|
+
test("Esc in summary view cancels the whole picker", () => {
|
|
745
|
+
const { picker, getDone } = setup();
|
|
746
|
+
picker.handleInput("1");
|
|
747
|
+
picker.handleInput("2"); // → summary
|
|
748
|
+
picker.handleInput("\x1b"); // Esc
|
|
749
|
+
expect(getDone()).toEqual({ cancelled: true });
|
|
750
|
+
});
|
|
751
|
+
|
|
752
|
+
test("multi-select last question transitions to summary", () => {
|
|
753
|
+
const TWO_MULTI: AskQuestion[] = [
|
|
754
|
+
{ header: "Tasks", question: "?", options: [{ label: "A" }, { label: "B" }], multiSelect: true },
|
|
755
|
+
{ header: "Priority", question: "?", options: [{ label: "H" }, { label: "L" }], multiSelect: true },
|
|
756
|
+
];
|
|
757
|
+
const { picker, getDone } = setup(TWO_MULTI);
|
|
758
|
+
picker.handleInput(" "); // Q1: toggle A
|
|
759
|
+
picker.handleInput("\t"); // → Q2
|
|
760
|
+
picker.handleInput(" "); // Q2: toggle H
|
|
761
|
+
picker.handleInput("\n"); // Enter on last multi → summary
|
|
762
|
+
expect(getDone()).toBeNull();
|
|
763
|
+
picker.handleInput("\n"); // confirm
|
|
764
|
+
expect(getDone()).toEqual({ answers: { 0: [0], 1: [0] } });
|
|
765
|
+
});
|
|
766
|
+
|
|
767
|
+
test("summary view ignores non-Enter/non-Esc keys", () => {
|
|
768
|
+
const { picker, getDone } = setup();
|
|
769
|
+
picker.handleInput("1");
|
|
770
|
+
picker.handleInput("2"); // → summary
|
|
771
|
+
// Junk keys: should NOT submit or cancel.
|
|
772
|
+
picker.handleInput("a");
|
|
773
|
+
picker.handleInput("\t");
|
|
774
|
+
picker.handleInput(" ");
|
|
775
|
+
expect(getDone()).toBeNull();
|
|
776
|
+
// Enter still works.
|
|
777
|
+
picker.handleInput("\n");
|
|
778
|
+
expect(getDone()).toEqual({ answers: { 0: 0, 1: 1 } });
|
|
779
|
+
});
|
|
780
|
+
|
|
781
|
+
test("summary view shows all answers in render()", () => {
|
|
782
|
+
const { picker } = setup();
|
|
783
|
+
picker.handleInput("1"); // Q1: JWT cookie (label index 0)
|
|
784
|
+
picker.handleInput("2"); // Q2: Bearer header (label index 1) → summary
|
|
785
|
+
const out = picker.render(80).join("\n");
|
|
786
|
+
// Recap shows both questions with their labels.
|
|
787
|
+
expect(out).toContain("JWT cookie");
|
|
788
|
+
expect(out).toContain("Bearer header");
|
|
789
|
+
// Header hint for the recap itself.
|
|
790
|
+
expect(out).toContain("Review your answers");
|
|
791
|
+
});
|
|
792
|
+
|
|
793
|
+
test("skipped questions render as '— skipped —' in summary", () => {
|
|
794
|
+
const { picker } = setup();
|
|
795
|
+
picker.handleInput("1"); // Q1 → advance
|
|
796
|
+
picker.handleInput("s"); // Q2: skip → advance (but already last; → summary)
|
|
797
|
+
// Q1 answered, Q2 skipped → allAnswered() returns true (skipped counts as answered).
|
|
798
|
+
const { getDone } = setup(); // re-setup for clarity
|
|
799
|
+
void getDone; // silence unused
|
|
800
|
+
// The above setup didn't help; let me assert against the original flow:
|
|
801
|
+
expect(picker.render(80).join("\n")).toContain("skipped");
|
|
802
|
+
});
|
|
803
|
+
|
|
804
|
+
test("free-text typed answer renders verbatim in summary", () => {
|
|
805
|
+
const FT: AskQuestion[] = [
|
|
806
|
+
{ header: "Title", question: "?", options: [{ label: "A" }] },
|
|
807
|
+
{ header: "Note", question: "?", options: [], freeText: true },
|
|
808
|
+
];
|
|
809
|
+
const { picker, getDone } = setup(FT);
|
|
810
|
+
picker.handleInput("1"); // Q1 → advance
|
|
811
|
+
picker.handleInput("hello world"); // free-text typing
|
|
812
|
+
picker.handleInput("\n"); // commit
|
|
813
|
+
// Q1 answered, Q2 answered → summary
|
|
814
|
+
expect(getDone()).toBeNull();
|
|
815
|
+
const out = picker.render(80).join("\n");
|
|
816
|
+
expect(out).toContain("hello world");
|
|
817
|
+
});
|
|
818
|
+
});
|
|
819
|
+
|
|
713
820
|
// ---------------------------------------------------------------------------
|
|
714
821
|
// Regression: render() must NEVER emit a line wider than `width`.
|
|
715
822
|
// Previously the side-by-side preview layout measured width with a regex
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
247
|
+
|
|
254
248
|
return;
|
|
255
249
|
}
|
|
256
250
|
|
|
257
251
|
if (sub === "reload") {
|
|
258
252
|
refreshRules();
|
|
259
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
410
|
+
|
|
424
411
|
return;
|
|
425
412
|
}
|
|
426
413
|
}
|
|
427
414
|
fs.writeFileSync(targetFile, text, "utf-8");
|
|
428
415
|
refreshRules();
|
|
429
|
-
|
|
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
|
-
|
|
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
|
-
|
|
480
|
+
|
|
497
481
|
return;
|
|
498
482
|
}
|
|
499
483
|
|
|
500
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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.
|
|
3
|
+
"version": "2.1.3",
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
+
|
|
202
194
|
return {
|
|
203
195
|
handled: true,
|
|
204
196
|
transformedText: notice,
|
package/workflows/execute.ts
CHANGED
|
@@ -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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
+
}
|
package/workflows/inspect.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
495
|
+
|
|
499
496
|
}
|
package/workflows/migrate.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
167
|
+
|
|
168
168
|
return {
|
|
169
169
|
handled: true,
|
|
170
170
|
transformedText: notice,
|
package/workflows/quick.ts
CHANGED
|
@@ -100,7 +100,7 @@ export function showStatus(
|
|
|
100
100
|
}
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
181
|
+
|
|
182
182
|
}
|
|
183
183
|
|
|
184
184
|
// =============================================================================
|
|
@@ -254,5 +254,5 @@ export async function showDiff(
|
|
|
254
254
|
}
|
|
255
255
|
}
|
|
256
256
|
|
|
257
|
-
|
|
257
|
+
|
|
258
258
|
}
|