pi-soly 2.2.6 → 2.3.1
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 +2 -2
- package/commands/_helpers.ts +6 -0
- package/commands/artifacts.ts +8 -8
- package/commands/docs.ts +5 -5
- package/commands/rules.ts +9 -9
- package/commands/rulewizard.ts +3 -1
- package/commands/soly.ts +19 -19
- package/commands/why.ts +2 -2
- package/commands.ts +1 -1
- package/index.ts +9 -2
- package/package.json +1 -1
- package/visual/chrome.ts +16 -1
- package/visual/data.ts +8 -0
- package/visual/footer.ts +10 -1
- package/workflows/done.ts +1 -1
- package/workflows/index.ts +3 -2
- package/workflows/new.ts +35 -8
- package/workflows/parser.ts +93 -47
package/README.md
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+

|
|
2
|
+
|
|
1
3
|
<div align="center">
|
|
2
4
|
|
|
3
5
|
# ⚡ pi-soly
|
|
@@ -11,8 +13,6 @@
|
|
|
11
13
|
|
|
12
14
|
</div>
|
|
13
15
|
|
|
14
|
-

|
|
15
|
-
|
|
16
16
|
## What it is
|
|
17
17
|
|
|
18
18
|
pi-soly turns a plain pi-coding-agent session into a structured project:
|
package/commands/_helpers.ts
CHANGED
|
@@ -40,6 +40,12 @@ export interface CommandsDeps {
|
|
|
40
40
|
getConfig: () => SolyConfig;
|
|
41
41
|
reloadConfig: () => void;
|
|
42
42
|
getIntentDocs: () => IntentDoc[];
|
|
43
|
+
/** Record a non-error soly event. Rendered as a sub-line under the
|
|
44
|
+
* Working indicator (└─ prefixed, dim/yellow by level). Use this for
|
|
45
|
+
* informational events (e.g. "reloaded 47 rules") — they shouldn't be
|
|
46
|
+
* popups. Errors must still go through `ui.notify(text, "error")` for
|
|
47
|
+
* the popup surface. */
|
|
48
|
+
recordEvent: (text: string, level?: "info" | "warning") => void;
|
|
43
49
|
}
|
|
44
50
|
|
|
45
51
|
/** Open a focused list modal (overlay) for the given grouped items. */
|
package/commands/artifacts.ts
CHANGED
|
@@ -6,16 +6,16 @@ import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-c
|
|
|
6
6
|
import { getArtifactServer, ensureArtifactServer, artifactDir } from "../artifact/session.ts";
|
|
7
7
|
import { openListPanel, openExternally, type CommandsDeps } from "./_helpers.ts";
|
|
8
8
|
|
|
9
|
-
type ArtifactsDeps = Pick<CommandsDeps, "getConfig">;
|
|
9
|
+
type ArtifactsDeps = Pick<CommandsDeps, "getConfig" | "recordEvent">;
|
|
10
10
|
|
|
11
11
|
export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps): void {
|
|
12
|
-
const { getConfig } = deps;
|
|
12
|
+
const { getConfig, recordEvent } = deps;
|
|
13
13
|
|
|
14
14
|
pi.registerCommand("artifacts", {
|
|
15
15
|
description: "browse this project's html_artifact gallery (list, open, clear)",
|
|
16
16
|
handler: async (args, ctx) => {
|
|
17
17
|
if (!getConfig().artifacts.server) {
|
|
18
|
-
|
|
18
|
+
recordEvent("artifact server is disabled (artifacts.server=false)");
|
|
19
19
|
return;
|
|
20
20
|
}
|
|
21
21
|
// Always re-resolve: reuse another window's server or start ours, and
|
|
@@ -27,7 +27,7 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
|
|
|
27
27
|
// ignore — handled by the count check below
|
|
28
28
|
}
|
|
29
29
|
if (!server || server.count === 0) {
|
|
30
|
-
|
|
30
|
+
recordEvent("no artifacts for this project yet (use the html_artifact tool)");
|
|
31
31
|
return;
|
|
32
32
|
}
|
|
33
33
|
const gallery = server.galleryUrl();
|
|
@@ -35,15 +35,15 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
|
|
|
35
35
|
|
|
36
36
|
if (sub === "clear") {
|
|
37
37
|
const n = server.clear();
|
|
38
|
-
|
|
38
|
+
recordEvent(`cleared ${n} artifact(s)`);
|
|
39
39
|
return;
|
|
40
40
|
}
|
|
41
41
|
if (sub === "open" || sub === "gallery") {
|
|
42
42
|
try {
|
|
43
43
|
await openExternally(pi, gallery);
|
|
44
|
-
|
|
44
|
+
recordEvent(`opened ${gallery}`);
|
|
45
45
|
} catch {
|
|
46
|
-
|
|
46
|
+
recordEvent(`soly artifacts gallery: ${gallery}`);
|
|
47
47
|
}
|
|
48
48
|
return;
|
|
49
49
|
}
|
|
@@ -80,7 +80,7 @@ export function registerArtifactsCommand(pi: ExtensionAPI, deps: ArtifactsDeps):
|
|
|
80
80
|
|
|
81
81
|
// Non-TUI: print the list + gallery URL.
|
|
82
82
|
const lines = server.list().map((a) => `🖼 ${a.title} — ${a.url}`);
|
|
83
|
-
|
|
83
|
+
recordEvent([`soly artifacts gallery: ${gallery}`, "", ...lines].join("\n"));
|
|
84
84
|
},
|
|
85
85
|
});
|
|
86
86
|
}
|
package/commands/docs.ts
CHANGED
|
@@ -7,10 +7,10 @@ import { formatTok, solyDirFor } from "../core.ts";
|
|
|
7
7
|
import { buildIntentStats, formatIntentStats, loadInlineIntentBodies, type IntentInlineDoc } from "../intent.ts";
|
|
8
8
|
import { openListPanel, type CommandUI, type CommandsDeps } from "./_helpers.ts";
|
|
9
9
|
|
|
10
|
-
type DocsDeps = Pick<CommandsDeps, "getIntentDocs">;
|
|
10
|
+
type DocsDeps = Pick<CommandsDeps, "getIntentDocs" | "recordEvent">;
|
|
11
11
|
|
|
12
12
|
export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
|
|
13
|
-
const { getIntentDocs } = deps;
|
|
13
|
+
const { getIntentDocs, recordEvent } = deps;
|
|
14
14
|
|
|
15
15
|
pi.registerCommand("docs", {
|
|
16
16
|
description: "manage soly intent docs (stats — show context breakdown)",
|
|
@@ -30,7 +30,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
|
|
|
30
30
|
if (sub === "list") {
|
|
31
31
|
const docs = getIntentDocs();
|
|
32
32
|
if (docs.length === 0) {
|
|
33
|
-
|
|
33
|
+
recordEvent("no intent docs found in .agents/docs/ — drop your vision/domain docs there");
|
|
34
34
|
return;
|
|
35
35
|
}
|
|
36
36
|
if (ctx.mode === "tui") {
|
|
@@ -55,7 +55,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
|
|
|
55
55
|
});
|
|
56
56
|
return;
|
|
57
57
|
}
|
|
58
|
-
|
|
58
|
+
recordEvent(docs.map((d) => `○ ${d.title || d.relPath} (${formatTok(d.tokens)} tok)`).join("\n"));
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
|
|
@@ -63,7 +63,7 @@ export function registerDocsCommand(pi: ExtensionAPI, deps: DocsDeps): void {
|
|
|
63
63
|
const docs = getIntentDocs();
|
|
64
64
|
const inlineBodies: IntentInlineDoc[] = loadInlineIntentBodies(docs);
|
|
65
65
|
const stats = buildIntentStats(docs, inlineBodies);
|
|
66
|
-
|
|
66
|
+
recordEvent(formatIntentStats(stats));
|
|
67
67
|
return;
|
|
68
68
|
}
|
|
69
69
|
|
package/commands/rules.ts
CHANGED
|
@@ -30,10 +30,10 @@ function ruleItem(r: RuleFile): ListItem {
|
|
|
30
30
|
};
|
|
31
31
|
}
|
|
32
32
|
|
|
33
|
-
type RulesDeps = Pick<CommandsDeps, "getRules" | "getOverridden" | "refreshRules" | "updateStatus">;
|
|
33
|
+
type RulesDeps = Pick<CommandsDeps, "getRules" | "getOverridden" | "refreshRules" | "updateStatus" | "recordEvent">;
|
|
34
34
|
|
|
35
35
|
export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
|
|
36
|
-
const { getRules, getOverridden, refreshRules, updateStatus } = deps;
|
|
36
|
+
const { getRules, getOverridden, refreshRules, updateStatus, recordEvent } = deps;
|
|
37
37
|
|
|
38
38
|
pi.registerCommand("rules", {
|
|
39
39
|
description: "manage soly rules (list, show, stats, analytics, reload, enable, disable)",
|
|
@@ -55,7 +55,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
|
|
|
55
55
|
const rules = getRules();
|
|
56
56
|
const overridden = getOverridden();
|
|
57
57
|
if (rules.length === 0 && overridden.length === 0) {
|
|
58
|
-
|
|
58
|
+
recordEvent("no rules loaded — check `.agents/rules/` or `~/.agents/rules/`", "warning");
|
|
59
59
|
return;
|
|
60
60
|
}
|
|
61
61
|
// Rich modal in the TUI; plain select elsewhere (RPC/print).
|
|
@@ -85,7 +85,7 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
|
|
|
85
85
|
for (const p of overridden) {
|
|
86
86
|
lines.push(`⊘ [overridden] ${p}`);
|
|
87
87
|
}
|
|
88
|
-
|
|
88
|
+
recordEvent(lines.join("\n"));
|
|
89
89
|
return;
|
|
90
90
|
}
|
|
91
91
|
|
|
@@ -100,20 +100,20 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
|
|
|
100
100
|
return;
|
|
101
101
|
}
|
|
102
102
|
const text = `---\n${r.meta.description ? `description: ${r.meta.description}\n` : ""}source: ${r.sourceLabel}\nenabled: ${r.enabled}\n---\n\n${r.body}`;
|
|
103
|
-
|
|
103
|
+
recordEvent(text);
|
|
104
104
|
return;
|
|
105
105
|
}
|
|
106
106
|
|
|
107
107
|
if (sub === "stats") {
|
|
108
108
|
const analytics = analyzeRules(getRules(), CONTEXT_WINDOW_TOKENS);
|
|
109
|
-
|
|
109
|
+
recordEvent(formatAnalyticsFull(analytics));
|
|
110
110
|
return;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
113
|
if (sub === "analytics") {
|
|
114
114
|
const rules = getRules();
|
|
115
115
|
const stats = buildRulesContextStats(rules, CONTEXT_WINDOW_TOKENS);
|
|
116
|
-
|
|
116
|
+
recordEvent(formatRulesContextStats(stats));
|
|
117
117
|
return;
|
|
118
118
|
}
|
|
119
119
|
|
|
@@ -129,14 +129,14 @@ export function registerRulesCommand(pi: ExtensionAPI, deps: RulesDeps): void {
|
|
|
129
129
|
}
|
|
130
130
|
r.enabled = sub === "enable";
|
|
131
131
|
updateStatus(ui);
|
|
132
|
-
|
|
132
|
+
recordEvent(`Rule ${target}: ${sub}d`);
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
136
|
if (sub === "reload") {
|
|
137
137
|
refreshRules();
|
|
138
138
|
updateStatus(ui);
|
|
139
|
-
|
|
139
|
+
recordEvent(`Reloaded ${getRules().length} rules`);
|
|
140
140
|
return;
|
|
141
141
|
}
|
|
142
142
|
|
package/commands/rulewizard.ts
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
// =============================================================================
|
|
4
4
|
|
|
5
5
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
|
+
import type { CommandsDeps } from "./_helpers.ts";
|
|
6
7
|
|
|
7
|
-
export function registerRulewizardCommand(pi: ExtensionAPI): void {
|
|
8
|
+
export function registerRulewizardCommand(pi: ExtensionAPI, deps: CommandsDeps): void {
|
|
9
|
+
void deps; // rulewizard doesn't currently emit non-error events
|
|
8
10
|
pi.registerCommand("rulewizard", {
|
|
9
11
|
description:
|
|
10
12
|
"interactive guide: decide whether a constraint should be a soly rule, an .editorconfig entry, or a linter config (eslint/biome/prettier). Use this BEFORE writing a new rule to avoid duplicating what linters already enforce.",
|
package/commands/soly.ts
CHANGED
|
@@ -25,10 +25,10 @@ import { initSolyProject } from "../init.js";
|
|
|
25
25
|
import { parseSolyCommand, type SolyCommand, type WorkflowVerb } from "../workflows/parser.ts";
|
|
26
26
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
27
27
|
|
|
28
|
-
type SolyDeps = Pick<CommandsDeps, "getState" | "getConfig" | "reloadConfig" | "updateStatus" | "refreshState">;
|
|
28
|
+
type SolyDeps = Pick<CommandsDeps, "getState" | "getConfig" | "reloadConfig" | "updateStatus" | "refreshState" | "recordEvent">;
|
|
29
29
|
|
|
30
30
|
export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
31
|
-
const { getState, getConfig, reloadConfig, updateStatus, refreshState } = deps;
|
|
31
|
+
const { getState, getConfig, reloadConfig, updateStatus, refreshState, recordEvent } = deps;
|
|
32
32
|
|
|
33
33
|
const solyBody = async (args: string, ctx: ExtensionCommandContext): Promise<void> => {
|
|
34
34
|
const ui: CommandUI = {
|
|
@@ -105,22 +105,22 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
105
105
|
switch (verb) {
|
|
106
106
|
case "new": {
|
|
107
107
|
const r = buildNewTransform(cmd, state, ui, ctx.cwd, getConfig().plan.defaultBranchPrefix);
|
|
108
|
-
if (r.handled && r.transformedText)
|
|
108
|
+
if (r.handled && r.transformedText) recordEvent(r.transformedText);
|
|
109
109
|
return;
|
|
110
110
|
}
|
|
111
111
|
case "done": {
|
|
112
112
|
const r = buildDoneTransform(cmd, state, ui, ctx.cwd, { defaultBranchPrefix: getConfig().plan.defaultBranchPrefix });
|
|
113
|
-
if (r.handled && r.transformedText)
|
|
113
|
+
if (r.handled && r.transformedText) recordEvent(r.transformedText);
|
|
114
114
|
return;
|
|
115
115
|
}
|
|
116
116
|
case "migrate": {
|
|
117
117
|
const r = buildMigrateTransform(state, ui, ctx.cwd);
|
|
118
|
-
if (r.handled && r.transformedText)
|
|
118
|
+
if (r.handled && r.transformedText) recordEvent(r.transformedText);
|
|
119
119
|
return;
|
|
120
120
|
}
|
|
121
121
|
case "plan": {
|
|
122
122
|
const r = buildPlanTransform(cmd, state);
|
|
123
|
-
if (r.handled && r.transformedText)
|
|
123
|
+
if (r.handled && r.transformedText) recordEvent(r.transformedText);
|
|
124
124
|
return;
|
|
125
125
|
}
|
|
126
126
|
case "discuss": {
|
|
@@ -129,13 +129,13 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
129
129
|
// plain-text discussion format; the LLM-driven path still
|
|
130
130
|
// gets the richer ask_pro flow.
|
|
131
131
|
const r = buildDiscussTransform(cmd, state, { hasAskPro: false });
|
|
132
|
-
if (r.handled && r.transformedText)
|
|
132
|
+
if (r.handled && r.transformedText) recordEvent(r.transformedText);
|
|
133
133
|
return;
|
|
134
134
|
}
|
|
135
135
|
case "execute": {
|
|
136
136
|
// Slash-command path doesn't have live interactive rules.
|
|
137
137
|
const r = buildExecuteTransform(cmd, state, []);
|
|
138
|
-
if (r.handled && r.transformedText)
|
|
138
|
+
if (r.handled && r.transformedText) recordEvent(r.transformedText);
|
|
139
139
|
return;
|
|
140
140
|
}
|
|
141
141
|
default:
|
|
@@ -181,7 +181,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
181
181
|
"info",
|
|
182
182
|
);
|
|
183
183
|
} else {
|
|
184
|
-
|
|
184
|
+
recordEvent(`${s.milestone} — no position set`);
|
|
185
185
|
}
|
|
186
186
|
},
|
|
187
187
|
},
|
|
@@ -244,7 +244,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
244
244
|
run: () => {
|
|
245
245
|
const s = getState();
|
|
246
246
|
const line = `${s.progress.percent}% · ${s.progress.completedPhases}/${s.progress.totalPhases} phases · ${s.progress.completedPlans}/${s.progress.totalPlans} plans`;
|
|
247
|
-
|
|
247
|
+
recordEvent(line);
|
|
248
248
|
},
|
|
249
249
|
},
|
|
250
250
|
phases: {
|
|
@@ -252,7 +252,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
252
252
|
run: () => {
|
|
253
253
|
const phases = getState().phases;
|
|
254
254
|
if (phases.length === 0) {
|
|
255
|
-
|
|
255
|
+
recordEvent("no phases");
|
|
256
256
|
return;
|
|
257
257
|
}
|
|
258
258
|
const current = getState().currentPhase?.number;
|
|
@@ -261,7 +261,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
261
261
|
const cr = (p.contextExists ? "C" : "·") + (p.researchExists ? "R" : "·");
|
|
262
262
|
return `${marker} ${String(p.number).padStart(2, "0")}. ${p.name} [${cr}] plans=${p.planCount}`;
|
|
263
263
|
});
|
|
264
|
-
|
|
264
|
+
recordEvent(lines.join("\n"));
|
|
265
265
|
},
|
|
266
266
|
},
|
|
267
267
|
tasks: {
|
|
@@ -269,7 +269,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
269
269
|
run: () => {
|
|
270
270
|
const s = getState();
|
|
271
271
|
if (s.tasks.length === 0) {
|
|
272
|
-
|
|
272
|
+
recordEvent("no tasks");
|
|
273
273
|
return;
|
|
274
274
|
}
|
|
275
275
|
const byFeature = new Map<string, typeof s.tasks>();
|
|
@@ -288,7 +288,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
288
288
|
}
|
|
289
289
|
out.push("");
|
|
290
290
|
}
|
|
291
|
-
|
|
291
|
+
recordEvent(out.join("\n"));
|
|
292
292
|
},
|
|
293
293
|
},
|
|
294
294
|
task: {
|
|
@@ -319,7 +319,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
319
319
|
if (summaryBody) {
|
|
320
320
|
showFile("SUMMARY.md", summaryBody);
|
|
321
321
|
} else {
|
|
322
|
-
|
|
322
|
+
recordEvent("no SUMMARY.md yet");
|
|
323
323
|
}
|
|
324
324
|
},
|
|
325
325
|
},
|
|
@@ -328,14 +328,14 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
328
328
|
run: () => {
|
|
329
329
|
const features = getState().features;
|
|
330
330
|
if (features.length === 0) {
|
|
331
|
-
|
|
331
|
+
recordEvent("no features");
|
|
332
332
|
return;
|
|
333
333
|
}
|
|
334
334
|
const lines = features.map((f) => {
|
|
335
335
|
const rm = f.readmeExists ? "R" : "·";
|
|
336
336
|
return ` ${f.name.padEnd(28)} tasks=${f.taskCount} [${rm}]`;
|
|
337
337
|
});
|
|
338
|
-
|
|
338
|
+
recordEvent(lines.join("\n"));
|
|
339
339
|
},
|
|
340
340
|
},
|
|
341
341
|
milestone: {
|
|
@@ -343,7 +343,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
343
343
|
run: () => {
|
|
344
344
|
const s = getState();
|
|
345
345
|
if (!s.milestone || s.milestone === "—") {
|
|
346
|
-
|
|
346
|
+
recordEvent("no milestone set");
|
|
347
347
|
return;
|
|
348
348
|
}
|
|
349
349
|
const candidates = [
|
|
@@ -369,7 +369,7 @@ export function registerSolyCommand(pi: ExtensionAPI, deps: SolyDeps): void {
|
|
|
369
369
|
refreshState();
|
|
370
370
|
updateStatus(ui);
|
|
371
371
|
const s = getState();
|
|
372
|
-
|
|
372
|
+
recordEvent(`re-read state: ${s.phases.length} phases · ${s.tasks.length} tasks`);
|
|
373
373
|
},
|
|
374
374
|
},
|
|
375
375
|
// ------------------------------------------------------------------
|
package/commands/why.ts
CHANGED
|
@@ -5,10 +5,10 @@
|
|
|
5
5
|
import type { ExtensionAPI, ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
|
|
6
6
|
import type { CommandsDeps } from "./_helpers.ts";
|
|
7
7
|
|
|
8
|
-
type WhyDeps = Pick<CommandsDeps, "getState" | "getRules">;
|
|
8
|
+
type WhyDeps = Pick<CommandsDeps, "getState" | "getRules" | "recordEvent">;
|
|
9
9
|
|
|
10
10
|
export function registerWhyCommand(pi: ExtensionAPI, deps: WhyDeps): void {
|
|
11
|
-
const { getState, getRules } = deps;
|
|
11
|
+
const { getState, getRules, recordEvent } = deps;
|
|
12
12
|
|
|
13
13
|
pi.registerCommand("why", {
|
|
14
14
|
description:
|
package/commands.ts
CHANGED
|
@@ -39,6 +39,6 @@ export function registerCommands(pi: ExtensionAPI, deps: CommandsDeps): void {
|
|
|
39
39
|
registerDocsCommand(pi, deps);
|
|
40
40
|
registerSolyCommand(pi, deps); // also registers /sly and /s
|
|
41
41
|
registerArtifactsCommand(pi, deps);
|
|
42
|
-
registerRulewizardCommand(pi);
|
|
42
|
+
registerRulewizardCommand(pi, deps);
|
|
43
43
|
registerWhyCommand(pi, deps);
|
|
44
44
|
}
|
package/index.ts
CHANGED
|
@@ -384,6 +384,10 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
384
384
|
}
|
|
385
385
|
},
|
|
386
386
|
getIntentDocs: () => intentDocs,
|
|
387
|
+
// Non-error soly events land in the Working sub-line (└─ prefixed).
|
|
388
|
+
// The route goes through the chrome so the sub-line auto-clears on
|
|
389
|
+
// the next agent_start.
|
|
390
|
+
recordEvent: (text, level) => chrome.recordEvent(text, level),
|
|
387
391
|
});
|
|
388
392
|
|
|
389
393
|
registerTools(pi, {
|
|
@@ -393,6 +397,7 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
393
397
|
});
|
|
394
398
|
|
|
395
399
|
registerWorkflows(pi, {
|
|
400
|
+
recordEvent: (text, level) => chrome.recordEvent(text, level),
|
|
396
401
|
getState: () => state,
|
|
397
402
|
getInteractiveRules: () =>
|
|
398
403
|
combinedRules()
|
|
@@ -531,9 +536,11 @@ export default function solyExtension(pi: ExtensionAPI) {
|
|
|
531
536
|
},
|
|
532
537
|
});
|
|
533
538
|
// Editors save in bursts (write to .tmp, rename, touch). Coalesce
|
|
534
|
-
// those rapid reload events into a single
|
|
539
|
+
// those rapid reload events into a single sub-line event under the
|
|
540
|
+
// Working indicator (└─ reloaded 47 rules). Errors here are real
|
|
541
|
+
// (disk I/O failed) and stay as popups.
|
|
535
542
|
hotReload.setNotifyHandler((reason) => {
|
|
536
|
-
|
|
543
|
+
chrome.recordEvent(`reloaded rules (${reason})`);
|
|
537
544
|
});
|
|
538
545
|
|
|
539
546
|
// Notifications (one-shot at startup)
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "pi-soly",
|
|
3
|
-
"version": "2.
|
|
3
|
+
"version": "2.3.1",
|
|
4
4
|
"description": "Workflow + project management for pi-coding-agent. Plans, state, MANDATORY rules, self-review, multi-question picker. One npm install, zero config. LLM drives the workflow inline via the soly_workflow tool — no external subagent plugin.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"main": "index.ts",
|
package/visual/chrome.ts
CHANGED
|
@@ -61,6 +61,11 @@ export type Chrome = {
|
|
|
61
61
|
updateWorking(ui: ExtensionUIContext, outputTokens: number): void;
|
|
62
62
|
/** Stop the telemetry line and restore the default message (agent_end). */
|
|
63
63
|
stopWorking(ui: ExtensionUIContext): void;
|
|
64
|
+
/** Record a non-error soly event. Rendered as a sub-line under the
|
|
65
|
+
* Working indicator (└─ prefixed, level-marker glyph). Auto-cleared on
|
|
66
|
+
* the next `startWorking`. Errors are still surfaced via
|
|
67
|
+
* `ui.notify(text, "error")` — call that directly for the popup. */
|
|
68
|
+
recordEvent(text: string, level?: "info" | "warning"): void;
|
|
64
69
|
/** Restore pi's native footer/widgets/indicator (session_shutdown / disable). */
|
|
65
70
|
dispose(ui?: ExtensionUIContext): void;
|
|
66
71
|
};
|
|
@@ -98,9 +103,15 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
|
|
|
98
103
|
};
|
|
99
104
|
|
|
100
105
|
return {
|
|
106
|
+
recordEvent(text: string, level: "info" | "warning" = "info"): void {
|
|
107
|
+
data.recentEvent = text;
|
|
108
|
+
data.recentEventLevel = level;
|
|
109
|
+
try { tui?.requestRender(); } catch { /* not mounted yet */ }
|
|
110
|
+
},
|
|
111
|
+
|
|
101
112
|
data,
|
|
102
113
|
|
|
103
|
-
install(ui): void {
|
|
114
|
+
install(ui: ExtensionUIContext): void {
|
|
104
115
|
if (!getConfig().enabled) return;
|
|
105
116
|
ui.setWorkingIndicator({ frames: getConfig().spinnerFrames, intervalMs: getConfig().spinnerIntervalMs });
|
|
106
117
|
ui.setFooter((t, theme, footerData) => {
|
|
@@ -133,6 +144,10 @@ export function createChrome(getConfig: () => ChromeConfig): Chrome {
|
|
|
133
144
|
startWorking(ui): void {
|
|
134
145
|
if (!getConfig().enabled || !getConfig().telemetry) return;
|
|
135
146
|
clearWorking();
|
|
147
|
+
// Each new turn starts with a clean sub-line — old events were
|
|
148
|
+
// from the previous turn, not relevant to the current one.
|
|
149
|
+
data.recentEvent = null;
|
|
150
|
+
data.recentEventLevel = null;
|
|
136
151
|
working = { ui, startMs: Date.now(), inputTokens: data.ctxTokens ?? 0, outputTokens: 0, timer: null };
|
|
137
152
|
renderWorking();
|
|
138
153
|
working.timer = setInterval(renderWorking, 1000);
|
package/visual/data.ts
CHANGED
|
@@ -39,6 +39,12 @@ export type ChromeData = {
|
|
|
39
39
|
verbLabel: string | null;
|
|
40
40
|
/** html_artifacts created this session (0 = no segment). */
|
|
41
41
|
artifactCount: number;
|
|
42
|
+
/** Most recent non-error soly event (e.g. "reloaded 47 rules"). Rendered
|
|
43
|
+
* as a sub-line under the Working indicator; auto-cleared by the next
|
|
44
|
+
* agent_start. null = no recent event. */
|
|
45
|
+
recentEvent: string | null;
|
|
46
|
+
/** Level of the recent event (used for glyph/color). */
|
|
47
|
+
recentEventLevel: "info" | "warning" | "error" | null;
|
|
42
48
|
};
|
|
43
49
|
|
|
44
50
|
/** A fresh ChromeData with everything empty/idle. */
|
|
@@ -58,5 +64,7 @@ export function emptyChromeData(): ChromeData {
|
|
|
58
64
|
phaseLabel: null,
|
|
59
65
|
verbLabel: null,
|
|
60
66
|
artifactCount: 0,
|
|
67
|
+
recentEvent: null,
|
|
68
|
+
recentEventLevel: null,
|
|
61
69
|
};
|
|
62
70
|
}
|
package/visual/footer.ts
CHANGED
|
@@ -124,6 +124,15 @@ export class SolyFooter implements Component {
|
|
|
124
124
|
}
|
|
125
125
|
|
|
126
126
|
render(width: number): string[] {
|
|
127
|
-
|
|
127
|
+
const out: string[] = [buildFooterLine(this.data, this.fd, width, { ascii: this.getAscii(), styler: themeStyler(this.theme) })];
|
|
128
|
+
const event = this.data.recentEvent;
|
|
129
|
+
if (event) {
|
|
130
|
+
const glyph = this.data.recentEventLevel === "warning" ? "└─ ⚠" : "└─";
|
|
131
|
+
const styled = this.data.recentEventLevel === "warning"
|
|
132
|
+
? themeStyler(this.theme).fg("warning", `${glyph} ${event}`)
|
|
133
|
+
: themeStyler(this.theme).dim(`${glyph} ${event}`);
|
|
134
|
+
out.push(" " + styled);
|
|
135
|
+
}
|
|
136
|
+
return out;
|
|
128
137
|
}
|
|
129
138
|
}
|
package/workflows/done.ts
CHANGED
|
@@ -95,7 +95,7 @@ export function buildDoneTransform(
|
|
|
95
95
|
if ("error" in parsed) {
|
|
96
96
|
return reply(`soly done: ${parsed.error}\n\nUsage: soly done <slug> OR soly done <prefix>/<slug>`);
|
|
97
97
|
}
|
|
98
|
-
const { name, prefix } = parsed;
|
|
98
|
+
const { name, prefix, autoSlugified, originalInput } = parsed;
|
|
99
99
|
const effectivePrefix = prefix ?? opts.defaultBranchPrefix ?? "";
|
|
100
100
|
const branchName = effectivePrefix ? `${effectivePrefix}/${name}` : name;
|
|
101
101
|
|
package/workflows/index.ts
CHANGED
|
@@ -32,6 +32,7 @@ import type { SolyState } from "../core.js";
|
|
|
32
32
|
import type { SolyConfig } from "../config.js";
|
|
33
33
|
|
|
34
34
|
export interface WorkflowsDeps {
|
|
35
|
+
recordEvent: (text: string, level?: "info" | "warning") => void;
|
|
35
36
|
getState: () => SolyState;
|
|
36
37
|
/** List of rule relPaths marked `interactive: true` — inlined into the
|
|
37
38
|
* execute instruction so the model knows which rules are explicitly out of
|
|
@@ -57,7 +58,7 @@ export interface WorkflowsDeps {
|
|
|
57
58
|
const MODE_VERBS: readonly string[] = ["execute", "plan", "discuss", "resume"];
|
|
58
59
|
|
|
59
60
|
export function registerWorkflows(pi: ExtensionAPI, deps: WorkflowsDeps): void {
|
|
60
|
-
const { getState, getInteractiveRules, getActiveTools, getConfig, onWorkflowUsed } = deps;
|
|
61
|
+
const { getState, getInteractiveRules, getActiveTools, getConfig, onWorkflowUsed, recordEvent } = deps;
|
|
61
62
|
|
|
62
63
|
// Self-review loop ("soly verify"). Owns its own agent_end + input hooks;
|
|
63
64
|
// fresh-context mode rewrites the next LLM call through the context manager.
|
|
@@ -276,7 +277,7 @@ State inspection lives on the slash form — \`/soly <sub>\`:
|
|
|
276
277
|
"and .agents/.continue-here.md. Preserve milestone/phase/plan position and key " +
|
|
277
278
|
"decisions in the summary. Drop implementation-detail noise.",
|
|
278
279
|
onComplete: () => {
|
|
279
|
-
|
|
280
|
+
recordEvent("session compacted — use 'soly resume' to pick up");
|
|
280
281
|
},
|
|
281
282
|
onError: (err) => {
|
|
282
283
|
ctx.ui.notify(`soly: compact failed — ${err.message}`, "error");
|
package/workflows/new.ts
CHANGED
|
@@ -89,7 +89,7 @@ export function buildNewTransform(
|
|
|
89
89
|
const parsed = parsePlanName(cmd.args.join(" "));
|
|
90
90
|
if ("error" in parsed) return reply(`soly new: ${parsed.error}`);
|
|
91
91
|
|
|
92
|
-
const { name, prefix } = parsed;
|
|
92
|
+
const { name, prefix, autoSlugified, originalInput } = parsed;
|
|
93
93
|
// Branch can be one of three shapes:
|
|
94
94
|
// - "feature/statistic-preparation" (user typed <prefix>/<slug>)
|
|
95
95
|
// - "feature/statistic-preparation" (config has defaultBranchPrefix "feature", user typed "statistic-preparation")
|
|
@@ -123,18 +123,45 @@ export function buildNewTransform(
|
|
|
123
123
|
|
|
124
124
|
const currentBranch = git(["branch", "--show-current"], { cwd: projectRoot }) || "HEAD (detached)";
|
|
125
125
|
// A plan branch is a kebab-case slug (no `<type>/` prefix after 1.15.x).
|
|
126
|
-
// The
|
|
127
|
-
//
|
|
128
|
-
// checkout
|
|
126
|
+
// The base can also be the long-lived integration branches `master`/`main`.
|
|
127
|
+
// Anything else (release tags, weird suffixes, feature branches from
|
|
128
|
+
// previous work) — we auto-checkout the base branch instead of
|
|
129
|
+
// blocking the user. The LLM driving soly shouldn't have to manage
|
|
130
|
+
// git state.
|
|
129
131
|
if (
|
|
130
132
|
currentBranch !== "master" &&
|
|
131
133
|
currentBranch !== "main" &&
|
|
132
134
|
!/^[a-z0-9][a-z0-9-]*[a-z0-9]$/.test(currentBranch)
|
|
133
135
|
) {
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
)
|
|
136
|
+
// Detect which base branch the repo uses: try `main` first, fall
|
|
137
|
+
// back to `master`. If neither exists, give up — the repo is in
|
|
138
|
+
// a state the user needs to resolve by hand.
|
|
139
|
+
const baseBranch = ["main", "master"].find((b) => {
|
|
140
|
+
try {
|
|
141
|
+
git(["rev-parse", "--verify", b], { cwd: projectRoot });
|
|
142
|
+
return true;
|
|
143
|
+
} catch {
|
|
144
|
+
return false;
|
|
145
|
+
}
|
|
146
|
+
});
|
|
147
|
+
if (!baseBranch) {
|
|
148
|
+
return reply(
|
|
149
|
+
`soly new: cannot find a base branch to base off — repo has neither "main" nor "master". ` +
|
|
150
|
+
`Run \`git checkout <base>\` first, or create a default branch.`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
try {
|
|
154
|
+
git(["checkout", baseBranch], { cwd: projectRoot });
|
|
155
|
+
ui.notify(
|
|
156
|
+
`auto-checkout ${baseBranch} (was on ${currentBranch})`,
|
|
157
|
+
"info",
|
|
158
|
+
);
|
|
159
|
+
} catch (err) {
|
|
160
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
161
|
+
return reply(
|
|
162
|
+
`soly new: failed to checkout ${baseBranch} (was on ${currentBranch}): ${msg}`,
|
|
163
|
+
);
|
|
164
|
+
}
|
|
138
165
|
}
|
|
139
166
|
|
|
140
167
|
let branchExisted = false;
|
package/workflows/parser.ts
CHANGED
|
@@ -37,12 +37,36 @@ export type WorkflowVerb =
|
|
|
37
37
|
* soly new statistic-preparation
|
|
38
38
|
* soly new login-redirect-bug
|
|
39
39
|
* soly new stats-rollup
|
|
40
|
+
*
|
|
41
|
+
* If the input doesn't match the strict kebab-case shape, we attempt
|
|
42
|
+
* to auto-slugify it (lowercase, replace non-slug chars with `-`,
|
|
43
|
+
* collapse, trim). For free-form prose like "this is a feature" or
|
|
44
|
+
* even Cyrillic text, the auto-slugified result becomes the plan name
|
|
45
|
+
* and the original input is preserved as the plan's description so
|
|
46
|
+
* the LLM has the human-authored context.
|
|
40
47
|
*/
|
|
41
48
|
export function parsePlanName(
|
|
42
49
|
raw: string,
|
|
43
|
-
): { name: string; prefix: string | null } | { error: string } {
|
|
50
|
+
): { name: string; prefix: string | null; autoSlugified: boolean; originalInput: string } | { error: string; tried: string } {
|
|
44
51
|
const trimmed = raw.trim();
|
|
45
|
-
if (!trimmed) return { error: "missing plan name" };
|
|
52
|
+
if (!trimmed) return { error: "missing plan name", tried: "" };
|
|
53
|
+
|
|
54
|
+
// Strict regex: ASCII alphanumerics + hyphen, must start and end with
|
|
55
|
+
// an alnum. The kebab-case shape we want to encourage.
|
|
56
|
+
// Unicode regex used for the lenient path below (e.g. Cyrillic, accents).
|
|
57
|
+
const SLUG_STRICT = /^[a-z0-9][a-z0-9-]*[a-z0-9]$/u;
|
|
58
|
+
const SLUG_LENIENT = /^[\p{L}\p{N}][\p{L}\p{N}_-]*[\p{L}\p{N}]$/u;
|
|
59
|
+
const MAX_LEN = 64;
|
|
60
|
+
const MIN_LEN = 2;
|
|
61
|
+
|
|
62
|
+
/** Lowercase, replace non-slug chars with `-`, collapse, trim. */
|
|
63
|
+
const autoSlugify = (s: string): string =>
|
|
64
|
+
s
|
|
65
|
+
.toLowerCase()
|
|
66
|
+
.normalize("NFKD")
|
|
67
|
+
.replace(/[^\p{L}\p{N}_-]/gu, "-")
|
|
68
|
+
.replace(/-+/g, "-")
|
|
69
|
+
.replace(/^-+|-+$/g, "");
|
|
46
70
|
|
|
47
71
|
// Optional `<prefix>/<slug>` form: prefix is one kebab-case word
|
|
48
72
|
// (no internal slashes), slug is the same kebab-case shape. Examples:
|
|
@@ -54,49 +78,67 @@ export function parsePlanName(
|
|
|
54
78
|
if (slashIdx > 0) {
|
|
55
79
|
const prefix = trimmed.slice(0, slashIdx);
|
|
56
80
|
const slug = trimmed.slice(slashIdx + 1);
|
|
57
|
-
const prefixOk =
|
|
58
|
-
const slugOk =
|
|
81
|
+
const prefixOk = SLUG_STRICT.test(prefix);
|
|
82
|
+
const slugOk = SLUG_STRICT.test(slug);
|
|
59
83
|
if (!prefixOk || !slugOk) {
|
|
60
84
|
return {
|
|
61
85
|
error:
|
|
62
86
|
`bad plan name "${trimmed}".\n` +
|
|
63
|
-
`\nExpected: <slug>
|
|
87
|
+
`\nExpected: <prefix>/<slug> (e.g. "feature/login-redirect-bug")\n` +
|
|
64
88
|
` prefix: one kebab-case word (e.g. "feature", "fix", "chore")\n` +
|
|
65
89
|
` slug: kebab-case (lowercase letters, digits, hyphens)\n` +
|
|
66
|
-
`\
|
|
90
|
+
`\nTip: pass free-form prose instead and soly will auto-slugify:\n` +
|
|
91
|
+
` soly new "add a button to the settings page" → add-a-button-to-the-settings-page`,
|
|
92
|
+
tried: trimmed,
|
|
67
93
|
};
|
|
68
94
|
}
|
|
69
|
-
return { name: slug, prefix };
|
|
95
|
+
return { name: slug, prefix, autoSlugified: false, originalInput: trimmed };
|
|
70
96
|
}
|
|
71
97
|
|
|
72
|
-
// Plain slug form.
|
|
73
|
-
// are reserved for phase numbers
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
const m = trimmed.match(/^[a-z0-9][a-z0-9-]*[a-z0-9]$/);
|
|
85
|
-
if (!m) {
|
|
86
|
-
return {
|
|
87
|
-
error:
|
|
88
|
-
`bad plan name "${trimmed}".\n` +
|
|
89
|
-
`\nExpected: <slug> OR <prefix>/<slug>\n` +
|
|
90
|
-
` length: 2-64 chars\n` +
|
|
91
|
-
` chars: lowercase letters, digits, and hyphens\n` +
|
|
92
|
-
` must start and end with a letter or digit\n` +
|
|
93
|
-
`\nExample: soly new statistic-preparation`,
|
|
94
|
-
};
|
|
98
|
+
// Plain slug form. If it's already kebab-case, accept as-is.
|
|
99
|
+
// Exception: pure-digit strings are reserved for phase numbers
|
|
100
|
+
// (`soly plan 11` → phase 11), not slugs.
|
|
101
|
+
if (
|
|
102
|
+
SLUG_STRICT.test(trimmed) &&
|
|
103
|
+
/[a-z]/.test(trimmed) &&
|
|
104
|
+
trimmed.length >= MIN_LEN &&
|
|
105
|
+
trimmed.length <= MAX_LEN
|
|
106
|
+
) {
|
|
107
|
+
return { name: trimmed, prefix: null, autoSlugified: false, originalInput: trimmed };
|
|
95
108
|
}
|
|
96
|
-
|
|
97
|
-
|
|
109
|
+
|
|
110
|
+
// Otherwise, try to auto-slugify (handles free-form prose, Cyrillic,
|
|
111
|
+
// accented chars, mixed punctuation). The result must be a valid slug
|
|
112
|
+
// under the lenient Unicode regex.
|
|
113
|
+
//
|
|
114
|
+
// Exception: pure-digit strings (e.g. "11") are reserved for phase
|
|
115
|
+
// numbers (`soly plan 11` → phase 11), not slugs. Don't auto-slugify them.
|
|
116
|
+
const candidate = autoSlugify(trimmed);
|
|
117
|
+
const isPureDigits = /^\d+$/.test(candidate);
|
|
118
|
+
if (
|
|
119
|
+
!isPureDigits &&
|
|
120
|
+
candidate.length >= MIN_LEN &&
|
|
121
|
+
candidate.length <= MAX_LEN &&
|
|
122
|
+
SLUG_LENIENT.test(candidate)
|
|
123
|
+
) {
|
|
124
|
+
return { name: candidate, prefix: null, autoSlugified: true, originalInput: trimmed };
|
|
98
125
|
}
|
|
99
|
-
|
|
126
|
+
|
|
127
|
+
// Couldn't make anything valid out of the input. Surface a clear error
|
|
128
|
+
// that explains what we tried and what works.
|
|
129
|
+
return {
|
|
130
|
+
error:
|
|
131
|
+
`bad plan name "${trimmed}".\n` +
|
|
132
|
+
`\nExpected: <slug> OR <prefix>/<slug>\n` +
|
|
133
|
+
` length: ${MIN_LEN}-${MAX_LEN} chars\n` +
|
|
134
|
+
` chars: Unicode letters/digits/hyphens/underscores (auto-slugified to kebab-case)\n` +
|
|
135
|
+
`\nTip: pass the plan description as a free-form string and soly will\n` +
|
|
136
|
+
`auto-slugify it for you:\n` +
|
|
137
|
+
` soly new "add a button to the settings page" → add-a-button-to-the-settings-page\n` +
|
|
138
|
+
`\nOr pick a short kebab-case name yourself:\n` +
|
|
139
|
+
` soly new statistic-preparation`,
|
|
140
|
+
tried: candidate,
|
|
141
|
+
};
|
|
100
142
|
}
|
|
101
143
|
|
|
102
144
|
export interface SolyCommand {
|
|
@@ -242,7 +284,7 @@ function parseNewTaskFlag(
|
|
|
242
284
|
*/
|
|
243
285
|
export type ExecuteTarget =
|
|
244
286
|
| { kind: "phase"; phase: number; plan: number | null; raw: string }
|
|
245
|
-
| { kind: "plan"; name: string; prefix: string | null; raw: string }
|
|
287
|
+
| { kind: "plan"; name: string; prefix: string | null; autoSlugified: boolean; originalInput: string; raw: string }
|
|
246
288
|
| { kind: "task"; taskId: string; raw: string }
|
|
247
289
|
| { kind: "all"; raw: string }
|
|
248
290
|
| { kind: "feature"; feature: string; raw: string };
|
|
@@ -286,16 +328,19 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
|
|
|
286
328
|
return { kind: "task", taskId, raw };
|
|
287
329
|
}
|
|
288
330
|
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
}
|
|
293
|
-
|
|
331
|
+
// Check phase shape FIRST — pure digits and NN.MM patterns are phase
|
|
332
|
+
// numbers, not plan slugs. Must come before parsePlanName because
|
|
333
|
+
// auto-slugify would turn "11.02" into "11-02" (a valid plan slug).
|
|
294
334
|
const phase = parsePhaseShape(target);
|
|
295
335
|
if (phase) {
|
|
296
336
|
return { kind: "phase", phase: phase.phase, plan: phase.plan, raw };
|
|
297
337
|
}
|
|
298
338
|
|
|
339
|
+
const plan = parsePlanName(target);
|
|
340
|
+
if (!("error" in plan)) {
|
|
341
|
+
return { kind: "plan", name: plan.name, prefix: plan.prefix, autoSlugified: plan.autoSlugified, originalInput: plan.originalInput, raw };
|
|
342
|
+
}
|
|
343
|
+
|
|
299
344
|
return null;
|
|
300
345
|
}
|
|
301
346
|
|
|
@@ -314,7 +359,7 @@ export function describeExecuteTarget(args: string[]): ExecuteTarget | null {
|
|
|
314
359
|
*/
|
|
315
360
|
export type PlanTarget =
|
|
316
361
|
| { kind: "phase"; phase: number; raw: string }
|
|
317
|
-
| { kind: "plan"; name: string; prefix: string | null; raw: string }
|
|
362
|
+
| { kind: "plan"; name: string; prefix: string | null; autoSlugified: boolean; originalInput: string; raw: string }
|
|
318
363
|
| { kind: "task"; taskId: string; raw: string }
|
|
319
364
|
| { kind: "new-task"; slug: string; feature: string; raw: string }
|
|
320
365
|
| { kind: "feature"; feature: string; raw: string };
|
|
@@ -360,17 +405,18 @@ export function describePlanTarget(args: string[]): PlanTarget | null {
|
|
|
360
405
|
return { kind: "task", taskId, raw };
|
|
361
406
|
}
|
|
362
407
|
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
}
|
|
367
|
-
|
|
368
|
-
// Plan target only matches plain N (no .MM — plan is per-phase, executed
|
|
369
|
-
// at the phase level by `soly execute <N.MM>`).
|
|
408
|
+
// Check phase shape FIRST — pure digits are phase numbers, not slugs.
|
|
409
|
+
// Must come before parsePlanName because auto-slugify would turn digits
|
|
410
|
+
// into a valid plan slug.
|
|
370
411
|
const phase = parsePhaseOnlyShape(target);
|
|
371
412
|
if (phase) {
|
|
372
413
|
return { kind: "phase", phase: phase.phase, raw };
|
|
373
414
|
}
|
|
374
415
|
|
|
416
|
+
const plan = parsePlanName(target);
|
|
417
|
+
if (!("error" in plan)) {
|
|
418
|
+
return { kind: "plan", name: plan.name, prefix: plan.prefix, autoSlugified: plan.autoSlugified, originalInput: plan.originalInput, raw };
|
|
419
|
+
}
|
|
420
|
+
|
|
375
421
|
return null;
|
|
376
422
|
}
|