@danypops/papyrus 0.8.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
 
@@ -174,7 +197,9 @@ checklist: {
174
197
 
175
198
  Proof types are `file`, `symbol`, `code`, `test`, `command`, `artifact`, and `url`. Existing array checklists remain readable as legacy items with `proof: missing`; Papyrus does not invent evidence.
176
199
 
177
- Papyrus also injects an Alef-style reconciliation block on every agent turn while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run review before marking it done. The injection disappears when every task is done or canceled.
200
+ Papyrus also injects an Alef-style reconciliation block at `before_agent_start` while work remains: `Current`, `Desired`, `Verify`, and `Next`. The agent is explicitly instructed to ask **“Did we accomplish this task?”** and run review before marking it done. The injection disappears when every task is done or canceled.
201
+
202
+ After assembling each system-prompt addition, Papyrus emits a versioned `papyrus.context-injection.v1` observation on Pi's shared extension event bus. It contains only exact byte/character sizes, Rule count, a labeled token estimate, prompt share, sequence, and a SHA-256 payload fingerprint; Rule/Task text, prompts, project paths, and credentials are never included. Jittor can persist and assess these observations without Papyrus maintaining a second telemetry store.
178
203
 
179
204
  Task edits mutate the existing Papyrus-owned Task identity and append an `updated` event; title, body, and labels can be revised without canceling the Task or creating a replacement. Lifecycle, relationships, gates, checklist metadata, scope, and focus remain intact.
180
205
 
@@ -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,
@@ -0,0 +1,80 @@
1
+ import { createHash } from "node:crypto";
2
+ import {
3
+ CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN,
4
+ PAPYRUS_CONTEXT_INJECTION_SCHEMA,
5
+ } from "../../src/constants.ts";
6
+ import type { Artifact } from "../../src/domain/artifact.ts";
7
+ import { ruleInjectionPreview } from "./rules.ts";
8
+
9
+ export interface ContextPayloadSize {
10
+ characters: number;
11
+ bytes: number;
12
+ }
13
+
14
+ export interface PapyrusContextInjectionObservation {
15
+ schema: typeof PAPYRUS_CONTEXT_INJECTION_SCHEMA;
16
+ observedAt: number;
17
+ sequence: number;
18
+ producerId: string;
19
+ before: ContextPayloadSize;
20
+ rules: ContextPayloadSize & { count: number };
21
+ tasks: ContextPayloadSize;
22
+ injected: ContextPayloadSize;
23
+ after: ContextPayloadSize;
24
+ estimatedTokens: number;
25
+ share: number;
26
+ fingerprint: string;
27
+ unchanged: boolean;
28
+ }
29
+
30
+ export interface BuildContextInjectionInput {
31
+ basePrompt: string;
32
+ rules: Array<Pick<Artifact, "title" | "body" | "extra">>;
33
+ taskSummary: string | null;
34
+ observedAt: number;
35
+ sequence: number;
36
+ producerId: string;
37
+ previousFingerprint?: string;
38
+ }
39
+
40
+ const encoder = new TextEncoder();
41
+
42
+ function size(value: string): ContextPayloadSize {
43
+ return { characters: value.length, bytes: encoder.encode(value).byteLength };
44
+ }
45
+
46
+ export function buildContextInjection(input: BuildContextInjectionInput): {
47
+ prompt: string;
48
+ ruleBlock: string;
49
+ taskBlock: string;
50
+ observation: PapyrusContextInjectionObservation;
51
+ } {
52
+ const ruleContent = input.rules.map(ruleInjectionPreview).join("\n");
53
+ const ruleBlock = ruleContent ? `\n\n## Active rules (Papyrus)\n\n${ruleContent}\n` : "";
54
+ const taskBlock = input.taskSummary ? `\n\n## Open tasks (Papyrus)\n\n${input.taskSummary}\n` : "";
55
+ const injected = `${ruleBlock}${taskBlock}`;
56
+ const prompt = `${input.basePrompt}${injected}`;
57
+ const fingerprint = createHash("sha256").update(injected).digest("hex");
58
+ const injectedSize = size(injected);
59
+ const afterSize = size(prompt);
60
+ return {
61
+ prompt,
62
+ ruleBlock,
63
+ taskBlock,
64
+ observation: {
65
+ schema: PAPYRUS_CONTEXT_INJECTION_SCHEMA,
66
+ observedAt: input.observedAt,
67
+ sequence: input.sequence,
68
+ producerId: input.producerId,
69
+ before: size(input.basePrompt),
70
+ rules: { ...size(ruleBlock), count: input.rules.length },
71
+ tasks: size(taskBlock),
72
+ injected: injectedSize,
73
+ after: afterSize,
74
+ estimatedTokens: Math.ceil(injectedSize.characters / CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN),
75
+ share: afterSize.characters === 0 ? 0 : injectedSize.characters / afterSize.characters,
76
+ fingerprint,
77
+ unchanged: input.previousFingerprint === fingerprint,
78
+ },
79
+ };
80
+ }
@@ -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",
@@ -7,12 +7,14 @@
7
7
  * Injection: active rules + open tasks appended to system prompt every turn.
8
8
  * "Are we there yet?" — the agent sees its open work items.
9
9
  */
10
+ import { randomUUID } from "node:crypto";
10
11
  import type { ExtensionAPI, ExtensionContext, ExtensionUIContext, Theme } from "@earendil-works/pi-coding-agent";
11
12
  import { Type } from "typebox";
12
13
  import { truncateToWidth } from "@earendil-works/pi-tui";
13
14
  import {
14
15
  TASK_DRIVER_MAX_TURNS,
15
16
  TASK_DRIVER_MAX_UNCHANGED_TURNS,
17
+ PAPYRUS_CONTEXT_INJECTION_CHANNEL,
16
18
  } from "../../src/constants.ts";
17
19
  import type { Artifact } from "../../src/domain/artifact.ts";
18
20
  import type { GateResult } from "../../src/domain/gate.ts";
@@ -23,6 +25,7 @@ import type { TaskGraph, TaskStatus } from "../../src/task-service.ts";
23
25
  import { ActiveTaskContinuation, automaticPauseReason, shouldResumeFocusOnHumanInput, type ActiveTaskMarker } from "./active-task-continuation.ts";
24
26
  import { buildTaskWidgetProjection, type TaskWidgetProjection } from "./task-widget.ts";
25
27
  import { TASK_STATUS_PRESENTATION, taskTreeConnector } from "./task-presentation.ts";
28
+ import { buildContextInjection } from "./context-injection-telemetry.ts";
26
29
 
27
30
  function text(t: string, details: Record<string, unknown> = {}) {
28
31
  return { content: [{ type: "text" as const, text: t }], details };
@@ -130,6 +133,9 @@ class TaskOverlay {
130
133
 
131
134
  export default async function (pi: ExtensionAPI) {
132
135
  registerDomainTools(pi);
136
+ let contextInjectionSequence = 0;
137
+ const contextInjectionProducerId = randomUUID();
138
+ let previousContextInjectionFingerprint: string | undefined;
133
139
  const taskContinuation = new ActiveTaskContinuation({
134
140
  maxTurns: TASK_DRIVER_MAX_TURNS,
135
141
  maxUnchangedTurns: TASK_DRIVER_MAX_UNCHANGED_TURNS,
@@ -312,9 +318,10 @@ export default async function (pi: ExtensionAPI) {
312
318
  // ── Interactive artifact browsers ──────────────────────────────────
313
319
 
314
320
  // Lazy imports keep TUI components out of non-interactive startup paths.
315
- const [tasksModule, docsModule, rulesModule, skillsModule] = await Promise.all([
321
+ const [tasksModule, docsModule, notesModule, rulesModule, skillsModule] = await Promise.all([
316
322
  import("./tasks.ts"),
317
323
  import("./docs.ts"),
324
+ import("./notes.ts"),
318
325
  import("./rules.ts"),
319
326
  import("./skills.ts"),
320
327
  ]);
@@ -332,6 +339,14 @@ export default async function (pi: ExtensionAPI) {
332
339
  description: "Browse and manage Papyrus documents (interactive)",
333
340
  handler: async (_args, ctx) => { await docsModule.showDocs(ctx); },
334
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
+ });
335
350
  pi.registerCommand("rules", {
336
351
  description: "Browse, preview, and toggle Papyrus rules (interactive)",
337
352
  handler: async (_args, ctx) => { await rulesModule.showRules(ctx); },
@@ -391,17 +406,18 @@ export default async function (pi: ExtensionAPI) {
391
406
  callService<Record<string, unknown>, Array<Pick<Artifact, "title" | "body" | "extra">>>("rules.injectable", { project_root: ctx.cwd }),
392
407
  callService<Record<string, unknown>, string | null>("tasks.context", { project_root: ctx.cwd }),
393
408
  ]);
394
- let prompt = event.systemPrompt ?? "";
395
- if (rules.length > 0) {
396
- const block = rules.map(rulesModule.ruleInjectionPreview).join("\n");
397
- prompt += `\n\n## Active rules (Papyrus)\n\n${block}\n`;
398
- }
399
- if (summary) {
400
- prompt += `\n\n## Open tasks (Papyrus)\n\n${summary}\n`;
401
- }
402
- if (prompt !== (event.systemPrompt ?? "")) {
403
- return { systemPrompt: prompt };
404
- }
409
+ const injection = buildContextInjection({
410
+ basePrompt: event.systemPrompt ?? "",
411
+ rules,
412
+ taskSummary: summary,
413
+ observedAt: Date.now(),
414
+ sequence: ++contextInjectionSequence,
415
+ producerId: contextInjectionProducerId,
416
+ previousFingerprint: previousContextInjectionFingerprint,
417
+ });
418
+ previousContextInjectionFingerprint = injection.observation.fingerprint;
419
+ pi.events.emit(PAPYRUS_CONTEXT_INJECTION_CHANNEL, injection.observation);
420
+ if (injection.prompt !== (event.systemPrompt ?? "")) return { systemPrompt: injection.prompt };
405
421
  } catch {
406
422
  // DB not ready
407
423
  }
@@ -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.8.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
@@ -17,6 +17,10 @@ export const GATE_OUTPUT_LIMIT = 200;
17
17
  export const GATE_MAX_BUFFER_BYTES = 1_048_576;
18
18
  export const GATE_FILE_MAX_BYTES = 1_048_576;
19
19
 
20
+ export const PAPYRUS_CONTEXT_INJECTION_CHANNEL = "papyrus.context-injection.v1";
21
+ export const PAPYRUS_CONTEXT_INJECTION_SCHEMA = "papyrus.context-injection/v1";
22
+ export const CONTEXT_ESTIMATE_CHARACTERS_PER_TOKEN = 4;
23
+
20
24
  /** Compact task-context limits keep recurring prompt injection bounded. */
21
25
  export const TASK_CONTEXT_CURRENT_LIMIT = 3;
22
26
  export const TASK_CONTEXT_REJECTED_LIMIT = 3;
@@ -54,6 +58,14 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
54
58
  export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
55
59
  export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
56
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;
57
69
  /** Persisted project and focused-graph Task view bounds. */
58
70
  export const TASK_SCOPE_MAX_TASKS = 1_000;
59
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 };