@groeponline/pi-wishcraft 0.25.0 → 0.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +10 -0
- package/docs/commands.md +3 -3
- package/docs/index.md +1 -1
- package/docs/skill-manager.md +1 -0
- package/package.json +1 -1
- package/queue/store.ts +39 -1
- package/queue/types.ts +11 -0
- package/src/extension/commands/queue-commands.ts +9 -2
- package/src/extension/queue/idea-review.ts +330 -0
- package/src/extension/queue/queue-integration.ts +4 -5
- package/src/extension/skills/skill-manager.ts +20 -25
- package/src/extension/skills/skill-registry.ts +19 -0
- package/src/extension/skills/skill-templates.ts +267 -0
- package/src/extension/welcome/welcome-integration.ts +9 -0
- package/src/welcome/banner.ts +2 -0
- package/src/welcome/overlay.ts +2 -0
- package/src/welcome/types.ts +1 -0
- package/src/welcome/widgets/queue-widget.ts +13 -3
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,16 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [0.27.0] - 2026-08-20
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
- `/ideas` review overlay: `reviewStatus` (idea / in-progress / done), tags, and Run with skill X. Welcome queue widget shows the next idea plus `/ideas next`.
|
|
9
|
+
|
|
10
|
+
## [0.26.0] - 2026-08-20
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `/skills new <name> [template]` writes a SKILL.md from standard, browser-workflow, CLI-workflow, or review-checklist. `ctrl+n` opens the template picker.
|
|
14
|
+
|
|
5
15
|
## [0.25.0] - 2026-08-20
|
|
6
16
|
|
|
7
17
|
## [0.24.0] - 2026-08-20
|
package/docs/commands.md
CHANGED
|
@@ -18,8 +18,8 @@ Commands and capture shortcuts:
|
|
|
18
18
|
- `/compact <text>`: compact now and queue `<text>` as the next prompt after successful compaction
|
|
19
19
|
- `/idea [@target] <text>`: command form of idea capture, useful for scripts and users who disable the sigil
|
|
20
20
|
- `/idea issue [id]`: hand the oldest active idea, or a specific idea, to the current agent for safe GitHub issue triage
|
|
21
|
-
- `/ideas`: open the
|
|
22
|
-
- `/ideas next`: send the oldest active idea
|
|
21
|
+
- `/ideas`: open the idea-review overlay (status, tags, run with skill)
|
|
22
|
+
- `/ideas next`: send the oldest active idea that is not review-done
|
|
23
23
|
- `/ideas issue [id]`: ask the current agent to dedupe and file a GitHub issue only when the target repo is clear and owned/controlled
|
|
24
24
|
- `/ideas send <id>`: send an idea to the current session
|
|
25
25
|
- `/queue`: open the queued-prompt picker
|
|
@@ -55,7 +55,7 @@ Set `captureSigil` to `false` if you often submit markdown headings and prefer `
|
|
|
55
55
|
}
|
|
56
56
|
```
|
|
57
57
|
|
|
58
|
-
Captured data is stored under the Pi agent directory in `powerline-footer/inbox.jsonl` and `powerline-footer/projects.json`. `inbox.jsonl` is a stable read surface for orchestrators and helper agents; each line is a queue item with `id`, `text`, `createdAt`, `updatedAt`, `source`, `target`, `intent`, `status`, and optional `
|
|
58
|
+
Captured data is stored under the Pi agent directory in `powerline-footer/inbox.jsonl` and `powerline-footer/projects.json`. `inbox.jsonl` is a stable read surface for orchestrators and helper agents; each line is a queue item with `id`, `text`, `createdAt`, `updatedAt`, `source`, `target`, `intent`, `status`, optional `error`, and for ideas optional `reviewStatus` (`idea` | `in-progress` | `done`) and `tags`. Legacy lines without those fields still parse. Writes should still go through Powerline commands or the store so locking and atomic writes are preserved. Ideas sent with `/ideas next` or `/ideas send <id>` include a small provenance header so the receiving agent can treat them as deferred captured context. `/ideas` opens the review overlay (same chrome as `/skills`): set review status and tags, or **Run with skill X** to insert the skill body plus the idea into the editor. The welcome queue widget shows the next idea and `/ideas next`; welcome dismisses on any key, so that widget does not bind enter-to-send. `/idea issue` and `/ideas issue` do not file issues directly from the extension; they send a guarded handoff prompt that tells the current agent to dedupe open issues first, create a GitHub issue only for a clear owned/controlled repo, and ask before filing when the target is unclear.
|
|
59
59
|
|
|
60
60
|
### Placement
|
|
61
61
|
|
package/docs/index.md
CHANGED
|
@@ -8,7 +8,7 @@ The README is the public landing page (`banner.png` only). Everything below live
|
|
|
8
8
|
- [Configuration](./configuration.md) — custom items, hooks, repairs, token budget, labels, templates, layout, cost alert, and display formats.
|
|
9
9
|
- [Bash mode](./bash-mode.md) — sticky shell, ghost suggestions, and shell config.
|
|
10
10
|
- [Stash & shortcuts](./stash-and-shortcuts.md) — editor stash, prompt history, clipboard/navigation shortcuts, and shortcut config.
|
|
11
|
-
- [Skill manager](./skill-manager.md) — browsing
|
|
11
|
+
- [Skill manager](./skill-manager.md) — browsing, inserting, `/skills doctor`, and `/skills new` templates.
|
|
12
12
|
- [Working vibes](./working-vibes.md) — themed loading messages, modes, and configuration.
|
|
13
13
|
- [Segments & theming](./segments.md) — segment reference, separators, thinking/path/git options, and theme overrides.
|
|
14
14
|
|
package/docs/skill-manager.md
CHANGED
|
@@ -4,5 +4,6 @@ Browse and insert your installed skills (`SKILL.md` files and `*.md`/`*.txt` pro
|
|
|
4
4
|
|
|
5
5
|
- **`/skills`** — open the skill manager. Filter with plain typing, `↑↓` to move, `enter` to open a skill's detail body, `↑↓` in the detail to scroll, `enter`/`tab` to insert the skill content into your prompt, `esc` to go back/close.
|
|
6
6
|
- **`/skills doctor`** — health table (not an essay): broken or missing frontmatter, descriptions over 240 characters, the same name in global and project, unused skills (usage ledger count 0). `↑↓` navigate, `enter` copies a row, `esc` closes.
|
|
7
|
+
- **`/skills new <name> [template]`** — write `~/.pi/agent/skills/<name>/SKILL.md` from a template (`standard`, `browser-workflow`, `CLI-workflow`, `review-checklist`), then drop an `$EDITOR` command in the prompt. `/skills new` or `ctrl+n` in the manager opens the template picker. Names reject empty values, `..`, and path separators. No GitHub/npm install.
|
|
7
8
|
|
|
8
9
|
The manager reuses the same skill discovery as inline `/command`/`$skill` triggers, so anything you can inline you can also browse and insert manually.
|
package/package.json
CHANGED
package/queue/store.ts
CHANGED
|
@@ -11,6 +11,7 @@ import { randomUUID } from "node:crypto";
|
|
|
11
11
|
import { getAgentPath } from "../src/paths/agent-dirs.ts";
|
|
12
12
|
import type {
|
|
13
13
|
CreateQueueItemInput,
|
|
14
|
+
IdeaReviewStatus,
|
|
14
15
|
PowerlineQueueItem,
|
|
15
16
|
QueueAliasMap,
|
|
16
17
|
QueueContext,
|
|
@@ -19,7 +20,7 @@ import type {
|
|
|
19
20
|
QueueSummary,
|
|
20
21
|
QueueTarget,
|
|
21
22
|
} from "./types.ts";
|
|
22
|
-
import { ACTIVE_QUEUE_STATUSES } from "./types.ts";
|
|
23
|
+
import { ACTIVE_QUEUE_STATUSES, IDEA_REVIEW_STATUSES } from "./types.ts";
|
|
23
24
|
|
|
24
25
|
const STORE_DIR = "powerline-footer";
|
|
25
26
|
const INBOX_FILE = "inbox.jsonl";
|
|
@@ -77,6 +78,30 @@ function normalizeStatus(value: unknown): QueueStatus | null {
|
|
|
77
78
|
: null;
|
|
78
79
|
}
|
|
79
80
|
|
|
81
|
+
function normalizeReviewStatus(value: unknown): IdeaReviewStatus | undefined {
|
|
82
|
+
return IDEA_REVIEW_STATUSES.includes(value as IdeaReviewStatus)
|
|
83
|
+
? (value as IdeaReviewStatus)
|
|
84
|
+
: undefined;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
const MAX_IDEA_TAGS = 8;
|
|
88
|
+
const MAX_TAG_CHARS = 32;
|
|
89
|
+
|
|
90
|
+
export function normalizeIdeaTags(value: unknown): string[] | undefined {
|
|
91
|
+
if (!Array.isArray(value)) return undefined;
|
|
92
|
+
const seen = new Set<string>();
|
|
93
|
+
const tags: string[] = [];
|
|
94
|
+
for (const entry of value) {
|
|
95
|
+
if (typeof entry !== "string") continue;
|
|
96
|
+
const tag = entry.trim().slice(0, MAX_TAG_CHARS);
|
|
97
|
+
if (!tag || seen.has(tag)) continue;
|
|
98
|
+
seen.add(tag);
|
|
99
|
+
tags.push(tag);
|
|
100
|
+
if (tags.length >= MAX_IDEA_TAGS) break;
|
|
101
|
+
}
|
|
102
|
+
return tags.length > 0 ? tags : undefined;
|
|
103
|
+
}
|
|
104
|
+
|
|
80
105
|
function normalizeItem(value: unknown): PowerlineQueueItem | null {
|
|
81
106
|
if (!isRecord(value)) return null;
|
|
82
107
|
if (typeof value.id !== "string" || !value.id.trim()) return null;
|
|
@@ -99,6 +124,9 @@ function normalizeItem(value: unknown): PowerlineQueueItem | null {
|
|
|
99
124
|
|
|
100
125
|
const sessionId = normalizeOptionalString(value.source.sessionId);
|
|
101
126
|
const error = normalizeOptionalString(value.error);
|
|
127
|
+
const reviewStatus =
|
|
128
|
+
intent === "idea" ? normalizeReviewStatus(value.reviewStatus) : undefined;
|
|
129
|
+
const tags = intent === "idea" ? normalizeIdeaTags(value.tags) : undefined;
|
|
102
130
|
|
|
103
131
|
return {
|
|
104
132
|
id: value.id.trim(),
|
|
@@ -112,6 +140,8 @@ function normalizeItem(value: unknown): PowerlineQueueItem | null {
|
|
|
112
140
|
intent,
|
|
113
141
|
status,
|
|
114
142
|
...(error ? { error } : {}),
|
|
143
|
+
...(reviewStatus ? { reviewStatus } : {}),
|
|
144
|
+
...(tags ? { tags } : {}),
|
|
115
145
|
};
|
|
116
146
|
}
|
|
117
147
|
|
|
@@ -142,6 +172,12 @@ export function createQueueItem(
|
|
|
142
172
|
): PowerlineQueueItem {
|
|
143
173
|
const now = input.now ?? Date.now();
|
|
144
174
|
const sourceSessionId = input.source.sessionId?.trim();
|
|
175
|
+
const reviewStatus =
|
|
176
|
+
input.intent === "idea"
|
|
177
|
+
? (input.reviewStatus ?? "idea")
|
|
178
|
+
: undefined;
|
|
179
|
+
const tags =
|
|
180
|
+
input.intent === "idea" ? normalizeIdeaTags(input.tags) : undefined;
|
|
145
181
|
return {
|
|
146
182
|
id: randomUUID().slice(0, 8),
|
|
147
183
|
text: input.text,
|
|
@@ -153,6 +189,8 @@ export function createQueueItem(
|
|
|
153
189
|
target: normalizeTarget(input.target) ?? input.target,
|
|
154
190
|
intent: input.intent,
|
|
155
191
|
status: input.status ?? "queued",
|
|
192
|
+
...(reviewStatus ? { reviewStatus } : {}),
|
|
193
|
+
...(tags ? { tags } : {}),
|
|
156
194
|
};
|
|
157
195
|
}
|
|
158
196
|
|
package/queue/types.ts
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
export type QueueIntent = "steer" | "follow-up" | "post-compact" | "idea";
|
|
2
2
|
export type QueueStatus = "queued" | "blocked" | "delivering" | "sent" | "failed";
|
|
3
|
+
/** Review language for captured ideas. Separate from delivery `QueueStatus`. */
|
|
4
|
+
export type IdeaReviewStatus = "idea" | "in-progress" | "done";
|
|
5
|
+
export const IDEA_REVIEW_STATUSES = [
|
|
6
|
+
"idea",
|
|
7
|
+
"in-progress",
|
|
8
|
+
"done",
|
|
9
|
+
] as const satisfies readonly IdeaReviewStatus[];
|
|
3
10
|
|
|
4
11
|
export type QueueTarget =
|
|
5
12
|
| { kind: "current-session" }
|
|
@@ -21,6 +28,8 @@ export interface PowerlineQueueItem {
|
|
|
21
28
|
intent: QueueIntent;
|
|
22
29
|
status: QueueStatus;
|
|
23
30
|
error?: string;
|
|
31
|
+
reviewStatus?: IdeaReviewStatus;
|
|
32
|
+
tags?: string[];
|
|
24
33
|
}
|
|
25
34
|
|
|
26
35
|
export interface QueueAliasMap {
|
|
@@ -43,6 +52,8 @@ export interface CreateQueueItemInput {
|
|
|
43
52
|
target: QueueTarget;
|
|
44
53
|
intent: QueueIntent;
|
|
45
54
|
status?: QueueStatus;
|
|
55
|
+
reviewStatus?: IdeaReviewStatus;
|
|
56
|
+
tags?: string[];
|
|
46
57
|
now?: number;
|
|
47
58
|
}
|
|
48
59
|
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
sendIdeaIssueHandoffById,
|
|
11
11
|
sendOrRetryQueueItem,
|
|
12
12
|
} from "../queue/queue-integration.ts";
|
|
13
|
+
import { openIdeasReview } from "../queue/idea-review.ts";
|
|
13
14
|
import { getCurrentEditorText } from "../shortcuts/shortcuts-router.ts";
|
|
14
15
|
import { getQueueContext } from "../queue/queue-context.ts";
|
|
15
16
|
import { config } from "../core/state.ts";
|
|
@@ -65,7 +66,7 @@ export function registerQueueCommands(
|
|
|
65
66
|
const id = parts[1];
|
|
66
67
|
|
|
67
68
|
if (!action) {
|
|
68
|
-
await
|
|
69
|
+
await openIdeasReview(pi, rt, ctx);
|
|
69
70
|
return;
|
|
70
71
|
}
|
|
71
72
|
|
|
@@ -78,6 +79,7 @@ export function registerQueueCommands(
|
|
|
78
79
|
const updated = rt.queueStore.update(item.id, {
|
|
79
80
|
status: "queued",
|
|
80
81
|
target: { kind: "current-session" },
|
|
82
|
+
reviewStatus: "in-progress",
|
|
81
83
|
error: undefined,
|
|
82
84
|
});
|
|
83
85
|
if (updated) deliverQueueItem(pi, rt, ctx, updated);
|
|
@@ -107,6 +109,7 @@ export function registerQueueCommands(
|
|
|
107
109
|
const updated = rt.queueStore.update(item.id, {
|
|
108
110
|
status: "queued",
|
|
109
111
|
target: { kind: "current-session" },
|
|
112
|
+
reviewStatus: "in-progress",
|
|
110
113
|
error: undefined,
|
|
111
114
|
});
|
|
112
115
|
if (updated) deliverQueueItem(pi, rt, ctx, updated);
|
|
@@ -121,7 +124,11 @@ export function registerQueueCommands(
|
|
|
121
124
|
}
|
|
122
125
|
|
|
123
126
|
if (action === "clear") {
|
|
124
|
-
rt.queueStore.
|
|
127
|
+
rt.queueStore.update(item.id, {
|
|
128
|
+
status: "sent",
|
|
129
|
+
reviewStatus: "done",
|
|
130
|
+
error: undefined,
|
|
131
|
+
});
|
|
125
132
|
ctx.ui.notify(`Cleared idea ${item.id}`, "info");
|
|
126
133
|
requestQueueRender(rt);
|
|
127
134
|
return;
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Idea-review overlay for `/ideas`.
|
|
3
|
+
* Review language (`reviewStatus`, `tags`) is separate from delivery QueueStatus.
|
|
4
|
+
* Overlay chrome matches `/skills` (showSelectOverlay, not ctx.ui.select).
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
8
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
IDEA_REVIEW_STATUSES,
|
|
12
|
+
type IdeaReviewStatus,
|
|
13
|
+
type PowerlineQueueItem,
|
|
14
|
+
} from "../../../queue/types.ts";
|
|
15
|
+
import { buildStashPreview } from "../history/stash-history.ts";
|
|
16
|
+
import {
|
|
17
|
+
loadSkillCatalog,
|
|
18
|
+
readSkillBodyStrict,
|
|
19
|
+
insertSkillBody,
|
|
20
|
+
} from "../skills/skill-registry.ts";
|
|
21
|
+
import { showSelectOverlay } from "../ui/overlay-chrome.ts";
|
|
22
|
+
import type { RuntimeState } from "../core/types.ts";
|
|
23
|
+
|
|
24
|
+
export type IdeaSelectOverlay = (
|
|
25
|
+
ctx: any,
|
|
26
|
+
title: string,
|
|
27
|
+
hint: string,
|
|
28
|
+
items: SelectItem[],
|
|
29
|
+
maxVisible: number,
|
|
30
|
+
) => Promise<SelectItem | null>;
|
|
31
|
+
import { getQueueContext } from "./queue-context.ts";
|
|
32
|
+
import {
|
|
33
|
+
deliverQueueItem,
|
|
34
|
+
requestQueueRender,
|
|
35
|
+
} from "./queue-integration.ts";
|
|
36
|
+
|
|
37
|
+
export const IDEA_TAG_PRESETS = [
|
|
38
|
+
"review",
|
|
39
|
+
"later",
|
|
40
|
+
"bug",
|
|
41
|
+
"feature",
|
|
42
|
+
"chore",
|
|
43
|
+
] as const;
|
|
44
|
+
|
|
45
|
+
export function ideaReviewStatusOf(
|
|
46
|
+
item: Pick<PowerlineQueueItem, "reviewStatus">,
|
|
47
|
+
): IdeaReviewStatus {
|
|
48
|
+
return item.reviewStatus ?? "idea";
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
export function formatIdeaReviewLabel(
|
|
52
|
+
item: PowerlineQueueItem,
|
|
53
|
+
previewWidth = 48,
|
|
54
|
+
): string {
|
|
55
|
+
const review = ideaReviewStatusOf(item);
|
|
56
|
+
const tags = item.tags?.length ? ` [${item.tags.join(", ")}]` : "";
|
|
57
|
+
return `${item.id} ${review}${tags} ${buildStashPreview(item.text, previewWidth)}`;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export function buildIdeaPickerItems(
|
|
61
|
+
items: readonly PowerlineQueueItem[],
|
|
62
|
+
): SelectItem[] {
|
|
63
|
+
return items.map((item) => ({
|
|
64
|
+
value: item.id,
|
|
65
|
+
label: formatIdeaReviewLabel(item),
|
|
66
|
+
description: item.tags?.join(", ") || ideaReviewStatusOf(item),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function buildIdeaActionItems(item: PowerlineQueueItem): SelectItem[] {
|
|
71
|
+
const review = ideaReviewStatusOf(item);
|
|
72
|
+
const tagSummary = item.tags?.length ? item.tags.join(", ") : "none";
|
|
73
|
+
return [
|
|
74
|
+
{
|
|
75
|
+
value: "send",
|
|
76
|
+
label: "Send to current session",
|
|
77
|
+
description: "Deliver as a prompt",
|
|
78
|
+
},
|
|
79
|
+
{
|
|
80
|
+
value: "skill",
|
|
81
|
+
label: "Run with skill X",
|
|
82
|
+
description: "Insert skill body plus this idea into the editor",
|
|
83
|
+
},
|
|
84
|
+
{
|
|
85
|
+
value: "status",
|
|
86
|
+
label: `Set status (${review})`,
|
|
87
|
+
description: "idea / in-progress / done",
|
|
88
|
+
},
|
|
89
|
+
{
|
|
90
|
+
value: "tags",
|
|
91
|
+
label: `Tags (${tagSummary})`,
|
|
92
|
+
description: "Toggle presets or clear",
|
|
93
|
+
},
|
|
94
|
+
{
|
|
95
|
+
value: "edit",
|
|
96
|
+
label: "Edit in prompt",
|
|
97
|
+
description: "Move text into the editor",
|
|
98
|
+
},
|
|
99
|
+
{
|
|
100
|
+
value: "clear",
|
|
101
|
+
label: "Clear",
|
|
102
|
+
description: "Mark delivery sent and review done",
|
|
103
|
+
},
|
|
104
|
+
{ value: "cancel", label: "Cancel" },
|
|
105
|
+
];
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export function buildReviewStatusItems(
|
|
109
|
+
current: IdeaReviewStatus,
|
|
110
|
+
): SelectItem[] {
|
|
111
|
+
return IDEA_REVIEW_STATUSES.map((status) => ({
|
|
112
|
+
value: status,
|
|
113
|
+
label: status === current ? `${status} (current)` : status,
|
|
114
|
+
description:
|
|
115
|
+
status === "idea"
|
|
116
|
+
? "Captured, not started"
|
|
117
|
+
: status === "in-progress"
|
|
118
|
+
? "Being worked"
|
|
119
|
+
: "Reviewed or finished",
|
|
120
|
+
}));
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function buildTagItems(tags: readonly string[] | undefined): SelectItem[] {
|
|
124
|
+
const current = new Set(tags ?? []);
|
|
125
|
+
const items: SelectItem[] = IDEA_TAG_PRESETS.map((tag) => ({
|
|
126
|
+
value: `toggle:${tag}`,
|
|
127
|
+
label: current.has(tag) ? `✓ ${tag}` : tag,
|
|
128
|
+
description: current.has(tag) ? "Remove tag" : "Add tag",
|
|
129
|
+
}));
|
|
130
|
+
items.push({
|
|
131
|
+
value: "clear",
|
|
132
|
+
label: "Clear tags",
|
|
133
|
+
description: "Remove all tags",
|
|
134
|
+
});
|
|
135
|
+
items.push({ value: "back", label: "Back" });
|
|
136
|
+
return items;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
export function toggleIdeaTag(
|
|
140
|
+
tags: readonly string[] | undefined,
|
|
141
|
+
tag: string,
|
|
142
|
+
): string[] | undefined {
|
|
143
|
+
const current = [...(tags ?? [])];
|
|
144
|
+
const next = current.includes(tag)
|
|
145
|
+
? current.filter((entry) => entry !== tag)
|
|
146
|
+
: [...current, tag];
|
|
147
|
+
return next.length > 0 ? next : undefined;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
export function composeSkillIdeaInsert(
|
|
151
|
+
skillBody: string,
|
|
152
|
+
ideaText: string,
|
|
153
|
+
): string {
|
|
154
|
+
const body = skillBody.trim();
|
|
155
|
+
const idea = ideaText.trim();
|
|
156
|
+
if (!body) return idea;
|
|
157
|
+
if (!idea) return body;
|
|
158
|
+
return `${body}\n\n${idea}`;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
export function pickNextReviewIdea(
|
|
162
|
+
items: readonly PowerlineQueueItem[],
|
|
163
|
+
): PowerlineQueueItem | null {
|
|
164
|
+
const ideas = items.filter((item) => item.intent === "idea");
|
|
165
|
+
return ideas.find((item) => ideaReviewStatusOf(item) !== "done") ?? null;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function activeIdeas(rt: RuntimeState, ctx: any): PowerlineQueueItem[] {
|
|
169
|
+
return rt.queueStore
|
|
170
|
+
.activeItems(getQueueContext(ctx))
|
|
171
|
+
.filter((item) => item.intent === "idea");
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
export function insertSkillAndIdea(
|
|
175
|
+
ctx: any,
|
|
176
|
+
skillName: string,
|
|
177
|
+
filePath: string,
|
|
178
|
+
ideaText: string,
|
|
179
|
+
): void {
|
|
180
|
+
try {
|
|
181
|
+
insertSkillBody(ctx, skillName, readSkillBodyStrict(filePath), ideaText);
|
|
182
|
+
ctx.ui.notify("Skill and idea inserted into the prompt", "info");
|
|
183
|
+
} catch (error) {
|
|
184
|
+
ctx.ui.notify(`Could not read skill: ${error instanceof Error ? error.message : String(error)}`, "error");
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
async function pickSkillForIdea(
|
|
189
|
+
ctx: any,
|
|
190
|
+
item: PowerlineQueueItem,
|
|
191
|
+
selectOverlay: IdeaSelectOverlay,
|
|
192
|
+
): Promise<void> {
|
|
193
|
+
const entries = loadSkillCatalog(ctx.cwd ?? process.cwd());
|
|
194
|
+
if (entries.length === 0) {
|
|
195
|
+
ctx.ui.notify("No skills installed", "info");
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
const selected = await selectOverlay(
|
|
199
|
+
ctx,
|
|
200
|
+
`Run ${item.id} with skill`,
|
|
201
|
+
"↑↓ navigate • enter insert • esc cancel",
|
|
202
|
+
entries.map((entry) => ({
|
|
203
|
+
value: entry.filePath,
|
|
204
|
+
label: entry.name,
|
|
205
|
+
description: entry.description || entry.category,
|
|
206
|
+
})),
|
|
207
|
+
Math.min(entries.length, 12),
|
|
208
|
+
);
|
|
209
|
+
if (!selected) return;
|
|
210
|
+
const entry = entries.find((candidate) => candidate.filePath === selected.value);
|
|
211
|
+
if (entry) insertSkillAndIdea(ctx, entry.name, entry.filePath, item.text);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
async function chooseIdeaReviewAction(
|
|
215
|
+
pi: ExtensionAPI,
|
|
216
|
+
rt: RuntimeState,
|
|
217
|
+
ctx: any,
|
|
218
|
+
item: PowerlineQueueItem,
|
|
219
|
+
selectOverlay: IdeaSelectOverlay,
|
|
220
|
+
): Promise<void> {
|
|
221
|
+
const selected = await selectOverlay(
|
|
222
|
+
ctx,
|
|
223
|
+
`Idea ${item.id}`,
|
|
224
|
+
buildStashPreview(item.text, 72),
|
|
225
|
+
buildIdeaActionItems(item),
|
|
226
|
+
8,
|
|
227
|
+
);
|
|
228
|
+
if (!selected || selected.value === "cancel") return;
|
|
229
|
+
|
|
230
|
+
if (selected.value === "send") {
|
|
231
|
+
const updated = rt.queueStore.update(item.id, {
|
|
232
|
+
status: "queued",
|
|
233
|
+
target: { kind: "current-session" },
|
|
234
|
+
reviewStatus: "in-progress",
|
|
235
|
+
error: undefined,
|
|
236
|
+
});
|
|
237
|
+
if (updated) deliverQueueItem(pi, rt, ctx, updated);
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
if (selected.value === "skill") {
|
|
242
|
+
await pickSkillForIdea(ctx, item, selectOverlay);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
if (selected.value === "status") {
|
|
247
|
+
const next = await selectOverlay(
|
|
248
|
+
ctx,
|
|
249
|
+
`Status for ${item.id}`,
|
|
250
|
+
"idea / in-progress / done",
|
|
251
|
+
buildReviewStatusItems(ideaReviewStatusOf(item)),
|
|
252
|
+
IDEA_REVIEW_STATUSES.length,
|
|
253
|
+
);
|
|
254
|
+
if (!next) return;
|
|
255
|
+
rt.queueStore.update(item.id, {
|
|
256
|
+
reviewStatus: next.value as IdeaReviewStatus,
|
|
257
|
+
});
|
|
258
|
+
ctx.ui.notify(`Idea ${item.id} → ${next.value}`, "info");
|
|
259
|
+
requestQueueRender(rt);
|
|
260
|
+
const fresh = rt.queueStore.get(item.id);
|
|
261
|
+
if (fresh) await chooseIdeaReviewAction(pi, rt, ctx, fresh, selectOverlay);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
if (selected.value === "tags") {
|
|
266
|
+
const next = await selectOverlay(
|
|
267
|
+
ctx,
|
|
268
|
+
`Tags for ${item.id}`,
|
|
269
|
+
"enter toggles • esc back",
|
|
270
|
+
buildTagItems(item.tags),
|
|
271
|
+
IDEA_TAG_PRESETS.length + 2,
|
|
272
|
+
);
|
|
273
|
+
if (!next || next.value === "back") {
|
|
274
|
+
const fresh = rt.queueStore.get(item.id);
|
|
275
|
+
if (fresh) await chooseIdeaReviewAction(pi, rt, ctx, fresh, selectOverlay);
|
|
276
|
+
return;
|
|
277
|
+
}
|
|
278
|
+
const tags =
|
|
279
|
+
next.value === "clear"
|
|
280
|
+
? undefined
|
|
281
|
+
: toggleIdeaTag(item.tags, next.value.replace(/^toggle:/, ""));
|
|
282
|
+
rt.queueStore.update(item.id, { tags });
|
|
283
|
+
requestQueueRender(rt);
|
|
284
|
+
const fresh = rt.queueStore.get(item.id);
|
|
285
|
+
if (fresh) await chooseIdeaReviewAction(pi, rt, ctx, fresh, selectOverlay);
|
|
286
|
+
return;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
if (selected.value === "edit") {
|
|
290
|
+
ctx.ui.setEditorText(item.text);
|
|
291
|
+
rt.queueStore.clear(item.id);
|
|
292
|
+
requestQueueRender(rt);
|
|
293
|
+
return;
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (selected.value === "clear") {
|
|
297
|
+
rt.queueStore.update(item.id, {
|
|
298
|
+
status: "sent",
|
|
299
|
+
reviewStatus: "done",
|
|
300
|
+
error: undefined,
|
|
301
|
+
});
|
|
302
|
+
ctx.ui.notify(`Cleared idea ${item.id}`, "info");
|
|
303
|
+
requestQueueRender(rt);
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
export async function openIdeasReview(
|
|
308
|
+
pi: ExtensionAPI,
|
|
309
|
+
rt: RuntimeState,
|
|
310
|
+
ctx: any,
|
|
311
|
+
selectOverlay: IdeaSelectOverlay = showSelectOverlay,
|
|
312
|
+
): Promise<void> {
|
|
313
|
+
const ideas = activeIdeas(rt, ctx);
|
|
314
|
+
if (ideas.length === 0) {
|
|
315
|
+
ctx.ui.notify("No ideas captured", "info");
|
|
316
|
+
return;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const selected = await selectOverlay(
|
|
320
|
+
ctx,
|
|
321
|
+
"Wishcraft ideas",
|
|
322
|
+
"↑↓ navigate • enter review • esc cancel",
|
|
323
|
+
buildIdeaPickerItems(ideas),
|
|
324
|
+
Math.min(ideas.length, 12),
|
|
325
|
+
);
|
|
326
|
+
if (!selected) return;
|
|
327
|
+
|
|
328
|
+
const item = ideas.find((candidate) => candidate.id === selected.value);
|
|
329
|
+
if (item) await chooseIdeaReviewAction(pi, rt, ctx, item, selectOverlay);
|
|
330
|
+
}
|
|
@@ -384,11 +384,10 @@ export function findNextIdea(
|
|
|
384
384
|
rt: RuntimeState,
|
|
385
385
|
ctx: any,
|
|
386
386
|
): PowerlineQueueItem | null {
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
);
|
|
387
|
+
const ideas = rt.queueStore
|
|
388
|
+
.activeItems(getQueueContext(ctx))
|
|
389
|
+
.filter((candidate) => candidate.intent === "idea");
|
|
390
|
+
return ideas.find((candidate) => candidate.reviewStatus !== "done") ?? null;
|
|
392
391
|
}
|
|
393
392
|
|
|
394
393
|
export function sendIdeaIssueHandoff(
|
|
@@ -14,21 +14,20 @@
|
|
|
14
14
|
import type { Theme } from "@earendil-works/pi-coding-agent";
|
|
15
15
|
import { matchesKey, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
|
|
16
16
|
import { rmSync } from "node:fs";
|
|
17
|
-
import { join } from "node:path";
|
|
18
17
|
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
19
18
|
import type { RuntimeState } from "../core/types.ts";
|
|
20
|
-
import { getAgentPath } from "../../paths/agent-dirs.ts";
|
|
21
19
|
import {
|
|
22
20
|
applySkillFilter,
|
|
23
21
|
getSkillUsage,
|
|
24
22
|
invalidateSkillCache,
|
|
25
23
|
loadSkillCatalog,
|
|
24
|
+
insertSkillBody,
|
|
26
25
|
readSkillBody,
|
|
27
|
-
recordSkillUsage,
|
|
28
26
|
type SkillCategory,
|
|
29
27
|
type SkillEntry,
|
|
30
28
|
} from "./skill-registry.ts";
|
|
31
29
|
import { runSkillDoctor } from "./skill-doctor.ts";
|
|
30
|
+
import { runSkillsNew } from "./skill-templates.ts";
|
|
32
31
|
|
|
33
32
|
const CATEGORY_LABELS: Record<SkillCategory | "all", string> = {
|
|
34
33
|
all: "alles",
|
|
@@ -90,21 +89,20 @@ function editorCommand(path: string): string {
|
|
|
90
89
|
return `!${ed} ${shellQuote(path)}`;
|
|
91
90
|
}
|
|
92
91
|
|
|
93
|
-
export async function showSkillManager(ctx: any): Promise<
|
|
92
|
+
export async function showSkillManager(ctx: any): Promise<"new" | null> {
|
|
94
93
|
invalidateSkillCache();
|
|
95
94
|
let entries = loadSkillCatalog(ctx.cwd ?? process.cwd());
|
|
96
95
|
const usage = getSkillUsage();
|
|
97
96
|
if (entries.length === 0) {
|
|
98
97
|
ctx.ui.notify("No skills found", "info");
|
|
99
|
-
return;
|
|
100
98
|
}
|
|
101
99
|
|
|
102
|
-
|
|
100
|
+
return ctx.ui.custom(
|
|
103
101
|
(
|
|
104
102
|
tui: any,
|
|
105
103
|
theme: Theme,
|
|
106
104
|
_keybindings: any,
|
|
107
|
-
done: (result: null) => void,
|
|
105
|
+
done: (result: "new" | null) => void,
|
|
108
106
|
) => {
|
|
109
107
|
const border = (text: string) => theme.fg("dim", text);
|
|
110
108
|
const wrapRow = (text: string, innerWidth: number): string =>
|
|
@@ -161,11 +159,7 @@ export async function showSkillManager(ctx: any): Promise<void> {
|
|
|
161
159
|
};
|
|
162
160
|
|
|
163
161
|
const insertBody = (entry: SkillEntry) => {
|
|
164
|
-
|
|
165
|
-
const current = ctx.ui.getEditorText?.() ?? "";
|
|
166
|
-
const separator = current && !current.endsWith("\n") ? "\n\n" : "";
|
|
167
|
-
ctx.ui.setEditorText(`${current}${separator}${body}\n`);
|
|
168
|
-
recordSkillUsage(entry.name);
|
|
162
|
+
insertSkillBody(ctx, entry.name, readSkillBody(entry.filePath));
|
|
169
163
|
ctx.ui.notify("Skill ingevoegd in je prompt", "info");
|
|
170
164
|
};
|
|
171
165
|
|
|
@@ -183,11 +177,12 @@ export async function showSkillManager(ctx: any): Promise<void> {
|
|
|
183
177
|
lines.push(border(`├${"─".repeat(innerWidth)}┤`));
|
|
184
178
|
|
|
185
179
|
if (f.length === 0) {
|
|
180
|
+
const emptyMsg =
|
|
181
|
+
entries.length === 0 && !query
|
|
182
|
+
? "No skills installed — ctrl+n to create one"
|
|
183
|
+
: `Geen skills voor "${query}"`;
|
|
186
184
|
lines.push(
|
|
187
|
-
wrapRow(
|
|
188
|
-
theme.fg("warning", `Geen skills voor "${query}"`),
|
|
189
|
-
innerWidth,
|
|
190
|
-
),
|
|
185
|
+
wrapRow(theme.fg("warning", emptyMsg), innerWidth),
|
|
191
186
|
);
|
|
192
187
|
} else {
|
|
193
188
|
// scroll-window rondom de selectie
|
|
@@ -396,13 +391,8 @@ export async function showSkillManager(ctx: any): Promise<void> {
|
|
|
396
391
|
return;
|
|
397
392
|
}
|
|
398
393
|
} else if (data === "\x0e") {
|
|
399
|
-
// ctrl+n =
|
|
400
|
-
|
|
401
|
-
ctx,
|
|
402
|
-
`!mkdir -p ${shellQuote(join(getAgentPath("skills"), "<naam>"))} && ${editorCommand(join(getAgentPath("skills"), "<naam>", "SKILL.md")).slice(1)}`,
|
|
403
|
-
"Nieuwe skill: vervang <naam>, enter draait 'm",
|
|
404
|
-
);
|
|
405
|
-
close();
|
|
394
|
+
// ctrl+n = new skill from a template
|
|
395
|
+
done("new");
|
|
406
396
|
return;
|
|
407
397
|
} else if (data === "\x04") {
|
|
408
398
|
// ctrl+d = verwijderen (met confirm)
|
|
@@ -482,7 +472,7 @@ export function registerSkillManagerCommand(
|
|
|
482
472
|
): void {
|
|
483
473
|
const runDoctor = deps.runDoctor ?? runSkillDoctor;
|
|
484
474
|
pi.registerCommand("skills", {
|
|
485
|
-
description: "Browse installed skills, or `doctor`
|
|
475
|
+
description: "Browse installed skills, or `doctor` / `new [template]`",
|
|
486
476
|
handler: async (args: string, ctx: any) => {
|
|
487
477
|
if (!rt.enabled || !ctx.hasUI) {
|
|
488
478
|
ctx.ui.notify("Powerline UI is disabled", "info");
|
|
@@ -493,7 +483,12 @@ export function registerSkillManagerCommand(
|
|
|
493
483
|
await runDoctor(ctx);
|
|
494
484
|
return;
|
|
495
485
|
}
|
|
496
|
-
|
|
486
|
+
if (sub === "new") {
|
|
487
|
+
await runSkillsNew(ctx, args);
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
490
|
+
const result = await showSkillManager(ctx);
|
|
491
|
+
if (result === "new") await runSkillsNew(ctx, "");
|
|
497
492
|
},
|
|
498
493
|
});
|
|
499
494
|
}
|
|
@@ -407,6 +407,25 @@ export function readSkillBody(path: string): string {
|
|
|
407
407
|
}
|
|
408
408
|
}
|
|
409
409
|
|
|
410
|
+
export function readSkillBodyStrict(path: string): string {
|
|
411
|
+
return stripFrontmatter(readFileSync(path, "utf8")).trim();
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
export function insertSkillBody(
|
|
415
|
+
ctx: any,
|
|
416
|
+
skillName: string,
|
|
417
|
+
body: string,
|
|
418
|
+
appendedText = "",
|
|
419
|
+
): void {
|
|
420
|
+
const chunk = appendedText.trim()
|
|
421
|
+
? `${body.trim()}\n\n${appendedText.trim()}`
|
|
422
|
+
: body.trim();
|
|
423
|
+
const current = ctx.ui.getEditorText?.() ?? "";
|
|
424
|
+
const separator = current && !current.endsWith("\n") ? "\n\n" : "";
|
|
425
|
+
ctx.ui.setEditorText(`${current}${separator}${chunk}\n`);
|
|
426
|
+
recordSkillUsage(skillName);
|
|
427
|
+
}
|
|
428
|
+
|
|
410
429
|
/** Compat-export: catalogus als ouderwets SkillInfo[] (manager v1 API). */
|
|
411
430
|
export function listSkills(): { name: string; description: string; path: string; source: string }[] {
|
|
412
431
|
return loadSkillCatalog().map((e) => ({
|
|
@@ -0,0 +1,267 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `/skills new` templates. No marketplace, no GitHub/npm install.
|
|
3
|
+
* Writes `~/.pi/agent/skills/<name>/SKILL.md` then opens $EDITOR.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import type { SelectItem } from "@earendil-works/pi-tui";
|
|
9
|
+
|
|
10
|
+
import { getAgentPath } from "../../paths/agent-dirs.ts";
|
|
11
|
+
import { showSelectOverlay } from "../ui/overlay-chrome.ts";
|
|
12
|
+
import { invalidateSkillCache } from "./skill-registry.ts";
|
|
13
|
+
|
|
14
|
+
export const SKILL_TEMPLATE_IDS = [
|
|
15
|
+
"standard",
|
|
16
|
+
"browser-workflow",
|
|
17
|
+
"cli-workflow",
|
|
18
|
+
"review-checklist",
|
|
19
|
+
] as const;
|
|
20
|
+
|
|
21
|
+
export type SkillTemplateId = (typeof SKILL_TEMPLATE_IDS)[number];
|
|
22
|
+
|
|
23
|
+
export class SkillNameError extends Error {
|
|
24
|
+
constructor(message: string) {
|
|
25
|
+
super(message);
|
|
26
|
+
this.name = "SkillNameError";
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const TEMPLATE_META: Record<
|
|
31
|
+
SkillTemplateId,
|
|
32
|
+
{ label: string; description: string }
|
|
33
|
+
> = {
|
|
34
|
+
standard: {
|
|
35
|
+
label: "standard",
|
|
36
|
+
description: "Name, description, and a short body",
|
|
37
|
+
},
|
|
38
|
+
"browser-workflow": {
|
|
39
|
+
label: "browser-workflow",
|
|
40
|
+
description: "UI verify with screenshots, no profile wipe",
|
|
41
|
+
},
|
|
42
|
+
"cli-workflow": {
|
|
43
|
+
label: "CLI-workflow",
|
|
44
|
+
description: "Command, flags, and expected output",
|
|
45
|
+
},
|
|
46
|
+
"review-checklist": {
|
|
47
|
+
label: "review-checklist",
|
|
48
|
+
description: "Diff review gates before merge",
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
|
|
52
|
+
export function isSkillTemplateId(value: string): value is SkillTemplateId {
|
|
53
|
+
return (SKILL_TEMPLATE_IDS as readonly string[]).includes(value);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sanitizeSkillName(raw: string): string {
|
|
57
|
+
const trimmed = raw.trim().toLowerCase().replace(/_/g, "-");
|
|
58
|
+
if (!trimmed) {
|
|
59
|
+
throw new SkillNameError("Skill name is required");
|
|
60
|
+
}
|
|
61
|
+
if (trimmed === "." || trimmed === ".." || trimmed.includes("..")) {
|
|
62
|
+
throw new SkillNameError("Skill name cannot contain path traversal");
|
|
63
|
+
}
|
|
64
|
+
if (trimmed.includes("/") || trimmed.includes("\\")) {
|
|
65
|
+
throw new SkillNameError("Skill name cannot contain path separators");
|
|
66
|
+
}
|
|
67
|
+
if (trimmed.length > 64) {
|
|
68
|
+
throw new SkillNameError("Skill name must be 64 characters or fewer");
|
|
69
|
+
}
|
|
70
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(trimmed)) {
|
|
71
|
+
throw new SkillNameError(
|
|
72
|
+
"Skill name must be lowercase letters, digits, and hyphens",
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return trimmed;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
export function parseSkillsNewArgs(args: string): {
|
|
79
|
+
name?: string;
|
|
80
|
+
template: SkillTemplateId;
|
|
81
|
+
} {
|
|
82
|
+
const parts = args.trim().split(/\s+/).filter(Boolean);
|
|
83
|
+
if (parts[0]?.toLowerCase() === "new") parts.shift();
|
|
84
|
+
if (parts.length === 0) return { template: "standard" };
|
|
85
|
+
if (parts.length === 1) {
|
|
86
|
+
const singleRaw = parts[0]!.toLowerCase();
|
|
87
|
+
if (isSkillTemplateId(singleRaw)) return { template: singleRaw };
|
|
88
|
+
return { name: parts[0], template: "standard" };
|
|
89
|
+
}
|
|
90
|
+
const templateRaw = parts[1]!.toLowerCase();
|
|
91
|
+
if (!isSkillTemplateId(templateRaw)) {
|
|
92
|
+
throw new SkillNameError(
|
|
93
|
+
`Unknown template '${parts[1]}'. Use: ${SKILL_TEMPLATE_IDS.join(", ")}`,
|
|
94
|
+
);
|
|
95
|
+
}
|
|
96
|
+
return { name: parts[0], template: templateRaw };
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
export function renderSkillTemplate(
|
|
100
|
+
id: SkillTemplateId,
|
|
101
|
+
name: string,
|
|
102
|
+
): string {
|
|
103
|
+
const title = name;
|
|
104
|
+
switch (id) {
|
|
105
|
+
case "standard":
|
|
106
|
+
return `---
|
|
107
|
+
name: ${title}
|
|
108
|
+
description: Short skill for ${title}. State when to use it in one sentence.
|
|
109
|
+
---
|
|
110
|
+
|
|
111
|
+
# ${title}
|
|
112
|
+
|
|
113
|
+
Use this skill when the operator asks for ${title}.
|
|
114
|
+
|
|
115
|
+
## Steps
|
|
116
|
+
|
|
117
|
+
1. Restate the goal in one line.
|
|
118
|
+
2. Do the work with the tools already in session.
|
|
119
|
+
3. Report what changed and how to verify it.
|
|
120
|
+
`;
|
|
121
|
+
case "browser-workflow":
|
|
122
|
+
return `---
|
|
123
|
+
name: ${title}
|
|
124
|
+
description: Browser UI verify for ${title}. Screenshot evidence, no profile wipe.
|
|
125
|
+
---
|
|
126
|
+
|
|
127
|
+
# ${title}
|
|
128
|
+
|
|
129
|
+
Use for visual checks in a real browser. Do not author layout here.
|
|
130
|
+
|
|
131
|
+
## Steps
|
|
132
|
+
|
|
133
|
+
1. Open the target URL in the existing session.
|
|
134
|
+
2. Snapshot the page, then act on stable refs.
|
|
135
|
+
3. Take a screenshot of the result.
|
|
136
|
+
4. Do not wipe profiles, cookies, or login state unless the operator asked.
|
|
137
|
+
`;
|
|
138
|
+
case "cli-workflow":
|
|
139
|
+
return `---
|
|
140
|
+
name: ${title}
|
|
141
|
+
description: CLI workflow for ${title}. Command, flags, and expected output.
|
|
142
|
+
---
|
|
143
|
+
|
|
144
|
+
# ${title}
|
|
145
|
+
|
|
146
|
+
Use when the work is a command-line tool or script.
|
|
147
|
+
|
|
148
|
+
## Steps
|
|
149
|
+
|
|
150
|
+
1. Name the binary and the exact invocation.
|
|
151
|
+
2. List required flags and inputs.
|
|
152
|
+
3. Run the command and capture exit code plus stdout/stderr.
|
|
153
|
+
4. State the expected output and how to rerun it.
|
|
154
|
+
`;
|
|
155
|
+
case "review-checklist":
|
|
156
|
+
return `---
|
|
157
|
+
name: ${title}
|
|
158
|
+
description: Review checklist for ${title}. Gates before merge, no rubber-stamp.
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
# ${title}
|
|
162
|
+
|
|
163
|
+
Use before marking a change merge-ready.
|
|
164
|
+
|
|
165
|
+
## Checklist
|
|
166
|
+
|
|
167
|
+
- [ ] Scope matches the request; no drive-by edits
|
|
168
|
+
- [ ] Tests cover the changed behavior
|
|
169
|
+
- [ ] No secrets in the diff
|
|
170
|
+
- [ ] Docs match the new surface
|
|
171
|
+
- [ ] Independent review is present; do not self-approve
|
|
172
|
+
`;
|
|
173
|
+
}
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function writeSkillFromTemplate(
|
|
177
|
+
name: string,
|
|
178
|
+
template: SkillTemplateId,
|
|
179
|
+
skillsRoot: string = getAgentPath("skills"),
|
|
180
|
+
): { filePath: string } {
|
|
181
|
+
const safe = sanitizeSkillName(name);
|
|
182
|
+
const dir = join(skillsRoot, safe);
|
|
183
|
+
const filePath = join(dir, "SKILL.md");
|
|
184
|
+
if (existsSync(filePath)) {
|
|
185
|
+
throw new SkillNameError(`Skill already exists: ${safe}`);
|
|
186
|
+
}
|
|
187
|
+
mkdirSync(dir, { recursive: true });
|
|
188
|
+
writeFileSync(filePath, renderSkillTemplate(template, safe), "utf8");
|
|
189
|
+
invalidateSkillCache();
|
|
190
|
+
return { filePath };
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
export function buildSkillTemplateItems(): SelectItem[] {
|
|
194
|
+
return SKILL_TEMPLATE_IDS.map((id) => ({
|
|
195
|
+
value: id,
|
|
196
|
+
label: TEMPLATE_META[id].label,
|
|
197
|
+
description: TEMPLATE_META[id].description,
|
|
198
|
+
}));
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function shellQuote(value: string): string {
|
|
202
|
+
return `'${value.replace(/'/g, `'"'"'`)}'`;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
export function editorCommandFor(path: string): string {
|
|
206
|
+
const ed = process.env.EDITOR?.trim() || "nvim";
|
|
207
|
+
return `!${ed} ${shellQuote(path)}`;
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
function appendEditorText(ctx: any, text: string): void {
|
|
211
|
+
const current = ctx.ui.getEditorText?.() ?? "";
|
|
212
|
+
const separator = current && !current.endsWith("\n") ? "\n" : "";
|
|
213
|
+
ctx.ui.setEditorText(`${current}${separator}${text}\n`);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
export async function pickSkillTemplate(
|
|
217
|
+
ctx: any,
|
|
218
|
+
): Promise<SkillTemplateId | null> {
|
|
219
|
+
const selected = await showSelectOverlay(
|
|
220
|
+
ctx,
|
|
221
|
+
"New skill template",
|
|
222
|
+
"↑↓ navigate • enter choose • esc cancel",
|
|
223
|
+
buildSkillTemplateItems(),
|
|
224
|
+
SKILL_TEMPLATE_IDS.length,
|
|
225
|
+
);
|
|
226
|
+
return selected && isSkillTemplateId(selected.value)
|
|
227
|
+
? selected.value
|
|
228
|
+
: null;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
export async function runSkillsNew(ctx: any, args: string): Promise<void> {
|
|
232
|
+
let parsed: { name?: string; template: SkillTemplateId };
|
|
233
|
+
try {
|
|
234
|
+
parsed = parseSkillsNewArgs(args);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
ctx.ui.notify(
|
|
237
|
+
error instanceof Error ? error.message : String(error),
|
|
238
|
+
"error",
|
|
239
|
+
);
|
|
240
|
+
return;
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
let template = parsed.template;
|
|
244
|
+
let name = parsed.name;
|
|
245
|
+
if (!name) {
|
|
246
|
+
const picked = await pickSkillTemplate(ctx);
|
|
247
|
+
if (!picked) return;
|
|
248
|
+
template = picked;
|
|
249
|
+
appendEditorText(ctx, `/skills new <name> ${template}`);
|
|
250
|
+
ctx.ui.notify(
|
|
251
|
+
`Replace <name> and run to create a ${template} skill.`,
|
|
252
|
+
"info",
|
|
253
|
+
);
|
|
254
|
+
return;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
try {
|
|
258
|
+
const { filePath } = writeSkillFromTemplate(name, template);
|
|
259
|
+
appendEditorText(ctx, editorCommandFor(filePath));
|
|
260
|
+
ctx.ui.notify(`Created ${name} (${template}). Enter runs the editor.`, "info");
|
|
261
|
+
} catch (error) {
|
|
262
|
+
ctx.ui.notify(
|
|
263
|
+
error instanceof Error ? error.message : String(error),
|
|
264
|
+
"error",
|
|
265
|
+
);
|
|
266
|
+
}
|
|
267
|
+
}
|
|
@@ -9,6 +9,7 @@ import { estimateInitialContextTokens } from "../../usage/context.ts";
|
|
|
9
9
|
import { isRecord } from "../settings/settings-io.ts";
|
|
10
10
|
import type { RuntimeState } from "../core/types.ts";
|
|
11
11
|
import { getQueueContext } from "../queue/queue-context.ts";
|
|
12
|
+
import { pickNextReviewIdea } from "../queue/idea-review.ts";
|
|
12
13
|
|
|
13
14
|
export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
|
|
14
15
|
const modelName = ctx.model?.name || ctx.model?.id || "No model";
|
|
@@ -20,6 +21,9 @@ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
|
|
|
20
21
|
const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
|
|
21
22
|
const hasStash =
|
|
22
23
|
rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
|
|
24
|
+
const nextIdeaText = pickNextReviewIdea(
|
|
25
|
+
rt.queueStore.activeItems(getQueueContext(ctx)),
|
|
26
|
+
)?.text;
|
|
23
27
|
const whatsNew = discoverWhatsNew();
|
|
24
28
|
|
|
25
29
|
const header = new WelcomeHeader(
|
|
@@ -31,6 +35,7 @@ export function setupWelcomeHeader(rt: RuntimeState, ctx: any) {
|
|
|
31
35
|
queueCount,
|
|
32
36
|
hasStash,
|
|
33
37
|
whatsNew,
|
|
38
|
+
nextIdeaText,
|
|
34
39
|
);
|
|
35
40
|
rt.welcomeHeaderActive = true;
|
|
36
41
|
|
|
@@ -86,6 +91,9 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
|
|
|
86
91
|
const queueCount = queueSummary.queueCount + queueSummary.ideaCount;
|
|
87
92
|
const hasStash =
|
|
88
93
|
rt.stashedEditorText !== null || rt.stashedPromptHistory.length > 0;
|
|
94
|
+
const nextIdeaText = pickNextReviewIdea(
|
|
95
|
+
rt.queueStore.activeItems(getQueueContext(ctx)),
|
|
96
|
+
)?.text;
|
|
89
97
|
const whatsNew = discoverWhatsNew();
|
|
90
98
|
|
|
91
99
|
ctx.ui
|
|
@@ -105,6 +113,7 @@ export function setupWelcomeOverlay(rt: RuntimeState, ctx: any) {
|
|
|
105
113
|
queueCount,
|
|
106
114
|
hasStash,
|
|
107
115
|
whatsNew,
|
|
116
|
+
nextIdeaText,
|
|
108
117
|
);
|
|
109
118
|
|
|
110
119
|
let countdown = 30;
|
package/src/welcome/banner.ts
CHANGED
|
@@ -24,6 +24,7 @@ export class WelcomeHeader implements Component {
|
|
|
24
24
|
queueCount?: number,
|
|
25
25
|
hasStash?: boolean,
|
|
26
26
|
whatsNew?: string[],
|
|
27
|
+
nextIdeaText?: string,
|
|
27
28
|
) {
|
|
28
29
|
this.data = {
|
|
29
30
|
modelName,
|
|
@@ -34,6 +35,7 @@ export class WelcomeHeader implements Component {
|
|
|
34
35
|
queueCount,
|
|
35
36
|
hasStash,
|
|
36
37
|
whatsNew,
|
|
38
|
+
nextIdeaText,
|
|
37
39
|
};
|
|
38
40
|
}
|
|
39
41
|
|
package/src/welcome/overlay.ts
CHANGED
|
@@ -30,6 +30,7 @@ export class WelcomeComponent implements Component {
|
|
|
30
30
|
queueCount?: number,
|
|
31
31
|
hasStash?: boolean,
|
|
32
32
|
whatsNew?: string[],
|
|
33
|
+
nextIdeaText?: string,
|
|
33
34
|
) {
|
|
34
35
|
this.data = {
|
|
35
36
|
modelName,
|
|
@@ -40,6 +41,7 @@ export class WelcomeComponent implements Component {
|
|
|
40
41
|
queueCount,
|
|
41
42
|
hasStash,
|
|
42
43
|
whatsNew,
|
|
44
|
+
nextIdeaText,
|
|
43
45
|
};
|
|
44
46
|
}
|
|
45
47
|
|
package/src/welcome/types.ts
CHANGED
|
@@ -5,9 +5,19 @@ export const QueueWidget: WelcomeWidget = {
|
|
|
5
5
|
render(ctx: WidgetRenderContext): string[] {
|
|
6
6
|
const { data, dim, color } = ctx;
|
|
7
7
|
const lines: string[] = [];
|
|
8
|
-
|
|
8
|
+
|
|
9
9
|
const prefix = dim("- ");
|
|
10
|
-
|
|
10
|
+
const idea = data.nextIdeaText?.trim();
|
|
11
|
+
if (idea) {
|
|
12
|
+
const singleLine = idea.replace(/\s+/g, " ");
|
|
13
|
+
const budget = Math.max(1, ctx.width - prefix.length - 3 - "/ideas next".length - 2);
|
|
14
|
+
const preview = singleLine.length > budget
|
|
15
|
+
? `${singleLine.slice(0, Math.max(0, budget - 1))}…`
|
|
16
|
+
: singleLine;
|
|
17
|
+
lines.push(
|
|
18
|
+
` ${prefix}${color("gitClean", preview)} · ${color("model", "/ideas next")}`,
|
|
19
|
+
);
|
|
20
|
+
} else if (data.queueCount && data.queueCount > 0) {
|
|
11
21
|
lines.push(` ${prefix}${color("gitClean", `${data.queueCount}`)} queued items ready`);
|
|
12
22
|
} else {
|
|
13
23
|
lines.push(` ${prefix}type ${color("model", "# <idea>")} to capture a thought`);
|
|
@@ -20,7 +30,7 @@ export const QueueWidget: WelcomeWidget = {
|
|
|
20
30
|
}
|
|
21
31
|
|
|
22
32
|
lines.push(` ${prefix}${dim("dreaming & mission queue ready")}`);
|
|
23
|
-
|
|
33
|
+
|
|
24
34
|
return lines;
|
|
25
35
|
}
|
|
26
36
|
};
|