@danypops/papyrus 0.9.0 → 0.10.1
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 +27 -4
- package/extension/src/artifact-browser.ts +28 -18
- package/extension/src/artifact-detail-format.ts +16 -0
- package/extension/src/artifact-detail-view.ts +95 -0
- package/extension/src/domain-tools.ts +46 -0
- package/extension/src/index.ts +10 -1
- package/extension/src/notes.ts +90 -0
- package/package.json +1 -1
- package/src/cli.ts +72 -1
- package/src/constants.ts +13 -0
- package/src/domain/artifact.ts +4 -0
- package/src/domain-services.ts +28 -4
- package/src/note-service.ts +205 -0
- package/src/ops.ts +23 -9
- package/src/service.ts +36 -2
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
|
-
- **`
|
|
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
|
-
- `/
|
|
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
|
|
119
|
+
All frontends use daemon-backed domain operations; none opens SQLite from the Pi process. **Show details** opens a bounded navigable view across Tasks, Notes, Docs, Rules, legacy Skills, templates, and workflow Skills. `↑/↓` scrolls, `←/→` pans wide relationships, and Esc returns to the browser; non-interactive clients receive stable text.
|
|
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
|
|
|
@@ -4,9 +4,12 @@ import { Container, Input, Spacer, truncateToWidth, visibleWidth } from "@earend
|
|
|
4
4
|
import { SEED_RELATIONS } from "../../src/constants.ts";
|
|
5
5
|
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
6
6
|
import type { OperationName } from "../../src/service.ts";
|
|
7
|
-
import {
|
|
7
|
+
import { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
8
|
+
import { showArtifactDetailView } from "./artifact-detail-view.ts";
|
|
8
9
|
import { callService } from "./service-client.ts";
|
|
9
10
|
|
|
11
|
+
export { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
12
|
+
|
|
10
13
|
const BROWSER_QUERY_LIMIT = 500;
|
|
11
14
|
const BROWSER_VISIBLE_ROWS = 20;
|
|
12
15
|
const DETAIL_GRAPH_DEPTH = 4;
|
|
@@ -18,6 +21,7 @@ export interface ArtifactBrowserConfig {
|
|
|
18
21
|
statusOrder: string[];
|
|
19
22
|
glyphs: Record<string, string>;
|
|
20
23
|
listOperation?: OperationName;
|
|
24
|
+
listInput?: Record<string, unknown>;
|
|
21
25
|
rowMeta(row: Artifact): string;
|
|
22
26
|
actions(row: Artifact): string[];
|
|
23
27
|
handleAction(choice: string, row: Artifact, ctx: ExtensionCommandContext): Promise<void>;
|
|
@@ -46,32 +50,38 @@ async function loadArtifacts(config: ArtifactBrowserConfig): Promise<Artifact[]>
|
|
|
46
50
|
return callService<Record<string, unknown>, Artifact[]>(config.listOperation ?? "artifact.query", {
|
|
47
51
|
kind: config.kind,
|
|
48
52
|
limit: BROWSER_QUERY_LIMIT,
|
|
53
|
+
...(config.listInput ?? {}),
|
|
49
54
|
});
|
|
50
55
|
}
|
|
51
56
|
|
|
57
|
+
export type ArtifactDetailLoader = (
|
|
58
|
+
operation: OperationName,
|
|
59
|
+
input: Record<string, unknown>,
|
|
60
|
+
) => Promise<Artifact | null>;
|
|
61
|
+
|
|
62
|
+
const loadArtifactDetails: ArtifactDetailLoader = (operation, input) =>
|
|
63
|
+
callService<Record<string, unknown>, Artifact | null>(operation, input);
|
|
64
|
+
|
|
52
65
|
export async function showArtifactDetails(
|
|
53
66
|
ctx: ExtensionCommandContext,
|
|
54
67
|
id: string,
|
|
55
68
|
operation: OperationName = "artifact.show",
|
|
69
|
+
input: Record<string, unknown> = {},
|
|
70
|
+
load: ArtifactDetailLoader = loadArtifactDetails,
|
|
56
71
|
): Promise<void> {
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
output += `\n\nMetadata:\n${formatMetadata(artifact.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
70
|
-
}
|
|
71
|
-
if (artifact.edges?.length) {
|
|
72
|
-
output += `\n\nEdges:\n${artifact.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
|
|
72
|
+
try {
|
|
73
|
+
const artifact = await load(operation, {
|
|
74
|
+
id,
|
|
75
|
+
...input,
|
|
76
|
+
tree: true,
|
|
77
|
+
depth: DETAIL_GRAPH_DEPTH,
|
|
78
|
+
max_nodes: DETAIL_GRAPH_NODES,
|
|
79
|
+
});
|
|
80
|
+
if (!artifact) { ctx.ui.notify(`Artifact ${id} not found`, "error"); return; }
|
|
81
|
+
await showArtifactDetailView(ctx, artifact);
|
|
82
|
+
} catch (error) {
|
|
83
|
+
ctx.ui.notify(`Show details failed: ${error instanceof Error ? error.message : error}`, "error");
|
|
73
84
|
}
|
|
74
|
-
ctx.ui.notify(output, "info");
|
|
75
85
|
}
|
|
76
86
|
|
|
77
87
|
export async function linkFromArtifact(ctx: ExtensionCommandContext, fromId: string, fixedRelation?: string): Promise<void> {
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
2
|
+
import { formatMetadata } from "./artifact-format.ts";
|
|
3
|
+
|
|
4
|
+
export function artifactDetailsText(artifact: Artifact): string {
|
|
5
|
+
let output = `${artifact.title}\n${artifact.id} [${artifact.kind}|${artifact.status}]`;
|
|
6
|
+
if (artifact.subtype) output += ` · ${artifact.subtype}`;
|
|
7
|
+
output += `\n\n${artifact.body || "(no body)"}`;
|
|
8
|
+
if (artifact.labels.length > 0) output += `\n\nLabels: ${artifact.labels.join(", ")}`;
|
|
9
|
+
if (Object.keys(artifact.extra).length > 0) {
|
|
10
|
+
output += `\n\nMetadata:\n${formatMetadata(artifact.extra).map((line) => ` ${line}`).join("\n")}`;
|
|
11
|
+
}
|
|
12
|
+
if (artifact.edges?.length) {
|
|
13
|
+
output += `\n\nRelationships:\n${artifact.edges.map((edge) => ` ${edge.from} --${edge.relation}--> ${edge.to}`).join("\n")}`;
|
|
14
|
+
}
|
|
15
|
+
return output;
|
|
16
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { ExtensionCommandContext, Theme } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { matchesKey, sliceByColumn, truncateToWidth, visibleWidth, wrapTextWithAnsi, type TUI } from "@earendil-works/pi-tui";
|
|
3
|
+
import {
|
|
4
|
+
ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS,
|
|
5
|
+
ARTIFACT_DETAIL_MAX_VISIBLE_LINES,
|
|
6
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
7
|
+
ARTIFACT_DETAIL_RESERVED_ROWS,
|
|
8
|
+
} from "../../src/constants.ts";
|
|
9
|
+
import type { Artifact } from "../../src/domain/artifact.ts";
|
|
10
|
+
import { artifactDetailsText } from "./artifact-detail-format.ts";
|
|
11
|
+
|
|
12
|
+
interface ArtifactDetailLine {
|
|
13
|
+
text: string;
|
|
14
|
+
wide: boolean;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
class ArtifactDetailViewport {
|
|
18
|
+
private offsetX = 0;
|
|
19
|
+
private offsetY = 0;
|
|
20
|
+
private renderedWidth = 0;
|
|
21
|
+
private lines: ArtifactDetailLine[] = [];
|
|
22
|
+
private readonly visibleLines: number;
|
|
23
|
+
private readonly narrative: string;
|
|
24
|
+
private readonly relationships: string[];
|
|
25
|
+
|
|
26
|
+
constructor(
|
|
27
|
+
private readonly tui: TUI,
|
|
28
|
+
private readonly theme: Theme,
|
|
29
|
+
artifact: Artifact,
|
|
30
|
+
private readonly close: () => void,
|
|
31
|
+
) {
|
|
32
|
+
this.visibleLines = Math.max(
|
|
33
|
+
ARTIFACT_DETAIL_MIN_VISIBLE_LINES,
|
|
34
|
+
Math.min(ARTIFACT_DETAIL_MAX_VISIBLE_LINES, tui.terminal.rows - ARTIFACT_DETAIL_RESERVED_ROWS),
|
|
35
|
+
);
|
|
36
|
+
this.narrative = artifactDetailsText({ ...artifact, edges: undefined });
|
|
37
|
+
this.relationships = (artifact.edges ?? []).map((edge) => `${edge.from} --${edge.relation}--> ${edge.to}`);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
invalidate(): void { this.renderedWidth = 0; }
|
|
41
|
+
|
|
42
|
+
render(width: number): string[] {
|
|
43
|
+
const contentWidth = Math.max(1, width - 2);
|
|
44
|
+
this.buildLines(contentWidth);
|
|
45
|
+
const wideWidth = this.relationships.reduce((maximum, line) => Math.max(maximum, visibleWidth(line)), 0);
|
|
46
|
+
this.offsetX = Math.min(this.offsetX, Math.max(0, wideWidth - contentWidth));
|
|
47
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
48
|
+
const end = Math.min(this.lines.length, this.offsetY + this.visibleLines);
|
|
49
|
+
const border = this.theme.fg("borderMuted", "─".repeat(Math.max(1, width)));
|
|
50
|
+
const footer = [
|
|
51
|
+
wideWidth > contentWidth ? `←/→ relationships · column ${this.offsetX + 1}/${wideWidth}` : "",
|
|
52
|
+
this.lines.length > this.visibleLines ? `↑/↓ scroll · ${this.offsetY + 1}-${end}/${this.lines.length}` : "",
|
|
53
|
+
"Esc back",
|
|
54
|
+
].filter(Boolean).join(" · ");
|
|
55
|
+
return [
|
|
56
|
+
border,
|
|
57
|
+
truncateToWidth(this.theme.bold("Artifact details"), width, ""),
|
|
58
|
+
border,
|
|
59
|
+
...this.lines.slice(this.offsetY, end).map((line) => line.wide
|
|
60
|
+
? ` ${sliceByColumn(line.text, this.offsetX, contentWidth, true)}`
|
|
61
|
+
: truncateToWidth(` ${line.text}`, width, "")),
|
|
62
|
+
truncateToWidth(this.theme.fg("dim", footer), width, ""),
|
|
63
|
+
border,
|
|
64
|
+
];
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
handleInput(data: string): void {
|
|
68
|
+
if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) { this.close(); return; }
|
|
69
|
+
if (matchesKey(data, "up")) this.offsetY = Math.max(0, this.offsetY - 1);
|
|
70
|
+
else if (matchesKey(data, "down")) this.offsetY = Math.min(Math.max(0, this.lines.length - this.visibleLines), this.offsetY + 1);
|
|
71
|
+
else if (matchesKey(data, "left")) this.offsetX = Math.max(0, this.offsetX - ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS);
|
|
72
|
+
else if (matchesKey(data, "right")) this.offsetX += ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS;
|
|
73
|
+
else return;
|
|
74
|
+
this.tui.requestRender();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
private buildLines(width: number): void {
|
|
78
|
+
if (this.renderedWidth === width) return;
|
|
79
|
+
this.renderedWidth = width;
|
|
80
|
+
const narrative = this.narrative.split("\n").flatMap((line) =>
|
|
81
|
+
(line.length === 0 ? [""] : wrapTextWithAnsi(line, width)).map((text) => ({ text, wide: false })));
|
|
82
|
+
const relationshipSection = this.relationships.length > 0
|
|
83
|
+
? [{ text: "", wide: false }, { text: "Relationships:", wide: false }, ...this.relationships.map((text) => ({ text, wide: true }))]
|
|
84
|
+
: [];
|
|
85
|
+
this.lines = [...narrative, ...relationshipSection];
|
|
86
|
+
this.offsetY = Math.min(this.offsetY, Math.max(0, this.lines.length - this.visibleLines));
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export async function showArtifactDetailView(ctx: ExtensionCommandContext, artifact: Artifact): Promise<void> {
|
|
91
|
+
const output = artifactDetailsText(artifact);
|
|
92
|
+
if (ctx.mode !== "tui") { ctx.ui.notify(output, "info"); return; }
|
|
93
|
+
await ctx.ui.custom<void>((tui, theme, _keybindings, done) =>
|
|
94
|
+
new ArtifactDetailViewport(tui, theme, artifact, done));
|
|
95
|
+
}
|
|
@@ -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",
|
package/extension/src/index.ts
CHANGED
|
@@ -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
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
|
@@ -29,6 +29,11 @@ export const TASK_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
|
29
29
|
export const TASK_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
30
30
|
export const TASK_DETAIL_RESERVED_ROWS = 8;
|
|
31
31
|
export const TASK_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;
|
|
32
|
+
/** Bounded navigable detail views for non-Task artifacts. */
|
|
33
|
+
export const ARTIFACT_DETAIL_MIN_VISIBLE_LINES = 8;
|
|
34
|
+
export const ARTIFACT_DETAIL_MAX_VISIBLE_LINES = 24;
|
|
35
|
+
export const ARTIFACT_DETAIL_RESERVED_ROWS = 8;
|
|
36
|
+
export const ARTIFACT_DETAIL_HORIZONTAL_PAN_COLUMNS = 4;
|
|
32
37
|
export const TASK_GRAPH_MIN_VISIBLE_LINES = 8;
|
|
33
38
|
export const TASK_GRAPH_MAX_VISIBLE_LINES = 30;
|
|
34
39
|
export const TASK_GRAPH_RESERVED_ROWS = 8;
|
|
@@ -58,6 +63,14 @@ export const TASK_HISTORY_MAX_LIMIT = 100;
|
|
|
58
63
|
export const TASK_EVENT_MAX_EVIDENCE_BYTES = 65_536;
|
|
59
64
|
export const TASK_EVENT_ACTOR_MAX_LENGTH = 128;
|
|
60
65
|
export const TASK_EVENT_REASON_MAX_LENGTH = 2_000;
|
|
66
|
+
/** Deferred human Note payload, inbox, and provenance bounds. */
|
|
67
|
+
export const NOTE_BODY_MAX_CHARACTERS = 10_000;
|
|
68
|
+
export const NOTE_TITLE_MAX_CHARACTERS = 80;
|
|
69
|
+
export const NOTE_LIST_DEFAULT_LIMIT = 50;
|
|
70
|
+
export const NOTE_LIST_MAX_LIMIT = 200;
|
|
71
|
+
export const NOTE_HISTORY_MAX_EVENTS = 20;
|
|
72
|
+
export const NOTE_PROVENANCE_MAX_LENGTH = 128;
|
|
73
|
+
export const NOTE_REASON_MAX_CHARACTERS = 2_000;
|
|
61
74
|
/** Persisted project and focused-graph Task view bounds. */
|
|
62
75
|
export const TASK_SCOPE_MAX_TASKS = 1_000;
|
|
63
76
|
export const TASK_PROJECT_ROOT_MAX_LENGTH = 4_096;
|
package/src/domain/artifact.ts
CHANGED
|
@@ -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
|
|
package/src/domain-services.ts
CHANGED
|
@@ -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
|
-
|
|
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 =
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
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 };
|