@danypops/papyrus 0.24.0 → 0.26.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 +2 -2
- package/extension/src/active-task-continuation.ts +1 -1
- package/extension/src/artifact-browser.ts +4 -4
- package/extension/src/discuss.ts +8 -2
- package/extension/src/discussion-picker.ts +111 -27
- package/extension/src/docs.ts +2 -2
- package/extension/src/domain-tools.ts +51 -29
- package/extension/src/index.ts +37 -9
- package/extension/src/playbooks.ts +2 -2
- package/extension/src/rules.ts +1 -1
- package/extension/src/skills.ts +4 -4
- package/extension/src/tasks.ts +20 -9
- package/extension/src/tool-rendering/artifact-card.ts +1 -1
- package/package.json +1 -1
- package/src/constants.ts +6 -0
- package/src/domain-services.ts +68 -11
- package/src/modules/playbooks.ts +2 -1
- package/src/task-context.ts +4 -4
package/README.md
CHANGED
|
@@ -122,7 +122,7 @@ Agent-facing domain tools own lifecycle invariants and sit above this store API:
|
|
|
122
122
|
- **`docs`** — create/update/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
|
|
123
123
|
- **`rules`** — create/update/list/show/preview, enable/disable, and attach governance gates to tasks
|
|
124
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
|
+
- **`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. A Playbook can declare named arguments (`{name, description?, required?}`, required defaults true); invoking with some unsupplied lists exactly which required ones are still missing and directs the agent to ask via `discuss` with `live:true` rather than guess
|
|
126
126
|
|
|
127
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.
|
|
128
128
|
|
|
@@ -175,7 +175,7 @@ Blocking is real: `tasks.complete` is refused while any `active` Discussion has
|
|
|
175
175
|
|
|
176
176
|
`open`/`reply` can also pose a structured choice instead of (or alongside) free text: `options` (2-10 entries) plus `options_mode` -- `single` is mutually exclusive (exactly one pick), `multi` allows several. The Discussion remembers the pending choice (`extra.discussion.pendingOptions`/`pendingOptionsMode`) until a `reply` answers it with `selected`, validated against exactly what was offered and the mode's cardinality; a reply can also pose the *next* round's choice in the same call.
|
|
177
177
|
|
|
178
|
-
Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select
|
|
178
|
+
Run `/discuss` for the interactive panel: browse every Discussion (the real `active`/`deferred`/`settled` state shown per row, alongside any choice awaiting an answer), open a scrollable transcript showing what was posed and picked in each round, and reply/defer/resume/settle or block/unblock a task without leaving the TUI. Replying to a pending choice shows a real picker -- the native single-select list for `single`, or a checkbox multi-select for `multi`, since no built-in multi-select exists in the Pi extension UI. Both modes append a numbered "type your own answer" row -- a genuinely open answer is exactly as valid as any posed option. The multi-select picker supports a number key as a direct quick-select (jump straight to that row instead of scrolling), and steadily highlights checked rows while dimming the rest so the eye reads "what's chosen" independent of cursor position; its cursor row blinks to mark focus. It also auto-cancels after 30s of zero input -- the very first keystroke of any kind stops that countdown permanently for that prompt. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Skills) -- `/discuss` browses and drives existing ones.
|
|
179
179
|
|
|
180
180
|
```bash
|
|
181
181
|
papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
|
|
@@ -43,7 +43,7 @@ function continuationPrompt(task: ActiveTaskMarker): string {
|
|
|
43
43
|
"Reconcile its lifecycle, take the next concrete action, use tools, submit it for review when implementation effort is ready, and run gates plus checklist review before completion.",
|
|
44
44
|
"Do not shrink the task's scope to whatever fits in this turn, and do not treat a status update or summary as a substitute for doing the work or as proof of completion.",
|
|
45
45
|
"If something blocks progress, do not reject or pause on the first obstacle -- only after it genuinely recurs, and only when the task truly cannot proceed without external input.",
|
|
46
|
-
`Active task: ${task.
|
|
46
|
+
`Active task: ${task.title.slice(0, TITLE_LIMIT)}`,
|
|
47
47
|
].join("\n");
|
|
48
48
|
}
|
|
49
49
|
|
|
@@ -78,7 +78,7 @@ export async function showArtifactDetails(
|
|
|
78
78
|
depth: DETAIL_GRAPH_DEPTH,
|
|
79
79
|
max_nodes: DETAIL_GRAPH_NODES,
|
|
80
80
|
});
|
|
81
|
-
if (!artifact) { ctx.ui.notify(
|
|
81
|
+
if (!artifact) { ctx.ui.notify("Artifact not found", "error"); return; }
|
|
82
82
|
await showArtifactDetailView(ctx, artifact);
|
|
83
83
|
} catch (error) {
|
|
84
84
|
ctx.ui.notify(`Show details failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
@@ -92,7 +92,7 @@ export async function linkFromArtifact(ctx: ExtensionCommandContext, fromId: str
|
|
|
92
92
|
if (!relation) return;
|
|
93
93
|
try {
|
|
94
94
|
await callService("graph.link", { from: fromId, relation, to: target });
|
|
95
|
-
ctx.ui.notify(`
|
|
95
|
+
ctx.ui.notify(`Artifacts linked via ${relation}`, "info");
|
|
96
96
|
} catch (error) {
|
|
97
97
|
ctx.ui.notify(`Link failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
98
98
|
}
|
|
@@ -101,8 +101,8 @@ export async function linkFromArtifact(ctx: ExtensionCommandContext, fromId: str
|
|
|
101
101
|
export async function setArtifactStatus(ctx: ExtensionCommandContext, id: string, status: string): Promise<void> {
|
|
102
102
|
try {
|
|
103
103
|
const artifact = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id, status });
|
|
104
|
-
if (!artifact) { ctx.ui.notify(
|
|
105
|
-
ctx.ui.notify(`${artifact.
|
|
104
|
+
if (!artifact) { ctx.ui.notify("Artifact not found", "error"); return; }
|
|
105
|
+
ctx.ui.notify(`${artifact.title} → [${artifact.status}]`, "info");
|
|
106
106
|
} catch (error) {
|
|
107
107
|
ctx.ui.notify(`Status change failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
108
108
|
}
|
package/extension/src/discuss.ts
CHANGED
|
@@ -83,8 +83,14 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
|
|
|
83
83
|
if (choice === "Reply") {
|
|
84
84
|
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
85
85
|
if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
|
|
86
|
-
const
|
|
87
|
-
if (!
|
|
86
|
+
const result = await pickDiscussionOptions(commandCtx, pending.pendingOptionsMode, pending.pendingOptions);
|
|
87
|
+
if (!result) return; // canceled
|
|
88
|
+
if (result.kind === "freeform") {
|
|
89
|
+
await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: result.text, source: SOURCE });
|
|
90
|
+
commandCtx.ui.notify("Reply added.", "info");
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
const { selected } = result;
|
|
88
94
|
const elaboration = await commandCtx.ui.input("Elaborate (optional):", selected.join(", "));
|
|
89
95
|
if (elaboration === undefined) return; // canceled
|
|
90
96
|
await callService("discuss.reply", { id: discussion.id, actor: ACTOR, content: elaboration || selected.join(", "), selected, source: SOURCE });
|
|
@@ -1,47 +1,118 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* discussion-picker.ts — the structured-choice picker for /discuss's "Reply" action
|
|
2
|
+
* discussion-picker.ts — the structured-choice picker for /discuss's "Reply" action and the
|
|
3
|
+
* discuss tool's own live:true synchronous ask.
|
|
3
4
|
*
|
|
4
|
-
* "single" mode (mutually exclusive) needs nothing bespoke
|
|
5
|
-
* provides exactly that (ctx.ui.select). "multi" (allow several) has no
|
|
6
|
-
* anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so
|
|
7
|
-
* genuinely domain-specific checkbox-list component, not a generic library
|
|
5
|
+
* "single" mode (mutually exclusive) needs nothing bespoke for the pick list itself: the Pi
|
|
6
|
+
* extension UI already provides exactly that (ctx.ui.select). "multi" (allow several) has no
|
|
7
|
+
* native equivalent anywhere in @earendil-works/pi-coding-agent or pi-tui (checked both) -- so
|
|
8
|
+
* that one is a small, genuinely domain-specific checkbox-list component, not a generic library
|
|
9
|
+
* replacement. Both modes get a numbered quick-select (press the row's digit instead of
|
|
10
|
+
* scrolling with arrows) and an appended "type your own answer" row, itself numbered the same
|
|
11
|
+
* way -- a genuinely open question is exactly as valid an answer as any of the posed options.
|
|
8
12
|
*/
|
|
9
13
|
import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
|
|
10
14
|
import { matchesKey, truncateToWidth } from "@earendil-works/pi-tui";
|
|
15
|
+
import { DISCUSSION_PICKER_IDLE_TIMEOUT_MS, DISCUSSION_PICKER_TICK_MS } from "../../src/constants.ts";
|
|
11
16
|
import type { DiscussionOptionsMode } from "../../src/domain/discussion.ts";
|
|
12
17
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
18
|
+
const FREEFORM_LABEL = "Something else (type your own answer)";
|
|
19
|
+
|
|
20
|
+
export type DiscussionPickResult = { kind: "selected"; selected: string[] } | { kind: "freeform"; text: string };
|
|
21
|
+
|
|
22
|
+
/** digit "1".."9" -> index 0-8, "0" -> index 9 (DISCUSSION_OPTIONS_MAX_COUNT is 10) -- standard terminal-menu numbering, not 0-indexed. */
|
|
23
|
+
function digitToIndex(data: string, rowCount: number): number | undefined {
|
|
24
|
+
if (data === "0") return rowCount >= 10 ? 9 : undefined;
|
|
25
|
+
if (data.length === 1 && data >= "1" && data <= "9") {
|
|
26
|
+
const index = Number(data) - 1;
|
|
27
|
+
return index < rowCount ? index : undefined;
|
|
28
|
+
}
|
|
29
|
+
return undefined;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function rowNumberLabel(index: number): string {
|
|
33
|
+
if (index < 9) return `${index + 1}`;
|
|
34
|
+
if (index === 9) return "0";
|
|
35
|
+
return " "; // beyond the 1-9,0 quick-select range (should not happen given DISCUSSION_OPTIONS_MAX_COUNT=10, but never crash rendering)
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async function promptFreeformAnswer(ctx: ExtensionContext): Promise<DiscussionPickResult | undefined> {
|
|
39
|
+
const text = await ctx.ui.input("Your answer:", "");
|
|
40
|
+
return text ? { kind: "freeform", text } : undefined;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Toggle with space or a row's number, confirm with enter (refuses an empty confirm -- at least
|
|
45
|
+
* one pick is required), cancel with escape. Picking the freeform row exits the checkbox flow
|
|
46
|
+
* entirely rather than adding it to the selection.
|
|
47
|
+
*
|
|
48
|
+
* Idle countdown: auto-cancels after DISCUSSION_PICKER_IDLE_TIMEOUT_MS of no input at all --
|
|
49
|
+
* the first keystroke of any kind stops it permanently (not a pause; it never resumes for this
|
|
50
|
+
* picker instance), since a countdown ticking while someone is actively engaging is pressure,
|
|
51
|
+
* not a nudge. The same tick also drives a slow, deliberately noticeable blink on the cursor
|
|
52
|
+
* row -- checked rows stay steadily highlighted, everything else stays dimmed, so the eye reads
|
|
53
|
+
* "what's chosen" at a glance independent of where the cursor happens to be.
|
|
54
|
+
*/
|
|
55
|
+
async function pickMultiple(ctx: ExtensionContext, title: string, options: string[], allowFreeform: boolean, idleTimeoutMs: number, tickMs: number): Promise<DiscussionPickResult | undefined> {
|
|
56
|
+
return ctx.ui.custom<DiscussionPickResult | undefined>((tui, theme, _keybindings, done) => {
|
|
57
|
+
const rows = allowFreeform ? [...options, FREEFORM_LABEL] : options;
|
|
58
|
+
const freeformIndex = allowFreeform ? rows.length - 1 : -1;
|
|
16
59
|
const checked = new Set<number>();
|
|
17
60
|
let selectedIndex = 0;
|
|
61
|
+
let hasInteracted = false;
|
|
62
|
+
let remainingMs = idleTimeoutMs;
|
|
63
|
+
let blinkOn = true;
|
|
64
|
+
const tick = setInterval(() => {
|
|
65
|
+
blinkOn = !blinkOn;
|
|
66
|
+
if (!hasInteracted) {
|
|
67
|
+
remainingMs -= tickMs;
|
|
68
|
+
if (remainingMs <= 0) { finish(undefined); return; }
|
|
69
|
+
}
|
|
70
|
+
tui.requestRender();
|
|
71
|
+
}, tickMs);
|
|
72
|
+
const finish = (value: DiscussionPickResult | undefined) => { clearInterval(tick); done(value); };
|
|
73
|
+
const chooseFreeform = () => { promptFreeformAnswer(ctx).then(finish); };
|
|
74
|
+
const toggle = (index: number) => {
|
|
75
|
+
if (index === freeformIndex) { chooseFreeform(); return; }
|
|
76
|
+
if (checked.has(index)) checked.delete(index); else checked.add(index);
|
|
77
|
+
tui.requestRender();
|
|
78
|
+
};
|
|
18
79
|
return {
|
|
19
80
|
invalidate() {},
|
|
20
81
|
render(width: number): string[] {
|
|
21
82
|
const lines: string[] = [
|
|
22
83
|
theme.bold(title),
|
|
23
|
-
theme.fg("muted", "space toggle \u00b7 enter confirm \u00b7 esc cancel"),
|
|
84
|
+
theme.fg("muted", "number/space toggle \u00b7 enter confirm \u00b7 esc cancel"),
|
|
24
85
|
"",
|
|
25
86
|
];
|
|
26
|
-
|
|
27
|
-
const
|
|
28
|
-
const
|
|
29
|
-
const
|
|
30
|
-
|
|
87
|
+
rows.forEach((option, index) => {
|
|
88
|
+
const isCursor = index === selectedIndex;
|
|
89
|
+
const isChecked = checked.has(index);
|
|
90
|
+
const cursorGlyph = isCursor && blinkOn ? theme.fg("accent", "\u276f") : " ";
|
|
91
|
+
const box = index === freeformIndex ? " " : isChecked ? theme.fg("success", "[x]") : "[ ]";
|
|
92
|
+
let label = option;
|
|
93
|
+
if (isChecked) label = theme.bold(theme.fg("success", label));
|
|
94
|
+
else if (isCursor) label = theme.bold(theme.fg("accent", label));
|
|
95
|
+
else label = theme.fg("dim", label);
|
|
96
|
+
lines.push(truncateToWidth(`${cursorGlyph} ${rowNumberLabel(index)}. ${box} ${label}`, width, ""));
|
|
31
97
|
});
|
|
32
98
|
lines.push("");
|
|
33
99
|
lines.push(theme.fg("dim", `${checked.size} selected`));
|
|
100
|
+
if (!hasInteracted) lines.push(theme.fg("dim", `auto-cancels in ${Math.max(0, Math.ceil(remainingMs / 1000))}s (press any key to stop)`));
|
|
34
101
|
return lines;
|
|
35
102
|
},
|
|
36
103
|
handleInput(data: string) {
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
104
|
+
hasInteracted = true;
|
|
105
|
+
const digit = digitToIndex(data, rows.length);
|
|
106
|
+
if (digit !== undefined) { selectedIndex = digit; toggle(digit); return; }
|
|
107
|
+
if (matchesKey(data, "up")) selectedIndex = (selectedIndex - 1 + rows.length) % rows.length;
|
|
108
|
+
else if (matchesKey(data, "down")) selectedIndex = (selectedIndex + 1) % rows.length;
|
|
109
|
+
else if (data === " ") { toggle(selectedIndex); return; }
|
|
40
110
|
else if (matchesKey(data, "enter")) {
|
|
111
|
+
if (selectedIndex === freeformIndex && checked.size === 0) { chooseFreeform(); return; }
|
|
41
112
|
if (checked.size === 0) return; // refuse an empty confirm -- selecting nothing isn't a valid answer
|
|
42
|
-
|
|
113
|
+
finish({ kind: "selected", selected: [...checked].sort((a, b) => a - b).map((index) => rows[index]!) });
|
|
43
114
|
return;
|
|
44
|
-
} else if (matchesKey(data, "escape")) {
|
|
115
|
+
} else if (matchesKey(data, "escape")) { finish(undefined); return; }
|
|
45
116
|
else return;
|
|
46
117
|
tui.requestRender();
|
|
47
118
|
},
|
|
@@ -50,15 +121,28 @@ async function pickMultiple(ctx: ExtensionContext, title: string, options: strin
|
|
|
50
121
|
}
|
|
51
122
|
|
|
52
123
|
/**
|
|
53
|
-
* Picks one (single) or several (multi) of the given options, or
|
|
54
|
-
* Takes the base ExtensionContext (just .ui) rather
|
|
55
|
-
* a tool's execute() only ever receives the
|
|
56
|
-
* this same picker, not just the /discuss
|
|
124
|
+
* Picks one (single) or several (multi) of the given options, or a freeform typed answer
|
|
125
|
+
* instead, or undefined if the user cancels. Takes the base ExtensionContext (just .ui) rather
|
|
126
|
+
* than the wider ExtensionCommandContext, since a tool's execute() only ever receives the
|
|
127
|
+
* former -- the discuss tool's own live mode reuses this same picker, not just the /discuss
|
|
128
|
+
* TUI panel. Single mode's numbered quick-select is not guaranteed: it delegates to Pi's own
|
|
129
|
+
* native ctx.ui.select, which this package does not control the key handling of.
|
|
57
130
|
*/
|
|
58
|
-
export async function pickDiscussionOptions(
|
|
131
|
+
export async function pickDiscussionOptions(
|
|
132
|
+
ctx: ExtensionContext,
|
|
133
|
+
mode: DiscussionOptionsMode,
|
|
134
|
+
options: string[],
|
|
135
|
+
allowFreeform = true,
|
|
136
|
+
/** Test seam: real timers, not faked global time -- pass tiny values to exercise the idle-cancel/blink logic quickly and deterministically. */
|
|
137
|
+
idleTimeoutMs = DISCUSSION_PICKER_IDLE_TIMEOUT_MS,
|
|
138
|
+
tickMs = DISCUSSION_PICKER_TICK_MS,
|
|
139
|
+
): Promise<DiscussionPickResult | undefined> {
|
|
59
140
|
if (mode === "single") {
|
|
60
|
-
const
|
|
61
|
-
|
|
141
|
+
const rows = allowFreeform ? [...options, FREEFORM_LABEL] : options;
|
|
142
|
+
const pick = await ctx.ui.select("Pick one:", rows);
|
|
143
|
+
if (!pick) return undefined;
|
|
144
|
+
if (allowFreeform && pick === FREEFORM_LABEL) return promptFreeformAnswer(ctx);
|
|
145
|
+
return { kind: "selected", selected: [pick] };
|
|
62
146
|
}
|
|
63
|
-
return pickMultiple(ctx, "Pick one or more:", options);
|
|
147
|
+
return pickMultiple(ctx, "Pick one or more:", options, allowFreeform, idleTimeoutMs, tickMs);
|
|
64
148
|
}
|
package/extension/src/docs.ts
CHANGED
|
@@ -45,13 +45,13 @@ export async function showDocs(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
45
45
|
const relation = await commandCtx.ui.select("Relation", DOC_RELATIONS);
|
|
46
46
|
if (!relation) return;
|
|
47
47
|
await callService("docs.link", { id: document.id, relation, target_id: targetId });
|
|
48
|
-
commandCtx.ui.notify(`Linked ${document.
|
|
48
|
+
commandCtx.ui.notify(`Linked "${document.title}" via ${relation}`, "info");
|
|
49
49
|
return;
|
|
50
50
|
}
|
|
51
51
|
const operation = choice === "Activate" ? "docs.activate" : choice === "Archive" ? "docs.archive" : choice === "Reopen" ? "docs.reopen" : undefined;
|
|
52
52
|
if (operation) {
|
|
53
53
|
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: document.id });
|
|
54
|
-
commandCtx.ui.notify(`${updated.
|
|
54
|
+
commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
55
55
|
}
|
|
56
56
|
},
|
|
57
57
|
});
|
|
@@ -42,8 +42,10 @@ async function liveAnswer(ctx: ExtensionContext, discussion: Artifact): Promise<
|
|
|
42
42
|
if (!ctx.hasUI) return undefined;
|
|
43
43
|
const pending = (() => { try { return readDiscussionExtra(discussion.extra); } catch { return undefined; } })();
|
|
44
44
|
if (pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode) {
|
|
45
|
-
const
|
|
46
|
-
|
|
45
|
+
const result = await pickDiscussionOptions(ctx, pending.pendingOptionsMode, pending.pendingOptions);
|
|
46
|
+
if (!result) return undefined;
|
|
47
|
+
if (result.kind === "freeform") return { content: result.text };
|
|
48
|
+
return { content: result.selected.join(", "), selected: result.selected };
|
|
47
49
|
}
|
|
48
50
|
const content = await ctx.ui.input(`Reply to "${discussion.title}":`, "");
|
|
49
51
|
return content ? { content } : undefined;
|
|
@@ -67,6 +69,15 @@ export function artifactLines(artifacts: Artifact[]): string[] {
|
|
|
67
69
|
return artifacts.map((artifact) => (titleCounts.get(artifact.title)! > 1 ? `${artifactLine(artifact)} (${artifact.id})` : artifactLine(artifact)));
|
|
68
70
|
}
|
|
69
71
|
|
|
72
|
+
/** Resolves internal ids for model text; ids resurface only when equal titles need disambiguation. */
|
|
73
|
+
async function artifactLabelsById(ids: readonly string[]): Promise<Map<string, string>> {
|
|
74
|
+
const uniqueIds = [...new Set(ids)];
|
|
75
|
+
const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
|
|
76
|
+
const titleCounts = new Map<string, number>();
|
|
77
|
+
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
78
|
+
return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
|
|
79
|
+
}
|
|
80
|
+
|
|
70
81
|
/**
|
|
71
82
|
* Exact, case-insensitive, trimmed title match against an already-fetched candidate set. Throws
|
|
72
83
|
* a clear "not found" or "ambiguous -- use id" error rather than guessing at a fuzzy match -- id
|
|
@@ -130,15 +141,15 @@ async function resolveNameArrayField(
|
|
|
130
141
|
* Returns null when action is neither, so callers fall through to their own dispatch.
|
|
131
142
|
*/
|
|
132
143
|
async function handleArtifactRemoveRestore(action: unknown, params: Record<string, unknown>): Promise<ReturnType<typeof text> | null> {
|
|
133
|
-
// Trashed/restored
|
|
134
|
-
//
|
|
135
|
-
//
|
|
144
|
+
// Trashed/restored artifacts stay directly showable, so known identities render by title on
|
|
145
|
+
// either side of the action. An unresolved explicit id stays in structured/error channels;
|
|
146
|
+
// normal model text does not turn that backend key into the artifact's public name.
|
|
136
147
|
const titleOf = async (): Promise<string> => {
|
|
137
148
|
try {
|
|
138
149
|
const artifact = await callService<Record<string, unknown>, Artifact | null>("artifact.show", { id: params["id"] });
|
|
139
|
-
return artifact ? `"${artifact.title}"` :
|
|
150
|
+
return artifact ? `"${artifact.title}"` : "unknown artifact";
|
|
140
151
|
} catch {
|
|
141
|
-
return
|
|
152
|
+
return "unknown artifact";
|
|
142
153
|
}
|
|
143
154
|
};
|
|
144
155
|
if (action === "remove") {
|
|
@@ -216,17 +227,26 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
216
227
|
// ever holds this extension's own registered session anyway (see session-identity.ts).
|
|
217
228
|
const resolvedSessionId = params.session_id ?? ctx.sessionManager.getSessionId();
|
|
218
229
|
const baseRequest = { project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "pi-tool", session_id: resolvedSessionId, ...sessionSecretField(resolvedSessionId as string) };
|
|
219
|
-
//
|
|
220
|
-
//
|
|
221
|
-
//
|
|
230
|
+
// Resolve the graph root first: every other name lookup must use the caller's final
|
|
231
|
+
// project/scope/root selection, otherwise `scope: all|graph` silently collapses back
|
|
232
|
+
// to the current project and forces callers to reach for an id.
|
|
233
|
+
await resolveNameFields(params, [
|
|
234
|
+
{ nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest: { ...baseRequest, scope: "project" } },
|
|
235
|
+
]);
|
|
236
|
+
const resolutionRequest = {
|
|
237
|
+
...baseRequest,
|
|
238
|
+
...(params.scope === undefined ? {} : { scope: params.scope }),
|
|
239
|
+
...(params.root_task_id === undefined ? {} : { root_task_id: params.root_task_id }),
|
|
240
|
+
};
|
|
241
|
+
// The daemon remains keyed by stable ids; the agent facade resolves names against the
|
|
242
|
+
// exact requested view before dispatching those internal ids.
|
|
222
243
|
await resolveNameFields(params, [
|
|
223
|
-
{ nameKey: "name", idKey: "id", listOperation: "tasks.list", baseRequest },
|
|
224
|
-
{ nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest },
|
|
225
|
-
{ nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest },
|
|
226
|
-
{ nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest },
|
|
227
|
-
{ nameKey: "root_task_name", idKey: "root_task_id", listOperation: "tasks.list", baseRequest },
|
|
244
|
+
{ nameKey: "name", idKey: "id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
245
|
+
{ nameKey: "dependency_name", idKey: "dependency_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
246
|
+
{ nameKey: "parent_name", idKey: "parent_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
247
|
+
{ nameKey: "child_name", idKey: "child_id", listOperation: "tasks.list", baseRequest: resolutionRequest },
|
|
228
248
|
]);
|
|
229
|
-
await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list",
|
|
249
|
+
await resolveNameArrayField(params, "depends_on_names", "depends_on", "tasks.list", resolutionRequest);
|
|
230
250
|
const request = { ...params, ...baseRequest };
|
|
231
251
|
if (action === "create") {
|
|
232
252
|
const artifact = await callService<Record<string, unknown>, Artifact>("tasks.create", request);
|
|
@@ -292,17 +312,19 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
292
312
|
const byId = new Map(plan.nodes.map((node) => [node.id, node]));
|
|
293
313
|
const titleCounts = new Map<string, number>();
|
|
294
314
|
for (const node of plan.nodes) titleCounts.set(node.title, (titleCounts.get(node.title) ?? 0) + 1);
|
|
315
|
+
const nodeLabel = (id: string): string => {
|
|
316
|
+
const node = byId.get(id);
|
|
317
|
+
if (!node) return "unknown task";
|
|
318
|
+
return (titleCounts.get(node.title) ?? 0) > 1 ? `${node.title} (${node.id})` : node.title;
|
|
319
|
+
};
|
|
295
320
|
const lines = plan.layers.flatMap((layer, index) => [
|
|
296
321
|
`Layer ${index + 1}`,
|
|
297
322
|
...layer.map((id) => {
|
|
298
323
|
const node = byId.get(id);
|
|
299
|
-
|
|
300
|
-
return (titleCounts.get(node.title) ?? 0) > 1
|
|
301
|
-
? ` [${node.state}] ${node.title} (${node.id})`
|
|
302
|
-
: ` [${node.state}] ${node.title}`;
|
|
324
|
+
return ` [${node?.state ?? "unknown"}] ${nodeLabel(id)}`;
|
|
303
325
|
}),
|
|
304
326
|
]);
|
|
305
|
-
if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.join(", ")}`);
|
|
327
|
+
if (plan.cycleIds.length > 0) lines.push(`Invalid cycle: ${plan.cycleIds.map(nodeLabel).join(", ")}`);
|
|
306
328
|
const output = lines.join("\n") || "No tasks in execution plan.";
|
|
307
329
|
return text(output, createPreviewDetails("tasks.plan", "Task execution plan", output));
|
|
308
330
|
}
|
|
@@ -316,8 +338,9 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
316
338
|
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}${item.reason ? ` — ${item.reason}` : ""}`).join("\n");
|
|
317
339
|
const focused = result.focused ? `\nActive: ${artifactLine(result.focused)}` : "";
|
|
318
340
|
const blockedLines = artifactLines(result.blocked.map((entry) => entry.artifact));
|
|
341
|
+
const dependencyLabels = await artifactLabelsById(result.blocked.flatMap((entry) => entry.dependencyIds));
|
|
319
342
|
const blocked = result.blocked.length > 0
|
|
320
|
-
? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
343
|
+
? `\nBlocked: ${result.blocked.map((entry, index) => `${blockedLines[index]} waits for ${entry.dependencyIds.map((id) => dependencyLabels.get(id) ?? "unknown task").join(", ")}`).join("; ")}`
|
|
321
344
|
: "";
|
|
322
345
|
const output = `${result.completed ? "Completed" : "Rejected"}: ${artifactLine(result.artifact)}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`;
|
|
323
346
|
return text(output, createPreviewDetails("tasks.complete", "Task completion", output));
|
|
@@ -524,11 +547,12 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
524
547
|
pi.registerTool({
|
|
525
548
|
name: "playbooks",
|
|
526
549
|
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
|
|
550
|
+
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. On create, `arguments` declares named inputs the Playbook needs: [{name, description?, required?}] (required defaults true). On invoke, `arguments` supplies known values as {name: value}; invoke renders which declared REQUIRED arguments are still missing and directs you to ask the human for them via the discuss tool with live:true -- never guess or invent a value for a missing required argument. 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
551
|
parameters: Type.Object({
|
|
529
552
|
action: Type.String(), id: Type.Optional(Type.String()), name: Type.Optional(Type.String()), title: Type.Optional(Type.String()),
|
|
530
553
|
body: Type.Optional(Type.String()), trigger: Type.Optional(Type.String()), steps: Type.Optional(Type.Array(Type.String())),
|
|
531
554
|
tools: Type.Optional(Type.Array(Type.String())), labels: Type.Optional(Type.Array(Type.String())),
|
|
555
|
+
arguments: Type.Optional(Type.Unknown()),
|
|
532
556
|
extra: Type.Optional(Type.Record(Type.String(), Type.Unknown())), status: Type.Optional(Type.String()),
|
|
533
557
|
text: Type.Optional(Type.String()), limit: Type.Optional(Type.Number()),
|
|
534
558
|
project_root: Type.Optional(Type.String()), reason: Type.Optional(Type.String()),
|
|
@@ -615,16 +639,14 @@ export function registerDomainTools(pi: ExtensionAPI): void {
|
|
|
615
639
|
const execution = run.execution.nodes.map((node) => (runTitleCounts.get(node.title) ?? 0) > 1
|
|
616
640
|
? ` [${node.state}] ${node.title} (${node.id})`
|
|
617
641
|
: ` [${node.state}] ${node.title}`).join("\n");
|
|
618
|
-
// Root task titles are free here (already present in execution.nodes); created docs/rules
|
|
619
|
-
// are a different kind not covered by this run's own execution nodes, so those still list by
|
|
620
|
-
// id below -- fetching their titles would mean an extra round-trip per artifact.
|
|
621
642
|
const nodeById = new Map(run.execution.nodes.map((node) => [node.id, node]));
|
|
622
|
-
const rootLabels = run.rootTaskIds.map((id) => nodeById.get(id)?.title ??
|
|
643
|
+
const rootLabels = run.rootTaskIds.map((id) => nodeById.get(id)?.title ?? "unknown task");
|
|
644
|
+
const createdLabels = await artifactLabelsById([...run.created.docs, ...run.created.rules]);
|
|
623
645
|
return text([
|
|
624
646
|
`Created Skill run ${run.runId}: ${run.created.tasks.length} tasks, ${run.created.rules.length} rules, ${run.created.docs.length} docs.`,
|
|
625
647
|
`Ready roots: ${rootLabels.join(", ") || "none"}.`,
|
|
626
|
-
`Context docs: ${run.created.docs.join(", ") || "none"}.`,
|
|
627
|
-
`Scoped rules: ${run.created.rules.join(", ") || "none"}.`,
|
|
648
|
+
`Context docs: ${run.created.docs.map((id) => createdLabels.get(id) ?? "unknown document").join(", ") || "none"}.`,
|
|
649
|
+
`Scoped rules: ${run.created.rules.map((id) => createdLabels.get(id) ?? "unknown rule").join(", ") || "none"}.`,
|
|
628
650
|
...(execution ? ["Execution:", execution] : []),
|
|
629
651
|
].join("\n"), createInvocationDetails("skills.run", run.runId, {
|
|
630
652
|
tasks: run.created.tasks,
|
package/extension/src/index.ts
CHANGED
|
@@ -47,6 +47,27 @@ function text(value: string, details: unknown = {}) {
|
|
|
47
47
|
return { content: [{ type: "text" as const, text: modelContent.text }], details };
|
|
48
48
|
}
|
|
49
49
|
|
|
50
|
+
function artifactTextLabel(artifact: Artifact): string {
|
|
51
|
+
return `[${artifact.kind}|${artifact.status}] ${artifact.title}`;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function artifactTextLines(artifacts: readonly Artifact[]): string[] {
|
|
55
|
+
const titleCounts = new Map<string, number>();
|
|
56
|
+
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
57
|
+
return artifacts.map((artifact) => titleCounts.get(artifact.title)! > 1
|
|
58
|
+
? `${artifactTextLabel(artifact)} (${artifact.id})`
|
|
59
|
+
: artifactTextLabel(artifact));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
/** Resolves graph protocol ids into model-facing names; equal titles retain ids only to disambiguate. */
|
|
63
|
+
async function artifactNamesById(ids: readonly string[]): Promise<Map<string, string>> {
|
|
64
|
+
const uniqueIds = [...new Set(ids)];
|
|
65
|
+
const artifacts = (await Promise.all(uniqueIds.map((id) => callService<Record<string, unknown>, Artifact | null>("artifact.show", { id })))).filter((artifact): artifact is Artifact => artifact !== null);
|
|
66
|
+
const titleCounts = new Map<string, number>();
|
|
67
|
+
for (const artifact of artifacts) titleCounts.set(artifact.title, (titleCounts.get(artifact.title) ?? 0) + 1);
|
|
68
|
+
return new Map(artifacts.map((artifact) => [artifact.id, titleCounts.get(artifact.title)! > 1 ? `${artifact.title} (${artifact.id})` : artifact.title]));
|
|
69
|
+
}
|
|
70
|
+
|
|
50
71
|
// ---------------------------------------------------------------------------
|
|
51
72
|
// Task widget (TodoOverlay pattern from rpiv-todo: factory form, requestRender)
|
|
52
73
|
// ---------------------------------------------------------------------------
|
|
@@ -272,7 +293,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
272
293
|
...params,
|
|
273
294
|
...(params.kind === "task" ? { project_root: params.project_root ?? ctx.cwd } : {}),
|
|
274
295
|
});
|
|
275
|
-
return text(`Created ${a
|
|
296
|
+
return text(`Created ${artifactTextLabel(a)}`, createArtifactDetails("artifact.create", a));
|
|
276
297
|
} catch (e) {
|
|
277
298
|
throw new Error(`papyrus_create failed: ${e instanceof Error ? e.message : e}`);
|
|
278
299
|
}
|
|
@@ -295,7 +316,7 @@ export default async function (pi: ExtensionAPI) {
|
|
|
295
316
|
try {
|
|
296
317
|
const rows = await callService<Record<string, unknown>, Artifact[]>("artifact.query", { ...params, limit: params.limit ?? 50 });
|
|
297
318
|
if (rows.length === 0) return text("No artifacts found.", createArtifactListDetails("artifact.query", rows));
|
|
298
|
-
const lines = rows.map((
|
|
319
|
+
const lines = artifactTextLines(rows).map((line, index) => `${index + 1}. ${line}`);
|
|
299
320
|
return text(`${rows.length} artifact(s):\n\n${lines.join("\n")}`, createArtifactListDetails("artifact.query", rows));
|
|
300
321
|
} catch (e) {
|
|
301
322
|
throw new Error(`papyrus_query failed: ${e instanceof Error ? e.message : e}`);
|
|
@@ -332,12 +353,15 @@ export default async function (pi: ExtensionAPI) {
|
|
|
332
353
|
try {
|
|
333
354
|
if (params.action === "link") {
|
|
334
355
|
await callService("graph.link", { from: params.from!, relation: params.relation!, to: params.to! });
|
|
335
|
-
const
|
|
356
|
+
const names = await artifactNamesById([params.from!, params.to!]);
|
|
357
|
+
const output = `Linked "${names.get(params.from!) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to!) ?? "unknown artifact"}"`;
|
|
336
358
|
return text(output, createPreviewDetails("graph.link", "Artifact relationship", output));
|
|
337
359
|
}
|
|
338
360
|
if (params.action === "unlink") {
|
|
339
361
|
const result = await callService<Record<string, unknown>, { removed: boolean }>("graph.unlink", { from: params.from!, relation: params.relation!, to: params.to! });
|
|
340
|
-
const
|
|
362
|
+
const names = await artifactNamesById([params.from!, params.to!]);
|
|
363
|
+
const relationship = `"${names.get(params.from!) ?? "unknown artifact"}" --${params.relation}--> "${names.get(params.to!) ?? "unknown artifact"}"`;
|
|
364
|
+
const output = result.removed ? `Unlinked ${relationship}` : `No such relationship: ${relationship}`;
|
|
341
365
|
return text(output, createPreviewDetails("graph.unlink", "Artifact relationship", output));
|
|
342
366
|
}
|
|
343
367
|
if (params.action === "tree") {
|
|
@@ -351,22 +375,25 @@ export default async function (pi: ExtensionAPI) {
|
|
|
351
375
|
if (!a) throw new Error(`artifact ${root} not found`);
|
|
352
376
|
const edges = a.edges ?? [];
|
|
353
377
|
if (edges.length === 0) return text(`${a.title} — no edges`, createGraphDetails("graph.tree", [a], []));
|
|
378
|
+
const names = await artifactNamesById(edges.flatMap((edge) => [edge.from, edge.to]));
|
|
354
379
|
return text(
|
|
355
|
-
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((edge
|
|
380
|
+
`Subgraph from ${a.title} (${edges.length} edges):\n\n${edges.map((edge) => ` "${names.get(edge.from) ?? "unknown artifact"}" --${edge.relation}--> "${names.get(edge.to) ?? "unknown artifact"}"`).join("\n")}`,
|
|
356
381
|
createGraphDetails("graph.tree", [a], edges),
|
|
357
382
|
);
|
|
358
383
|
}
|
|
359
384
|
if (params.action === "status") {
|
|
360
385
|
const a = await callService<Record<string, unknown>, Artifact | null>("graph.status", { id: params.id!, status: params.status! });
|
|
361
386
|
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
362
|
-
return text(`Updated ${a.
|
|
387
|
+
return text(`Updated "${a.title}" → [${a.status}]`, createArtifactDetails("graph.status", a));
|
|
363
388
|
}
|
|
364
389
|
if (params.action === "history") {
|
|
365
390
|
const page = await callService<Record<string, unknown>, { events: Array<Record<string, unknown>> }>("graph.history", {
|
|
366
391
|
id: params.id, actor: params.actor, session_id: params.session_id, since: params.since, limit: params.limit,
|
|
367
392
|
});
|
|
368
393
|
if (page.events.length === 0) return text("No recorded events.", createPreviewDetails("graph.history", "Mutation event log", "No recorded events."));
|
|
369
|
-
const
|
|
394
|
+
const eventIds = page.events.map((event) => event["artifactId"]).filter((id): id is string => typeof id === "string");
|
|
395
|
+
const names = await artifactNamesById(eventIds);
|
|
396
|
+
const output = page.events.map((event) => `${event["occurredAt"]} "${typeof event["artifactId"] === "string" ? names.get(event["artifactId"]) ?? "unknown artifact" : "unknown artifact"}" ${event["type"]} · ${event["actor"]}/${event["source"]}`).join("\n");
|
|
370
397
|
return text(output, createPreviewDetails("graph.history", "Mutation event log", output));
|
|
371
398
|
}
|
|
372
399
|
throw new Error(`unknown action: ${params.action}; use link, tree, status, or history`);
|
|
@@ -397,12 +424,13 @@ export default async function (pi: ExtensionAPI) {
|
|
|
397
424
|
max_nodes: params.max_nodes,
|
|
398
425
|
});
|
|
399
426
|
if (!a) throw new Error(`artifact ${params.id} not found`);
|
|
400
|
-
let out = `${a
|
|
427
|
+
let out = `${artifactTextLabel(a)}\n\n${a.body}`;
|
|
401
428
|
if (Object.keys(a.extra).length > 0) {
|
|
402
429
|
out += `\n\nMetadata:\n${formatMetadata(a.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
403
430
|
}
|
|
404
431
|
if (a.edges?.length) {
|
|
405
|
-
|
|
432
|
+
const names = await artifactNamesById(a.edges.flatMap((edge) => [edge.from, edge.to]));
|
|
433
|
+
out += `\n\nEdges:\n${a.edges.map((edge) => ` "${names.get(edge.from) ?? "unknown artifact"}" --${edge.relation}--> "${names.get(edge.to) ?? "unknown artifact"}"`).join("\n")}`;
|
|
406
434
|
}
|
|
407
435
|
if (params.run_gates) {
|
|
408
436
|
const results = await callService<Record<string, unknown>, GateResult[]>("gates.run", { id: params.id });
|
|
@@ -86,12 +86,12 @@ export async function showPlaybooks(ctx: ExtensionCommandContext): Promise<void>
|
|
|
86
86
|
const relation = await commandCtx.ui.select("Relation", PLAYBOOK_RELATIONS);
|
|
87
87
|
if (!relation) return;
|
|
88
88
|
await callService("graph.link", { from: playbook.id, relation, to: targetId });
|
|
89
|
-
commandCtx.ui.notify(`Linked ${playbook.
|
|
89
|
+
commandCtx.ui.notify(`Linked "${playbook.title}" via ${relation}`, "info");
|
|
90
90
|
return;
|
|
91
91
|
}
|
|
92
92
|
const operation = choice === "Disable" ? "playbooks.disable" : "playbooks.enable";
|
|
93
93
|
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: playbook.id });
|
|
94
|
-
commandCtx.ui.notify(`${updated.
|
|
94
|
+
commandCtx.ui.notify(`${updated.title} \u2192 [${updated.status}]`, "info");
|
|
95
95
|
},
|
|
96
96
|
});
|
|
97
97
|
}
|
package/extension/src/rules.ts
CHANGED
|
@@ -44,7 +44,7 @@ export async function showRules(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
44
44
|
} else {
|
|
45
45
|
const operation = choice === "Disable" ? "rules.disable" : "rules.enable";
|
|
46
46
|
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: rule.id });
|
|
47
|
-
commandCtx.ui.notify(`${updated.
|
|
47
|
+
commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
48
48
|
}
|
|
49
49
|
},
|
|
50
50
|
});
|
package/extension/src/skills.ts
CHANGED
|
@@ -32,11 +32,11 @@ export function skillRowMeta(skill: Artifact): string {
|
|
|
32
32
|
|
|
33
33
|
export function skillInvocationPrompt(skill: Artifact): string {
|
|
34
34
|
if (skill.subtype === "artifact-template") {
|
|
35
|
-
return [`Create an artifact using Papyrus template \"${skill.title}\".`, `
|
|
35
|
+
return [`Create an artifact using Papyrus template \"${skill.title}\".`, `template_name: ${skill.title}`, "Ask for or infer the title and all required template fields, then call the skills domain tool with action=instantiate."].join("\n");
|
|
36
36
|
}
|
|
37
37
|
if (skill.subtype === "workflow") {
|
|
38
38
|
return [
|
|
39
|
-
`Run Papyrus workflow Skill \"${skill.title}\"
|
|
39
|
+
`Run Papyrus workflow Skill \"${skill.title}\".`,
|
|
40
40
|
"Collect its required arguments, then call the skills domain tool with action=run.",
|
|
41
41
|
].join("\n");
|
|
42
42
|
}
|
|
@@ -44,7 +44,7 @@ export function skillInvocationPrompt(skill: Artifact): string {
|
|
|
44
44
|
const steps = strings(skill.extra["steps"]);
|
|
45
45
|
const tools = strings(skill.extra["tools"]);
|
|
46
46
|
return [
|
|
47
|
-
`Apply Papyrus skill \"${skill.title}\"
|
|
47
|
+
`Apply Papyrus skill \"${skill.title}\".`,
|
|
48
48
|
`Trigger: ${trigger}`,
|
|
49
49
|
...(skill.body ? [`Context: ${skill.body}`] : []),
|
|
50
50
|
...(steps.length > 0 ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
|
|
@@ -120,7 +120,7 @@ export async function showSkills(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
120
120
|
} else {
|
|
121
121
|
const operation = choice === "Disable" ? "skills.disable" : "skills.enable";
|
|
122
122
|
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: skill.id });
|
|
123
|
-
commandCtx.ui.notify(`${updated.
|
|
123
|
+
commandCtx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
124
124
|
}
|
|
125
125
|
},
|
|
126
126
|
});
|
package/extension/src/tasks.ts
CHANGED
|
@@ -32,6 +32,12 @@ const STATUS_ACTIONS: Record<string, string[]> = {
|
|
|
32
32
|
|
|
33
33
|
type TaskRow = Artifact;
|
|
34
34
|
|
|
35
|
+
function taskChoiceLabels(tasks: readonly Artifact[]): string[] {
|
|
36
|
+
const titleCounts = new Map<string, number>();
|
|
37
|
+
for (const task of tasks) titleCounts.set(task.title, (titleCounts.get(task.title) ?? 0) + 1);
|
|
38
|
+
return tasks.map((task) => titleCounts.get(task.title)! > 1 ? `${task.title} (${task.id})` : task.title);
|
|
39
|
+
}
|
|
40
|
+
|
|
35
41
|
export interface TaskHierarchyRow {
|
|
36
42
|
task: TaskRow;
|
|
37
43
|
depth: number;
|
|
@@ -101,9 +107,10 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
101
107
|
if (scope === "graph") {
|
|
102
108
|
const projectGraph = await loadTaskGraph(ctx.cwd, sessionId, "project");
|
|
103
109
|
const roots = projectGraph.rootIds.map((id) => projectGraph.nodes.find((node) => node.task.id === id)?.task).filter((task): task is Artifact => task !== undefined);
|
|
104
|
-
const
|
|
110
|
+
const rootLabels = taskChoiceLabels(roots);
|
|
111
|
+
const selected = await ctx.ui.select("Focused root or epic", rootLabels);
|
|
105
112
|
if (!selected) continue;
|
|
106
|
-
rootTaskId = roots.
|
|
113
|
+
rootTaskId = roots[rootLabels.indexOf(selected)]?.id;
|
|
107
114
|
if (!rootTaskId) continue;
|
|
108
115
|
}
|
|
109
116
|
await callService("tasks.set_scope", { project_root: ctx.cwd, scope, ...(rootTaskId ? { root_task_id: rootTaskId } : {}) });
|
|
@@ -131,17 +138,20 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
131
138
|
|
|
132
139
|
if (choice === "Remove dependency" || choice === "Remove from parent") {
|
|
133
140
|
const relatedIds = choice === "Remove dependency" ? node!.dependencyIds : node!.parentIds;
|
|
134
|
-
const
|
|
141
|
+
const relatedTasks = relatedIds.map((relatedId) => graph.nodes.find((entry) => entry.task.id === relatedId)?.task).filter((task): task is Artifact => task !== undefined);
|
|
142
|
+
const relatedTitles = taskChoiceLabels(relatedTasks);
|
|
135
143
|
const selected = await ctx.ui.select(choice === "Remove dependency" ? "Remove which dependency?" : "Remove from which parent?", relatedTitles);
|
|
136
144
|
if (!selected) continue;
|
|
137
|
-
const
|
|
145
|
+
const relatedTask = relatedTasks[relatedTitles.indexOf(selected)];
|
|
146
|
+
if (!relatedTask) continue;
|
|
147
|
+
const relatedId = relatedTask.id;
|
|
138
148
|
try {
|
|
139
149
|
if (choice === "Remove dependency") {
|
|
140
150
|
await callService("tasks.undepend", { id: action.row.id, dependency_id: relatedId, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
141
|
-
ctx.ui.notify(`Removed dependency on ${
|
|
151
|
+
ctx.ui.notify(`Removed dependency on ${relatedTask.title}`, "info");
|
|
142
152
|
} else {
|
|
143
153
|
await callService("tasks.uncontain", { parent_id: relatedId, child_id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
144
|
-
ctx.ui.notify(`Removed from parent ${
|
|
154
|
+
ctx.ui.notify(`Removed from parent ${relatedTask.title}`, "info");
|
|
145
155
|
}
|
|
146
156
|
} catch (error) {
|
|
147
157
|
ctx.ui.notify(`Relationship removal failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
@@ -222,19 +232,20 @@ export async function showTasks(ctx: ExtensionCommandContext): Promise<void> {
|
|
|
222
232
|
const gates = result.gates.map((gate) => `${gate.passed ? "✓" : "✗"} ${gate.gate.type}: ${gate.gate.target}`).join("\n");
|
|
223
233
|
const checklist = result.checklist.map((item) => `${item.accepted ? "✓" : "✗"} proof: ${item.item}`).join("\n");
|
|
224
234
|
const focused = result.focused ? `\nActive: ${result.focused.title}` : "";
|
|
235
|
+
const taskById = new Map(graph.nodes.map((entry) => [entry.task.id, entry.task]));
|
|
225
236
|
const blocked = result.blocked.length > 0
|
|
226
|
-
? `\nWaiting: ${result.blocked.map((entry) => `${entry.artifact.title} needs ${entry.dependencyIds.join(", ")}`).join("; ")}`
|
|
237
|
+
? `\nWaiting: ${result.blocked.map((entry) => `${entry.artifact.title} needs ${entry.dependencyIds.map((id) => taskById.get(id)?.title ?? "unknown task").join(", ")}`).join("; ")}`
|
|
227
238
|
: "";
|
|
228
239
|
ctx.ui.notify(
|
|
229
240
|
result.completed
|
|
230
|
-
? `Completed ${result.artifact.
|
|
241
|
+
? `Completed ${result.artifact.title}${focused}${blocked}${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`
|
|
231
242
|
: `Review rejected${checklist ? `\n${checklist}` : ""}${gates ? `\n${gates}` : ""}`,
|
|
232
243
|
result.completed ? "info" : "warning",
|
|
233
244
|
);
|
|
234
245
|
} else {
|
|
235
246
|
const updated = await callService<Record<string, unknown>, Artifact>(operation, { id: action.row.id, actor: "user", source: "tasks-tui", session_id: sessionId });
|
|
236
247
|
action.row.status = updated.status;
|
|
237
|
-
ctx.ui.notify(`${updated.
|
|
248
|
+
ctx.ui.notify(`${updated.title} → [${updated.status}]`, "info");
|
|
238
249
|
}
|
|
239
250
|
} catch (error) {
|
|
240
251
|
ctx.ui.notify(`Task action failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
@@ -85,7 +85,7 @@ export class ArtifactCard implements Component {
|
|
|
85
85
|
const status = `${statusGlyph(artifact.status)} ${artifact.status}`;
|
|
86
86
|
const header = [
|
|
87
87
|
this.theme.fg("toolTitle", this.theme.bold(`${kindGlyph(artifact.kind)} ${artifact.kind.toUpperCase()}`)),
|
|
88
|
-
this.theme.fg("accent", artifact.id),
|
|
88
|
+
...(this.expanded ? [this.theme.fg("accent", artifact.id)] : []),
|
|
89
89
|
this.theme.fg(statusColor(artifact.status), status),
|
|
90
90
|
].join(" ");
|
|
91
91
|
const lines = [truncateToWidth(header, safeWidth)];
|
package/package.json
CHANGED
package/src/constants.ts
CHANGED
|
@@ -100,6 +100,9 @@ export const SKILL_MAX_RENDERED_BYTES = 1_048_576;
|
|
|
100
100
|
export const SKILL_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
|
|
101
101
|
export const SKILL_INVOCATION_MAX_CALL_DEPTH = 4;
|
|
102
102
|
export const PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS = 20;
|
|
103
|
+
export const PLAYBOOK_ARGUMENT_MAX_COUNT = 20;
|
|
104
|
+
export const PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH = 64;
|
|
105
|
+
export const PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH = 500;
|
|
103
106
|
|
|
104
107
|
/**
|
|
105
108
|
* At the core, a workflow Skill creates Tasks and begins a pipeline -- an Ansible playbook or
|
|
@@ -172,6 +175,9 @@ export const DISCUSSION_ACTOR_MAX_LENGTH = 128;
|
|
|
172
175
|
export const DISCUSSION_OPTIONS_MIN_COUNT = 2;
|
|
173
176
|
export const DISCUSSION_OPTIONS_MAX_COUNT = 10;
|
|
174
177
|
export const DISCUSSION_OPTION_MAX_LENGTH = 200;
|
|
178
|
+
/** The multi-select picker's idle auto-cancel countdown and its render tick (also drives the cursor-row blink). Single-select has no equivalent -- it delegates to Pi's own native ctx.ui.select, whose input loop this package does not control. */
|
|
179
|
+
export const DISCUSSION_PICKER_IDLE_TIMEOUT_MS = 30_000;
|
|
180
|
+
export const DISCUSSION_PICKER_TICK_MS = 500;
|
|
175
181
|
/** Bounds for the generic graph projection protocol (external bounded contexts). */
|
|
176
182
|
export const GRAPH_PROJECTION_MAX_ARTIFACTS_PER_BATCH = 500;
|
|
177
183
|
export const GRAPH_PROJECTION_MAX_EDGES_PER_BATCH = 1_000;
|
package/src/domain-services.ts
CHANGED
|
@@ -4,6 +4,9 @@ import {
|
|
|
4
4
|
ARTIFACT_LABEL_MAX_LENGTH,
|
|
5
5
|
ARTIFACT_SCOPE_MAX_ARTIFACTS,
|
|
6
6
|
ARTIFACT_TITLE_MAX_LENGTH,
|
|
7
|
+
PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH,
|
|
8
|
+
PLAYBOOK_ARGUMENT_MAX_COUNT,
|
|
9
|
+
PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH,
|
|
7
10
|
PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS,
|
|
8
11
|
RULE_TEXT_HARD_LIMIT_CHARACTERS,
|
|
9
12
|
SKILL_INVOCATION_MAX_CALL_DEPTH,
|
|
@@ -440,7 +443,7 @@ export function updateSkill(artifacts: ArtifactStore, id: string, input: UpdateS
|
|
|
440
443
|
|
|
441
444
|
function skillInvocationBody(skill: Artifact): string {
|
|
442
445
|
if (skill.subtype === "artifact-template") {
|
|
443
|
-
return `Create an artifact using Papyrus template "${skill.title}".\
|
|
446
|
+
return `Create an artifact using Papyrus template "${skill.title}".\ntemplate_name: ${skill.title}\nAsk for or infer all required template fields, then call the skills domain tool instantiate action.`;
|
|
444
447
|
}
|
|
445
448
|
if (skill.subtype === "workflow") {
|
|
446
449
|
const definition = validateSkillDefinition(skill.extra["definition"]);
|
|
@@ -448,7 +451,7 @@ function skillInvocationBody(skill: Artifact): string {
|
|
|
448
451
|
.filter(([, input]) => input.required && input.default === undefined)
|
|
449
452
|
.map(([name]) => name);
|
|
450
453
|
return [
|
|
451
|
-
`Run Papyrus workflow Skill "${skill.title}"
|
|
454
|
+
`Run Papyrus workflow Skill "${skill.title}".`,
|
|
452
455
|
`Required arguments: ${required.length > 0 ? required.join(", ") : "none"}.`,
|
|
453
456
|
"Call the skills domain tool with action=run and arguments after collecting required values.",
|
|
454
457
|
].join("\n");
|
|
@@ -457,7 +460,7 @@ function skillInvocationBody(skill: Artifact): string {
|
|
|
457
460
|
const steps = Array.isArray(skill.extra["steps"]) ? skill.extra["steps"].filter((step): step is string => typeof step === "string") : [];
|
|
458
461
|
const tools = Array.isArray(skill.extra["tools"]) ? skill.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
|
|
459
462
|
return [
|
|
460
|
-
`Apply Papyrus skill "${skill.title}"
|
|
463
|
+
`Apply Papyrus skill "${skill.title}".`,
|
|
461
464
|
`Trigger: ${trigger}`,
|
|
462
465
|
...(skill.body ? [`Context: ${skill.body}`] : []),
|
|
463
466
|
...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
|
|
@@ -489,16 +492,16 @@ export function skillInvocation(artifacts: ArtifactStore, id: string, visited: S
|
|
|
489
492
|
const target = artifacts.get(edge.to);
|
|
490
493
|
if (!target) continue; // dangling edge -- defensive, should not happen
|
|
491
494
|
if (target.kind !== "skill") {
|
|
492
|
-
linkedArtifactLines.push(`- ${edge.relation} ${target.kind} "${target.title}"
|
|
495
|
+
linkedArtifactLines.push(`- ${edge.relation} ${target.kind} "${target.title}"`);
|
|
493
496
|
continue;
|
|
494
497
|
}
|
|
495
498
|
if (visited.has(target.id)) {
|
|
496
|
-
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}"
|
|
499
|
+
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" -- already invoked above in this chain, not repeated.`);
|
|
497
500
|
} else if (depth + 1 > SKILL_INVOCATION_MAX_CALL_DEPTH) {
|
|
498
|
-
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}"
|
|
501
|
+
linkedSkillSections.push(`Also linked via ${edge.relation} to skill "${target.title}" -- call depth limit reached, invoke it separately.`);
|
|
499
502
|
} else {
|
|
500
503
|
const nested = skillInvocation(artifacts, target.id, visited, depth + 1);
|
|
501
|
-
linkedSkillSections.push(`Also invoke linked skill (${edge.relation}) "${target.title}"
|
|
504
|
+
linkedSkillSections.push(`Also invoke linked skill (${edge.relation}) "${target.title}":\n${nested}`);
|
|
502
505
|
}
|
|
503
506
|
}
|
|
504
507
|
if (linkedArtifactLines.length > 0) {
|
|
@@ -522,12 +525,46 @@ export function transitionSkill(artifacts: ArtifactStore, id: string, action: Sk
|
|
|
522
525
|
* mechanically instantiated into other artifacts; a Playbook is never instantiated, it's read
|
|
523
526
|
* and followed, and it never composes other Playbooks the way a Skill can call another Skill.
|
|
524
527
|
*/
|
|
528
|
+
export interface PlaybookArgument {
|
|
529
|
+
name: string;
|
|
530
|
+
description?: string;
|
|
531
|
+
/** Defaults true: naming an argument at all is a signal it matters, so an author must opt out explicitly to make one optional. */
|
|
532
|
+
required: boolean;
|
|
533
|
+
}
|
|
534
|
+
|
|
535
|
+
/** Rejects malformed input rather than silently dropping a bad entry -- the same posture creation validation already takes everywhere else. */
|
|
536
|
+
function validatePlaybookArguments(value: unknown): PlaybookArgument[] | undefined {
|
|
537
|
+
if (value === undefined) return undefined;
|
|
538
|
+
if (!Array.isArray(value)) throw new Error("playbook arguments must be an array");
|
|
539
|
+
if (value.length > PLAYBOOK_ARGUMENT_MAX_COUNT) throw new Error(`playbook arguments cannot exceed ${PLAYBOOK_ARGUMENT_MAX_COUNT} entries`);
|
|
540
|
+
const seen = new Set<string>();
|
|
541
|
+
return value.map((entry, index) => {
|
|
542
|
+
if (typeof entry !== "object" || entry === null || Array.isArray(entry)) throw new Error(`argument at index ${index} must be an object`);
|
|
543
|
+
const record = entry as Record<string, unknown>;
|
|
544
|
+
const name = record["name"];
|
|
545
|
+
if (typeof name !== "string" || name.trim().length === 0 || name.length > PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH) {
|
|
546
|
+
throw new Error(`argument name must be between 1 and ${PLAYBOOK_ARGUMENT_NAME_MAX_LENGTH} characters`);
|
|
547
|
+
}
|
|
548
|
+
if (seen.has(name)) throw new Error(`argument name "${name}" is declared more than once`);
|
|
549
|
+
seen.add(name);
|
|
550
|
+
const description = record["description"];
|
|
551
|
+
if (description !== undefined && (typeof description !== "string" || description.length > PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH)) {
|
|
552
|
+
throw new Error(`argument "${name}" description cannot exceed ${PLAYBOOK_ARGUMENT_DESCRIPTION_MAX_LENGTH} characters`);
|
|
553
|
+
}
|
|
554
|
+
const required = record["required"];
|
|
555
|
+
if (required !== undefined && typeof required !== "boolean") throw new Error(`argument "${name}" required must be a boolean`);
|
|
556
|
+
return { name, ...(description !== undefined ? { description: description as string } : {}), required: required !== false };
|
|
557
|
+
});
|
|
558
|
+
}
|
|
559
|
+
|
|
525
560
|
export interface CreatePlaybookInput {
|
|
526
561
|
title: string;
|
|
527
562
|
body?: string;
|
|
528
563
|
trigger?: string;
|
|
529
564
|
steps?: string[];
|
|
530
565
|
tools?: string[];
|
|
566
|
+
/** Declares named arguments this Playbook needs -- see playbookInvocation for how a missing required one surfaces. */
|
|
567
|
+
arguments?: unknown;
|
|
531
568
|
labels?: string[];
|
|
532
569
|
extra?: Record<string, unknown>;
|
|
533
570
|
projectRoot?: string;
|
|
@@ -538,6 +575,7 @@ export type UpdatePlaybookInput = UpdateContentInput;
|
|
|
538
575
|
|
|
539
576
|
export function createPlaybook(artifacts: ArtifactStore, scopes: ArtifactScopeStore, input: CreatePlaybookInput, context?: ArtifactEventContext): Artifact {
|
|
540
577
|
const projectRoot = input.projectRoot === undefined ? undefined : normalizeProjectRoot(input.projectRoot);
|
|
578
|
+
const declaredArguments = validatePlaybookArguments(input.arguments);
|
|
541
579
|
const playbook = artifacts.create({
|
|
542
580
|
kind: "playbook",
|
|
543
581
|
status: "active", // explicit; see createDocument for why defaultStatusFor is not trusted here
|
|
@@ -549,6 +587,7 @@ export function createPlaybook(artifacts: ArtifactStore, scopes: ArtifactScopeSt
|
|
|
549
587
|
...(input.trigger ? { trigger: input.trigger } : {}),
|
|
550
588
|
...(input.steps ? { steps: input.steps } : {}),
|
|
551
589
|
...(input.tools ? { tools: input.tools } : {}),
|
|
590
|
+
...(declaredArguments ? { arguments: declaredArguments } : {}),
|
|
552
591
|
},
|
|
553
592
|
}, context);
|
|
554
593
|
scopes.assign(playbook.id, projectRoot, projectRoot === undefined ? "unscoped" : "explicit");
|
|
@@ -587,22 +626,40 @@ export function transitionPlaybook(artifacts: ArtifactStore, id: string, action:
|
|
|
587
626
|
return artifacts.setStatus(id, target, context)!;
|
|
588
627
|
}
|
|
589
628
|
|
|
590
|
-
/**
|
|
591
|
-
|
|
629
|
+
/**
|
|
630
|
+
* Renders trigger/steps/tools/arguments into readable guidance, plus any real linked artifacts.
|
|
631
|
+
* No nested playbook-calls-playbook composition -- a Playbook is a flat procedure, not a
|
|
632
|
+
* composable bundle. `provided` is the caller's already-known argument values (e.g. from the
|
|
633
|
+
* conversation so far); any declared *required* argument missing from it is called out
|
|
634
|
+
* explicitly, directing the agent to discuss (live:true) rather than guess or silently proceed.
|
|
635
|
+
*/
|
|
636
|
+
export function playbookInvocation(artifacts: ArtifactStore, id: string, provided: Record<string, string> = {}): string {
|
|
592
637
|
const playbook = requireKind(artifacts, id, "playbook");
|
|
593
638
|
const trigger = typeof playbook.extra["trigger"] === "string" ? playbook.extra["trigger"] : "manual invocation";
|
|
594
639
|
const steps = Array.isArray(playbook.extra["steps"]) ? playbook.extra["steps"].filter((step): step is string => typeof step === "string") : [];
|
|
595
640
|
const tools = Array.isArray(playbook.extra["tools"]) ? playbook.extra["tools"].filter((tool): tool is string => typeof tool === "string") : [];
|
|
641
|
+
const declaredArguments = Array.isArray(playbook.extra["arguments"]) ? (playbook.extra["arguments"] as PlaybookArgument[]) : [];
|
|
642
|
+
const argumentLines = declaredArguments.map((argument) => {
|
|
643
|
+
const value = provided[argument.name];
|
|
644
|
+
if (value !== undefined) return `- ${argument.name}: ${value}`;
|
|
645
|
+
const qualifier = argument.required ? "required" : "optional";
|
|
646
|
+
return `- ${argument.name} (${qualifier}${argument.description ? `: ${argument.description}` : ""}) -- not yet provided`;
|
|
647
|
+
});
|
|
648
|
+
const missingRequired = declaredArguments.filter((argument) => argument.required && provided[argument.name] === undefined);
|
|
596
649
|
const sections = [[
|
|
597
|
-
`Apply Papyrus playbook "${playbook.title}"
|
|
650
|
+
`Apply Papyrus playbook "${playbook.title}".`,
|
|
598
651
|
`Trigger: ${trigger}`,
|
|
599
652
|
...(playbook.body ? [`Context: ${playbook.body}`] : []),
|
|
653
|
+
...(argumentLines.length > 0 ? ["Arguments:", ...argumentLines] : []),
|
|
654
|
+
...(missingRequired.length > 0
|
|
655
|
+
? [`Missing required argument(s): ${missingRequired.map((argument) => argument.name).join(", ")}. Ask the human for these directly -- the discuss tool with live:true asks synchronously and gets a real answer in this same turn -- before proceeding with the steps below. Do not guess or invent a value.`]
|
|
656
|
+
: []),
|
|
600
657
|
...(steps.length ? ["Steps:", ...steps.map((step, index) => `${index + 1}. ${step}`)] : []),
|
|
601
658
|
...(tools.length ? [`Tools: ${tools.join(", ")}`] : []),
|
|
602
659
|
].join("\n")];
|
|
603
660
|
const edges = artifacts.relationships({ artifactIds: [id] }).filter((edge) => edge.from === id).slice(0, PLAYBOOK_INVOCATION_MAX_LINKED_ARTIFACTS);
|
|
604
661
|
const linkedLines = edges
|
|
605
|
-
.map((edge) => { const target = artifacts.get(edge.to); return target ? `- ${edge.relation} ${target.kind} "${target.title}"
|
|
662
|
+
.map((edge) => { const target = artifacts.get(edge.to); return target ? `- ${edge.relation} ${target.kind} "${target.title}"` : undefined; })
|
|
606
663
|
.filter((line): line is string => line !== undefined);
|
|
607
664
|
if (linkedLines.length > 0) sections.push(["Linked context (query Papyrus for full detail before proceeding):", ...linkedLines].join("\n"));
|
|
608
665
|
return sections.join("\n\n");
|
package/src/modules/playbooks.ts
CHANGED
|
@@ -61,12 +61,13 @@ export function playbooksOperations(artifacts: ArtifactStore, scopes: ArtifactSc
|
|
|
61
61
|
define("playbooks.create", (input: OperationInput) => createPlaybook(artifacts, scopes, {
|
|
62
62
|
title: string(input, "title"), body: optionalString(input, "body"), trigger: optionalString(input, "trigger"),
|
|
63
63
|
steps: input["steps"] as string[] | undefined, tools: input["tools"] as string[] | undefined,
|
|
64
|
+
arguments: input["arguments"],
|
|
64
65
|
labels: input["labels"] as string[] | undefined, extra: input["extra"] as Record<string, unknown> | undefined,
|
|
65
66
|
projectRoot: optionalString(input, "project_root"),
|
|
66
67
|
}, eventContext(input))),
|
|
67
68
|
define("playbooks.list", (input: OperationInput) => listPlaybooks(artifacts, scopes, artifactFilter(input))),
|
|
68
69
|
define("playbooks.show", (input: OperationInput) => showPlaybook(artifacts, string(input, "id"))),
|
|
69
|
-
define("playbooks.invoke", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"))),
|
|
70
|
+
define("playbooks.invoke", (input: OperationInput) => playbookInvocation(artifacts, string(input, "id"), input["arguments"] as Record<string, string> | undefined)),
|
|
70
71
|
define("playbooks.enable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "enable", eventContext(input))),
|
|
71
72
|
define("playbooks.disable", (input: OperationInput) => transitionPlaybook(artifacts, string(input, "id"), "disable", eventContext(input))),
|
|
72
73
|
define("playbooks.assign_project", (input: OperationInput) => assignPlaybookProject(artifacts, scopes, string(input, "id"), optionalString(input, "project_root"))),
|
package/src/task-context.ts
CHANGED
|
@@ -29,7 +29,7 @@ function renderCurrent(task: Artifact): string[] {
|
|
|
29
29
|
const desired = task.body.trim() || task.title;
|
|
30
30
|
const gates = gatesFrom(task);
|
|
31
31
|
return [
|
|
32
|
-
`Current: ${task.title}
|
|
32
|
+
`Current: ${task.title}`,
|
|
33
33
|
`Desired: ${desired}`,
|
|
34
34
|
`Verify: ${gates.length > 0 ? gates.map(renderGate).join("; ") : "inspect the desired outcome; no automated gates configured"}`,
|
|
35
35
|
];
|
|
@@ -56,7 +56,7 @@ function deferredBlockingDiscussions(artifacts: ArtifactStore, activeTaskId: str
|
|
|
56
56
|
if (!inScope(edge.to, activeTaskId, taskIds)) continue;
|
|
57
57
|
const blockedTask = artifacts.get(edge.to);
|
|
58
58
|
if (!blockedTask || blockedTask.status === "done" || blockedTask.status === "canceled") continue;
|
|
59
|
-
lines.push(`${discussion.title}
|
|
59
|
+
lines.push(`${discussion.title} -- blocks "${blockedTask.title}"`);
|
|
60
60
|
}
|
|
61
61
|
}
|
|
62
62
|
return lines;
|
|
@@ -77,8 +77,8 @@ export function taskContext(artifacts: ArtifactStore, activeTaskId?: string, tas
|
|
|
77
77
|
const rejected = open.filter((task) => task.status === "rejected").slice(0, TASK_CONTEXT_REJECTED_LIMIT);
|
|
78
78
|
const lines = tasks.length > 0 ? [`Progress: ${done}/${tasks.length} done`] : [];
|
|
79
79
|
for (const task of current) lines.push(...renderCurrent(task));
|
|
80
|
-
if (next) lines.push(`Next: ${next.title}
|
|
81
|
-
if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) =>
|
|
80
|
+
if (next) lines.push(`Next: ${next.title}`);
|
|
81
|
+
if (rejected.length > 0) lines.push(`Rejected: ${rejected.map((task) => task.title).join(", ")}`);
|
|
82
82
|
if (deferredDiscussions.length > 0) {
|
|
83
83
|
lines.push("", "Deferred discussions blocking this scope -- resume and re-surface these, do not leave them dormant:");
|
|
84
84
|
for (const line of deferredDiscussions) lines.push(`• ${line}`);
|