@danypops/pi-papyrus 0.55.5 → 0.55.7

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 CHANGED
@@ -17,7 +17,7 @@ Agent-facing domain tools own lifecycle invariants and sit above this store API.
17
17
  - **docs** (`docs_create`, `docs_list`, `docs_show`, `docs_activate`, `docs_archive`, `docs_reopen`, `docs_link`, `docs_assign_project`, `docs_update`) — activate/archive/reopen and document-safe graph links; Note mutations remain behind the Notes facade
18
18
  - **rules** (`rules_create`, `rules_list`, `rules_show`, `rules_preview`, `rules_enable`, `rules_disable`, `rules_gate`, `rules_assign_project`, `rules_update`) — enable/disable and attach governance gates to tasks
19
19
  - **playbooks** (`playbooks_create`, `playbooks_list`, `playbooks_show`, `playbooks_invoke`, `playbooks_preview`, `playbooks_enable`, `playbooks_disable`, `playbooks_assign_project`, `playbooks_update`, `playbooks_contain`, `playbooks_uncontain`, `playbooks_depend`, `playbooks_undepend`) — a trigger and an ordered list of steps. Each step is a plain prose string (a task), or a structured object: `{kind:'doc',title,body?,subtype?,labels?}` creates a Doc, `{kind:'rule',title,body?,condition?,action?,severity?,labels?}` creates a Rule, `{kind:'call',title,playbookId,arguments?}` nests another Playbook's own run as a pipeline step gated in the same sequence, `{kind:'task',title?,body}` is an explicit task step. `playbooks_invoke` recycles the shared blueprint materialization engine: it compiles the steps and any `contain`/`depend` composition into real artifacts (a Task per plain/task step, a Doc/Rule per doc/rule step, a nested run per call step), wires task-like steps with `dependsOn` so completing one auto-focuses the next, and focuses the first real task. No text dump — one step surfaces at a time, as it becomes the focused task, same as any other Task. `playbooks_contain`/`playbooks_uncontain` nest a child Playbook inside a parent (its steps run after the parent's own, as part of it); `playbooks_depend`/`playbooks_undepend` chain a prerequisite Playbook before another (it must fully complete first) -- both are whole-Playbook composition, distinct from a `call` step's finer-grained, single-step nesting. `playbooks_preview` renders the whole tree as text with no side effects, for reading before invoking. A Playbook can declare named arguments (`{name, description?, required?, type?('string'|'number'|'boolean', default 'string'), enum?, default?}`, required defaults true; referenced in step text/call arguments as `{{name}}`); invoking with a required one unsupplied creates nothing and reports exactly which are still missing, directing the agent to ask via `discuss_open`/`discuss_reply` with `live:true` rather than guess
20
- - **discuss** (`discuss_open`, `discuss_reply`, `discuss_defer`, `discuss_resume`, `discuss_settle`, `discuss_block`, `discuss_unblock`, `discuss_show`, `discuss_rounds`, `discuss_list`) — a Discussion persists across multiple rounds and can genuinely block a Task's completion until settled or deferred (`discuss_block`/`discuss_unblock`). `discuss_reply` is refused once deferred or settled -- `discuss_resume` first. `discuss_open`/`discuss_reply` can pose a structured choice via `options` (2-10 entries, each a bare string or `{title, description}`) + `options_mode` (`single`/`multi`); a later `discuss_reply` answers it via `selected`, validated against it. `live: true` on either gets the human's answer synchronously in the same tool call -- the operation itself always durably records the round first (exactly like every other Vehicle operation), then an optional local prompt (the pending choice's picker if one was posed, otherwise a freeform question) runs via a per-operation `interactiveFollowUps` resolver (see `@danypops/vehicle-client-pi`), degrading silently to the plain async round when there's no interactive UI
20
+ - **discuss** (`discuss_open`, `discuss_reply`, `discuss_defer`, `discuss_resume`, `discuss_settle`, `discuss_block`, `discuss_unblock`, `discuss_show`, `discuss_rounds`, `discuss_list`) — a Discussion persists across multiple rounds and can genuinely block a Task's completion until settled or deferred (`discuss_block`/`discuss_unblock`). `discuss_reply` is refused once deferred or settled -- `discuss_resume` first. `discuss_open`/`discuss_reply` can pose a structured choice via `options` (2-10 entries, each a bare string or `{title, description}`) + `options_mode` (`single`/`multi`); a later `discuss_reply` answers it via `selected`, validated against it. Adding `correct_options` + `explanation` turns that same choice into a graded quiz/knowledge assessment -- see "Quiz assessments" below. `live: true` on either gets the human's answer synchronously in the same tool call -- the operation itself always durably records the round first (exactly like every other Vehicle operation), then an optional local prompt (the pending choice's picker if one was posed, otherwise a freeform question) runs via a per-operation `interactiveFollowUps` resolver (see `@danypops/vehicle-client-pi`), degrading silently to the plain async round when there's no interactive UI
21
21
 
22
22
  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.
23
23
 
@@ -61,7 +61,7 @@ Blocking is real: `tasks.complete` is refused while any `active` Discussion has
61
61
 
62
62
  `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.
63
63
 
64
- 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/Playbooks) -- `/discuss` browses and drives existing ones.
64
+ 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. A quiz's options display lettered (A, B, C, ...) in this same picker. Opening a *new* Discussion is left to the agent (same as Docs/Rules/Playbooks) -- `/discuss` browses and drives existing ones.
65
65
 
66
66
  ```bash
67
67
  papyrus discuss open --title "Naming" --actor alice --content "Should we rename this?" --blocks-json '["task-id"]' --json
@@ -74,6 +74,34 @@ papyrus discuss settle <discussion-id> --settlement "Agreed: renaming to X" --js
74
74
  papyrus discuss show <discussion-id> --json
75
75
  ```
76
76
 
77
+ ### Quiz assessments
78
+
79
+ A posed choice becomes a graded knowledge assessment by adding `correct_options` (one or more entries drawn verbatim from `options` -- exact text, never an index or a display letter) + `explanation` (**required**, always shown after grading -- especially when the answer is wrong) to `discuss_open`/`discuss_reply`. Options are dynamic (any count from 2 up to the enforced 10-option ceiling, not hard-coded to four) and display lettered A, B, C, ... in every surface (the live picker, the `/discuss` transcript, the tool-call card) -- single letters always suffice since the 10-option ceiling sits well under the 26-letter limit.
80
+
81
+ - A `"single"` quiz (the participant can only pick one) must have exactly one correct option; a `"multi"` quiz may have several, graded correct iff the reply's `selected` set exactly matches the correct set -- no partial credit.
82
+ - The correct answer is never exposed before submission: `discuss_open`'s own response, and every `discuss_show`/`discuss_rounds` read of an unanswered quiz, carries only a `quiz: true` marker on that round -- never the answer. Server-side, it's held in dedicated hidden storage that Discuss's own general-purpose read queries structurally cannot select, not merely omitted at the API layer.
83
+ - Once answered, the round that carries `selected` also carries `quizResult: { correct, correctOptions, explanation }` -- the durable, permanent record of what was asked, what was picked, and whether it was right, so a later turn can adapt to demonstrated knowledge.
84
+ - Malformed quizzes are rejected: too few options (below the existing 2-option floor), a duplicate/unknown `correct_options` entry, more than one correct option under `"single"` mode, or `correct_options`/`explanation` given without the other.
85
+
86
+ ```bash
87
+ # A four-option quiz with one correct answer.
88
+ papyrus discuss open --title "Geography check" --actor agent \
89
+ --content "What is the capital of France?" \
90
+ --options-json '["Paris","London","Berlin","Madrid"]' --options-mode single \
91
+ --correct-options-json '["Paris"]' \
92
+ --explanation "Paris has been the capital of France since 987 AD." --json
93
+
94
+ # A "select all that apply" quiz with more than one correct option.
95
+ papyrus discuss open --title "Primes check" --actor agent \
96
+ --content "Which of these are prime numbers?" \
97
+ --options-json '["2","3","4","9"]' --options-mode multi \
98
+ --correct-options-json '["2","3"]' \
99
+ --explanation "2 and 3 are prime; 4 and 9 are not." --json
100
+
101
+ # The participant answers like any other posed choice -- grading and the explanation come back in the same call.
102
+ papyrus discuss reply <discussion-id> --actor human --content "Paris" --selected-json '["Paris"]' --json
103
+ ```
104
+
77
105
  ## Tasks
78
106
 
79
107
  Run `/tasks` for the interactive task panel:
@@ -15,7 +15,7 @@
15
15
  * human-authored inbox.
16
16
  */
17
17
 
18
- import { type Artifact, type DiscussionAndRounds, readDiscussionExtra } from "@danypops/papyrus";
18
+ import { type Artifact, type DiscussionAndRounds, quizOptionLabel, readDiscussionExtra } from "@danypops/papyrus";
19
19
  import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
20
20
  import { showArtifactBrowser } from "../artifact/artifact-browser.ts";
21
21
  import { DISCUSSION_STATE_PRESENTATION, DOC_STATUS_PRESENTATION } from "../artifact/artifact-status-presentation.ts";
@@ -111,27 +111,37 @@ export async function showDiscussions(ctx: ExtensionCommandContext): Promise<voi
111
111
  const transcript = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.show", { id: discussion.id });
112
112
  const question = transcript.rounds.at(-1)?.content?.trim() || `Reply to "${discussion.title}":`;
113
113
  const subtitle = discussion.title;
114
+ // A quiz's options display lettered (A, B, C, ...) in the picker -- the correct answer itself
115
+ // never reaches this client before submission (see domain/discussion.ts's own comment on why).
114
116
  const answer =
115
117
  pending?.pendingOptions && pending.pendingOptions.length > 0 && pending.pendingOptionsMode
116
118
  ? await askQuestion(commandCtx, {
117
119
  question,
118
120
  subtitle,
119
121
  options: pending.pendingOptions.map((title, index) => ({
120
- title,
122
+ title: pending.pendingIsQuiz ? `${quizOptionLabel(index)}. ${title}` : title,
121
123
  description: pending.pendingOptionDescriptions?.[index] || undefined,
122
124
  })),
123
125
  allowMultiple: pending.pendingOptionsMode === "multi",
124
126
  })
125
127
  : await askQuestion(commandCtx, { question, subtitle });
126
128
  if (!answer) return; // canceled
127
- await callService("discuss.reply", {
129
+ const replied = await callService<Record<string, unknown>, DiscussionAndRounds>("discuss.reply", {
128
130
  id: discussion.id,
129
131
  actor: ACTOR,
130
132
  content: answer.content,
131
133
  ...(answer.selected ? { selected: answer.selected } : {}),
132
134
  source: SOURCE,
133
135
  });
134
- commandCtx.ui.notify(answer.selected ? `Selected: ${answer.selected.join(", ")}` : "Reply added.", "info");
136
+ const quizResult = replied.rounds[0]?.quizResult;
137
+ const message = quizResult
138
+ ? quizResult.correct
139
+ ? `✅ Correct! ${quizResult.explanation}`
140
+ : `❌ Incorrect -- correct answer(s): ${quizResult.correctOptions.join(", ")}. ${quizResult.explanation}`
141
+ : answer.selected
142
+ ? `Selected: ${answer.selected.join(", ")}`
143
+ : "Reply added.";
144
+ commandCtx.ui.notify(message, "info");
135
145
  return;
136
146
  }
137
147
  if (choice === "Defer") {
@@ -15,6 +15,7 @@ import {
15
15
  ARTIFACT_DETAIL_RESERVED_ROWS,
16
16
  type Artifact,
17
17
  type DiscussionRound,
18
+ quizOptionLabel,
18
19
  readDiscussionExtra,
19
20
  } from "@danypops/papyrus";
20
21
  import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
@@ -126,17 +127,48 @@ class DiscussionTranscriptViewport {
126
127
  const roundHeader =
127
128
  theme.fg("accent", `[round ${round.roundNumber}] `) + theme.bold(round.actor) + theme.fg("dim", ` · ${round.occurredAt}`);
128
129
  const body = renderMarkdownBody(round.content, width - 2, this.activeTheme).map((line) => ({ text: ` ${line}` }));
130
+ // Quiz options get lettered (A, B, C, ...) and a distinct "Quiz" label; a plain, non-quiz posed
131
+ // choice keeps its existing comma-joined "Posed" presentation unchanged (backward compatible).
129
132
  const posed =
130
133
  round.options && round.options.length > 0
131
134
  ? [
132
135
  {
133
- text: ` ${theme.fg("muted", `Posed (${round.optionsMode === "multi" ? "pick several" : "pick one"}): ${round.options.join(", ")}`)}`,
136
+ text: ` ${theme.fg(
137
+ "muted",
138
+ round.quiz
139
+ ? `Quiz (${round.optionsMode === "multi" ? "pick all that apply" : "pick one"}): ${round.options
140
+ .map((option, optionIndex) => `${quizOptionLabel(optionIndex)}. ${option}`)
141
+ .join(" ")}`
142
+ : `Posed (${round.optionsMode === "multi" ? "pick several" : "pick one"}): ${round.options.join(", ")}`,
143
+ )}`,
134
144
  },
135
145
  ]
136
146
  : [];
137
147
  const picked =
138
148
  round.selected && round.selected.length > 0 ? [{ text: ` ${theme.fg("success", `Selected: ${round.selected.join(", ")}`)}` }] : [];
139
- return [{ text: roundHeader }, ...body, ...posed, ...picked, ...(index < this.rounds.length - 1 ? [{ text: "" }] : [])];
149
+ // The graded verdict -- always includes the explanation, especially when wrong. Only ever
150
+ // present on the round that actually answered a pending quiz (see domain/discussion.ts).
151
+ const quizVerdict = round.quizResult
152
+ ? [
153
+ {
154
+ text: ` ${theme.fg(
155
+ round.quizResult.correct ? "success" : "error",
156
+ round.quizResult.correct
157
+ ? "✅ Correct!"
158
+ : `❌ Incorrect -- correct answer(s): ${round.quizResult.correctOptions.join(", ")}.`,
159
+ )}`,
160
+ },
161
+ { text: ` ${theme.fg("muted", round.quizResult.explanation)}` },
162
+ ]
163
+ : [];
164
+ return [
165
+ { text: roundHeader },
166
+ ...body,
167
+ ...posed,
168
+ ...picked,
169
+ ...quizVerdict,
170
+ ...(index < this.rounds.length - 1 ? [{ text: "" }] : []),
171
+ ];
140
172
  });
141
173
  this.lines = [...header, ...(transcript.length > 0 ? transcript : [{ text: theme.fg("muted", "No rounds recorded.") }])];
142
174
  this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
@@ -1,4 +1,4 @@
1
- import type { Artifact } from "@danypops/papyrus";
1
+ import { type Artifact, type DiscussionQuizResult, quizOptionLabel } from "@danypops/papyrus";
2
2
  import { expandHint } from "@danypops/vehicle-client-pi/expand-hint";
3
3
  import type { Theme } from "@earendil-works/pi-coding-agent";
4
4
  import { type Component, truncateToWidth } from "@earendil-works/pi-tui";
@@ -7,11 +7,16 @@ import { detailViewTheme, measure, statusColor, statusGlyph } from "../../tool-r
7
7
  import { isArtifact, isArtifactArray, type RenderableDiscussionParent } from "./shared.ts";
8
8
 
9
9
  /** A Discussion round -- discuss.open/reply/show/rounds' own transcript entry. Detected the
10
- * same name-independent, shape-based way as the others in this directory. */
10
+ * same name-independent, shape-based way as the others in this directory. quiz/quizResult are
11
+ * the two quiz-safe fields (see domain/discussion.ts) -- optional, so a plain non-quiz round
12
+ * renders exactly as before. */
11
13
  export interface DiscussionRoundOutput {
12
14
  roundNumber: number;
13
15
  actor: string;
14
16
  content: string;
17
+ options?: string[];
18
+ quiz?: boolean;
19
+ quizResult?: DiscussionQuizResult;
15
20
  }
16
21
 
17
22
  export function isDiscussionRound(value: unknown): value is DiscussionRoundOutput {
@@ -55,10 +60,27 @@ export function isDiscussionListOutput(value: unknown): value is DiscussionListO
55
60
  return isArtifactArray(row.discussions);
56
61
  }
57
62
 
63
+ /** Appended to a quiz round's own body text -- lettered options when posed, the graded verdict +
64
+ * explanation (always shown, especially when wrong) once answered. A plain, non-quiz round's
65
+ * body is untouched (backward compatible). */
66
+ function quizBodySuffix(round: DiscussionRoundOutput): string {
67
+ const posed =
68
+ round.quiz && round.options && round.options.length > 0
69
+ ? `\n${round.options.map((option, index) => `${quizOptionLabel(index)}. ${option}`).join(" ")}`
70
+ : "";
71
+ const verdict = round.quizResult
72
+ ? `\n${round.quizResult.correct ? "✅ Correct!" : `❌ Incorrect -- correct answer(s): ${round.quizResult.correctOptions.join(", ")}.`} ${round.quizResult.explanation}`
73
+ : "";
74
+ return `${posed}${verdict}`;
75
+ }
76
+
58
77
  export function roundsSection(rounds: readonly DiscussionRoundOutput[]): DetailSection {
59
78
  return {
60
79
  heading: `Rounds (${rounds.length}):`,
61
- items: rounds.map((round) => ({ byline: `${round.actor} · round ${round.roundNumber}`, body: round.content })),
80
+ items: rounds.map((round) => ({
81
+ byline: `${round.actor} · round ${round.roundNumber}`,
82
+ body: `${round.content}${quizBodySuffix(round)}`,
83
+ })),
62
84
  };
63
85
  }
64
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/pi-papyrus",
3
- "version": "0.55.5",
3
+ "version": "0.55.7",
4
4
  "description": "Pi host extension for Papyrus: native tools, TUI panels, and context injection over the daemon-backed graph store",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
@@ -12,23 +12,23 @@
12
12
  "typecheck": "tsc --noEmit -p tsconfig.json"
13
13
  },
14
14
  "peerDependencies": {
15
- "@danypops/vehicle-client-pi": "^0.42.0",
15
+ "@danypops/vehicle-client-pi": "^0.42.1",
16
16
  "@earendil-works/pi-coding-agent": "*",
17
17
  "@earendil-works/pi-tui": "*",
18
18
  "typebox": "*"
19
19
  },
20
20
  "dependencies": {
21
- "@danypops/jittor": "^0.18.1",
22
- "@danypops/papyrus": "^0.54.2",
23
- "@danypops/vehicle-client": "^0.10.0",
24
- "@danypops/vehicle-core": "^0.12.3",
25
- "@danypops/vehicle-server": "^0.18.1",
21
+ "@danypops/jittor": "^0.19.1",
22
+ "@danypops/papyrus": "^0.59.0",
23
+ "@danypops/vehicle-client": "^0.10.1",
24
+ "@danypops/vehicle-core": "^0.17.1",
25
+ "@danypops/vehicle-server": "^0.24.5",
26
26
  "beautiful-mermaid": "1.1.3",
27
27
  "malevich-tui-components": "^0.24.0"
28
28
  },
29
29
  "devDependencies": {
30
30
  "@danypops/pi-tui-harness": "^0.0.2",
31
- "@danypops/vehicle-client-pi": "^0.42.0",
31
+ "@danypops/vehicle-client-pi": "^0.42.1",
32
32
  "@danypops/vehicle-conformance": "^0.3.0",
33
33
  "@earendil-works/pi-coding-agent": "^0.80.10",
34
34
  "bun-types": "latest",