@danypops/papyrus 0.9.0 → 0.10.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 CHANGED
@@ -9,7 +9,7 @@ Artifacts are rows in SQLite. Edges are typed relations. Kinds and relations are
9
9
  ```text
10
10
  Pi tools + TUI
11
11
 
12
- tasks / docs / rules / skills domain tools
12
+ tasks / notes / docs / rules / skills domain tools
13
13
 
14
14
  Papyrus client → authenticated loopback daemon
15
15
 
@@ -40,6 +40,10 @@ papyrus tasks focus <task-id>
40
40
  papyrus tasks pause
41
41
  papyrus tasks unpause
42
42
  papyrus tasks complete <task-id>
43
+
44
+ # Deferred human-intent inbox
45
+ papyrus notes capture "Review release provenance later"
46
+ papyrus notes list --json
43
47
  ```
44
48
 
45
49
  For repository work, install the versioned ownership guard once:
@@ -94,7 +98,8 @@ The `papyrus_*` tools are the low-level graph-store API:
94
98
  Agent-facing domain tools own lifecycle invariants and sit above this store API:
95
99
 
96
100
  - **`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
97
- - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links
101
+ - **`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
102
+ - **`docs`** — create/list/show, activate/archive/reopen, and document-safe graph links; Note mutations remain behind the Notes facade
98
103
  - **`rules`** — create/list/show/preview, enable/disable, and attach governance gates to tasks
99
104
  - **`skills`** — create/list/show/invoke/run, enable/disable, create compatibility templates, and atomically instantiate parameterized workflow runs
100
105
 
@@ -105,11 +110,29 @@ Internally, application services depend on the `ArtifactStore` and `GateRunner`
105
110
  ## Interactive frontends
106
111
 
107
112
  - `/tasks` — project/focused-graph scope, task lifecycle, append-only history, gates, dependencies, and nested metadata
108
- - `/docs`searchable documents, lifecycle, details, and graph links
113
+ - `/note <request>` directly capture one project-scoped deferred request without creating a Task
114
+ - `/notes` — searchable project Notes inbox with consume, promote, and disposition-aware archive actions
115
+ - `/docs` — searchable non-Note documents, lifecycle, details, and graph links
109
116
  - `/rules` — severity/condition rows, exact injection preview, enable/disable, and task gating
110
117
  - `/skills` — trigger/tools rows, invocation into the editor, and artifact templates
111
118
 
112
- All four use daemon-backed domain operations; none opens SQLite from the Pi process.
119
+ All frontends use daemon-backed domain operations; none opens SQLite from the Pi process.
120
+
121
+ ## Notes
122
+
123
+ Notes are project-scoped `doc/note` artifacts for human requests that should be considered later. Capturing a Note does not create work, inject the entire inbox into prompts, or imply acceptance. The agent can use the `notes` domain tool to list and consume open Notes, decide whether to create a Task, Doc, Rule, or Skill through its owning domain tool, then promote the Note by linking that artifact. Archive requires one of `completed`, `duplicate`, `declined`, or `superseded`; promote archives with a `promoted` disposition and target ID. Capture, consumption, and disposition provenance remain in bounded Note history.
124
+
125
+ The default inbox contains draft and consumed/active Notes, is bounded to 50 rows, and has a hard limit of 200. Bodies are capped at 10,000 characters. Generic document and graph lifecycle operations reject Note mutations so they cannot bypass disposition provenance.
126
+
127
+ ```bash
128
+ papyrus notes capture "Investigate the retry policy" --json
129
+ papyrus notes list --limit 25 --json
130
+ papyrus notes show <note-id> --json
131
+ papyrus notes consume <note-id> --json
132
+ # Create the resulting artifact with tasks/docs/rules/skills first, then:
133
+ papyrus notes promote <note-id> <target-id> --reason "Converted to tracked work" --json
134
+ papyrus notes archive <note-id> declined --reason "No longer relevant" --json
135
+ ```
113
136
 
114
137
  ## Tasks
115
138
 
@@ -18,6 +18,7 @@ export interface ArtifactBrowserConfig {
18
18
  statusOrder: string[];
19
19
  glyphs: Record<string, string>;
20
20
  listOperation?: OperationName;
21
+ listInput?: Record<string, unknown>;
21
22
  rowMeta(row: Artifact): string;
22
23
  actions(row: Artifact): string[];
23
24
  handleAction(choice: string, row: Artifact, ctx: ExtensionCommandContext): Promise<void>;
@@ -46,6 +47,7 @@ async function loadArtifacts(config: ArtifactBrowserConfig): Promise<Artifact[]>
46
47
  return callService<Record<string, unknown>, Artifact[]>(config.listOperation ?? "artifact.query", {
47
48
  kind: config.kind,
48
49
  limit: BROWSER_QUERY_LIMIT,
50
+ ...(config.listInput ?? {}),
49
51
  });
50
52
  }
51
53
 
@@ -53,9 +55,11 @@ export async function showArtifactDetails(
53
55
  ctx: ExtensionCommandContext,
54
56
  id: string,
55
57
  operation: OperationName = "artifact.show",
58
+ input: Record<string, unknown> = {},
56
59
  ): Promise<void> {
57
60
  const artifact = await callService<Record<string, unknown>, Artifact | null>(operation, {
58
61
  id,
62
+ ...input,
59
63
  tree: true,
60
64
  depth: DETAIL_GRAPH_DEPTH,
61
65
  max_nodes: DETAIL_GRAPH_NODES,
@@ -7,6 +7,7 @@ import type { TaskExecutionPlan } from "../../src/task-execution.ts";
7
7
  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
+ import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
10
11
  import { callService } from "./service-client.ts";
11
12
 
12
13
  function text(message: string, details: Record<string, unknown> = {}) {
@@ -159,6 +160,51 @@ export function registerDomainTools(pi: ExtensionAPI): void {
159
160
  },
160
161
  });
161
162
 
163
+ pi.registerTool({
164
+ name: "notes",
165
+ label: "Notes",
166
+ description: "Deferred human-intent inbox. ACTIONS: capture, list, show, consume, promote, archive. Capture stores a request without creating work. Consume marks it considered. To promote, first create the resulting Task, Doc, Rule, or Skill through its domain tool, then link it with target_id. Archive requires an explicit disposition.",
167
+ parameters: Type.Object({
168
+ action: Type.String(),
169
+ id: Type.Optional(Type.String()),
170
+ body: Type.Optional(Type.String()),
171
+ title: Type.Optional(Type.String()),
172
+ status: Type.Optional(Type.Union([Type.Literal("draft"), Type.Literal("active"), Type.Literal("archived")])),
173
+ text: Type.Optional(Type.String()),
174
+ limit: Type.Optional(Type.Number()),
175
+ target_id: Type.Optional(Type.String()),
176
+ disposition: Type.Optional(Type.Union(NOTE_DISPOSITIONS.map((value) => Type.Literal(value)))),
177
+ reason: Type.Optional(Type.String()),
178
+ session_id: Type.Optional(Type.String()),
179
+ project_root: Type.Optional(Type.String()),
180
+ }),
181
+ async execute(_id, params, _signal, _onUpdate, ctx) {
182
+ try {
183
+ const action = params.action;
184
+ const request = { ...params, project_root: params.project_root ?? ctx.cwd, actor: "agent", source: "notes-tool" };
185
+ if (action === "capture") {
186
+ const artifact = await callService<Record<string, unknown>, Artifact>("notes.capture", request);
187
+ return text(`Captured note ${artifactLine(artifact)}`, { artifact });
188
+ }
189
+ if (action === "list") {
190
+ const rows = await callService<Record<string, unknown>, Artifact[]>("notes.list", request);
191
+ return text(rows.length ? rows.map(artifactLine).join("\n") : "No open notes.", { rows });
192
+ }
193
+ if (action === "show") {
194
+ const artifact = await callService<Record<string, unknown>, Artifact>("notes.show", request);
195
+ return text(`${artifactLine(artifact)}\n\n${artifact.body}`, { artifact });
196
+ }
197
+ const operations = { consume: "notes.consume", promote: "notes.promote", archive: "notes.archive" } as const;
198
+ const operation = operations[action as keyof typeof operations];
199
+ if (!operation) return text(`Unknown notes action: ${action}`);
200
+ const artifact = await callService<Record<string, unknown>, Artifact>(operation, request);
201
+ return text(`${action}: ${artifactLine(artifact)}`, { artifact });
202
+ } catch (error) {
203
+ return text(`notes failed: ${error instanceof Error ? error.message : error}`);
204
+ }
205
+ },
206
+ });
207
+
162
208
  pi.registerTool({
163
209
  name: "docs",
164
210
  label: "Documents",
@@ -318,9 +318,10 @@ export default async function (pi: ExtensionAPI) {
318
318
  // ── Interactive artifact browsers ──────────────────────────────────
319
319
 
320
320
  // Lazy imports keep TUI components out of non-interactive startup paths.
321
- const [tasksModule, docsModule, rulesModule, skillsModule] = await Promise.all([
321
+ const [tasksModule, docsModule, notesModule, rulesModule, skillsModule] = await Promise.all([
322
322
  import("./tasks.ts"),
323
323
  import("./docs.ts"),
324
+ import("./notes.ts"),
324
325
  import("./rules.ts"),
325
326
  import("./skills.ts"),
326
327
  ]);
@@ -338,6 +339,14 @@ export default async function (pi: ExtensionAPI) {
338
339
  description: "Browse and manage Papyrus documents (interactive)",
339
340
  handler: async (_args, ctx) => { await docsModule.showDocs(ctx); },
340
341
  });
342
+ pi.registerCommand("note", {
343
+ description: "Capture a deferred request directly in Papyrus",
344
+ handler: async (args, ctx) => { await notesModule.captureNote(args, ctx); },
345
+ });
346
+ pi.registerCommand("notes", {
347
+ description: "Browse and triage the project Notes inbox",
348
+ handler: async (_args, ctx) => { await notesModule.showNotes(ctx); },
349
+ });
341
350
  pi.registerCommand("rules", {
342
351
  description: "Browse, preview, and toggle Papyrus rules (interactive)",
343
352
  handler: async (_args, ctx) => { await rulesModule.showRules(ctx); },
@@ -0,0 +1,90 @@
1
+ import type { ExtensionCommandContext } from "@earendil-works/pi-coding-agent";
2
+ import { NOTE_DISPOSITIONS } from "../../src/note-service.ts";
3
+ import type { Artifact } from "../../src/domain/artifact.ts";
4
+ import { showArtifactBrowser, showArtifactDetails } from "./artifact-browser.ts";
5
+ import { callService } from "./service-client.ts";
6
+
7
+ const NOTE_GLYPHS: Record<string, string> = { draft: "○", active: "●", archived: "■" };
8
+
9
+ export function noteRowMeta(note: Artifact): string {
10
+ const history = Array.isArray(note.extra["noteHistory"]) ? note.extra["noteHistory"].length : 0;
11
+ return `${history} event${history === 1 ? "" : "s"}`;
12
+ }
13
+
14
+ export function noteCaptureInput(request: string, projectRoot: string): Record<string, unknown> | null {
15
+ const body = request.trim();
16
+ if (!body) return null;
17
+ return { body, project_root: projectRoot, actor: "human", source: "note-command" };
18
+ }
19
+
20
+ export async function captureNote(request: string, ctx: ExtensionCommandContext): Promise<Artifact | null> {
21
+ const input = noteCaptureInput(request, ctx.cwd);
22
+ if (!input) {
23
+ ctx.ui.notify("Usage: /note <request for later>", "warning");
24
+ return null;
25
+ }
26
+ try {
27
+ const note = await callService<Record<string, unknown>, Artifact>("notes.capture", input);
28
+ ctx.ui.notify(`Captured note: ${note.title}`, "info");
29
+ return note;
30
+ } catch (error) {
31
+ ctx.ui.notify(`Note capture failed: ${error instanceof Error ? error.message : error}`, "error");
32
+ return null;
33
+ }
34
+ }
35
+
36
+ export async function showNotes(ctx: ExtensionCommandContext): Promise<void> {
37
+ await showArtifactBrowser(ctx, {
38
+ kind: "note",
39
+ title: "Notes inbox",
40
+ listOperation: "notes.list",
41
+ listInput: { project_root: ctx.cwd },
42
+ statusOrder: ["draft", "active", "archived"],
43
+ glyphs: NOTE_GLYPHS,
44
+ rowMeta: noteRowMeta,
45
+ actions: (note) => [
46
+ "Show details",
47
+ ...(note.status === "draft" ? ["Consume"] : []),
48
+ "Promote",
49
+ "Archive",
50
+ ],
51
+ handleAction: async (choice, note, commandCtx) => {
52
+ if (choice === "Show details") {
53
+ await showArtifactDetails(commandCtx, note.id, "notes.show", { project_root: commandCtx.cwd });
54
+ return;
55
+ }
56
+ if (choice === "Consume") {
57
+ await callService("notes.consume", { id: note.id, project_root: commandCtx.cwd, actor: "human", source: "notes-tui" });
58
+ commandCtx.ui.notify(`Consumed ${note.title}`, "info");
59
+ return;
60
+ }
61
+ if (choice === "Promote") {
62
+ const targetId = await commandCtx.ui.input("Resulting artifact id:", "");
63
+ if (!targetId) return;
64
+ const reason = await commandCtx.ui.input("Disposition note (optional):", "");
65
+ await callService("notes.promote", {
66
+ id: note.id,
67
+ target_id: targetId,
68
+ project_root: commandCtx.cwd,
69
+ actor: "human",
70
+ source: "notes-tui",
71
+ ...(reason ? { reason } : {}),
72
+ });
73
+ commandCtx.ui.notify(`Promoted ${note.title} → ${targetId}`, "info");
74
+ return;
75
+ }
76
+ const disposition = await commandCtx.ui.select("Archive disposition", [...NOTE_DISPOSITIONS]);
77
+ if (!disposition) return;
78
+ const reason = await commandCtx.ui.input("Reason (optional):", "");
79
+ await callService("notes.archive", {
80
+ id: note.id,
81
+ disposition,
82
+ project_root: commandCtx.cwd,
83
+ actor: "human",
84
+ source: "notes-tui",
85
+ ...(reason ? { reason } : {}),
86
+ });
87
+ commandCtx.ui.notify(`Archived ${note.title} · ${disposition}`, "info");
88
+ },
89
+ });
90
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@danypops/papyrus",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
5
5
  "type": "module",
6
6
  "keywords": ["pi-package"],
package/src/cli.ts CHANGED
@@ -58,6 +58,12 @@ const USAGE = `Usage:
58
58
  papyrus service <install|start|stop|restart|status>
59
59
  papyrus migrate task-focus [--json]
60
60
  papyrus skills run <id> [--arguments-json <json>] [--run-id <id>] [--json]
61
+ papyrus notes capture <request> [--title <title>] [--json]
62
+ papyrus notes list [--status <draft|active|archived>] [--text <query>] [--limit <count>] [--json]
63
+ papyrus notes show <id> [--json]
64
+ papyrus notes consume <id> [--reason <reason>] [--json]
65
+ papyrus notes promote <id> <target-id> [--reason <reason>] [--json]
66
+ papyrus notes archive <id> <completed|duplicate|declined|superseded> [--reason <reason>] [--json]
61
67
  papyrus tasks plan [--json]
62
68
  papyrus tasks graph [--json]
63
69
  papyrus tasks active [--json]
@@ -85,7 +91,7 @@ function usage(): never {
85
91
 
86
92
  type TaskCliClient = Pick<PapyrusClient, "call">;
87
93
  type MigrationResult = { from: number; to: number; applied: string[] };
88
- type CliArtifact = { id: string; title: string; status: string };
94
+ type CliArtifact = { id: string; title: string; status: string; body?: string };
89
95
  type CliCompletion = Omit<TaskCompletion, "artifact" | "blocked"> & {
90
96
  artifact: CliArtifact;
91
97
  blocked: Array<Omit<TaskBlockage, "artifact"> & { artifact: CliArtifact }>;
@@ -168,6 +174,66 @@ export async function runSkillCli(args: string[], client: TaskCliClient, project
168
174
  ].join("\n");
169
175
  }
170
176
 
177
+ export async function runNoteCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
178
+ const json = args.includes("--json");
179
+ const positional: string[] = [];
180
+ let title: string | undefined;
181
+ let status: string | undefined;
182
+ let text: string | undefined;
183
+ let reason: string | undefined;
184
+ let limit: number | undefined;
185
+ for (let index = 0; index < args.length; index++) {
186
+ const argument = args[index]!;
187
+ if (argument === "--json") continue;
188
+ if (["--title", "--status", "--text", "--reason", "--limit"].includes(argument)) {
189
+ const value = args[++index];
190
+ if (value === undefined) throw new Error(`${argument} requires a value`);
191
+ if (argument === "--title") title = value;
192
+ else if (argument === "--status") status = value;
193
+ else if (argument === "--text") text = value;
194
+ else if (argument === "--reason") reason = value;
195
+ else {
196
+ limit = Number(value);
197
+ if (!Number.isInteger(limit)) throw new Error("--limit requires an integer");
198
+ }
199
+ continue;
200
+ }
201
+ if (argument.startsWith("--")) throw new Error(`unknown notes option ${argument}`);
202
+ positional.push(argument);
203
+ }
204
+ const [action, id, target] = positional;
205
+ let result: CliArtifact | CliArtifact[];
206
+ let human: string;
207
+ if (action === "capture") {
208
+ if (!id || target) throw new Error("notes capture requires exactly one request argument");
209
+ result = await client.call("notes.capture", { body: id, ...(title ? { title } : {}), project_root: projectRoot, actor: "human", source: "cli" }) as CliArtifact;
210
+ human = `Captured: ${artifactLabel(result)}`;
211
+ } else if (action === "list") {
212
+ if (id) throw new Error("notes list accepts no positional arguments");
213
+ result = await client.call("notes.list", { project_root: projectRoot, ...(status ? { status } : {}), ...(text ? { text } : {}), ...(limit === undefined ? {} : { limit }) }) as CliArtifact[];
214
+ human = result.length > 0 ? result.map((note) => `[${note.status}] ${artifactLabel(note)}`).join("\n") : "No open notes.";
215
+ } else if (action === "show") {
216
+ if (!id || target) throw new Error("notes show requires exactly one note id");
217
+ result = await client.call("notes.show", { id, project_root: projectRoot }) as CliArtifact;
218
+ human = `${artifactLabel(result)}\n\n${result.body ?? ""}`.trimEnd();
219
+ } else if (action === "consume") {
220
+ if (!id || target) throw new Error("notes consume requires exactly one note id");
221
+ result = await client.call("notes.consume", { id, project_root: projectRoot, actor: "agent", source: "cli", ...(reason ? { reason } : {}) }) as CliArtifact;
222
+ human = `Consumed: ${artifactLabel(result)}`;
223
+ } else if (action === "promote") {
224
+ if (!id || !target || positional.length !== 3) throw new Error("notes promote requires a note id and target artifact id");
225
+ result = await client.call("notes.promote", { id, target_id: target, project_root: projectRoot, actor: "agent", source: "cli", ...(reason ? { reason } : {}) }) as CliArtifact;
226
+ human = `Promoted: ${artifactLabel(result)} → ${target}`;
227
+ } else if (action === "archive") {
228
+ if (!id || !target || positional.length !== 3) throw new Error("notes archive requires a note id and disposition");
229
+ result = await client.call("notes.archive", { id, disposition: target, project_root: projectRoot, actor: "human", source: "cli", ...(reason ? { reason } : {}) }) as CliArtifact;
230
+ human = `Archived: ${artifactLabel(result)} · ${target}`;
231
+ } else {
232
+ throw new Error("notes action must be capture, list, show, consume, promote, or archive");
233
+ }
234
+ return json ? JSON.stringify(result) : human;
235
+ }
236
+
171
237
  export async function runTaskCli(args: string[], client: TaskCliClient, projectRoot: string = process.cwd()): Promise<string> {
172
238
  const json = args.includes("--json");
173
239
  const positional: string[] = [];
@@ -357,6 +423,11 @@ export async function main(args: string[] = process.argv.slice(2)): Promise<void
357
423
  console.log(await runSkillCli(args.slice(1), client));
358
424
  return;
359
425
  }
426
+ if (command === "notes") {
427
+ const client = await connectPapyrusClient();
428
+ console.log(await runNoteCli(args.slice(1), client));
429
+ return;
430
+ }
360
431
  if (command === "migrate") {
361
432
  const client = await connectPapyrusClient();
362
433
  console.log(await runMigrationCli(args.slice(1), client));
package/src/constants.ts CHANGED
@@ -58,6 +58,14 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
58
58
  export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
59
59
  export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
60
60
  export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
61
+ /** Deferred human Note payload, inbox, and provenance bounds. */
62
+ export const NOTE_BODY_MAX_CHARACTERS = 10_000;
63
+ export const NOTE_TITLE_MAX_CHARACTERS = 80;
64
+ export const NOTE_LIST_DEFAULT_LIMIT = 50;
65
+ export const NOTE_LIST_MAX_LIMIT = 200;
66
+ export const NOTE_HISTORY_MAX_EVENTS = 20;
67
+ export const NOTE_PROVENANCE_MAX_LENGTH = 128;
68
+ export const NOTE_REASON_MAX_CHARACTERS = 2_000;
61
69
  /** Persisted project and focused-graph Task view bounds. */
62
70
  export const TASK_SCOPE_MAX_TASKS = 1_000;
63
71
  export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
@@ -39,8 +39,12 @@ export interface UpdateArtifactInput {
39
39
  export interface ArtifactQuery {
40
40
  kind?: string;
41
41
  status?: string;
42
+ statuses?: string[];
43
+ subtype?: string;
44
+ excludeSubtype?: string;
42
45
  text?: string;
43
46
  labels?: string[];
47
+ extraEquals?: Record<string, string | number | boolean>;
44
48
  limit?: number;
45
49
  }
46
50
 
@@ -1,6 +1,7 @@
1
1
  import type { Artifact, CreateArtifactInput } from "./domain/artifact.ts";
2
2
  import { validateSkillDefinition } from "./domain/skill-definition.ts";
3
3
  import type { ArtifactStore } from "./ports/artifact-store.ts";
4
+ import { NOTE_SUBTYPE } from "./note-service.ts";
4
5
 
5
6
  export interface ListFilter {
6
7
  status?: string;
@@ -15,6 +16,19 @@ function requireKind(artifacts: ArtifactStore, id: string, kind: string): Artifa
15
16
  return artifact;
16
17
  }
17
18
 
19
+ function rejectsNoteTemplate(artifacts: ArtifactStore, templateId: string | undefined, subtype: string | undefined): boolean {
20
+ if (subtype === NOTE_SUBTYPE) return true;
21
+ if (!templateId) return false;
22
+ const template = artifacts.get(templateId);
23
+ const defaults = template?.extra["defaults"];
24
+ return typeof defaults === "object" && defaults !== null && !Array.isArray(defaults)
25
+ && (defaults as Record<string, unknown>)["subtype"] === NOTE_SUBTYPE;
26
+ }
27
+
28
+ function requireNotesFacade(): never {
29
+ throw new Error("note creation requires notes.capture");
30
+ }
31
+
18
32
  export interface CreateDocumentInput {
19
33
  title: string;
20
34
  body?: string;
@@ -34,6 +48,7 @@ const DOCUMENT_TRANSITIONS: Record<DocumentTransition, { from: string[]; to: str
34
48
  };
35
49
 
36
50
  export function createDocument(artifacts: ArtifactStore, input: CreateDocumentInput): Artifact {
51
+ if (rejectsNoteTemplate(artifacts, input.templateId, input.subtype)) requireNotesFacade();
37
52
  return artifacts.create({
38
53
  kind: "doc",
39
54
  title: input.title,
@@ -46,23 +61,29 @@ export function createDocument(artifacts: ArtifactStore, input: CreateDocumentIn
46
61
  }
47
62
 
48
63
  export function listDocuments(artifacts: ArtifactStore, filter: ListFilter): Artifact[] {
49
- return artifacts.query({ kind: "doc", ...filter });
64
+ return artifacts.query({ kind: "doc", excludeSubtype: NOTE_SUBTYPE, ...filter });
65
+ }
66
+
67
+ function requireDocument(artifacts: ArtifactStore, id: string): Artifact {
68
+ const document = requireKind(artifacts, id, "doc");
69
+ if (document.subtype === NOTE_SUBTYPE) throw new Error("note access requires a notes.* operation");
70
+ return document;
50
71
  }
51
72
 
52
73
  export function showDocument(artifacts: ArtifactStore, id: string): Artifact {
53
- requireKind(artifacts, id, "doc");
74
+ requireDocument(artifacts, id);
54
75
  return artifacts.get(id, { tree: true })!;
55
76
  }
56
77
 
57
78
  export function transitionDocument(artifacts: ArtifactStore, id: string, action: DocumentTransition): Artifact {
58
- const document = requireKind(artifacts, id, "doc");
79
+ const document = requireDocument(artifacts, id);
59
80
  const transition = DOCUMENT_TRANSITIONS[action];
60
81
  if (!transition.from.includes(document.status)) throw new Error(`cannot ${action} document from ${document.status}`);
61
82
  return artifacts.setStatus(id, transition.to)!;
62
83
  }
63
84
 
64
85
  export function linkDocument(artifacts: ArtifactStore, id: string, relation: DocumentRelation, targetId: string): Artifact {
65
- requireKind(artifacts, id, "doc");
86
+ requireDocument(artifacts, id);
66
87
  if (!artifacts.get(targetId)) throw new Error(`target artifact "${targetId}" not found`);
67
88
  artifacts.link({ from: id, relation, to: targetId });
68
89
  return showDocument(artifacts, id);
@@ -165,6 +186,7 @@ export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput):
165
186
  throw new Error("workflow Skill definition cannot be mixed with legacy trigger, steps, or tools");
166
187
  }
167
188
  const definition = input.definition === undefined ? undefined : validateSkillDefinition(input.definition);
189
+ if (definition?.blueprints.docs.some((document) => document.subtype === NOTE_SUBTYPE)) requireNotesFacade();
168
190
  return artifacts.create({
169
191
  kind: "skill",
170
192
  subtype: definition ? "workflow" : undefined,
@@ -182,6 +204,7 @@ export function createSkill(artifacts: ArtifactStore, input: CreateSkillInput):
182
204
  }
183
205
 
184
206
  export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateArtifactTemplateInput): Artifact {
207
+ if (input.targetKind === "doc" && input.defaults?.["subtype"] === NOTE_SUBTYPE) requireNotesFacade();
185
208
  return artifacts.create({
186
209
  kind: "skill",
187
210
  subtype: "artifact-template",
@@ -197,6 +220,7 @@ export function createArtifactTemplate(artifacts: ArtifactStore, input: CreateAr
197
220
  }
198
221
 
199
222
  export function instantiateTemplate(artifacts: ArtifactStore, templateId: string, input: CreateArtifactInput): Artifact {
223
+ if (rejectsNoteTemplate(artifacts, templateId, input.subtype)) requireNotesFacade();
200
224
  return artifacts.create({ ...input, templateId });
201
225
  }
202
226
 
@@ -0,0 +1,205 @@
1
+ import {
2
+ NOTE_BODY_MAX_CHARACTERS,
3
+ NOTE_HISTORY_MAX_EVENTS,
4
+ NOTE_LIST_DEFAULT_LIMIT,
5
+ NOTE_LIST_MAX_LIMIT,
6
+ NOTE_PROVENANCE_MAX_LENGTH,
7
+ NOTE_REASON_MAX_CHARACTERS,
8
+ NOTE_TITLE_MAX_CHARACTERS,
9
+ TASK_PROJECT_ROOT_MAX_LENGTH,
10
+ } from "./constants.ts";
11
+ import type { Artifact } from "./domain/artifact.ts";
12
+ import { requireAtomicArtifactStore } from "./ports/atomic-artifact-store.ts";
13
+ import type { ArtifactStore } from "./ports/artifact-store.ts";
14
+
15
+ export const NOTE_SUBTYPE = "note";
16
+ export const NOTE_DISPOSITIONS = ["completed", "duplicate", "declined", "superseded"] as const;
17
+ export type NoteDisposition = typeof NOTE_DISPOSITIONS[number];
18
+
19
+ export interface NoteProvenance {
20
+ actor?: string;
21
+ source?: string;
22
+ sessionId?: string;
23
+ reason?: string;
24
+ }
25
+
26
+ export interface CaptureNoteInput extends NoteProvenance {
27
+ body: string;
28
+ title?: string;
29
+ projectRoot: string;
30
+ }
31
+
32
+ export interface ListNotesInput {
33
+ projectRoot: string;
34
+ status?: "draft" | "active" | "archived";
35
+ text?: string;
36
+ limit?: number;
37
+ }
38
+
39
+ export interface ArchiveNoteInput extends NoteProvenance {
40
+ projectRoot: string;
41
+ disposition: NoteDisposition;
42
+ }
43
+
44
+ interface NoteHistoryEvent {
45
+ action: "captured" | "consumed" | "promoted" | "archived";
46
+ at: string;
47
+ actor: string;
48
+ source: string;
49
+ sessionId?: string;
50
+ reason?: string;
51
+ targetId?: string;
52
+ disposition?: NoteDisposition | "promoted";
53
+ }
54
+
55
+ function requiredBounded(value: string, field: string, maximum: number): string {
56
+ const normalized = value.trim();
57
+ if (!normalized) throw new Error(`${field} is required`);
58
+ if (normalized.length > maximum) throw new Error(`${field} exceeds ${maximum} characters`);
59
+ return normalized;
60
+ }
61
+
62
+ function optionalBounded(value: string | undefined, field: string, maximum: number): string | undefined {
63
+ if (value === undefined) return undefined;
64
+ return requiredBounded(value, field, maximum);
65
+ }
66
+
67
+ function noteTitle(body: string, requested?: string): string {
68
+ if (requested !== undefined) return requiredBounded(requested, "note title", NOTE_TITLE_MAX_CHARACTERS);
69
+ const firstLine = body.split(/\r?\n/, 1)[0]!.replace(/\s+/g, " ").trim();
70
+ return firstLine.slice(0, NOTE_TITLE_MAX_CHARACTERS) || "Deferred note";
71
+ }
72
+
73
+ function provenance(input: NoteProvenance, defaults: { actor: string; source: string }): Omit<NoteHistoryEvent, "action" | "at"> {
74
+ return {
75
+ actor: optionalBounded(input.actor, "note actor", NOTE_PROVENANCE_MAX_LENGTH) ?? defaults.actor,
76
+ source: optionalBounded(input.source, "note source", NOTE_PROVENANCE_MAX_LENGTH) ?? defaults.source,
77
+ ...(input.sessionId ? { sessionId: requiredBounded(input.sessionId, "note session id", NOTE_PROVENANCE_MAX_LENGTH) } : {}),
78
+ ...(input.reason ? { reason: requiredBounded(input.reason, "note reason", NOTE_REASON_MAX_CHARACTERS) } : {}),
79
+ };
80
+ }
81
+
82
+ function history(artifact: Artifact): NoteHistoryEvent[] {
83
+ const value = artifact.extra["noteHistory"];
84
+ if (!Array.isArray(value)) return [];
85
+ return value.filter((entry): entry is NoteHistoryEvent => typeof entry === "object" && entry !== null && !Array.isArray(entry));
86
+ }
87
+
88
+ function appendHistory(artifact: Artifact, event: NoteHistoryEvent): Record<string, unknown> {
89
+ return {
90
+ ...artifact.extra,
91
+ noteHistory: [...history(artifact), event].slice(-NOTE_HISTORY_MAX_EVENTS),
92
+ };
93
+ }
94
+
95
+ function event(action: NoteHistoryEvent["action"], input: NoteProvenance, extra: Partial<NoteHistoryEvent> = {}): NoteHistoryEvent {
96
+ return {
97
+ action,
98
+ at: new Date().toISOString(),
99
+ ...provenance(input, { actor: action === "captured" ? "human" : "agent", source: "notes" }),
100
+ ...extra,
101
+ };
102
+ }
103
+
104
+ export class Notes {
105
+ constructor(private readonly artifacts: ArtifactStore) {}
106
+
107
+ capture(input: CaptureNoteInput): Artifact {
108
+ const projectRoot = requiredBounded(input.projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
109
+ const body = requiredBounded(input.body, "note body", NOTE_BODY_MAX_CHARACTERS);
110
+ const captured = event("captured", input);
111
+ return this.artifacts.create({
112
+ kind: "doc",
113
+ subtype: NOTE_SUBTYPE,
114
+ status: "draft",
115
+ title: noteTitle(body, input.title),
116
+ body,
117
+ labels: ["note", "inbox"],
118
+ extra: { projectRoot, noteHistory: [captured] },
119
+ });
120
+ }
121
+
122
+ list(input: ListNotesInput): Artifact[] {
123
+ const projectRoot = requiredBounded(input.projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
124
+ const limit = input.limit ?? NOTE_LIST_DEFAULT_LIMIT;
125
+ if (!Number.isInteger(limit) || limit < 1 || limit > NOTE_LIST_MAX_LIMIT) {
126
+ throw new Error(`note limit must be an integer from 1 to ${NOTE_LIST_MAX_LIMIT}`);
127
+ }
128
+ return this.artifacts.query({
129
+ kind: "doc",
130
+ subtype: NOTE_SUBTYPE,
131
+ ...(input.status ? { status: input.status } : { statuses: ["draft", "active"] }),
132
+ ...(input.text ? { text: input.text } : {}),
133
+ extraEquals: { projectRoot },
134
+ limit,
135
+ });
136
+ }
137
+
138
+ show(id: string, projectRoot: string): Artifact {
139
+ const note = this.requireNote(id);
140
+ this.requireProject(note, projectRoot);
141
+ return this.artifacts.get(id, { tree: true })!;
142
+ }
143
+
144
+ consume(id: string, input: NoteProvenance & { projectRoot: string }): Artifact {
145
+ const atomic = requireAtomicArtifactStore(this.artifacts);
146
+ return atomic.atomic(() => {
147
+ const note = this.requireNote(id);
148
+ this.requireProject(note, input.projectRoot);
149
+ if (note.status === "archived") throw new Error("cannot consume an archived note");
150
+ if (note.status === "active") return this.artifacts.get(id, { tree: true })!;
151
+ this.artifacts.setExtra(id, appendHistory(note, event("consumed", input)));
152
+ this.artifacts.setStatus(id, "active");
153
+ return this.artifacts.get(id, { tree: true })!;
154
+ });
155
+ }
156
+
157
+ promote(id: string, targetId: string, input: NoteProvenance & { projectRoot: string }): Artifact {
158
+ const atomic = requireAtomicArtifactStore(this.artifacts);
159
+ return atomic.atomic(() => {
160
+ const note = this.requireNote(id);
161
+ this.requireProject(note, input.projectRoot);
162
+ if (note.status === "archived") throw new Error("cannot promote an archived note");
163
+ if (targetId === id) throw new Error("a note cannot promote to itself");
164
+ if (!this.artifacts.get(targetId)) throw new Error(`promotion target "${targetId}" not found`);
165
+ const promoted = event("promoted", input, { disposition: "promoted", targetId });
166
+ const disposition = { kind: "promoted", targetId, ...(promoted.reason ? { reason: promoted.reason } : {}) };
167
+ this.artifacts.link({ from: id, relation: "relates_to", to: targetId });
168
+ this.artifacts.setExtra(id, {
169
+ ...appendHistory(note, promoted),
170
+ disposition,
171
+ });
172
+ this.artifacts.setStatus(id, "archived");
173
+ return this.artifacts.get(id, { tree: true })!;
174
+ });
175
+ }
176
+
177
+ archive(id: string, input: ArchiveNoteInput): Artifact {
178
+ if (!NOTE_DISPOSITIONS.includes(input.disposition)) throw new Error("note disposition must be completed, duplicate, declined, or superseded");
179
+ const atomic = requireAtomicArtifactStore(this.artifacts);
180
+ return atomic.atomic(() => {
181
+ const note = this.requireNote(id);
182
+ this.requireProject(note, input.projectRoot);
183
+ if (note.status === "archived") throw new Error("note is already archived");
184
+ const archived = event("archived", input, { disposition: input.disposition });
185
+ const details = { kind: input.disposition, ...(archived.reason ? { reason: archived.reason } : {}) };
186
+ this.artifacts.setExtra(id, {
187
+ ...appendHistory(note, archived),
188
+ disposition: details,
189
+ });
190
+ this.artifacts.setStatus(id, "archived");
191
+ return this.artifacts.get(id, { tree: true })!;
192
+ });
193
+ }
194
+
195
+ private requireNote(id: string): Artifact {
196
+ const artifact = this.artifacts.get(id);
197
+ if (!artifact || artifact.kind !== "doc" || artifact.subtype !== NOTE_SUBTYPE) throw new Error(`note "${id}" not found`);
198
+ return artifact;
199
+ }
200
+
201
+ private requireProject(note: Artifact, projectRoot: string): void {
202
+ const requested = requiredBounded(projectRoot, "project_root", TASK_PROJECT_ROOT_MAX_LENGTH);
203
+ if (note.extra["projectRoot"] !== requested) throw new Error(`note "${note.id}" is outside project scope`);
204
+ }
205
+ }
package/src/ops.ts CHANGED
@@ -6,7 +6,7 @@ import { createRequire } from "node:module";
6
6
  import { exec } from "node:child_process";
7
7
  import type { Db } from "./db.ts";
8
8
  import { inTransaction } from "./db.ts";
9
- import type { Artifact, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
9
+ import type { Artifact, ArtifactQuery, CreateArtifactInput, UpdateArtifactInput } from "./domain/artifact.ts";
10
10
  import type { Gate, GateResult, GateRunOptions } from "./domain/gate.ts";
11
11
  export type { Artifact } from "./domain/artifact.ts";
12
12
  export type { Gate, GateResult } from "./domain/gate.ts";
@@ -172,22 +172,36 @@ export function getArtifact(db: Db, id: string, opts?: { tree?: boolean; depth?:
172
172
  return art;
173
173
  }
174
174
 
175
- export function queryArtifacts(db: Db, filter: {
176
- kind?: string;
177
- status?: string;
178
- text?: string;
179
- labels?: string[];
180
- limit?: number;
181
- }): Artifact[] {
175
+ export function queryArtifacts(db: Db, filter: ArtifactQuery): Artifact[] {
182
176
  let sql = "SELECT * FROM artifacts";
183
177
  const conditions: string[] = [];
184
178
  const params: unknown[] = [];
185
179
  if (filter.kind) { conditions.push("kind = ?"); params.push(filter.kind); }
186
180
  if (filter.status) { conditions.push("status = ?"); params.push(filter.status); }
181
+ if (filter.statuses) {
182
+ if (filter.statuses.length === 0) return [];
183
+ conditions.push(`status IN (${filter.statuses.map(() => "?").join(", ")})`);
184
+ params.push(...filter.statuses);
185
+ }
186
+ if (filter.subtype) { conditions.push("subtype = ?"); params.push(filter.subtype); }
187
+ if (filter.excludeSubtype) { conditions.push("subtype != ?"); params.push(filter.excludeSubtype); }
187
188
  if (filter.text) { conditions.push("(title LIKE ? OR body LIKE ?)"); params.push(`%${filter.text}%`, `%${filter.text}%`); }
189
+ for (const label of filter.labels ?? []) {
190
+ conditions.push("EXISTS (SELECT 1 FROM json_each(artifacts.labels) WHERE value = ?)");
191
+ params.push(label);
192
+ }
193
+ for (const [key, value] of Object.entries(filter.extraEquals ?? {})) {
194
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(key)) throw new Error(`invalid extra query key "${key}"`);
195
+ conditions.push("json_extract(extra, ?) = ?");
196
+ params.push(`$.${key}`, value);
197
+ }
188
198
  if (conditions.length) sql += " WHERE " + conditions.join(" AND ");
189
199
  sql += " ORDER BY updated_at DESC";
190
- if (filter.limit) sql += ` LIMIT ${Math.floor(filter.limit)}`;
200
+ if (filter.limit !== undefined) {
201
+ if (!Number.isInteger(filter.limit) || filter.limit < 1) throw new Error("artifact query limit must be a positive integer");
202
+ sql += " LIMIT ?";
203
+ params.push(filter.limit);
204
+ }
191
205
  const rows = db.prepare(sql).all(...params) as Record<string, unknown>[];
192
206
  return rows.map(rowToArtifact);
193
207
  }
package/src/service.ts CHANGED
@@ -40,6 +40,7 @@ import {
40
40
  } from "./domain-services.ts";
41
41
  import { taskContext } from "./task-context.ts";
42
42
  import { instantiateSkillWorkflow } from "./skill-execution.ts";
43
+ import { Notes, type NoteDisposition } from "./note-service.ts";
43
44
 
44
45
  export const EXPECTED_OPERATION_NAMES = [
45
46
  "system.migrate",
@@ -85,6 +86,12 @@ export const EXPECTED_OPERATION_NAMES = [
85
86
  "docs.archive",
86
87
  "docs.reopen",
87
88
  "docs.link",
89
+ "notes.capture",
90
+ "notes.list",
91
+ "notes.show",
92
+ "notes.consume",
93
+ "notes.promote",
94
+ "notes.archive",
88
95
  "rules.create",
89
96
  "rules.list",
90
97
  "rules.show",
@@ -162,6 +169,7 @@ function handlers(
162
169
  artifacts: ArtifactStore,
163
170
  gates: GateRunner,
164
171
  tasks: Tasks,
172
+ notes: Notes,
165
173
  events: TaskEventStore,
166
174
  scopes: TaskScopeStore,
167
175
  migrate: () => unknown,
@@ -191,6 +199,7 @@ function handlers(
191
199
  "system.migrate": () => migrate(),
192
200
  "artifact.create": (input) => {
193
201
  const normalized = normalizeCreateInput(input);
202
+ if (normalized.kind === "doc" && normalized.subtype === "note") throw new Error("note creation requires notes.capture");
194
203
  if (normalized.kind !== "task") return artifacts.create(normalized);
195
204
  return tasks.create({
196
205
  id: normalized.id,
@@ -229,7 +238,9 @@ function handlers(
229
238
  }),
230
239
  "graph.status": (input) => {
231
240
  const id = string(input, "id");
232
- if (artifacts.get(id)?.kind === "task") throw new Error("task lifecycle changes require a tasks.* operation so history and review invariants are preserved");
241
+ const artifact = artifacts.get(id);
242
+ if (artifact?.kind === "task") throw new Error("task lifecycle changes require a tasks.* operation so history and review invariants are preserved");
243
+ if (artifact?.kind === "doc" && artifact.subtype === "note") throw new Error("note lifecycle changes require a notes.* operation so disposition provenance is preserved");
233
244
  return artifacts.setStatus(id, string(input, "status"));
234
245
  },
235
246
  "gates.run": (input) => {
@@ -307,6 +318,28 @@ function handlers(
307
318
  "docs.archive": (input) => transitionDocument(artifacts, string(input, "id"), "archive"),
308
319
  "docs.reopen": (input) => transitionDocument(artifacts, string(input, "id"), "reopen"),
309
320
  "docs.link": (input) => linkDocument(artifacts, string(input, "id"), string(input, "relation") as DocumentRelation, string(input, "target_id")),
321
+ "notes.capture": (input) => notes.capture({
322
+ body: string(input, "body"), title: optionalString(input, "title"), projectRoot: string(input, "project_root"),
323
+ actor: optionalString(input, "actor"), source: optionalString(input, "source"), sessionId: optionalString(input, "session_id"),
324
+ }),
325
+ "notes.list": (input) => notes.list({
326
+ projectRoot: string(input, "project_root"), status: optionalString(input, "status") as "draft" | "active" | "archived" | undefined,
327
+ text: optionalString(input, "text"), limit: optionalNumber(input, "limit"),
328
+ }),
329
+ "notes.show": (input) => notes.show(string(input, "id"), string(input, "project_root")),
330
+ "notes.consume": (input) => notes.consume(string(input, "id"), {
331
+ projectRoot: string(input, "project_root"), actor: optionalString(input, "actor"), source: optionalString(input, "source"),
332
+ sessionId: optionalString(input, "session_id"), reason: optionalString(input, "reason"),
333
+ }),
334
+ "notes.promote": (input) => notes.promote(string(input, "id"), string(input, "target_id"), {
335
+ projectRoot: string(input, "project_root"), actor: optionalString(input, "actor"), source: optionalString(input, "source"),
336
+ sessionId: optionalString(input, "session_id"), reason: optionalString(input, "reason"),
337
+ }),
338
+ "notes.archive": (input) => notes.archive(string(input, "id"), {
339
+ projectRoot: string(input, "project_root"), disposition: string(input, "disposition") as NoteDisposition,
340
+ actor: optionalString(input, "actor"), source: optionalString(input, "source"), sessionId: optionalString(input, "session_id"),
341
+ reason: optionalString(input, "reason"),
342
+ }),
310
343
  "rules.create": (input) => createRule(artifacts, {
311
344
  title: string(input, "title"), body: optionalString(input, "body"), condition: optionalString(input, "condition"),
312
345
  action: optionalString(input, "rule_action") ?? optionalString(input, "governance_action"),
@@ -364,7 +397,8 @@ export function createPapyrusService(path: string): PapyrusService {
364
397
  const events = new SQLiteTaskEventStore(db);
365
398
  const scopes = new SQLiteTaskScopeStore(db);
366
399
  const tasks = new Tasks(artifacts, gates, focus, events, scopes);
367
- const registry = handlers(artifacts, gates, tasks, events, scopes, () => migrateDb(db));
400
+ const notes = new Notes(artifacts);
401
+ const registry = handlers(artifacts, gates, tasks, notes, events, scopes, () => migrateDb(db));
368
402
  const state = (): SchemaState => {
369
403
  const current = schemaVersion(db);
370
404
  return { current, required: SQLITE_SCHEMA_VERSION, migrationRequired: current !== SQLITE_SCHEMA_VERSION };