@danypops/papyrus 0.42.2 → 0.44.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/package.json +4 -4
- package/src/service.ts +1 -0
- package/src/vehicle/artifact-trash-vehicle.ts +2 -2
- package/src/vehicle/artifact-vehicle-shared.ts +22 -5
- package/src/vehicle/discuss-vehicle.ts +355 -0
- package/src/vehicle/docs-vehicle.ts +10 -3
- package/src/vehicle/notes-vehicle.ts +10 -3
- package/src/vehicle/papyrus-vehicle.ts +7 -5
- package/src/vehicle/playbooks-vehicle.ts +2 -1
- package/src/vehicle/rules-vehicle.ts +10 -3
- package/src/vehicle/tasks-vehicle.ts +3 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/papyrus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"description": "Daemon-backed graph artifacts, evidence-bearing tasks, rules, skills, and native TUI workflows for Pi",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"keywords": ["pi-package"],
|
|
@@ -34,8 +34,8 @@
|
|
|
34
34
|
},
|
|
35
35
|
"files": ["src", "README.md"],
|
|
36
36
|
"dependencies": {
|
|
37
|
-
"@danypops/vehicle-core": "^0.
|
|
38
|
-
"@danypops/vehicle-client": "^0.
|
|
39
|
-
"@danypops/vehicle-server": "^0.
|
|
37
|
+
"@danypops/vehicle-core": "^0.10.0",
|
|
38
|
+
"@danypops/vehicle-client": "^0.5.0",
|
|
39
|
+
"@danypops/vehicle-server": "^0.11.0"
|
|
40
40
|
}
|
|
41
41
|
}
|
package/src/service.ts
CHANGED
|
@@ -8,7 +8,7 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
|
8
8
|
import { removeArtifactSubtree } from "../artifact-subtree.ts";
|
|
9
9
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
10
10
|
import type { ArtifactTrashStore } from "../ports/artifact-trash-store.ts";
|
|
11
|
-
import { looseObjectSchema, numberProp, passthroughOutput, stringProp } from "./artifact-vehicle-shared.ts";
|
|
11
|
+
import { looseObjectSchema, numberProp, passthroughOutput, stringProp, validationError } from "./artifact-vehicle-shared.ts";
|
|
12
12
|
|
|
13
13
|
const OWNER = "artifact";
|
|
14
14
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
@@ -26,7 +26,7 @@ function eventContext(input: Record<string, unknown>): { actor?: string; source?
|
|
|
26
26
|
|
|
27
27
|
function requireId(input: Record<string, unknown>): string {
|
|
28
28
|
const id = input.id;
|
|
29
|
-
if (typeof id !== "string" || id.length === 0) throw
|
|
29
|
+
if (typeof id !== "string" || id.length === 0) throw validationError("id is required");
|
|
30
30
|
return id;
|
|
31
31
|
}
|
|
32
32
|
|
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
* VehicleRegistry projection (notes-vehicle.ts, rules-vehicle.ts, docs-vehicle.ts,
|
|
4
4
|
* artifact-trash-vehicle.ts).
|
|
5
5
|
*/
|
|
6
|
-
import { defineVehicleSchema, type VehicleContentBlock, type VehicleSchemaCodec } from "@danypops/vehicle-core";
|
|
6
|
+
import { defineVehicleSchema, type VehicleContentBlock, VehicleError, type VehicleSchemaCodec } from "@danypops/vehicle-core";
|
|
7
7
|
import type { Artifact } from "../domain/artifact.ts";
|
|
8
8
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
9
9
|
import type { TaskExecutionPlan } from "../task-execution.ts";
|
|
@@ -47,6 +47,19 @@ export const passthroughOutput: VehicleSchemaCodec<unknown> = defineVehicleSchem
|
|
|
47
47
|
export const stringProp = { type: "string" } as const;
|
|
48
48
|
export const numberProp = { type: "number" } as const;
|
|
49
49
|
|
|
50
|
+
/**
|
|
51
|
+
* A plain `throw new Error(...)` inside any resolve()/execute() step here is caught by
|
|
52
|
+
* vehicle-registry.ts's generic dispatch and re-wrapped as VehicleError("handler-failed",
|
|
53
|
+
* `${key} handler failed`, {category: "internal"}) -- built to catch a genuine crash, but
|
|
54
|
+
* it can't distinguish that from an ordinary, expected validation/lookup failure, so it
|
|
55
|
+
* discards the original message and category either way. Every guard clause and name
|
|
56
|
+
* resolution below must throw a VehicleError directly so it passes through that dispatch
|
|
57
|
+
* unchanged (vehicle-registry.ts only rewraps errors that are NOT already a VehicleError).
|
|
58
|
+
*/
|
|
59
|
+
export function validationError(message: string): VehicleError {
|
|
60
|
+
return new VehicleError("validation-failed", message, { category: "validation" });
|
|
61
|
+
}
|
|
62
|
+
|
|
50
63
|
/** A known LLM tool-calling quirk: a nested-object field arrives JSON-stringified rather than as a real object. Mutates input[key] in place when it's a string, leaves it untouched otherwise. */
|
|
51
64
|
export function normalizeJsonEncodedField(input: Record<string, unknown>, key: string): void {
|
|
52
65
|
const value = input[key];
|
|
@@ -54,7 +67,7 @@ export function normalizeJsonEncodedField(input: Record<string, unknown>, key: s
|
|
|
54
67
|
try {
|
|
55
68
|
input[key] = JSON.parse(value);
|
|
56
69
|
} catch {
|
|
57
|
-
throw
|
|
70
|
+
throw validationError(`${key} must be valid JSON`);
|
|
58
71
|
}
|
|
59
72
|
}
|
|
60
73
|
|
|
@@ -62,10 +75,14 @@ export function normalizeJsonEncodedField(input: Record<string, unknown>, key: s
|
|
|
62
75
|
export function matchArtifactByName(candidates: readonly Artifact[], name: string): string {
|
|
63
76
|
const needle = name.trim().toLowerCase();
|
|
64
77
|
const matches = candidates.filter((artifact) => artifact.title.trim().toLowerCase() === needle);
|
|
65
|
-
if (matches.length === 0)
|
|
78
|
+
if (matches.length === 0) {
|
|
79
|
+
throw new VehicleError("artifact-not-found", `no artifact named "${name}" found in this scope`, { category: "not_found" });
|
|
80
|
+
}
|
|
66
81
|
if (matches.length > 1) {
|
|
67
|
-
throw new
|
|
82
|
+
throw new VehicleError(
|
|
83
|
+
"artifact-name-ambiguous",
|
|
68
84
|
`${matches.length} artifacts are named "${name}": ${matches.map((a) => `${a.title} (${a.id})`).join(", ")} -- use id to disambiguate`,
|
|
85
|
+
{ category: "conflict" },
|
|
69
86
|
);
|
|
70
87
|
}
|
|
71
88
|
return matches[0]!.id;
|
|
@@ -85,7 +102,7 @@ export function resolveArtifactIdWidened(
|
|
|
85
102
|
try {
|
|
86
103
|
return matchArtifactByName(fetchCandidates(), name);
|
|
87
104
|
} catch (error) {
|
|
88
|
-
if (!(error instanceof
|
|
105
|
+
if (!(error instanceof VehicleError) || error.code !== "artifact-not-found" || !fetchWidened) throw error;
|
|
89
106
|
return matchArtifactByName(fetchWidened(), name);
|
|
90
107
|
}
|
|
91
108
|
}
|
|
@@ -0,0 +1,355 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Discuss projected as a real VehicleRegistry: one VehicleOperation per real
|
|
3
|
+
* action, the last of the six domains (notes/rules/docs/skills/playbooks/tasks
|
|
4
|
+
* already done) to leave pi-papyrus's own hand-rolled pi.registerTool().
|
|
5
|
+
*
|
|
6
|
+
* open/reply get a `content` block AND keep their full {discussion, rounds}
|
|
7
|
+
* output shape -- vehicle-client-pi's own interactiveFollowUps hook (see
|
|
8
|
+
* registerDiscussVehicleTools in pi-papyrus) reads `rounds[0].content` off
|
|
9
|
+
* this exact output to drive the optional live human round-trip, the same
|
|
10
|
+
* way the retired tool's own liveAnswer() did.
|
|
11
|
+
*
|
|
12
|
+
* Wraps modules/discuss.ts's operation definitions -- the raw RPC dispatch
|
|
13
|
+
* (service.ts's moduleRegistry) stays registered unchanged for pi-papyrus's
|
|
14
|
+
* own /discuss TUI, which never went through the retired mega-tool.
|
|
15
|
+
*/
|
|
16
|
+
import { bindVehicleOperation, defineVehicleOperation, type VehicleContentBlock } from "@danypops/vehicle-core";
|
|
17
|
+
import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
18
|
+
import type { DiscussionAndRounds, Discussions } from "../discussion-service.ts";
|
|
19
|
+
import type { Artifact } from "../domain/artifact.ts";
|
|
20
|
+
import { DISCUSSION_SUBTYPE, type DiscussionRound } from "../domain/discussion.ts";
|
|
21
|
+
import { discussOperations } from "../modules/discuss.ts";
|
|
22
|
+
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
23
|
+
import {
|
|
24
|
+
looseObjectSchema,
|
|
25
|
+
numberProp,
|
|
26
|
+
passthroughOutput,
|
|
27
|
+
resolveArtifactIdWidened,
|
|
28
|
+
stringProp,
|
|
29
|
+
validationError,
|
|
30
|
+
} from "./artifact-vehicle-shared.ts";
|
|
31
|
+
|
|
32
|
+
const OWNER = "discuss";
|
|
33
|
+
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
34
|
+
const arrayProp = { type: "array" } as const;
|
|
35
|
+
/** Purely a client-side hint (see vehicle-client-pi's interactiveFollowUps) -- never read server-side, but must still be declared or the schema's additionalProperties:false rejects it outright. */
|
|
36
|
+
const boolProp = { type: "boolean" } as const;
|
|
37
|
+
|
|
38
|
+
function artifactLine(artifact: Artifact): string {
|
|
39
|
+
return `[${artifact.status}] ${artifact.title}`;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function roundsTranscript(rounds: readonly DiscussionRound[]): string {
|
|
43
|
+
return rounds.map((round) => ` [round ${round.roundNumber}] ${round.actor}: ${round.content}`).join("\n") || " (no rounds)";
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* Resolves a discussion's id from either an explicit id or its exact title.
|
|
48
|
+
* Discussions.list() has no project-scoping concept at all (unlike Tasks),
|
|
49
|
+
* so there is no widened retry to attempt -- one unscoped candidate set is
|
|
50
|
+
* the whole search space already.
|
|
51
|
+
*/
|
|
52
|
+
function resolveDiscussionId(discussions: Discussions, id: unknown, name: unknown): string {
|
|
53
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
54
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
55
|
+
return resolveArtifactIdWidened(name, () => discussions.list({}));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Resolves a real Task's id from its title, excluding Discussion rows
|
|
60
|
+
* (kind=task, subtype=discussion) from the candidate set -- a Discussion and
|
|
61
|
+
* a Task can otherwise share a title with no way to tell them apart. Unscoped
|
|
62
|
+
* (matches rules.gate's own precedent in rules-vehicle.ts), since neither
|
|
63
|
+
* blocks_task_names nor task_name here carry a project_root to scope by.
|
|
64
|
+
*/
|
|
65
|
+
function resolveRealTaskId(artifacts: ArtifactStore, id: unknown, name: unknown): string | undefined {
|
|
66
|
+
if (typeof id === "string" && id.length > 0) return id;
|
|
67
|
+
if (typeof name !== "string" || name.length === 0) return undefined;
|
|
68
|
+
return resolveArtifactIdWidened(name, () => artifacts.query({ kind: "task", excludeSubtype: DISCUSSION_SUBTYPE, text: name }));
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function resolveRealTaskIds(artifacts: ArtifactStore, ids: unknown, names: unknown): string[] | undefined {
|
|
72
|
+
if (Array.isArray(ids)) return ids as string[];
|
|
73
|
+
if (!Array.isArray(names) || names.length === 0) return undefined;
|
|
74
|
+
return names.map((entry) => {
|
|
75
|
+
const resolved = resolveRealTaskId(artifacts, undefined, String(entry));
|
|
76
|
+
if (!resolved) throw validationError(`no task named "${entry}" found`);
|
|
77
|
+
return resolved;
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* Normalizes `options` from the model-friendly union (a bare string, or
|
|
83
|
+
* {title, description} for a real tradeoff worth spelling out) into the two
|
|
84
|
+
* parallel arrays discussions.open()/reply() actually expect -- ported
|
|
85
|
+
* verbatim from the retired tool's own normalizeDiscussOptions. Mutates
|
|
86
|
+
* input in place.
|
|
87
|
+
*/
|
|
88
|
+
function normalizeOptions(input: Record<string, unknown>): void {
|
|
89
|
+
const raw = input.options;
|
|
90
|
+
if (!Array.isArray(raw)) return;
|
|
91
|
+
const titles: string[] = [];
|
|
92
|
+
const descriptions: string[] = [];
|
|
93
|
+
let anyDescription = false;
|
|
94
|
+
for (const entry of raw) {
|
|
95
|
+
if (typeof entry === "string") {
|
|
96
|
+
titles.push(entry);
|
|
97
|
+
descriptions.push("");
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
if (entry && typeof entry === "object" && typeof (entry as Record<string, unknown>).title === "string") {
|
|
101
|
+
const record = entry as Record<string, unknown>;
|
|
102
|
+
titles.push(record.title as string);
|
|
103
|
+
const description = typeof record.description === "string" ? record.description : "";
|
|
104
|
+
if (description) anyDescription = true;
|
|
105
|
+
descriptions.push(description);
|
|
106
|
+
continue;
|
|
107
|
+
}
|
|
108
|
+
titles.push(String(entry));
|
|
109
|
+
descriptions.push("");
|
|
110
|
+
}
|
|
111
|
+
input.options = titles;
|
|
112
|
+
if (anyDescription) input.option_descriptions = descriptions;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/** Matches the retired tool's own convention: an agent-driven open/reply with no explicit human actor still needs a real, non-generic audit-trail label. */
|
|
116
|
+
function defaultActorToAgent(input: Record<string, unknown>): void {
|
|
117
|
+
if (typeof input.actor !== "string" || input.actor.length === 0) input.actor = "agent";
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
const optionsUnionSchema = { type: "array" } as const;
|
|
121
|
+
|
|
122
|
+
export function registerDiscussVehicleOperations(registry: VehicleRegistry, discussions: Discussions, artifacts: ArtifactStore): void {
|
|
123
|
+
const moduleOperations = new Map(discussOperations(discussions).map((op) => [op.name, op]));
|
|
124
|
+
const call = <Output>(name: string, input: Record<string, unknown>): Output => moduleOperations.get(name)!.execute(input) as Output;
|
|
125
|
+
|
|
126
|
+
const define = (
|
|
127
|
+
action: string,
|
|
128
|
+
description: string,
|
|
129
|
+
effect: "read" | "local-write",
|
|
130
|
+
properties: Record<string, { type: string; enum?: readonly string[] }>,
|
|
131
|
+
required: readonly string[],
|
|
132
|
+
resolve: (input: Record<string, unknown>) => Record<string, unknown>,
|
|
133
|
+
wrap: (raw: unknown, resolvedInput: Record<string, unknown>) => unknown = (raw) => raw,
|
|
134
|
+
): void => {
|
|
135
|
+
const operation = defineVehicleOperation({
|
|
136
|
+
name: `discuss.${action}`,
|
|
137
|
+
version: 1,
|
|
138
|
+
description,
|
|
139
|
+
input: looseObjectSchema(properties, required),
|
|
140
|
+
output: passthroughOutput,
|
|
141
|
+
permissions: ["discuss:read", "discuss:write"],
|
|
142
|
+
effect,
|
|
143
|
+
idempotency: { mode: effect === "read" ? "safe" : "unsafe" },
|
|
144
|
+
limits: LIMITS,
|
|
145
|
+
});
|
|
146
|
+
registry.register(
|
|
147
|
+
OWNER,
|
|
148
|
+
bindVehicleOperation(operation, () => async (context) => {
|
|
149
|
+
const resolvedInput = resolve({ ...(context.input as Record<string, unknown>) });
|
|
150
|
+
const raw = call(`discuss.${action}`, resolvedInput);
|
|
151
|
+
return wrap(raw, resolvedInput);
|
|
152
|
+
}),
|
|
153
|
+
);
|
|
154
|
+
};
|
|
155
|
+
|
|
156
|
+
const contentBlock = (text: string): VehicleContentBlock => ({ type: "text", text });
|
|
157
|
+
|
|
158
|
+
define(
|
|
159
|
+
"open",
|
|
160
|
+
"Opens a new Discussion and starts round 1. Optionally poses a structured choice via options (2-10 entries) + options_mode ('single' mutually exclusive, 'multi' allows several) -- each option a bare string (self-evident) or {title, description} (a real tradeoff worth spelling out; description REQUIRED once there are 3+ options). Optionally blocks one or more Tasks immediately via blocks_task_ids/blocks_task_names. Pass live:true to get a human's answer synchronously in this same call, via an interactive prompt -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.",
|
|
161
|
+
"local-write",
|
|
162
|
+
{
|
|
163
|
+
title: stringProp,
|
|
164
|
+
actor: stringProp,
|
|
165
|
+
content: stringProp,
|
|
166
|
+
body: stringProp,
|
|
167
|
+
labels: arrayProp,
|
|
168
|
+
blocks_task_ids: arrayProp,
|
|
169
|
+
blocks_task_names: arrayProp,
|
|
170
|
+
options: optionsUnionSchema,
|
|
171
|
+
options_mode: { type: "string", enum: ["single", "multi"] },
|
|
172
|
+
option_descriptions: arrayProp,
|
|
173
|
+
live: boolProp,
|
|
174
|
+
},
|
|
175
|
+
["title", "content"],
|
|
176
|
+
(input) => {
|
|
177
|
+
normalizeOptions(input);
|
|
178
|
+
defaultActorToAgent(input);
|
|
179
|
+
const blocksTaskIds = resolveRealTaskIds(artifacts, input.blocks_task_ids, input.blocks_task_names);
|
|
180
|
+
return { ...input, ...(blocksTaskIds ? { blocks_task_ids: blocksTaskIds } : {}) };
|
|
181
|
+
},
|
|
182
|
+
(raw) => {
|
|
183
|
+
const result = raw as DiscussionAndRounds;
|
|
184
|
+
return { ...result, content: [contentBlock(`Opened discussion ${artifactLine(result.discussion)}`)] };
|
|
185
|
+
},
|
|
186
|
+
);
|
|
187
|
+
|
|
188
|
+
define(
|
|
189
|
+
"reply",
|
|
190
|
+
"Adds a round to an existing Discussion. Refused once deferred or settled -- resume first. Answers a currently pending posed choice via `selected` (validated against it), or poses a new choice via options/options_mode. Prefer `name` over `id`. Pass live:true to get a human's answer synchronously in this same call, via the pending choice's picker if one was posed, otherwise a freeform question -- only takes effect with an interactive UI available, otherwise degrades silently to the normal durably-recorded round.",
|
|
191
|
+
"local-write",
|
|
192
|
+
{
|
|
193
|
+
id: stringProp,
|
|
194
|
+
name: stringProp,
|
|
195
|
+
actor: stringProp,
|
|
196
|
+
content: stringProp,
|
|
197
|
+
selected: arrayProp,
|
|
198
|
+
options: optionsUnionSchema,
|
|
199
|
+
options_mode: { type: "string", enum: ["single", "multi"] },
|
|
200
|
+
option_descriptions: arrayProp,
|
|
201
|
+
live: boolProp,
|
|
202
|
+
},
|
|
203
|
+
["content"],
|
|
204
|
+
(input) => {
|
|
205
|
+
normalizeOptions(input);
|
|
206
|
+
defaultActorToAgent(input);
|
|
207
|
+
return { ...input, id: resolveDiscussionId(discussions, input.id, input.name) };
|
|
208
|
+
},
|
|
209
|
+
(raw) => {
|
|
210
|
+
const result = raw as DiscussionAndRounds;
|
|
211
|
+
return {
|
|
212
|
+
...result,
|
|
213
|
+
content: [contentBlock(`Round ${result.rounds[0]?.roundNumber} added to "${result.discussion.title}"`)],
|
|
214
|
+
};
|
|
215
|
+
},
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
define(
|
|
219
|
+
"defer",
|
|
220
|
+
"Pauses a Discussion without settling it -- explicitly non-blocking, resumable later via resume.",
|
|
221
|
+
"local-write",
|
|
222
|
+
{ id: stringProp, name: stringProp, reason: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
223
|
+
[],
|
|
224
|
+
(input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
|
|
225
|
+
(raw) => {
|
|
226
|
+
const artifact = raw as Artifact;
|
|
227
|
+
return { ...artifact, content: [contentBlock(artifactLine(artifact))] };
|
|
228
|
+
},
|
|
229
|
+
);
|
|
230
|
+
|
|
231
|
+
define(
|
|
232
|
+
"resume",
|
|
233
|
+
"Resumes a deferred Discussion back to active.",
|
|
234
|
+
"local-write",
|
|
235
|
+
{ id: stringProp, name: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
236
|
+
[],
|
|
237
|
+
(input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
|
|
238
|
+
(raw) => {
|
|
239
|
+
const artifact = raw as Artifact;
|
|
240
|
+
return { ...artifact, content: [contentBlock(artifactLine(artifact))] };
|
|
241
|
+
},
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
define(
|
|
245
|
+
"settle",
|
|
246
|
+
"Settles a Discussion -- terminal, archives it. A settled Discussion can never be replied to or resumed again.",
|
|
247
|
+
"local-write",
|
|
248
|
+
{ id: stringProp, name: stringProp, settlement: stringProp, actor: stringProp, source: stringProp, session_id: stringProp },
|
|
249
|
+
["settlement"],
|
|
250
|
+
(input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
|
|
251
|
+
(raw) => {
|
|
252
|
+
const artifact = raw as Artifact;
|
|
253
|
+
return { ...artifact, content: [contentBlock(artifactLine(artifact))] };
|
|
254
|
+
},
|
|
255
|
+
);
|
|
256
|
+
|
|
257
|
+
define(
|
|
258
|
+
"block",
|
|
259
|
+
"Blocks a Task's completion until this Discussion is settled or deferred. Prefer name/task_name over id/task_id.",
|
|
260
|
+
"local-write",
|
|
261
|
+
{
|
|
262
|
+
id: stringProp,
|
|
263
|
+
name: stringProp,
|
|
264
|
+
task_id: stringProp,
|
|
265
|
+
task_name: stringProp,
|
|
266
|
+
actor: stringProp,
|
|
267
|
+
source: stringProp,
|
|
268
|
+
session_id: stringProp,
|
|
269
|
+
},
|
|
270
|
+
[],
|
|
271
|
+
(input) => {
|
|
272
|
+
const discussionId = resolveDiscussionId(discussions, input.id, input.name);
|
|
273
|
+
const taskId = resolveRealTaskId(artifacts, input.task_id, input.task_name);
|
|
274
|
+
if (!taskId) throw validationError("task_id or task_name is required");
|
|
275
|
+
return { ...input, id: discussionId, task_id: taskId };
|
|
276
|
+
},
|
|
277
|
+
(_raw, resolvedInput) => {
|
|
278
|
+
const discussion = discussions.show(resolvedInput.id as string).discussion;
|
|
279
|
+
const task = artifacts.get(resolvedInput.task_id as string);
|
|
280
|
+
const message = `"${discussion.title}" now blocks "${task?.title ?? resolvedInput.task_id}"`;
|
|
281
|
+
return { blocked: true, content: [contentBlock(message)] };
|
|
282
|
+
},
|
|
283
|
+
);
|
|
284
|
+
|
|
285
|
+
define(
|
|
286
|
+
"unblock",
|
|
287
|
+
"Removes a blocking relationship between this Discussion and a Task -- idempotent, a no-op if the edge is already absent. Prefer name/task_name over id/task_id.",
|
|
288
|
+
"local-write",
|
|
289
|
+
{
|
|
290
|
+
id: stringProp,
|
|
291
|
+
name: stringProp,
|
|
292
|
+
task_id: stringProp,
|
|
293
|
+
task_name: stringProp,
|
|
294
|
+
actor: stringProp,
|
|
295
|
+
source: stringProp,
|
|
296
|
+
session_id: stringProp,
|
|
297
|
+
},
|
|
298
|
+
[],
|
|
299
|
+
(input) => {
|
|
300
|
+
const discussionId = resolveDiscussionId(discussions, input.id, input.name);
|
|
301
|
+
const taskId = resolveRealTaskId(artifacts, input.task_id, input.task_name);
|
|
302
|
+
if (!taskId) throw validationError("task_id or task_name is required");
|
|
303
|
+
return { ...input, id: discussionId, task_id: taskId };
|
|
304
|
+
},
|
|
305
|
+
(raw, resolvedInput) => {
|
|
306
|
+
const unblocked = (raw as { unblocked: boolean }).unblocked;
|
|
307
|
+
const discussion = discussions.show(resolvedInput.id as string).discussion;
|
|
308
|
+
const task = artifacts.get(resolvedInput.task_id as string);
|
|
309
|
+
const message = unblocked
|
|
310
|
+
? `"${discussion.title}" no longer blocks "${task?.title ?? resolvedInput.task_id}"`
|
|
311
|
+
: "No such blocking relationship.";
|
|
312
|
+
return { unblocked, content: [contentBlock(message)] };
|
|
313
|
+
},
|
|
314
|
+
);
|
|
315
|
+
|
|
316
|
+
define(
|
|
317
|
+
"show",
|
|
318
|
+
"Shows a Discussion's full transcript (every round). Prefer name over id.",
|
|
319
|
+
"read",
|
|
320
|
+
{ id: stringProp, name: stringProp },
|
|
321
|
+
[],
|
|
322
|
+
(input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
|
|
323
|
+
(raw) => {
|
|
324
|
+
const result = raw as DiscussionAndRounds;
|
|
325
|
+
return { ...result, content: [contentBlock(`${artifactLine(result.discussion)}\n\n${roundsTranscript(result.rounds)}`)] };
|
|
326
|
+
},
|
|
327
|
+
);
|
|
328
|
+
|
|
329
|
+
define(
|
|
330
|
+
"rounds",
|
|
331
|
+
"Lists a Discussion's rounds, optionally after a given round number. Prefer name over id.",
|
|
332
|
+
"read",
|
|
333
|
+
{ id: stringProp, name: stringProp, after_round: numberProp, limit: numberProp },
|
|
334
|
+
[],
|
|
335
|
+
(input) => ({ ...input, id: resolveDiscussionId(discussions, input.id, input.name) }),
|
|
336
|
+
(raw) => {
|
|
337
|
+
const rounds = raw as DiscussionRound[];
|
|
338
|
+
return { rounds, content: [contentBlock(roundsTranscript(rounds))] };
|
|
339
|
+
},
|
|
340
|
+
);
|
|
341
|
+
|
|
342
|
+
define(
|
|
343
|
+
"list",
|
|
344
|
+
"Lists Discussions, optionally filtered by state (active/deferred/settled).",
|
|
345
|
+
"read",
|
|
346
|
+
{ state: { type: "string", enum: ["active", "deferred", "settled"] }, limit: numberProp },
|
|
347
|
+
[],
|
|
348
|
+
(input) => input,
|
|
349
|
+
(raw) => {
|
|
350
|
+
const rows = raw as Artifact[];
|
|
351
|
+
const text = rows.length ? rows.map((row) => artifactLine(row)).join("\n") : "No discussions found.";
|
|
352
|
+
return { discussions: rows, content: [contentBlock(text)] };
|
|
353
|
+
},
|
|
354
|
+
);
|
|
355
|
+
}
|
|
@@ -10,7 +10,14 @@ import { listDocuments } from "../domain-services.ts";
|
|
|
10
10
|
import { docsOperations } from "../modules/docs.ts";
|
|
11
11
|
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
12
12
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
looseObjectSchema,
|
|
15
|
+
numberProp,
|
|
16
|
+
passthroughOutput,
|
|
17
|
+
resolveArtifactIdWidened,
|
|
18
|
+
stringProp,
|
|
19
|
+
validationError,
|
|
20
|
+
} from "./artifact-vehicle-shared.ts";
|
|
14
21
|
|
|
15
22
|
const OWNER = "docs";
|
|
16
23
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
@@ -23,7 +30,7 @@ function resolveDocId(
|
|
|
23
30
|
name: unknown,
|
|
24
31
|
): string {
|
|
25
32
|
if (typeof id === "string" && id.length > 0) return id;
|
|
26
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
33
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
27
34
|
return resolveArtifactIdWidened(
|
|
28
35
|
name,
|
|
29
36
|
() => listDocuments(artifacts, scopes, { text: name, projectRoot }),
|
|
@@ -34,7 +41,7 @@ function resolveDocId(
|
|
|
34
41
|
/** Cross-kind resolution for a link target -- can be a doc, task, rule, or playbook. Unscoped, matching the exact behavior of the artifact.query-backed resolution it replaces. */
|
|
35
42
|
function resolveTargetId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
|
|
36
43
|
if (typeof id === "string" && id.length > 0) return id;
|
|
37
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
44
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("target_id or target_name is required");
|
|
38
45
|
return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
|
|
39
46
|
}
|
|
40
47
|
|
|
@@ -12,7 +12,14 @@ import type { VehicleRegistry } from "@danypops/vehicle-server";
|
|
|
12
12
|
import { notesOperations } from "../modules/notes.ts";
|
|
13
13
|
import { NOTE_DISPOSITIONS, type Notes } from "../note-service.ts";
|
|
14
14
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
15
|
-
import {
|
|
15
|
+
import {
|
|
16
|
+
looseObjectSchema,
|
|
17
|
+
numberProp,
|
|
18
|
+
passthroughOutput,
|
|
19
|
+
resolveArtifactIdWidened,
|
|
20
|
+
stringProp,
|
|
21
|
+
validationError,
|
|
22
|
+
} from "./artifact-vehicle-shared.ts";
|
|
16
23
|
|
|
17
24
|
const OWNER = "notes";
|
|
18
25
|
|
|
@@ -21,14 +28,14 @@ const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes:
|
|
|
21
28
|
/** Resolves a note's id from either an explicit id or its title within projectRoot. */
|
|
22
29
|
function resolveNoteId(notes: Notes, projectRoot: string, id: unknown, name: unknown): string {
|
|
23
30
|
if (typeof id === "string" && id.length > 0) return id;
|
|
24
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
31
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
25
32
|
return resolveArtifactIdWidened(name, () => notes.list({ projectRoot, text: name }));
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
/** Cross-kind equivalent for a promotion target -- a target can be a task, doc, rule, or playbook, not just a note. Unscoped by project, matching the exact behavior of the artifact.query-backed resolution it replaces. */
|
|
29
36
|
function resolveArtifactId(artifacts: ArtifactStore, id: unknown, name: unknown): string {
|
|
30
37
|
if (typeof id === "string" && id.length > 0) return id;
|
|
31
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
38
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("target_id or target_name is required");
|
|
32
39
|
return resolveArtifactIdWidened(name, () => artifacts.query({ text: name }));
|
|
33
40
|
}
|
|
34
41
|
|
|
@@ -1,14 +1,13 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Composition root for every domain projected onto Vehicle -- one VehicleRegistry,
|
|
3
3
|
* one HTTP mount (see service.ts's createApp). Operation names are already globally
|
|
4
|
-
* unique via their own dotted prefix (notes.*, rules.*, docs.*, playbooks.*,
|
|
5
|
-
* so merging costs nothing and avoids a separate registry/mount/client
|
|
6
|
-
*
|
|
7
|
-
* discuss still registers via pi-papyrus's own pi.registerTool() in domain-tools.ts,
|
|
8
|
-
* not here -- see the papyrus Vehicle migration task for why.
|
|
4
|
+
* unique via their own dotted prefix (notes.*, rules.*, docs.*, playbooks.*, discuss.*,
|
|
5
|
+
* artifact.*), so merging costs nothing and avoids a separate registry/mount/client
|
|
6
|
+
* per domain.
|
|
9
7
|
*/
|
|
10
8
|
import { VehicleRegistry } from "@danypops/vehicle-server";
|
|
11
9
|
import type { AuthorityRegistry } from "../authority-registry.ts";
|
|
10
|
+
import type { Discussions } from "../discussion-service.ts";
|
|
12
11
|
import type { Notes } from "../note-service.ts";
|
|
13
12
|
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
14
13
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
@@ -18,6 +17,7 @@ import type { TaskScopeStore } from "../ports/task-scope-store.ts";
|
|
|
18
17
|
import type { SessionIdentity } from "../session-identity-service.ts";
|
|
19
18
|
import type { Tasks } from "../task-service.ts";
|
|
20
19
|
import { registerArtifactTrashOperations } from "./artifact-trash-vehicle.ts";
|
|
20
|
+
import { registerDiscussVehicleOperations } from "./discuss-vehicle.ts";
|
|
21
21
|
import { registerDocsVehicleOperations } from "./docs-vehicle.ts";
|
|
22
22
|
import { registerNotesVehicleOperations } from "./notes-vehicle.ts";
|
|
23
23
|
import { registerPlaybooksVehicleOperations } from "./playbooks-vehicle.ts";
|
|
@@ -32,6 +32,7 @@ export interface PapyrusVehicleDeps {
|
|
|
32
32
|
events: TaskEventStore;
|
|
33
33
|
taskScopes: TaskScopeStore;
|
|
34
34
|
tasks: Tasks;
|
|
35
|
+
discussions: Discussions;
|
|
35
36
|
sessionIdentity: SessionIdentity;
|
|
36
37
|
}
|
|
37
38
|
|
|
@@ -53,6 +54,7 @@ export function createPapyrusVehicleRegistry(deps: PapyrusVehicleDeps): VehicleR
|
|
|
53
54
|
sessionIdentity: deps.sessionIdentity,
|
|
54
55
|
});
|
|
55
56
|
registerTasksVehicleOperations(registry, { tasks: deps.tasks, artifacts: deps.artifacts, sessionIdentity: deps.sessionIdentity });
|
|
57
|
+
registerDiscussVehicleOperations(registry, deps.discussions, deps.artifacts);
|
|
56
58
|
registerArtifactTrashOperations(registry, deps.artifacts);
|
|
57
59
|
return registry;
|
|
58
60
|
}
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
passthroughOutput,
|
|
38
38
|
resolveArtifactIdWidened,
|
|
39
39
|
stringProp,
|
|
40
|
+
validationError,
|
|
40
41
|
} from "./artifact-vehicle-shared.ts";
|
|
41
42
|
|
|
42
43
|
const OWNER = "playbooks";
|
|
@@ -54,7 +55,7 @@ export interface PlaybooksVehicleDeps {
|
|
|
54
55
|
/** Unscoped resolution -- a Playbook is commonly cross-project (e.g. a lab-deploy playbook), matching the hand-rolled tool's own resolutionRequest choice. */
|
|
55
56
|
function resolvePlaybookId(artifacts: ArtifactStore, scopes: ArtifactScopeStore, id: unknown, name: unknown): string {
|
|
56
57
|
if (typeof id === "string" && id.length > 0) return id;
|
|
57
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
58
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
58
59
|
return resolveArtifactIdWidened(name, () => listPlaybooks(artifacts, scopes, { text: name }));
|
|
59
60
|
}
|
|
60
61
|
|
|
@@ -10,7 +10,14 @@ import { listRules } from "../domain-services.ts";
|
|
|
10
10
|
import { rulesOperations } from "../modules/rules.ts";
|
|
11
11
|
import type { ArtifactScopeStore } from "../ports/artifact-scope-store.ts";
|
|
12
12
|
import type { ArtifactStore } from "../ports/artifact-store.ts";
|
|
13
|
-
import {
|
|
13
|
+
import {
|
|
14
|
+
looseObjectSchema,
|
|
15
|
+
numberProp,
|
|
16
|
+
passthroughOutput,
|
|
17
|
+
resolveArtifactIdWidened,
|
|
18
|
+
stringProp,
|
|
19
|
+
validationError,
|
|
20
|
+
} from "./artifact-vehicle-shared.ts";
|
|
14
21
|
|
|
15
22
|
const OWNER = "rules";
|
|
16
23
|
const LIMITS = { defaultTimeoutMs: 5_000, maxTimeoutMs: 30_000, maxRequestBytes: 65_536, maxResponseBytes: 262_144 };
|
|
@@ -24,7 +31,7 @@ function resolveRuleId(
|
|
|
24
31
|
name: unknown,
|
|
25
32
|
): string {
|
|
26
33
|
if (typeof id === "string" && id.length > 0) return id;
|
|
27
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
34
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
28
35
|
return resolveArtifactIdWidened(
|
|
29
36
|
name,
|
|
30
37
|
() => listRules(artifacts, scopes, { text: name, projectRoot }),
|
|
@@ -151,7 +158,7 @@ export function registerRulesVehicleOperations(registry: VehicleRegistry, artifa
|
|
|
151
158
|
[],
|
|
152
159
|
(input) => {
|
|
153
160
|
const taskId = resolveTaskId(artifacts, input.project_root as string | undefined, input.task_id, input.task_name);
|
|
154
|
-
if (!taskId) throw
|
|
161
|
+
if (!taskId) throw validationError("task_id or task_name is required");
|
|
155
162
|
return {
|
|
156
163
|
...input,
|
|
157
164
|
id: resolveRuleId(artifacts, scopes, input.project_root as string | undefined, input.id, input.name),
|
|
@@ -34,6 +34,7 @@ import {
|
|
|
34
34
|
passthroughOutput,
|
|
35
35
|
resolveArtifactIdWidened,
|
|
36
36
|
stringProp,
|
|
37
|
+
validationError,
|
|
37
38
|
} from "./artifact-vehicle-shared.ts";
|
|
38
39
|
|
|
39
40
|
const OWNER = "tasks";
|
|
@@ -70,8 +71,8 @@ function resolveTaskId(
|
|
|
70
71
|
name: unknown,
|
|
71
72
|
): string {
|
|
72
73
|
if (typeof id === "string" && id.length > 0) return id;
|
|
73
|
-
if (typeof name !== "string" || name.length === 0) throw
|
|
74
|
-
if (!filter.projectRoot) throw
|
|
74
|
+
if (typeof name !== "string" || name.length === 0) throw validationError("id or name is required");
|
|
75
|
+
if (!filter.projectRoot) throw validationError("project_root is required when resolving a task by name");
|
|
75
76
|
return resolveArtifactIdWidened(
|
|
76
77
|
name,
|
|
77
78
|
() => tasks.list({ ...filter, text: name }),
|