@danypops/papyrus 0.21.6 → 0.23.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/README.md +12 -6
- package/extension/src/artifact-status-presentation.ts +5 -0
- package/extension/src/discussion-picker.ts +9 -4
- package/extension/src/docs.ts +10 -1
- package/extension/src/domain-tools.ts +88 -16
- package/extension/src/index.ts +8 -1
- package/extension/src/playbook-bridge.ts +90 -0
- package/extension/src/playbooks.ts +62 -0
- package/extension/src/rules.ts +9 -2
- package/extension/src/skills.ts +9 -1
- package/package.json +1 -1
- package/src/authority-registry.ts +1 -1
- package/src/cli.ts +140 -3
- package/src/constants.ts +7 -1
- package/src/db.ts +20 -0
- package/src/domain/artifact.ts +24 -0
- package/src/domain-services.ts +181 -4
- package/src/modules/docs.ts +5 -2
- package/src/modules/playbooks.ts +77 -0
- package/src/modules/rules.ts +5 -2
- package/src/modules/skills.ts +5 -2
- package/src/service.ts +15 -0
package/README.md
CHANGED
|
@@ -119,9 +119,10 @@ Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
|
119
119
|
|
|
120
120
|
- **`tasks`** — create/update/list/show/plan, manage the singleton active focus, replace evidence-bearing checklists, hierarchy/dependencies, lifecycle transitions, non-blocking gates, and review completion that focuses one deterministic ready successor without claiming effort
|
|
121
121
|
- **`notes`** — capture/list/show deferred human intent, mark it consumed, promote it to an existing Task/Doc/Rule/Skill, or archive it with an explicit disposition
|
|
122
|
-
- **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
|
|
123
|
-
- **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
124
|
-
- **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
|
|
122
|
+
- **`docs`** — create/update/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
|
|
123
|
+
- **`rules`** — create/update/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
124
|
+
- **`skills`** — create/update/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
|
|
125
|
+
- **`playbooks`** — a completely different beast from Skills, not a subtype: a trigger and an ordered list of steps an agent reads and follows, never mechanically instantiated and never composed the way Skills call other Skills. create/update/list/show/invoke, enable/disable
|
|
125
126
|
|
|
126
127
|
Every tool operation is registered in the daemon’s `/api/v1/ops` registry; parity is verified in tests. The task consumer uses the `tasks.graph` operation, which returns task nodes with explicit parent, child, and dependency IDs rather than leaking SQLite rows or asking the UI to reconstruct relationships.
|
|
127
128
|
|
|
@@ -131,14 +132,19 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
|
|
|
131
132
|
|
|
132
133
|
Every agent domain tool (tasks, docs, rules, skills, notes, discuss) addresses its artifacts by `name` (the exact title) wherever `id` would otherwise be required -- `dependency_name`/`parent_name`/`child_name`/`root_task_name`/`depends_on_names` (tasks), `target_name` (docs link, searches every kind since a link target can be any of them), `task_name` (rules gate, discuss block/unblock), `template_name` (skills instantiate), and `blocks_task_names` (discuss open) are the name-based equivalents of their `*_id` counterparts. Resolution is an exact, case-insensitive, trimmed title match scoped like a plain list call; an unmatched or ambiguous name fails with a clear error (ambiguous names list the real ids, since that's the one point disambiguation genuinely needs them). Results returned to the agent likewise lead with name and status, never id, unless two artifacts in the same result share a title -- id is a backend implementation detail, not a conversational handle. `id` itself still works exactly as before for every action, in every tool.
|
|
133
134
|
|
|
135
|
+
### Mutability
|
|
136
|
+
|
|
137
|
+
Tasks, Docs, Rules, and Skills all support first-class `update` (title/body/labels, at least one required) alongside creation -- a Doc's body is no longer immutable once created. Every update is bounded the same way creation is (Rules keep their own stricter combined condition+action+body ceiling; Docs/Skills share Tasks' own length bounds) and recorded on the artifact's append-only mutation history, queryable via `graph.history`. An artifact carrying a `source:<system>` label (e.g. `source:web-spider` on an ingested page) is a read-only projection from a system Papyrus doesn't own the source of; updating one is refused with a clear error rather than silently forking it -- capture a correction as a new linked Doc instead until a write-back capability to that system exists. Notes stay behind their own facade for any content change, same as every other Notes mutation.
|
|
138
|
+
|
|
134
139
|
## Interactive frontends
|
|
135
140
|
|
|
136
141
|
- `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
|
|
137
142
|
- `/note <request>` — directly capture one project-scoped deferred request without creating a Task
|
|
138
143
|
- `/notes` — searchable project Notes inbox with consume, promote, and disposition-aware archive actions
|
|
139
|
-
- `/docs` — searchable non-Note documents, lifecycle, details, and graph links
|
|
140
|
-
- `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
|
|
141
|
-
- `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
|
|
144
|
+
- `/docs` — searchable non-Note documents, lifecycle, details, edit, and graph links
|
|
145
|
+
- `/rules` — severity/condition rows, exact injection preview, edit, enable/disable, and task gating
|
|
146
|
+
- `/skills` — trigger/tools rows, edit, invocation into the editor, and artifact templates
|
|
147
|
+
- `/playbooks` — trigger/tools rows, edit, invocation into the editor, and graph links
|
|
142
148
|
|
|
143
149
|
All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. User-authored bodies render as width-aware Markdown with headings, emphasis, links, quotes, lists, tables, inline/fenced code, syntax highlighting, and every color/decorative style derived dynamically from the active Pi theme. Generated lifecycle, metadata, checklist, gate, history, and relationship sections keep explicit semantic theme colors. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable source text.
|
|
144
150
|
|
|
@@ -36,6 +36,11 @@ export const SKILL_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
|
36
36
|
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
37
37
|
};
|
|
38
38
|
|
|
39
|
+
export const PLAYBOOK_STATUS_PRESENTATION: Record<string, StatusPresentation> = {
|
|
40
|
+
active: { label: "active", glyph: "●", color: "success" },
|
|
41
|
+
deprecated: { label: "deprecated", glyph: "○", color: "muted" },
|
|
42
|
+
};
|
|
43
|
+
|
|
39
44
|
/**
|
|
40
45
|
* Keyed by extra.discussion.state, not the shared Doc status column -- a settled Discussion's
|
|
41
46
|
* doc.status becomes "archived", but a deferred one stays "active" at the doc level (see
|
|
@@ -6,12 +6,12 @@
|
|
|
6
6
|
* anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so this is a small,
|
|
7
7
|
* genuinely domain-specific checkbox-list component, not a generic library replacement.
|
|
8
8
|
*/
|
|
9
|
-
import type {
|
|
9
|
+
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
10
|
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
11
11
|
import type { DiscussionOptionsMode } from "../../src/domain/discussion.ts";
|
|
12
12
|
|
|
13
13
|
/** Toggle with space, confirm with enter (refuses an empty confirm -- at least one pick is required), cancel with escape. */
|
|
14
|
-
async function pickMultiple(ctx:
|
|
14
|
+
async function pickMultiple(ctx: ExtensionContext, title: string, options: string[]): Promise<string[] | undefined> {
|
|
15
15
|
return ctx.ui.custom<string[] | undefined>((tui, theme, _keybindings, done) => {
|
|
16
16
|
const checked = new Set<number>();
|
|
17
17
|
let selectedIndex = 0;
|
|
@@ -49,8 +49,13 @@ async function pickMultiple(ctx: ExtensionCommandContext, title: string, options
|
|
|
49
49
|
});
|
|
50
50
|
}
|
|
51
51
|
|
|
52
|
-
/**
|
|
53
|
-
|
|
52
|
+
/**
|
|
53
|
+
* Picks one (single) or several (multi) of the given options, or undefined if the user cancels.
|
|
54
|
+
* Takes the base ExtensionContext (just .ui) rather than the wider ExtensionCommandContext, since
|
|
55
|
+
* a tool's execute() only ever receives the former -- the discuss tool's own live mode reuses
|
|
56
|
+
* this same picker, not just the /discuss TUI panel.
|
|
57
|
+
*/
|
|
58
|
+
export async function pickDiscussionOptions(ctx: ExtensionContext, mode: DiscussionOptionsMode, options: string[]): Promise<string[] | undefined> {
|
|
54
59
|
if (mode === "single") {
|
|
55
60
|
const pick = await ctx.ui.select("Pick one:", options);
|
|
56
61
|
return pick ? [pick] : undefined;
|
package/extension/src/docs.ts
CHANGED
|
@@ -24,12 +24,21 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
24
24
|
statusOrder: ["draft", "active", "archived"],
|
|
25
25
|
presentation: DOC_STATUS_PRESENTATION,
|
|
26
26
|
rowMeta: documentRowMeta,
|
|
27
|
-
actions: (document) => ["Show details", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
|
|
27
|
+
actions: (document) => ["Show details", "Edit", "Link artifact", ...(DOC_ACTIONS[document.status] ?? [])],
|
|
28
28
|
handleAction: async (choice, document, commandCtx) => {
|
|
29
29
|
if (choice === "Show details") {
|
|
30
30
|
await showArtifactDetails(commandCtx, document.id, "docs.show");
|
|
31
31
|
return;
|
|
32
32
|
}
|
|
33
|
+
if (choice === "Edit") {
|
|
34
|
+
const title = await commandCtx.ui.input("Title:", document.title);
|
|
35
|
+
if (title === undefined) return; // canceled
|
|
36
|
+
const body = await commandCtx.ui.input("Body:", document.body);
|
|
37
|
+
if (body === undefined) return; // canceled
|
|
38
|
+
const updated = await callService<Record<string, unknown>, Artifact>("docs.update", { id: document.id, title, body });
|
|
39
|
+
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
33
42
|
if (choice === "Link artifact") {
|
|
34
43
|
const targetId = await commandCtx.ui.input("Target artifact id:", "");
|
|
35
44
|
if (!targetId) return;
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
1
|
+
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
2
2
|
import { Type } from "typebox";
|
|
3
3
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
4
4
|
import { PROOF_TYPES } from "../../src/domain/checklist.ts";
|
|
@@ -8,7 +8,8 @@ import type { TaskHistoryPage } from "../../src/domain/task-event.ts";
|
|
|
8
8
|
import type { TaskCompletion, TaskGraph } from "../../src/task-service.ts";
|
|
9
9
|
import type { SkillWorkflowRunResult } from "../../src/skill-execution.ts";
|
|
10
10
|
import type { DiscussionAndRounds } from "../../src/discussion-service.ts";
|
|
11
|
-
import type
|
|
11
|
+
import { readDiscussionExtra, type DiscussionRound } from "../../src/domain/discussion.ts";
|
|
12
|
+
import { pickDiscussionOptions } from "./discussion-picker.ts";
|
|
12
13
|
import type { OperationName } from "../../src/service.ts";
|
|
13
14
|
import { emitTaskFocusEvent } from "./task-focus-events.ts";
|
|
14
15
|
import { sessionSecretField } from "./session-identity.ts";
|
|
@@ -30,6 +31,24 @@ function text(message: string, details: unknown = {}) {
|
|
|
30
31
|
return { content: [{ type: "text" as const, text: modelContent.text }], details };
|
|
31
32
|
}
|
|
32
33
|
|
|
34
|
+
/**
|
|
35
|
+
* live:true's synchronous half: renders the same picker /discuss's own "Reply" action uses when
|
|
36
|
+
* the just-created round posed a structured choice, or a plain freeform prompt otherwise -- so
|
|
37
|
+
* "ask" covers both a completely open question and a choice tied to this specific Discussion.
|
|
38
|
+
* Returns undefined on cancel or when no interactive UI is available, never throws -- an
|
|
39
|
+
* unanswered live prompt still leaves the round it already recorded intact.
|
|
40
|
+
*/
|
|
41
|
+
async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<{ content: string; selected?: string[] } | undefined> {
|
|
42
|
+
if (!ctx.hasUI) return undefined;
|
|
43
|
+
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
44
|
+
if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
|
|
45
|
+
const selected = await pickDiscussionOptions(ctx, pending.pendingOptionsMode, pending.pendingOptions);
|
|
46
|
+
return selected ? { content: selected.join(", "), selected } : undefined;
|
|
47
|
+
}
|
|
48
|
+
const content = await ctx.ui.input(`Reply to "${discussion.title}":`, "");
|
|
49
|
+
return content ? { content } : undefined;
|
|
50
|
+
}
|
|
51
|
+
|
|
33
52
|
/**
|
|
34
53
|
* Every domain tool's primary interfacing point is an artifact's NAME, not its id -- id is a
|
|
35
54
|
* backend implementation detail (a stable key other operations need, and titles aren't
|
|
@@ -397,7 +416,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
397
416
|
pi.registerTool({
|
|
398
417
|
name: "docs",
|
|
399
418
|
label: "Documents",
|
|
400
|
-
description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
|
|
419
|
+
description: "Document domain tool. ACTIONS: create, list, show, activate, archive, reopen, link, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection (e.g. web-spider-ingested Docs) -- capture a correction as a new linked Doc instead. remove moves a Doc to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the doc's exact title) over `id`, and `target_name` over `target_id` for link -- both are backend implementation details, resolved from name automatically (target_name searches across every kind, since a link target can be a doc, task, rule, or skill). Prefer this over low-level papyrus_* tools for document work.",
|
|
401
420
|
parameters: Type.Object({
|
|
402
421
|
action: Type.String(),
|
|
403
422
|
id: Type.Optional(Type.String()),
|
|
@@ -443,7 +462,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
443
462
|
}
|
|
444
463
|
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
445
464
|
if (trashResult) return trashResult;
|
|
446
|
-
const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project" } as const;
|
|
465
|
+
const operations = { activate: "docs.activate", archive: "docs.archive", reopen: "docs.reopen", link: "docs.link", assign_project: "docs.assign_project", update: "docs.update" } as const;
|
|
447
466
|
const operation = operations[action as keyof typeof operations];
|
|
448
467
|
if (!operation) throw new Error(`unknown docs action: ${action}`);
|
|
449
468
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
@@ -457,7 +476,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
457
476
|
pi.registerTool({
|
|
458
477
|
name: "rules",
|
|
459
478
|
label: "Rules",
|
|
460
|
-
description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
|
|
479
|
+
description: "Rule domain tool. ACTIONS: create, list, show, preview, enable, disable, gate, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. Active rules inject into the agent system prompt. update changes title/body/labels (at least one required); body updates still enforce the same combined condition+action+body context-tax bound as creation, and are refused for a read-only external projection. remove moves a Rule to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the rule's exact title) over `id`, and `task_name` over `task_id` for gate -- both are backend implementation details, resolved from name automatically.",
|
|
461
480
|
parameters: Type.Object({
|
|
462
481
|
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
463
482
|
body: Type.Optional(Type.String()), condition: Type.Optional(Type.String()), rule_action: Type.Optional(Type.String()),
|
|
@@ -491,7 +510,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
491
510
|
}
|
|
492
511
|
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
493
512
|
if (trashResult) return trashResult;
|
|
494
|
-
const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project" } as const;
|
|
513
|
+
const operations = { show: "rules.show", enable: "rules.enable", disable: "rules.disable", gate: "rules.gate", assign_project: "rules.assign_project", update: "rules.update" } as const;
|
|
495
514
|
const operation = operations[action as keyof typeof operations];
|
|
496
515
|
if (!operation) throw new Error(`unknown rules action: ${action}`);
|
|
497
516
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
@@ -502,10 +521,56 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
502
521
|
},
|
|
503
522
|
});
|
|
504
523
|
|
|
524
|
+
pi.registerTool({
|
|
525
|
+
name: "playbooks",
|
|
526
|
+
label: "Playbooks",
|
|
527
|
+
description: "Playbook domain tool -- a completely different beast from the skills tool, not a subtype of it. A Playbook is a trigger and an ordered list of steps an agent reads and follows; it is never mechanically instantiated the way a Skill's artifact-template or workflow blueprint is, and it never composes other Playbooks. ACTIONS: create, list, show, invoke, enable, disable, assign_project, update, remove, restore. project_root is optional at creation (omitted = unscoped); assign_project reassigns it later, or unscopes when project_root is omitted. invoke renders the trigger/steps/tools into guidance plus any real linked artifacts. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Playbook to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the playbook's exact title) over `id` -- id is a backend implementation detail, resolved from name automatically.",
|
|
528
|
+
parameters: Type.Object({
|
|
529
|
+
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
530
|
+
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
531
|
+
tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
|
|
532
|
+
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
533
|
+
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()),
|
|
534
|
+
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
535
|
+
}),
|
|
536
|
+
renderCall(args, theme) { return renderPapyrusToolCall("Playbooks", args, theme); },
|
|
537
|
+
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
538
|
+
async execute(_id, rawParams) {
|
|
539
|
+
try {
|
|
540
|
+
const params: Record<string, unknown> = { ...rawParams };
|
|
541
|
+
const action = params.action;
|
|
542
|
+
await resolveNameFields(params, [
|
|
543
|
+
{ nameKey: "name", idKey: "id", listOperation: "playbooks.list", baseRequest: { project_root: params.project_root } },
|
|
544
|
+
]);
|
|
545
|
+
if (action === "create") {
|
|
546
|
+
const artifact = await callService<Record<string, unknown>, Artifact>("playbooks.create", params);
|
|
547
|
+
return text(`Created playbook ${artifactLine(artifact)}`, createArtifactDetails("playbooks.create", artifact));
|
|
548
|
+
}
|
|
549
|
+
if (action === "list") {
|
|
550
|
+
const rows = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", params);
|
|
551
|
+
return text(rows.length ? artifactLines(rows).join("\n") : "No playbooks found.", createArtifactListDetails("playbooks.list", rows));
|
|
552
|
+
}
|
|
553
|
+
if (action === "invoke") {
|
|
554
|
+
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", params);
|
|
555
|
+
return text(invocation, createPreviewDetails("playbooks.invoke", "Playbook invocation", invocation));
|
|
556
|
+
}
|
|
557
|
+
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
558
|
+
if (trashResult) return trashResult;
|
|
559
|
+
const operations = { show: "playbooks.show", enable: "playbooks.enable", disable: "playbooks.disable", assign_project: "playbooks.assign_project", update: "playbooks.update" } as const;
|
|
560
|
+
const operation = operations[action as keyof typeof operations];
|
|
561
|
+
if (!operation) throw new Error(`unknown playbooks action: ${action}`);
|
|
562
|
+
const artifact = await callService<Record<string, unknown>, Artifact>(operation, params);
|
|
563
|
+
return text(`${artifactLine(artifact)}${action === "show" ? `\n\n${artifact.body}` : ""}`, createArtifactDetails(operation, artifact));
|
|
564
|
+
} catch (error) {
|
|
565
|
+
throw new Error(`playbooks failed: ${error instanceof Error ? error.message : error}`);
|
|
566
|
+
}
|
|
567
|
+
},
|
|
568
|
+
});
|
|
569
|
+
|
|
505
570
|
pi.registerTool({
|
|
506
571
|
name: "skills",
|
|
507
572
|
label: "Skills",
|
|
508
|
-
description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
|
|
573
|
+
description: "Papyrus Skill workflow and compatibility-template domain tool. Papyrus Skills are parameterized Task/Rule/Doc bundles, distinct from prompt-only skills. ACTIONS: create, create_template, list, show, invoke, run, enable, disable, instantiate, assign_project, update, remove, restore. run validates arguments and atomically creates one scoped workflow run. project_root is optional at creation (omitted = unscoped) for create/create_template; assign_project reassigns it later, or unscopes when project_root is omitted. update changes title/body/labels (at least one required) and is refused for a read-only external projection. remove moves a Skill to a time-gated trash, excluded from list/query but still directly showable, restorable via restore until the purge deadline. PREFER `name` (the skill's exact title) over `id`, and `template_name` over `template_id` for instantiate -- both are backend implementation details, resolved from name automatically.",
|
|
509
574
|
parameters: Type.Object({
|
|
510
575
|
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
511
576
|
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
@@ -570,7 +635,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
570
635
|
}
|
|
571
636
|
const trashResult = await handleArtifactRemoveRestore(action, params);
|
|
572
637
|
if (trashResult) return trashResult;
|
|
573
|
-
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project" } as const;
|
|
638
|
+
const operations = { show: "skills.show", enable: "skills.enable", disable: "skills.disable", instantiate: "skills.instantiate", assign_project: "skills.assign_project", update: "skills.update" } as const;
|
|
574
639
|
const operation = operations[action as keyof typeof operations];
|
|
575
640
|
if (!operation) throw new Error(`unknown skills action: ${action}`);
|
|
576
641
|
const artifact = await callService<Record<string, unknown>, Artifact>(operation, action === "instantiate" ? request : params);
|
|
@@ -584,7 +649,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
584
649
|
pi.registerTool({
|
|
585
650
|
name: "discuss",
|
|
586
651
|
label: "Discuss",
|
|
587
|
-
description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
|
|
652
|
+
description: "Native Papyrus deliberation with a real lifecycle -- distinct from a one-shot ask: a Discussion persists, takes multiple rounds, and can genuinely block a Task's completion until settled or deferred. ACTIONS: open, reply, defer, resume, settle, block, unblock, show, rounds, list. open starts round 1 and optionally blocks_task_ids immediately. reply is refused once deferred or settled -- resume first. defer is explicitly non-blocking (paused, resumable); settle is terminal and archives the discussion. block/unblock manage the blocking relationship to a task independently of open. A task's completion is refused while any active Discussion blocks it. open/reply can pose a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several); reply answers a currently pending choice via selected, validated against it. Pass live:true on open or reply to get the human's answer synchronously in this same call, via an interactive prompt (the pending choice's picker if one was posed, otherwise a freeform question) -- covers a completely open question with no artifact (open with no prior discussion) and a question tied to a specific existing artifact (reply, addressed by name) alike. Only takes effect with an interactive UI available; otherwise degrades silently to the normal async round. PREFER `name` (the discussion's exact title) over `id`, `task_name`/`blocks_task_names` over `task_id`/`blocks_task_ids` -- all are backend implementation details, resolved from name automatically.",
|
|
588
653
|
parameters: Type.Object({
|
|
589
654
|
action: Type.String(),
|
|
590
655
|
id: Type.Optional(Type.String()),
|
|
@@ -606,6 +671,7 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
606
671
|
options: Type.Optional(Type.Array(Type.String())),
|
|
607
672
|
options_mode: Type.Optional(Type.String()),
|
|
608
673
|
selected: Type.Optional(Type.Array(Type.String())),
|
|
674
|
+
live: Type.Optional(Type.Boolean()),
|
|
609
675
|
}),
|
|
610
676
|
renderCall(args, theme) { return renderPapyrusToolCall("Discuss", args, theme); },
|
|
611
677
|
renderResult(result, options, theme, context) { return renderPapyrusToolResult(result, options, theme, context); },
|
|
@@ -619,13 +685,19 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
619
685
|
{ nameKey: "task_name", idKey: "task_id", listOperation: "tasks.list", baseRequest: taskScope },
|
|
620
686
|
]);
|
|
621
687
|
await resolveNameArrayField(params, "blocks_task_names", "blocks_task_ids", "tasks.list", taskScope);
|
|
622
|
-
if (action === "open") {
|
|
623
|
-
const
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
688
|
+
if (action === "open" || action === "reply") {
|
|
689
|
+
const operation = action === "open" ? "discuss.open" : "discuss.reply";
|
|
690
|
+
const result = await callService<Record<string, unknown>, DiscussionAndRounds>(operation, params);
|
|
691
|
+
const fallback = action === "open"
|
|
692
|
+
? text(`Opened discussion ${artifactLine(result.discussion)}`, createArtifactDetails("discuss.open", result.discussion))
|
|
693
|
+
: text(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`, createArtifactDetails("discuss.reply", result.discussion));
|
|
694
|
+
if (params.live !== true) return fallback;
|
|
695
|
+
const answer = await liveAnswer(ctx, result.discussion);
|
|
696
|
+
if (!answer) return fallback;
|
|
697
|
+
const answered = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
|
|
698
|
+
id: result.discussion.id, actor: "human", content: answer.content, ...(answer.selected ? { selected: answer.selected } : {}), source: "discuss-live",
|
|
699
|
+
});
|
|
700
|
+
return text(`"${answered.discussion.title}": ${answer.content}`, createArtifactDetails("discuss.reply", answered.discussion));
|
|
629
701
|
}
|
|
630
702
|
if (action === "block" || action === "unblock") {
|
|
631
703
|
const operation = action === "block" ? "discuss.block" : "discuss.unblock";
|
package/extension/src/index.ts
CHANGED
|
@@ -22,6 +22,7 @@ import type { GateResult } from "../../src/domain/gate.ts";
|
|
|
22
22
|
import { formatMetadata } from "./artifact-format.ts";
|
|
23
23
|
import { callService } from "./service-client.ts";
|
|
24
24
|
import { registerDomainTools } from "./domain-tools.ts";
|
|
25
|
+
import { registerPlaybookBridge } from "./playbook-bridge.ts";
|
|
25
26
|
import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
|
|
26
27
|
import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
|
|
27
28
|
import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
|
|
@@ -159,6 +160,7 @@ class TaskOverlay {
|
|
|
159
160
|
export default async function (pi: ExtensionAPI) {
|
|
160
161
|
setTaskFocusEventBus(pi);
|
|
161
162
|
registerDomainTools(pi);
|
|
163
|
+
registerPlaybookBridge(pi);
|
|
162
164
|
let contextInjectionSequence = 0;
|
|
163
165
|
const contextInjectionProducerId = randomUUID();
|
|
164
166
|
let previousContextInjectionFingerprint: string | undefined;
|
|
@@ -416,12 +418,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
416
418
|
// ── Interactive artifact browsers ──────────────────────────────────
|
|
417
419
|
|
|
418
420
|
// Lazy imports keep TUI components out of non-interactive startup paths.
|
|
419
|
-
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, discussModule] = await Promise.all([
|
|
421
|
+
const [tasksModule, docsModule, notesModule, rulesModule, skillsModule, playbooksModule, discussModule] = await Promise.all([
|
|
420
422
|
import("./tasks.ts"),
|
|
421
423
|
import("./docs.ts"),
|
|
422
424
|
import("./notes.ts"),
|
|
423
425
|
import("./rules.ts"),
|
|
424
426
|
import("./skills.ts"),
|
|
427
|
+
import("./playbooks.ts"),
|
|
425
428
|
import("./discuss.ts"),
|
|
426
429
|
]);
|
|
427
430
|
let overlay: TaskOverlay | undefined;
|
|
@@ -455,6 +458,10 @@ export default async function (pi: ExtensionAPI) {
|
|
|
455
458
|
description: "Browse and invoke Papyrus skills and templates (interactive)",
|
|
456
459
|
handler: async (_args, ctx) => { await skillsModule.showSkills(ctx); },
|
|
457
460
|
});
|
|
461
|
+
pi.registerCommand("playbooks", {
|
|
462
|
+
description: "Browse, edit, and invoke Papyrus playbooks -- trigger/steps guidance an agent reads and follows (interactive)",
|
|
463
|
+
handler: async (_args, ctx) => { await playbooksModule.showPlaybooks(ctx); },
|
|
464
|
+
});
|
|
458
465
|
pi.registerCommand("discuss", {
|
|
459
466
|
description: "Browse Papyrus Discussions and reply, defer, resume, settle, or block/unblock a task (interactive)",
|
|
460
467
|
handler: async (_args, ctx) => { await discussModule.showDiscussions(ctx); },
|
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* playbook-bridge.ts — materializes active Papyrus Playbooks as real SKILL.md files so they
|
|
3
|
+
* show up in Pi's own native skill catalog and become /skill:name-invocable, through Pi's
|
|
4
|
+
* unmodified, documented resources_discover mechanism (no Pi source touched).
|
|
5
|
+
*
|
|
6
|
+
* Playbooks live in SQLite, not on disk, so they can't satisfy Pi's skill-loading pipeline
|
|
7
|
+
* directly (Skill.filePath is required there). This bridges the gap the other direction:
|
|
8
|
+
* on every resources_discover (session start and /reload), wipe and rebuild a cache directory
|
|
9
|
+
* from the current playbooks.list, so a disabled/removed/renamed Playbook's stale file is never
|
|
10
|
+
* served. Any failure degrades to "no extra skills this cycle" -- a Papyrus daemon hiccup must
|
|
11
|
+
* never break Pi's own resource discovery.
|
|
12
|
+
*/
|
|
13
|
+
import { existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { homedir } from "node:os";
|
|
15
|
+
import { join } from "node:path";
|
|
16
|
+
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
|
17
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
18
|
+
import { callService } from "./service-client.ts";
|
|
19
|
+
|
|
20
|
+
const PLAYBOOK_BRIDGE_MAX_PLAYBOOKS = 100;
|
|
21
|
+
const PLAYBOOK_BRIDGE_DESCRIPTION_MAX_CHARACTERS = 1000;
|
|
22
|
+
|
|
23
|
+
function slugify(title: string): string {
|
|
24
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 64);
|
|
25
|
+
return slug.length > 0 ? slug : "playbook";
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function playbookCacheDir(): string {
|
|
29
|
+
const base = process.env["XDG_CACHE_HOME"] ?? join(homedir(), ".cache");
|
|
30
|
+
return join(base, "papyrus", "playbooks");
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function stringList(value: unknown): string[] {
|
|
34
|
+
return Array.isArray(value) ? value.filter((entry): entry is string => typeof entry === "string") : [];
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function playbookSkillMarkdown(playbook: Artifact): string {
|
|
38
|
+
const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
|
|
39
|
+
const steps = stringList(playbook.extra["steps"]);
|
|
40
|
+
const tools = stringList(playbook.extra["tools"]);
|
|
41
|
+
const description = trigger.replace(/\n/g, " ").slice(0, PLAYBOOK_BRIDGE_DESCRIPTION_MAX_CHARACTERS);
|
|
42
|
+
return [
|
|
43
|
+
"---",
|
|
44
|
+
`name: ${slugify(playbook.title)}`,
|
|
45
|
+
`description: ${description}`,
|
|
46
|
+
"---",
|
|
47
|
+
"",
|
|
48
|
+
`# ${playbook.title}`,
|
|
49
|
+
"",
|
|
50
|
+
`Materialized from a live Papyrus playbook; edits here are lost on the next refresh. Edit the playbook itself instead (the playbooks tool, action=update), then /reload.`,
|
|
51
|
+
"",
|
|
52
|
+
`Trigger: ${trigger}`,
|
|
53
|
+
"",
|
|
54
|
+
...(playbook.body ? [`Context: ${playbook.body}`, ""] : []),
|
|
55
|
+
...(steps.length > 0 ? ["## Steps", "", ...steps.map((step, index) => `${index + 1}. ${step}`), ""] : []),
|
|
56
|
+
...(tools.length > 0 ? [`Tools: ${tools.join(", ")}`, ""] : []),
|
|
57
|
+
].join("\n");
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Exported for direct testing without a real ExtensionAPI. */
|
|
61
|
+
export async function materializePlaybookSkillPaths(): Promise<string[]> {
|
|
62
|
+
const dir = playbookCacheDir();
|
|
63
|
+
if (existsSync(dir)) rmSync(dir, { recursive: true, force: true });
|
|
64
|
+
const playbooks = await callService<Record<string, unknown>, Artifact[]>("playbooks.list", { status: "active", limit: PLAYBOOK_BRIDGE_MAX_PLAYBOOKS });
|
|
65
|
+
if (playbooks.length === 0) return [];
|
|
66
|
+
mkdirSync(dir, { recursive: true });
|
|
67
|
+
const usedSlugs = new Set<string>();
|
|
68
|
+
const paths: string[] = [];
|
|
69
|
+
for (const playbook of playbooks) {
|
|
70
|
+
let slug = slugify(playbook.title);
|
|
71
|
+
if (usedSlugs.has(slug)) slug = `${slug}-${playbook.id.slice(0, 8)}`; // a real title collision, not the common case
|
|
72
|
+
usedSlugs.add(slug);
|
|
73
|
+
const skillDir = join(dir, slug);
|
|
74
|
+
mkdirSync(skillDir, { recursive: true });
|
|
75
|
+
const filePath = join(skillDir, "SKILL.md");
|
|
76
|
+
writeFileSync(filePath, playbookSkillMarkdown(playbook), "utf8");
|
|
77
|
+
paths.push(filePath);
|
|
78
|
+
}
|
|
79
|
+
return paths;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export function registerPlaybookBridge(pi: ExtensionAPI): void {
|
|
83
|
+
pi.on("resources_discover", async () => {
|
|
84
|
+
try {
|
|
85
|
+
return { skillPaths: await materializePlaybookSkillPaths() };
|
|
86
|
+
} catch {
|
|
87
|
+
return {};
|
|
88
|
+
}
|
|
89
|
+
});
|
|
90
|
+
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
3
|
+
import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
|
|
4
|
+
import { PLAYBOOK_STATUS_PRESENTATION } from "./artifact-status-presentation.ts";
|
|
5
|
+
import { callService } from "./service-client.ts";
|
|
6
|
+
|
|
7
|
+
const PLAYBOOK_RELATIONS = ["references", "documents", "relates_to", "contains", "part_of"];
|
|
8
|
+
|
|
9
|
+
function strings(value: unknown): string[] {
|
|
10
|
+
return Array.isArray(value) ? value.filter((item): item is string => typeof item === "string") : [];
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
export function playbookRowMeta(playbook: Artifact): string {
|
|
14
|
+
const trigger = typeof playbook.extra["trigger"] === "string" ? `when ${playbook.extra["trigger"]}` : "manual invocation";
|
|
15
|
+
const tools = strings(playbook.extra["tools"]);
|
|
16
|
+
return [trigger, tools.join(", ")].filter(Boolean).join(" \u00b7 ");
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function showPlaybooks(ctx: ExtensionCommandContext): Promise<void> {
|
|
20
|
+
await showArtifactBrowser(ctx, {
|
|
21
|
+
kind: "playbook",
|
|
22
|
+
title: "Playbooks",
|
|
23
|
+
listOperation: "playbooks.list",
|
|
24
|
+
statusOrder: ["active", "deprecated"],
|
|
25
|
+
presentation: PLAYBOOK_STATUS_PRESENTATION,
|
|
26
|
+
rowMeta: playbookRowMeta,
|
|
27
|
+
actions: (playbook) => ["Show details", "Edit", "Invoke", "Link artifact", playbook.status === "active" ? "Disable" : "Enable"],
|
|
28
|
+
handleAction: async (choice, playbook, commandCtx) => {
|
|
29
|
+
if (choice === "Show details") {
|
|
30
|
+
await showArtifactDetails(commandCtx, playbook.id, "playbooks.show");
|
|
31
|
+
return;
|
|
32
|
+
}
|
|
33
|
+
if (choice === "Edit") {
|
|
34
|
+
const title = await commandCtx.ui.input("Title:", playbook.title);
|
|
35
|
+
if (title === undefined) return; // canceled
|
|
36
|
+
const body = await commandCtx.ui.input("Body:", playbook.body);
|
|
37
|
+
if (body === undefined) return; // canceled
|
|
38
|
+
const updated = await callService<Record<string, unknown>, Artifact>("playbooks.update", { id: playbook.id, title, body });
|
|
39
|
+
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
if (choice === "Invoke") {
|
|
43
|
+
const invocation = await callService<Record<string, unknown>, string>("playbooks.invoke", { id: playbook.id });
|
|
44
|
+
commandCtx.ui.setEditorText(invocation);
|
|
45
|
+
commandCtx.ui.notify("Invocation placed in the editor", "info");
|
|
46
|
+
return;
|
|
47
|
+
}
|
|
48
|
+
if (choice === "Link artifact") {
|
|
49
|
+
const targetId = await commandCtx.ui.input("Target artifact id:", "");
|
|
50
|
+
if (!targetId) return;
|
|
51
|
+
const relation = await commandCtx.ui.select("Relation", PLAYBOOK_RELATIONS);
|
|
52
|
+
if (!relation) return;
|
|
53
|
+
await callService("graph.link", { from: playbook.id, relation, to: targetId });
|
|
54
|
+
commandCtx.ui.notify(`Linked ${playbook.id} --${relation}--> ${targetId}`, "info");
|
|
55
|
+
return;
|
|
56
|
+
}
|
|
57
|
+
const operation = choice === "Disable" ? "playbooks.disable" : "playbooks.enable";
|
|
58
|
+
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: playbook.id });
|
|
59
|
+
commandCtx.ui.notify(`${updated.id} \u2192 [${updated.status}]`, "info");
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
}
|
package/extension/src/rules.ts
CHANGED
|
@@ -25,10 +25,17 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
25
25
|
statusOrder: ["active", "deprecated"],
|
|
26
26
|
presentation: RULE_STATUS_PRESENTATION,
|
|
27
27
|
rowMeta: ruleRowMeta,
|
|
28
|
-
actions: (rule) => ["Show details", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
28
|
+
actions: (rule) => ["Show details", "Edit", "Preview injection", "Link gated task", rule.status === "active" ? "Disable" : "Enable"],
|
|
29
29
|
handleAction: async (choice, rule, commandCtx) => {
|
|
30
30
|
if (choice === "Show details") await showArtifactDetails(commandCtx, rule.id, "rules.show");
|
|
31
|
-
else if (choice === "
|
|
31
|
+
else if (choice === "Edit") {
|
|
32
|
+
const title = await commandCtx.ui.input("Title:", rule.title);
|
|
33
|
+
if (title === undefined) return; // canceled
|
|
34
|
+
const body = await commandCtx.ui.input("Body:", rule.body);
|
|
35
|
+
if (body === undefined) return; // canceled
|
|
36
|
+
const updated = await callService<Record<string, unknown>, Artifact>("rules.update", { id: rule.id, title, body });
|
|
37
|
+
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
38
|
+
} else if (choice === "Preview injection") {
|
|
32
39
|
const preview = await callService<Record<string, unknown>, string>("rules.preview", { id: rule.id });
|
|
33
40
|
commandCtx.ui.notify(preview, "info");
|
|
34
41
|
} else if (choice === "Link gated task") {
|
package/extension/src/skills.ts
CHANGED
|
@@ -76,12 +76,20 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
76
76
|
rowMeta: skillRowMeta,
|
|
77
77
|
actions: (skill) => [
|
|
78
78
|
"Show details",
|
|
79
|
+
"Edit",
|
|
79
80
|
skill.subtype === "artifact-template" ? "Use template" : skill.subtype === "workflow" ? "Run workflow" : "Invoke skill",
|
|
80
81
|
skill.status === "active" ? "Disable" : "Enable",
|
|
81
82
|
],
|
|
82
83
|
handleAction: async (choice, skill, commandCtx) => {
|
|
83
84
|
if (choice === "Show details") await showArtifactDetails(commandCtx, skill.id, "skills.show");
|
|
84
|
-
else if (choice === "
|
|
85
|
+
else if (choice === "Edit") {
|
|
86
|
+
const title = await commandCtx.ui.input("Title:", skill.title);
|
|
87
|
+
if (title === undefined) return; // canceled
|
|
88
|
+
const body = await commandCtx.ui.input("Body:", skill.body);
|
|
89
|
+
if (body === undefined) return; // canceled
|
|
90
|
+
const updated = await callService<Record<string, unknown>, Artifact>("skills.update", { id: skill.id, title, body });
|
|
91
|
+
commandCtx.ui.notify(`Updated "${updated.title}"`, "info");
|
|
92
|
+
} else if (choice === "Run workflow") {
|
|
85
93
|
const source = await commandCtx.ui.input("Workflow arguments JSON:", "{}");
|
|
86
94
|
if (source === undefined) return;
|
|
87
95
|
try {
|
package/package.json
CHANGED
|
@@ -22,7 +22,7 @@ import type { ArtifactEventContext } from "./domain/artifact-event.ts";
|
|
|
22
22
|
import type { Artifact, ArtifactLink } from "./domain/artifact.ts";
|
|
23
23
|
import type { ArtifactStore } from "./ports/artifact-store.ts";
|
|
24
24
|
|
|
25
|
-
export type ArtifactAction = "create" | "link" | "status";
|
|
25
|
+
export type ArtifactAction = "create" | "link" | "status" | "update";
|
|
26
26
|
|
|
27
27
|
export interface AuthorityClaim {
|
|
28
28
|
/** Module id that owns this kind/subtype/relation, e.g. "notes", "tasks". */
|