@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 +30 -5
- package/extension/src/artifact-browser.ts +4 -0
- package/extension/src/context-injection-telemetry.ts +80 -0
- package/extension/src/domain-tools.ts +46 -0
- package/extension/src/index.ts +28 -12
- package/extension/src/notes.ts +90 -0
- package/package.json +1 -1
- package/src/cli.ts +72 -1
- package/src/constants.ts +12 -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.
|
|
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
|
|
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",
|
package/extension/src/index.ts
CHANGED
|
@@ -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
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
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
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;
|
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 };
|