storymapper 0.1.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/ARCHITECTURE.md +334 -0
- package/LICENSE +201 -0
- package/NOTICE +6 -0
- package/README.md +239 -0
- package/frontend/css/storymap.css +4637 -0
- package/frontend/js/adapters.js +423 -0
- package/frontend/js/card-animate.js +384 -0
- package/frontend/js/core/graph.js +825 -0
- package/frontend/js/core.js +3908 -0
- package/frontend/js/dialog-ticket-import.js +506 -0
- package/frontend/js/dnd.js +322 -0
- package/frontend/js/filter.js +215 -0
- package/frontend/js/main.js +2499 -0
- package/frontend/js/project-io.js +109 -0
- package/frontend/js/query-autocomplete.js +196 -0
- package/frontend/js/query-engine.js +339 -0
- package/frontend/js/query.js +280 -0
- package/frontend/js/renderer-card.js +639 -0
- package/frontend/js/renderer-dependencies.js +974 -0
- package/frontend/js/renderer-kanban.js +505 -0
- package/frontend/js/renderer-storymap.js +2530 -0
- package/frontend/js/renderer-ticket-editor.js +455 -0
- package/frontend/js/renderer-ticket-modal.js +758 -0
- package/frontend/js/save-pipeline.js +170 -0
- package/frontend/js/smartbar-autocomplete.js +162 -0
- package/frontend/js/store.js +197 -0
- package/frontend/js/ticket-editor-boot.js +24 -0
- package/frontend/js/ticket-form.js +2095 -0
- package/frontend/js/ticket-import.js +477 -0
- package/frontend/js/ui-shell.js +441 -0
- package/frontend/js/view-process-steps.js +233 -0
- package/frontend/js/view-requirements.js +361 -0
- package/frontend/js/view-settings.js +1864 -0
- package/frontend/js/view-table.js +659 -0
- package/frontend/js/wheel-pan.js +65 -0
- package/frontend/storymap.html +87 -0
- package/frontend/ticket-editor.html +29 -0
- package/package.json +76 -0
- package/server/bus.js +16 -0
- package/server/core/graph.js +10 -0
- package/server/core.js +10 -0
- package/server/identity.js +134 -0
- package/server/index.js +283 -0
- package/server/ingest.js +212 -0
- package/server/mcp.js +2510 -0
- package/server/server.js +1599 -0
- package/server/slice.js +103 -0
- package/server/storage.js +571 -0
- package/server/validation.js +225 -0
- package/shared/core/graph.js +825 -0
- package/shared/core.js +3908 -0
- package/shared/project-io.js +109 -0
- package/shared/query-autocomplete.js +196 -0
- package/shared/query-engine.js +339 -0
- package/shared/query.js +280 -0
- package/shared/ticket-import.js +477 -0
- package/skill/SKILL.md +458 -0
- package/skill/reference/anatomy.md +196 -0
- package/skill/reference/spec-evolution.md +52 -0
- package/skill/reference/tools.md +156 -0
- package/skill/reference/workflows.md +203 -0
package/server/mcp.js
ADDED
|
@@ -0,0 +1,2510 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* MCP server. Exposes the same operations as the REST layer via stdio
|
|
5
|
+
* JSON-RPC for Claude Desktop / Claude Code clients.
|
|
6
|
+
*
|
|
7
|
+
* Conventions:
|
|
8
|
+
* - `ok(payload)` returns `{ content: [{type:"text", text: JSON.stringify(payload)}] }`
|
|
9
|
+
* - `fail(message, extras)` returns `{ isError: true, content: [...] }` with
|
|
10
|
+
* a JSON body so coding agents can parse `kind`, `missing[]`, etc.
|
|
11
|
+
* - Mutating tools default to a COMPACT response: only the changed entity,
|
|
12
|
+
* plus `revision` and `savedAt`. Use `get_*` to fetch the full snapshot.
|
|
13
|
+
* - Every write is attributed to an AI actor (so the audit trail shows
|
|
14
|
+
* "Claude" vs. "HTTP User" on the REST side).
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
// SM-310: native, dependency-free MCP layer — replaces @modelcontextprotocol/sdk.
|
|
18
|
+
// SM-312: the native MCP layer is the standalone, zero-dependency `native-mcp`
|
|
19
|
+
// package (packages/native-mcp) — replaces @modelcontextprotocol/sdk.
|
|
20
|
+
const { createRegistry, createStdioServer } = require("../packages/native-mcp");
|
|
21
|
+
const { z } = require("zod");
|
|
22
|
+
const core = require("./core.js");
|
|
23
|
+
const coreGraph = require("./core/graph.js");
|
|
24
|
+
const validation = require("./validation.js");
|
|
25
|
+
const bus = require("./bus.js");
|
|
26
|
+
const identity = require("./identity.js");
|
|
27
|
+
const ingest = require("./ingest.js"); // SM-201 R-5: attachment → Markdown
|
|
28
|
+
const slice = require("./slice.js"); // SM-201 R-5: Markdown → sections
|
|
29
|
+
const queryEngine = require("../shared/query-engine.js"); // SM-190: JQL query engine
|
|
30
|
+
const ticketImport = require("../shared/ticket-import.js"); // SM-290: bulk import engine
|
|
31
|
+
const projectIO = require("../shared/project-io.js"); // SM-291: export envelope
|
|
32
|
+
|
|
33
|
+
// SM-129: the MCP actor + override logic now live in the shared identity
|
|
34
|
+
// seam so REST and MCP attribute writes through one module. Kept as a local
|
|
35
|
+
// alias so the existing AI_ACTOR references below don't churn.
|
|
36
|
+
const AI_ACTOR = identity.AI_ACTOR;
|
|
37
|
+
|
|
38
|
+
// Some MCP clients stringify nested object arguments when the input schema
|
|
39
|
+
// is `z.any()` (no visible object shape in the exported JSON schema), so a
|
|
40
|
+
// payload like { releaseId: "r-x" } arrives as the literal string
|
|
41
|
+
// '{"releaseId":"r-x"}' and downstream normalizers silently drop it.
|
|
42
|
+
// `tolerateJsonString` wraps any schema in a preprocess step that parses
|
|
43
|
+
// strings back into objects before validation. Combined with the strict
|
|
44
|
+
// object schemas below, structured clients get proper JSON-schema hints
|
|
45
|
+
// AND defensive clients keep working.
|
|
46
|
+
function tolerateJsonString(schema) {
|
|
47
|
+
return z.preprocess((v) => {
|
|
48
|
+
if (typeof v === "string") {
|
|
49
|
+
try { return JSON.parse(v); } catch (_) { return v; }
|
|
50
|
+
}
|
|
51
|
+
return v;
|
|
52
|
+
}, schema);
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
const positionSchema = tolerateJsonString(z.object({
|
|
56
|
+
releaseId: z.string().nullable().optional(),
|
|
57
|
+
epicId: z.string().nullable().optional(),
|
|
58
|
+
processStepId: z.string().nullable().optional(),
|
|
59
|
+
sortOrder: z.number().nullable().optional()
|
|
60
|
+
}).partial()).optional();
|
|
61
|
+
|
|
62
|
+
// SM-260: structured acceptance-criteria schema (key is `text`, never `label`).
|
|
63
|
+
// tolerateJsonString so the bridge's stringified array survives — a bare
|
|
64
|
+
// z.any() array is silently dropped by Claude Code's MCP bridge.
|
|
65
|
+
const acceptanceCriteriaSchema = tolerateJsonString(z.array(z.object({
|
|
66
|
+
text: z.string(),
|
|
67
|
+
completed: z.boolean().optional(),
|
|
68
|
+
id: z.string().optional()
|
|
69
|
+
}).passthrough())).optional();
|
|
70
|
+
|
|
71
|
+
const ticketPatchSchema = tolerateJsonString(z.object({
|
|
72
|
+
title: z.string().optional(),
|
|
73
|
+
description: z.string().optional(),
|
|
74
|
+
type: z.string().optional(),
|
|
75
|
+
status: z.string().optional(),
|
|
76
|
+
position: positionSchema,
|
|
77
|
+
labels: z.array(z.string()).optional(),
|
|
78
|
+
acceptanceCriteria: acceptanceCriteriaSchema,
|
|
79
|
+
definitionOfReady: z.any().optional(),
|
|
80
|
+
definitionOfDone: z.any().optional()
|
|
81
|
+
}).partial());
|
|
82
|
+
|
|
83
|
+
const reorderScopeSchema = tolerateJsonString(z.object({
|
|
84
|
+
releaseId: z.string().nullable().optional(),
|
|
85
|
+
processStepId: z.string().nullable().optional(),
|
|
86
|
+
epicId: z.string().nullable().optional()
|
|
87
|
+
}).partial()).optional();
|
|
88
|
+
|
|
89
|
+
// E21.H — Kanban column mapping. Tolerates a JSON-string argument so
|
|
90
|
+
// Claude Code's MCP bridge (which stringifies arrays/objects passed to
|
|
91
|
+
// `z.any()`-typed params) doesn't silently no-op the call.
|
|
92
|
+
const kanbanColumnsSchema = tolerateJsonString(z.array(z.object({
|
|
93
|
+
id: z.string(),
|
|
94
|
+
name: z.string().optional(),
|
|
95
|
+
statusIds: z.array(z.string()).optional()
|
|
96
|
+
}).passthrough()));
|
|
97
|
+
|
|
98
|
+
// SM-201 R-5 — a requirement's source anchor (back-ref into the PRD).
|
|
99
|
+
// tolerateJsonString so the MCP bridge's stringified object survives.
|
|
100
|
+
const sourceAnchorSchema = tolerateJsonString(z.object({
|
|
101
|
+
attachmentId: z.string().optional(),
|
|
102
|
+
sectionId: z.string().optional(),
|
|
103
|
+
charStart: z.number().nullable().optional(),
|
|
104
|
+
charEnd: z.number().nullable().optional()
|
|
105
|
+
}).partial());
|
|
106
|
+
|
|
107
|
+
// SM-31 — shared "any object" schema for tool args whose inner shape the
|
|
108
|
+
// schema doesn't constrain (e.g. set_definitions.definitions, project_
|
|
109
|
+
// update.patch, project_create.{definitions, workflow, entityTypeConfig}).
|
|
110
|
+
// Wraps z.object({}).passthrough() with tolerateJsonString so a stringified
|
|
111
|
+
// object survives Claude Code's MCP bridge instead of becoming a silent
|
|
112
|
+
// no-op the way `z.any()` did.
|
|
113
|
+
const passthroughObjectSchema = tolerateJsonString(z.object({}).passthrough());
|
|
114
|
+
|
|
115
|
+
// SM-164 — get_config/set_config value: an object (definitions / workflow /
|
|
116
|
+
// governance) OR an array (kanban_columns / link_types). Tolerates a JSON-
|
|
117
|
+
// string for the Claude Code MCP bridge, same as the schemas above.
|
|
118
|
+
const configValueSchema = tolerateJsonString(z.union([
|
|
119
|
+
z.object({}).passthrough(),
|
|
120
|
+
z.array(z.any())
|
|
121
|
+
]));
|
|
122
|
+
|
|
123
|
+
const CONFIG_SECTIONS = ["definitions", "workflow", "kanban_columns", "governance", "link_types"];
|
|
124
|
+
|
|
125
|
+
function genRequestId() {
|
|
126
|
+
return "sw-" + Math.random().toString(36).slice(2, 10) + "-" + Date.now().toString(36);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function ok(payload) {
|
|
130
|
+
return { content: [{ type: "text", text: JSON.stringify(payload, null, 2) }] };
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function fail(message, extras) {
|
|
134
|
+
const body = Object.assign({ error: message }, extras || {});
|
|
135
|
+
return { isError: true, content: [{ type: "text", text: JSON.stringify(body, null, 2) }] };
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// SM-129: delegate to the shared identity seam (args.actor override → AI_ACTOR).
|
|
139
|
+
function actorFromArgs(args) {
|
|
140
|
+
return identity.resolveAiActor(args);
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function findEntity(snap, kind, id) {
|
|
144
|
+
return (snap[kind] || []).find(x => x.id === id);
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
// SM-125: the single compact ticket shape — identical to list_tickets' compact
|
|
148
|
+
// mode (one source of truth). Ticket-returning mutators return this by default;
|
|
149
|
+
// pass verbose:true on the tool to get the full ticket instead.
|
|
150
|
+
function ticketSummary(t) {
|
|
151
|
+
return t ? {
|
|
152
|
+
id: t.id, ticketKey: t.ticketKey, type: t.type,
|
|
153
|
+
status: t.status, title: t.title, position: t.position
|
|
154
|
+
} : t;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// SM-177: render the foldProductDescription model (SM-176) to a Markdown
|
|
158
|
+
// product description — the deterministic, traceable skeleton (direction B).
|
|
159
|
+
const PRODUCT_DOC_BADGE = { shipped: "shipped", "in-progress": "in progress", planned: "planned" };
|
|
160
|
+
function renderProductDoc(model, opts) {
|
|
161
|
+
opts = opts || {};
|
|
162
|
+
const L = [];
|
|
163
|
+
L.push("# Product Description — " + (opts.projectName || "Product"));
|
|
164
|
+
L.push("");
|
|
165
|
+
L.push("_" + model.features.length + " feature(s)"
|
|
166
|
+
+ (model.orphans.length ? ", " + model.orphans.length + " ungrouped item(s)" : "") + "._");
|
|
167
|
+
L.push("");
|
|
168
|
+
for (const f of model.features) {
|
|
169
|
+
L.push("## " + (f.epic.title || f.epic.ticketKey));
|
|
170
|
+
const rel = f.release ? (f.release.name || f.release.id) : "Unscheduled";
|
|
171
|
+
L.push("*[" + (PRODUCT_DOC_BADGE[f.status] || f.status) + "] · " + rel + " · " + f.epic.ticketKey + "*");
|
|
172
|
+
// SM-180: cross-release evolution — the superseded chain folded into this feature.
|
|
173
|
+
if (Array.isArray(f.history) && f.history.length) {
|
|
174
|
+
const chain = f.history.map(h =>
|
|
175
|
+
h.epic.ticketKey + (h.release ? " (" + (h.release.name || h.release.id) + ")" : ""));
|
|
176
|
+
L.push("");
|
|
177
|
+
L.push("_Evolved from: " + chain.join(" → ") + " → current._");
|
|
178
|
+
}
|
|
179
|
+
L.push("");
|
|
180
|
+
if (f.stories.length) {
|
|
181
|
+
L.push("**Stories**");
|
|
182
|
+
for (const s of f.stories) {
|
|
183
|
+
L.push("- `" + s.status + "` " + s.ticketKey + " — " + s.title);
|
|
184
|
+
for (const ac of (s.acceptanceCriteria || [])) {
|
|
185
|
+
if (ac && ac.text) L.push(" - " + ac.text);
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
L.push("");
|
|
189
|
+
}
|
|
190
|
+
if (opts.includeTests && f.tests.length) {
|
|
191
|
+
L.push("**Tests**");
|
|
192
|
+
for (const t of f.tests) {
|
|
193
|
+
L.push("- " + t.ticketKey + " — " + t.title + (t.health ? " (" + t.health + ")" : ""));
|
|
194
|
+
}
|
|
195
|
+
L.push("");
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
if (model.orphans.length) {
|
|
199
|
+
L.push("## Ungrouped work items");
|
|
200
|
+
for (const o of model.orphans) {
|
|
201
|
+
const rel = o.release ? (o.release.name || o.release.id) : "Unscheduled";
|
|
202
|
+
L.push("- `" + o.status + "` " + o.ticketKey + " — " + o.title + " · " + rel);
|
|
203
|
+
}
|
|
204
|
+
L.push("");
|
|
205
|
+
}
|
|
206
|
+
return L.join("\n");
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
// SM-164 — unified project-config dispatch. Each section keeps its own read +
|
|
210
|
+
// validate + apply path (no behaviour change vs. the former ten get/set tools);
|
|
211
|
+
// only the tool envelope is unified into get_config / set_config.
|
|
212
|
+
|
|
213
|
+
function readConfigSection(project, section, type) {
|
|
214
|
+
switch (section) {
|
|
215
|
+
case "definitions": return project.definitions;
|
|
216
|
+
case "workflow":
|
|
217
|
+
return type ? core.getWorkflowForType(project, type)
|
|
218
|
+
: (project.workflow || core.STORYMAPPER_DEFAULT_WORKFLOW);
|
|
219
|
+
case "kanban_columns": return (project.boards && project.boards.kanban
|
|
220
|
+
&& project.boards.kanban.columns) || [];
|
|
221
|
+
case "governance": return project.governance || core.STORYMAPPER_DEFAULT_GOVERNANCE;
|
|
222
|
+
case "link_types": return project.linkTypes || [];
|
|
223
|
+
default: return undefined;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
// Apply a config-section write to `cur`, returning the next snapshot. Throws
|
|
228
|
+
// { statusCode, kind? } on invalid input — persist() translates that to fail().
|
|
229
|
+
// Governance's UNKNOWN_PREDICATE check runs in the handler (richer error body).
|
|
230
|
+
function applyConfigSection(section, cur, value, actor) {
|
|
231
|
+
switch (section) {
|
|
232
|
+
case "definitions": {
|
|
233
|
+
validation.definitionsInput(value);
|
|
234
|
+
const merged = Object.assign({}, cur.project, { definitions: value });
|
|
235
|
+
return Object.assign({}, cur, { project: core.normalizeProject(merged) });
|
|
236
|
+
}
|
|
237
|
+
case "workflow": {
|
|
238
|
+
const wf = core.normalizeWorkflow(value);
|
|
239
|
+
if (!wf || wf.statuses.length === 0) {
|
|
240
|
+
throw Object.assign(new Error("workflow.statuses must be a non-empty array"), { statusCode: 400 });
|
|
241
|
+
}
|
|
242
|
+
const next = Object.assign({}, cur, {
|
|
243
|
+
project: Object.assign({}, cur.project, { workflow: wf })
|
|
244
|
+
});
|
|
245
|
+
return core.normalizeSnapshot(next);
|
|
246
|
+
}
|
|
247
|
+
case "kanban_columns": {
|
|
248
|
+
const cols = Array.isArray(value) ? value : [];
|
|
249
|
+
const nextBoards = Object.assign({}, cur.project.boards || {}, { kanban: { columns: cols } });
|
|
250
|
+
const next = Object.assign({}, cur, {
|
|
251
|
+
project: Object.assign({}, cur.project, { boards: nextBoards })
|
|
252
|
+
});
|
|
253
|
+
return core.normalizeSnapshot(next);
|
|
254
|
+
}
|
|
255
|
+
case "governance": {
|
|
256
|
+
const incoming = (value && typeof value === "object") ? value : {};
|
|
257
|
+
return core.ops.updateProject(cur, { governance: core.normalizeGovernance(incoming) }, actor);
|
|
258
|
+
}
|
|
259
|
+
case "link_types": {
|
|
260
|
+
const list = Array.isArray(value) ? value : [];
|
|
261
|
+
return core.ops.updateProject(cur, { linkTypes: list }, actor);
|
|
262
|
+
}
|
|
263
|
+
default:
|
|
264
|
+
throw Object.assign(new Error("unknown config section: " + section), { statusCode: 400 });
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/**
|
|
269
|
+
* SM-94 — Run the SM-93 governance gates for a tool action against a
|
|
270
|
+
* specific ticket. If any gate fails, returns a complete fail() response
|
|
271
|
+
* with kind = first failing errorKind, the full errors[] array, and a
|
|
272
|
+
* rendered message (title/reason/suggestion/skillRef). Returns null when
|
|
273
|
+
* everything passes — caller proceeds with its persist().
|
|
274
|
+
*
|
|
275
|
+
* The `extraContext` object provides values for the message templater
|
|
276
|
+
* (e.g. {definitionKey, targetKey} so the rendered suggestion can name
|
|
277
|
+
* the actual ticket the agent was working on).
|
|
278
|
+
*/
|
|
279
|
+
function runGovernanceGates(snap, toolAction, ticket, extraContext) {
|
|
280
|
+
const result = core.evaluateGates(toolAction, snap, ticket, snap.project.governance);
|
|
281
|
+
if (result.ok) return null;
|
|
282
|
+
const first = result.errors[0];
|
|
283
|
+
const template = ((snap.project.governance || {}).messages || {})[first.kind];
|
|
284
|
+
const ctx = Object.assign({}, extraContext || {}, first.context || {});
|
|
285
|
+
const message = core.renderMessage(template, ctx);
|
|
286
|
+
return fail(message.reason || ("gate failed: " + first.kind), {
|
|
287
|
+
kind: first.kind,
|
|
288
|
+
errors: result.errors,
|
|
289
|
+
message: message,
|
|
290
|
+
statusCode: 422
|
|
291
|
+
});
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
/**
|
|
295
|
+
* SM-94 — soft-warning variant of runGovernanceGates. Returns a `warnings`
|
|
296
|
+
* array that the caller can include in its successful response. Each
|
|
297
|
+
* warning carries its rendered message so the agent doesn't need to
|
|
298
|
+
* re-render templates client-side.
|
|
299
|
+
*/
|
|
300
|
+
function collectGovernanceWarnings(snap, toolAction, ticket, extraContext) {
|
|
301
|
+
const result = core.evaluateWarnings(toolAction, snap, ticket, snap.project.governance);
|
|
302
|
+
if (!result.warnings || result.warnings.length === 0) return [];
|
|
303
|
+
return result.warnings.map(w => {
|
|
304
|
+
const template = ((snap.project.governance || {}).messages || {})[w.kind];
|
|
305
|
+
const ctx = Object.assign({}, extraContext || {}, w.context || {});
|
|
306
|
+
return { kind: w.kind, context: w.context, message: core.renderMessage(template, ctx) };
|
|
307
|
+
});
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
/**
|
|
311
|
+
* Run a load → mutate → save cycle. The `mutate` callback receives the
|
|
312
|
+
* current snapshot and returns the next one. Throws from `mutate` are
|
|
313
|
+
* caught and translated to fail() with statusCode/kind/missing preserved.
|
|
314
|
+
*/
|
|
315
|
+
async function persist(storage, projectId, op, mutate, actor) {
|
|
316
|
+
// SM-149: run load → mutate → save atomically inside the storage mutex so
|
|
317
|
+
// two concurrent tool calls on the same project can't lose each other's
|
|
318
|
+
// write (the old loadProject()+saveProject() pair had a lost-update window).
|
|
319
|
+
let before = null;
|
|
320
|
+
try {
|
|
321
|
+
const saved = await storage.mutate(projectId, { actor, op }, (current) => {
|
|
322
|
+
before = current;
|
|
323
|
+
const next = mutate(current);
|
|
324
|
+
validation.snapshotLimits(next);
|
|
325
|
+
return next;
|
|
326
|
+
});
|
|
327
|
+
return { saved, before };
|
|
328
|
+
} catch (err) {
|
|
329
|
+
return { error: fail(err.message || "error", {
|
|
330
|
+
statusCode: err.statusCode || 500,
|
|
331
|
+
kind: err.kind,
|
|
332
|
+
missing: err.missing
|
|
333
|
+
})};
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
/**
|
|
338
|
+
* Build the MCP server.
|
|
339
|
+
*
|
|
340
|
+
* storage — shared Storage instance (SQLite via better-sqlite3)
|
|
341
|
+
* opts.httpUrl — base URL of the HTTP server for the standalone-MCP
|
|
342
|
+
* cross-process bridge (request_switch_project). When set,
|
|
343
|
+
* the tool POSTs to /api/internal/switch-request and long-
|
|
344
|
+
* polls /api/internal/switch-response. When null, falls
|
|
345
|
+
* back to in-process bus emit (useful for unit tests that
|
|
346
|
+
* share the same process).
|
|
347
|
+
* opts.fetchImpl — fetch override for tests (defaults to global fetch).
|
|
348
|
+
*/
|
|
349
|
+
function buildServer(storage, opts) {
|
|
350
|
+
opts = opts || {};
|
|
351
|
+
const httpUrl = typeof opts.httpUrl === "string" ? opts.httpUrl : null;
|
|
352
|
+
const fetchImpl = opts.fetchImpl || (typeof fetch === "function" ? fetch : null);
|
|
353
|
+
// SM-312: the native registry is validator-agnostic (zod-free). The 70 tool
|
|
354
|
+
// defs below still pass a Zod SHAPE as `inputSchema` (ergonomic); this adapter
|
|
355
|
+
// emits the JSON Schema (z.toJSONSchema) + a validate() (z.object(shape)
|
|
356
|
+
// .passthrough().parse — preserving tolerateJsonString + the `actor` passthrough)
|
|
357
|
+
// so the extractable native package stays zod-free while Storymapper keeps Zod.
|
|
358
|
+
// `server` inherits dispatch/listTools/_registeredTools from the registry;
|
|
359
|
+
// only registerTool/register are wrapped.
|
|
360
|
+
const registry = createRegistry();
|
|
361
|
+
const server = Object.create(registry);
|
|
362
|
+
server.registerTool = server.register = function registerZodTool(name, meta, handler) {
|
|
363
|
+
const shape = (meta && meta.inputSchema) || {};
|
|
364
|
+
const objectSchema = z.object(shape).passthrough();
|
|
365
|
+
let jsonSchema;
|
|
366
|
+
try { jsonSchema = z.toJSONSchema(objectSchema, { io: "input" }); }
|
|
367
|
+
catch (_) { jsonSchema = { type: "object", additionalProperties: true }; }
|
|
368
|
+
return registry.register(name, {
|
|
369
|
+
description: meta && meta.description,
|
|
370
|
+
inputSchema: jsonSchema,
|
|
371
|
+
validate: (args) => objectSchema.parse(args == null ? {} : args)
|
|
372
|
+
}, handler);
|
|
373
|
+
};
|
|
374
|
+
|
|
375
|
+
// E20.E: cross-process live-sync. The HTTP server's bus is in its OWN
|
|
376
|
+
// process; MCP subprocess writes never reach connected browsers unless
|
|
377
|
+
// we explicitly forward them. We listen on our local bus and POST each
|
|
378
|
+
// change event to the HTTP server's `/api/internal/notify-change` endpoint,
|
|
379
|
+
// which re-emits on its bus and broadcasts to matching subscribers.
|
|
380
|
+
if (httpUrl && fetchImpl) {
|
|
381
|
+
bus.on("change", async (ev) => {
|
|
382
|
+
try {
|
|
383
|
+
await fetchImpl(new URL("/api/internal/notify-change", httpUrl), {
|
|
384
|
+
method: "POST",
|
|
385
|
+
headers: { "Content-Type": "application/json" },
|
|
386
|
+
body: JSON.stringify(ev)
|
|
387
|
+
});
|
|
388
|
+
} catch (e) {
|
|
389
|
+
process.stderr.write(`[storymap-mcp] notify-change POST failed: ${e.message}\n`);
|
|
390
|
+
}
|
|
391
|
+
});
|
|
392
|
+
}
|
|
393
|
+
|
|
394
|
+
// -------------------- projects -----------------------------------------
|
|
395
|
+
|
|
396
|
+
server.registerTool("list_projects", {
|
|
397
|
+
description: "List all (non-deleted) project IDs",
|
|
398
|
+
inputSchema: {}
|
|
399
|
+
}, async () => {
|
|
400
|
+
const projects = await storage.listProjects();
|
|
401
|
+
return ok({ projects });
|
|
402
|
+
});
|
|
403
|
+
|
|
404
|
+
server.registerTool("project_get", {
|
|
405
|
+
description: "Load the full snapshot of a project",
|
|
406
|
+
inputSchema: { projectId: z.string() }
|
|
407
|
+
}, async ({ projectId }) => {
|
|
408
|
+
const snap = await storage.loadProject(projectId);
|
|
409
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
410
|
+
return ok({ snapshot: snap });
|
|
411
|
+
});
|
|
412
|
+
|
|
413
|
+
server.registerTool("project_create", {
|
|
414
|
+
description: "Create a new project. Optional fields: definitions (DoR/DoD), workflow (statuses + transitions + per-type overrides), entityTypeConfig (per-type modal config), ticketTypes (allowed type names).",
|
|
415
|
+
inputSchema: {
|
|
416
|
+
id: z.string(),
|
|
417
|
+
name: z.string(),
|
|
418
|
+
description: z.string().optional(),
|
|
419
|
+
ticketPrefix: z.string().optional(),
|
|
420
|
+
definitions: passthroughObjectSchema.optional(), // SM-31
|
|
421
|
+
workflow: passthroughObjectSchema.optional(), // SM-31
|
|
422
|
+
entityTypeConfig: passthroughObjectSchema.optional(), // SM-31
|
|
423
|
+
ticketTypes: z.array(z.string()).optional()
|
|
424
|
+
}
|
|
425
|
+
}, async (args) => {
|
|
426
|
+
const actor = actorFromArgs(args);
|
|
427
|
+
const body = {
|
|
428
|
+
id: args.id, name: args.name,
|
|
429
|
+
description: args.description,
|
|
430
|
+
ticketPrefix: args.ticketPrefix,
|
|
431
|
+
definitions: args.definitions,
|
|
432
|
+
workflow: args.workflow,
|
|
433
|
+
entityTypeConfig: args.entityTypeConfig,
|
|
434
|
+
ticketTypes: args.ticketTypes
|
|
435
|
+
};
|
|
436
|
+
try { validation.projectInput(body); }
|
|
437
|
+
catch (err) { return fail(err.message, { statusCode: err.statusCode || 400 }); }
|
|
438
|
+
const project = core.normalizeProject(Object.assign({}, body, { createdBy: actor, updatedBy: actor }));
|
|
439
|
+
// SM-254: seed a default release + process step so the project is usable.
|
|
440
|
+
const snap = core.seedDefaultScaffold(
|
|
441
|
+
core.normalizeSnapshot({ project, tickets: [], releases: [], processSteps: [] }), actor);
|
|
442
|
+
const r = await storage.saveProject(project.id, snap, { actor, op: "project_create" });
|
|
443
|
+
return ok({ revision: r.revision, savedAt: r.savedAt, snapshot: r.snapshot });
|
|
444
|
+
});
|
|
445
|
+
|
|
446
|
+
server.registerTool("project_update", {
|
|
447
|
+
description: "Patch project header fields (name, description, ticketPrefix, definitions)",
|
|
448
|
+
inputSchema: { projectId: z.string(), patch: passthroughObjectSchema } // SM-31
|
|
449
|
+
}, async ({ projectId, patch }) => {
|
|
450
|
+
const actor = AI_ACTOR;
|
|
451
|
+
// Route through core.ops.updateProject so the allowed-field list applies
|
|
452
|
+
// (id / ticketPrefix / ticketCounter are locked — a blind Object.assign
|
|
453
|
+
// would let a patch reset the counter and break existing ticketKeys). SM-148.
|
|
454
|
+
const r = await persist(storage, projectId, "project_update", (cur) => {
|
|
455
|
+
const next = core.ops.updateProject(cur, patch || {}, actor);
|
|
456
|
+
validation.projectInput(next.project); // SM-148: keep header validation (name/prefix)
|
|
457
|
+
return next;
|
|
458
|
+
}, actor);
|
|
459
|
+
if (r.error) return r.error;
|
|
460
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, project: r.saved.snapshot.project });
|
|
461
|
+
});
|
|
462
|
+
|
|
463
|
+
server.registerTool("project_delete", {
|
|
464
|
+
description: "Soft-delete a project (filtered out of list_projects)",
|
|
465
|
+
inputSchema: { projectId: z.string() }
|
|
466
|
+
}, async ({ projectId }) => {
|
|
467
|
+
await storage.deleteProject(projectId, { actor: AI_ACTOR });
|
|
468
|
+
return ok({ deleted: projectId });
|
|
469
|
+
});
|
|
470
|
+
|
|
471
|
+
// -------------------- tickets ------------------------------------------
|
|
472
|
+
|
|
473
|
+
server.registerTool("list_tickets", {
|
|
474
|
+
description: "List non-deleted tickets of a project. Filters (all optional, AND-combined): "
|
|
475
|
+
+ "`status` (exact match), `type` (exact match), `releaseId` / `processStepId` / `epicId` "
|
|
476
|
+
+ "(exact match, OR pass 'none' or '' to filter for tickets WITHOUT that assignment — "
|
|
477
|
+
+ "e.g. backlog tickets or orphans). `epicId` resolves via the `contains` link graph "
|
|
478
|
+
+ "(position.epicId is never persisted — SM-52). Set `compact: true` to return only "
|
|
479
|
+
+ "{id, ticketKey, type, status, title, position} per ticket — shrinks ~80-ticket "
|
|
480
|
+
+ "projects from ~400 KB to ~10 KB (SM-98).",
|
|
481
|
+
inputSchema: {
|
|
482
|
+
projectId: z.string(),
|
|
483
|
+
status: z.string().optional(),
|
|
484
|
+
type: z.string().optional(),
|
|
485
|
+
releaseId: z.string().optional(),
|
|
486
|
+
processStepId: z.string().optional(),
|
|
487
|
+
epicId: z.string().optional(),
|
|
488
|
+
compact: z.boolean().optional()
|
|
489
|
+
}
|
|
490
|
+
}, async ({ projectId, status, type, releaseId, processStepId, epicId, compact }) => {
|
|
491
|
+
const snap = await storage.loadProject(projectId);
|
|
492
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
493
|
+
let list = snap.tickets.filter(t => !t.isDeleted);
|
|
494
|
+
if (status) list = list.filter(t => t.status === status);
|
|
495
|
+
if (type) list = list.filter(t => t.type === type);
|
|
496
|
+
// Sentinel: "" or "none" → absent. Any other string → exact match.
|
|
497
|
+
const isAbsentSentinel = (v) => v === "" || v === "none";
|
|
498
|
+
const matchField = (filterVal, ticketVal) => {
|
|
499
|
+
if (filterVal === undefined) return true;
|
|
500
|
+
if (isAbsentSentinel(filterVal)) return ticketVal == null;
|
|
501
|
+
return ticketVal === filterVal;
|
|
502
|
+
};
|
|
503
|
+
if (releaseId !== undefined) {
|
|
504
|
+
list = list.filter(t => matchField(releaseId, t.position && t.position.releaseId));
|
|
505
|
+
}
|
|
506
|
+
if (processStepId !== undefined) {
|
|
507
|
+
list = list.filter(t => matchField(processStepId, t.position && t.position.processStepId));
|
|
508
|
+
}
|
|
509
|
+
// epicId: position.epicId is always null (containers live on contains-links).
|
|
510
|
+
// Resolve via the link graph instead.
|
|
511
|
+
if (epicId !== undefined) {
|
|
512
|
+
const containerByChild = new Map();
|
|
513
|
+
for (const t of snap.tickets) {
|
|
514
|
+
for (const ln of (t.links || [])) {
|
|
515
|
+
if (ln.linkTypeId === "contains") containerByChild.set(ln.targetTicketId, t.id);
|
|
516
|
+
}
|
|
517
|
+
}
|
|
518
|
+
if (isAbsentSentinel(epicId)) {
|
|
519
|
+
list = list.filter(t => !containerByChild.has(t.id));
|
|
520
|
+
} else {
|
|
521
|
+
list = list.filter(t => containerByChild.get(t.id) === epicId);
|
|
522
|
+
}
|
|
523
|
+
}
|
|
524
|
+
if (compact === true) {
|
|
525
|
+
list = list.map(ticketSummary);
|
|
526
|
+
}
|
|
527
|
+
return ok({ tickets: list });
|
|
528
|
+
});
|
|
529
|
+
|
|
530
|
+
server.registerTool("next_actionable", {
|
|
531
|
+
description: "What can I start NOW? Returns the work-item tickets that are ready to pull: "
|
|
532
|
+
+ "status category 'todo', DoR met (all required DoR items checked) OR already at status "
|
|
533
|
+
+ "'ready', and NOT blocked by an unresolved blocking/precedence predecessor (a linked "
|
|
534
|
+
+ "ticket that isn't done). Epics are excluded (containers, not work). Ordered as a do-next "
|
|
535
|
+
+ "queue: position.sortOrder then ticketKey. Each item is the compact ticket summary "
|
|
536
|
+
+ "{id, ticketKey, type, status, title, position} plus a `reason`. Optional `releaseId` / "
|
|
537
|
+
+ "`type` narrow the queue. Use this to decide the next task instead of scanning list_tickets.",
|
|
538
|
+
inputSchema: {
|
|
539
|
+
projectId: z.string(),
|
|
540
|
+
releaseId: z.string().optional(),
|
|
541
|
+
type: z.string().optional()
|
|
542
|
+
}
|
|
543
|
+
}, async ({ projectId, releaseId, type }) => {
|
|
544
|
+
const snap = await storage.loadProject(projectId);
|
|
545
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
546
|
+
const ids = coreGraph.actionableTickets(snap, { releaseId, type });
|
|
547
|
+
const byId = new Map(snap.tickets.map(t => [t.id, t]));
|
|
548
|
+
const tickets = ids.map(id => {
|
|
549
|
+
const t = byId.get(id);
|
|
550
|
+
const s = ticketSummary(t);
|
|
551
|
+
s.reason = t.status === "ready" ? "ready to pull" : "DoR met, unblocked";
|
|
552
|
+
return s;
|
|
553
|
+
});
|
|
554
|
+
return ok({ tickets });
|
|
555
|
+
});
|
|
556
|
+
|
|
557
|
+
server.registerTool("generate_product_doc", {
|
|
558
|
+
description: "Generate a current-state product description (Markdown) by FOLDING the tickets "
|
|
559
|
+
+ "(SM-176/177, direction B). Each epic is a feature: its release, a derived status "
|
|
560
|
+
+ "(shipped/in-progress/planned from its stories), the realising stories + their acceptance "
|
|
561
|
+
+ "criteria, and — with includeTests:true — its linked test-definitions and their health. "
|
|
562
|
+
+ "Optional releaseId scopes to one release. Optional `types` is an allowlist of work-item "
|
|
563
|
+
+ "types that count as content — pass e.g. [\"epic\",\"user-story\",\"technical-task-backend\","
|
|
564
|
+
+ "\"technical-task-ui\"] for a PRD so bugs and tests are excluded (the noise the raw fold "
|
|
565
|
+
+ "shows). This is the deterministic, TRACEABLE skeleton — narrate/refine the prose yourself. "
|
|
566
|
+
+ "Pass saveAsAttachment:true to ALSO write the generated Markdown back as a project-level "
|
|
567
|
+
+ "attachment (SM-182 round-trip — closes the loop so the current doc is persisted alongside "
|
|
568
|
+
+ "the source PRD); attachmentFilename defaults to 'product-description.md'.",
|
|
569
|
+
inputSchema: {
|
|
570
|
+
projectId: z.string(),
|
|
571
|
+
releaseId: z.string().optional(),
|
|
572
|
+
includeTests: z.boolean().optional(),
|
|
573
|
+
types: tolerateJsonString(z.array(z.string())).optional(),
|
|
574
|
+
saveAsAttachment: z.boolean().optional(),
|
|
575
|
+
attachmentFilename: z.string().optional()
|
|
576
|
+
}
|
|
577
|
+
}, async ({ projectId, releaseId, includeTests, types, saveAsAttachment, attachmentFilename }) => {
|
|
578
|
+
const snap = await storage.loadProject(projectId);
|
|
579
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
580
|
+
const model = coreGraph.foldProductDescription(snap, {
|
|
581
|
+
releaseId,
|
|
582
|
+
types: Array.isArray(types) ? types : undefined
|
|
583
|
+
});
|
|
584
|
+
const markdown = renderProductDoc(model, {
|
|
585
|
+
projectName: (snap.project && snap.project.name) || projectId,
|
|
586
|
+
includeTests: includeTests === true
|
|
587
|
+
});
|
|
588
|
+
const result = { markdown };
|
|
589
|
+
if (saveAsAttachment === true) {
|
|
590
|
+
const filename = (attachmentFilename && String(attachmentFilename).trim()) || "product-description.md";
|
|
591
|
+
try {
|
|
592
|
+
// Project-level (no ticketId): the regenerated doc lives next to the source PRD.
|
|
593
|
+
result.attachment = await storage.addAttachment(projectId,
|
|
594
|
+
{ filename, mimeType: "text/markdown", buffer: Buffer.from(markdown, "utf8") },
|
|
595
|
+
actorFromArgs({}));
|
|
596
|
+
} catch (e) {
|
|
597
|
+
return fail(e.message || "doc write-back failed", e.statusCode ? { statusCode: e.statusCode } : undefined);
|
|
598
|
+
}
|
|
599
|
+
}
|
|
600
|
+
return ok(result);
|
|
601
|
+
});
|
|
602
|
+
|
|
603
|
+
// -------------------- attachments — binary seam (SM-184) ------------------
|
|
604
|
+
// Transport-agnostic agent access to binary reference docs (PRDs, designs):
|
|
605
|
+
// content flows over the MCP channel as base64 for SMALL files, and via a
|
|
606
|
+
// downloadUrl (out-of-band HTTP) for large ones — never via filesystem paths,
|
|
607
|
+
// so this survives a multi-user/remote server future.
|
|
608
|
+
const ATTACHMENT_INLINE_MAX = 256 * 1024; // inline base64 only up to 256 KB
|
|
609
|
+
function attachmentDownloadUrl(meta) {
|
|
610
|
+
const base = httpUrl ? httpUrl.replace(/\/$/, "") : "";
|
|
611
|
+
return base + "/api/projects/" + encodeURIComponent(meta.projectId)
|
|
612
|
+
+ "/attachments/" + encodeURIComponent(meta.id);
|
|
613
|
+
}
|
|
614
|
+
|
|
615
|
+
server.registerTool("attachment_list", {
|
|
616
|
+
description: "List binary attachments (metadata only — no content) of a project. Pass ticketId "
|
|
617
|
+
+ "to scope to one ticket, or ticketId=\"none\" for project-level attachments only (e.g. a "
|
|
618
|
+
+ "source PRD). Returns [{id, filename, mimeType, size, ticketId, uploadedAt}].",
|
|
619
|
+
inputSchema: { projectId: z.string(), ticketId: z.string().optional() }
|
|
620
|
+
}, async ({ projectId, ticketId }) => {
|
|
621
|
+
const list = await storage.listAttachments(projectId, ticketId != null ? { ticketId } : {});
|
|
622
|
+
return ok({ attachments: list });
|
|
623
|
+
});
|
|
624
|
+
|
|
625
|
+
server.registerTool("attachment_put", {
|
|
626
|
+
description: "Upload a binary attachment (base64) to a project. OMIT ticketId for a "
|
|
627
|
+
+ "PROJECT-LEVEL attachment (e.g. a source PRD/PLD that precedes the tickets it will be "
|
|
628
|
+
+ "decomposed into) — this is the standard create flow: project_create → attachment_put "
|
|
629
|
+
+ "(no ticketId) → attachment_get it back to read + decompose. Pass ticketId only to attach "
|
|
630
|
+
+ "a doc to one specific ticket. Returns {id, filename, mimeType, size, ...}. Max 25 MB.",
|
|
631
|
+
inputSchema: {
|
|
632
|
+
projectId: z.string(),
|
|
633
|
+
filename: z.string(),
|
|
634
|
+
contentBase64: z.string(),
|
|
635
|
+
mimeType: z.string().optional(),
|
|
636
|
+
ticketId: z.string().optional()
|
|
637
|
+
}
|
|
638
|
+
}, async ({ projectId, filename, contentBase64, mimeType, ticketId }) => {
|
|
639
|
+
// Buffer.from(…,"base64") never throws (lenient); empty/garbage falls through
|
|
640
|
+
// to storage's own length/limit guards, which we translate to a clean error.
|
|
641
|
+
const buffer = Buffer.from(String(contentBase64 || ""), "base64");
|
|
642
|
+
try {
|
|
643
|
+
const meta = await storage.addAttachment(projectId, { ticketId, filename, mimeType, buffer }, actorFromArgs({}));
|
|
644
|
+
return ok({ attachment: meta });
|
|
645
|
+
} catch (e) {
|
|
646
|
+
// Parity with the rest of the surface: storage throws { statusCode } for
|
|
647
|
+
// project-not-found (404) / too-large (413) — emit the structured shape.
|
|
648
|
+
return fail(e.message || "attachment upload failed", e.statusCode ? { statusCode: e.statusCode } : undefined);
|
|
649
|
+
}
|
|
650
|
+
});
|
|
651
|
+
|
|
652
|
+
server.registerTool("attachment_get", {
|
|
653
|
+
description: "Read a binary attachment. ALWAYS returns metadata + a downloadUrl (the REST "
|
|
654
|
+
+ "endpoint — fetch out-of-band for large files). For SMALL files (<=256 KB) it also inlines "
|
|
655
|
+
+ "contentBase64 so you can read it directly off the channel. For a big PDF, use the URL "
|
|
656
|
+
+ "instead of bloating the context.",
|
|
657
|
+
inputSchema: { attachmentId: z.string() }
|
|
658
|
+
}, async ({ attachmentId }) => {
|
|
659
|
+
const meta = await storage.getAttachment(attachmentId);
|
|
660
|
+
if (!meta) return fail("attachment not found: " + attachmentId);
|
|
661
|
+
const out = {
|
|
662
|
+
id: meta.id, projectId: meta.projectId, ticketId: meta.ticketId,
|
|
663
|
+
filename: meta.filename, mimeType: meta.mimeType, size: meta.size,
|
|
664
|
+
downloadUrl: attachmentDownloadUrl(meta)
|
|
665
|
+
};
|
|
666
|
+
if (meta.size <= ATTACHMENT_INLINE_MAX) {
|
|
667
|
+
try { out.contentBase64 = require("fs").readFileSync(meta.absPath).toString("base64"); }
|
|
668
|
+
catch (_) { /* file missing — metadata + url still useful */ }
|
|
669
|
+
}
|
|
670
|
+
return ok(out);
|
|
671
|
+
});
|
|
672
|
+
|
|
673
|
+
server.registerTool("attachment_delete", {
|
|
674
|
+
description: "Delete a binary attachment by id. Returns {deleted: id|null}.",
|
|
675
|
+
inputSchema: { attachmentId: z.string() }
|
|
676
|
+
}, async ({ attachmentId }) => {
|
|
677
|
+
const removed = await storage.removeAttachment(attachmentId, actorFromArgs({}));
|
|
678
|
+
return ok({ deleted: removed ? attachmentId : null });
|
|
679
|
+
});
|
|
680
|
+
|
|
681
|
+
// ---------------------------------------------------------------------------
|
|
682
|
+
// SM-196/R-5 — spec layer: ingest a PRD into candidate sections, then author
|
|
683
|
+
// requirement SpecObjects under a spec module. The DECOMPOSITION is the
|
|
684
|
+
// agent's job; these tools are only the substrate (no NLP on the server).
|
|
685
|
+
// ---------------------------------------------------------------------------
|
|
686
|
+
|
|
687
|
+
server.registerTool("ingest_slice_candidates", {
|
|
688
|
+
description: "Read a project attachment (a source PRD), convert it to Markdown server-side "
|
|
689
|
+
+ "(pdf2json/fflate — no OCR, no CDN), and algorithmically pre-slice it at headings into an "
|
|
690
|
+
+ "ORDERED list of candidate sections, each with a DOORS-style sectionPath + char range. This "
|
|
691
|
+
+ "is the substrate for requirement extraction: call it on a project-level attachment, then "
|
|
692
|
+
+ "YOU (the agent) turn each section's body into atomic `requirement` tickets via "
|
|
693
|
+
+ "requirement_create (anchoring sourceAnchor.{attachmentId,sectionId,charStart,charEnd}). "
|
|
694
|
+
+ "Pass includeMarkdown:true to also get the full converted Markdown.",
|
|
695
|
+
inputSchema: { attachmentId: z.string(), includeMarkdown: z.boolean().optional() }
|
|
696
|
+
}, async ({ attachmentId, includeMarkdown }) => {
|
|
697
|
+
const meta = await storage.getAttachment(attachmentId);
|
|
698
|
+
if (!meta) return fail("attachment not found: " + attachmentId);
|
|
699
|
+
let buffer;
|
|
700
|
+
try { buffer = require("fs").readFileSync(meta.absPath); }
|
|
701
|
+
catch (_e) { return fail("attachment file unreadable: " + attachmentId, { statusCode: 404 }); }
|
|
702
|
+
let converted;
|
|
703
|
+
try {
|
|
704
|
+
converted = await ingest.ingestToMarkdown(buffer, { filename: meta.filename, mimeType: meta.mimeType });
|
|
705
|
+
} catch (e) {
|
|
706
|
+
return fail(e.message || "ingest failed", { statusCode: e.statusCode || 500, kind: e.kind });
|
|
707
|
+
}
|
|
708
|
+
const sections = slice.sliceMarkdown(converted.markdown);
|
|
709
|
+
const out = {
|
|
710
|
+
attachmentId, filename: meta.filename, format: converted.format,
|
|
711
|
+
sectionCount: sections.length,
|
|
712
|
+
sections: sections.map(s => ({
|
|
713
|
+
sectionPath: s.sectionPath, level: s.level, heading: s.heading,
|
|
714
|
+
body: s.body, charStart: s.charStart, charEnd: s.charEnd
|
|
715
|
+
}))
|
|
716
|
+
};
|
|
717
|
+
if (includeMarkdown) out.markdown = converted.markdown;
|
|
718
|
+
return ok(out);
|
|
719
|
+
});
|
|
720
|
+
|
|
721
|
+
server.registerTool("spec_module_create", {
|
|
722
|
+
description: "Create a spec module — the container for the requirements sliced from ONE source "
|
|
723
|
+
+ "PRD (DOORS module / ReqIF Specification). Bind it to its source via sourceAttachmentId. "
|
|
724
|
+
+ "Requirements created with this module's id are contained (ordered by sectionPath). The "
|
|
725
|
+
+ "module is board-excluded (not on kanban/story-map). Compact response by default.",
|
|
726
|
+
inputSchema: {
|
|
727
|
+
projectId: z.string(),
|
|
728
|
+
title: z.string(),
|
|
729
|
+
sourceAttachmentId: z.string().optional(),
|
|
730
|
+
description: z.string().optional(),
|
|
731
|
+
verbose: z.boolean().optional()
|
|
732
|
+
}
|
|
733
|
+
}, async (args) => {
|
|
734
|
+
const actor = AI_ACTOR;
|
|
735
|
+
const r = await persist(storage, args.projectId, "spec_module_create", (cur) => {
|
|
736
|
+
validation.ticketInput({ title: args.title, type: "spec-module", description: args.description }, cur.project);
|
|
737
|
+
return core.ops.createTicket(cur, {
|
|
738
|
+
type: "spec-module", title: args.title, description: args.description,
|
|
739
|
+
sourceAttachmentId: args.sourceAttachmentId
|
|
740
|
+
}, actor);
|
|
741
|
+
}, actor);
|
|
742
|
+
if (r.error) return r.error;
|
|
743
|
+
const ticket = r.saved.snapshot.tickets[r.saved.snapshot.tickets.length - 1];
|
|
744
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
745
|
+
ticket: args.verbose ? ticket : { id: ticket.id, ticketKey: ticket.ticketKey, type: ticket.type,
|
|
746
|
+
title: ticket.title, sourceAttachmentId: ticket.sourceAttachmentId } });
|
|
747
|
+
});
|
|
748
|
+
|
|
749
|
+
server.registerTool("requirement_create", {
|
|
750
|
+
description: "Create a `requirement` SpecObject — one atomic, sliced PRD statement. Provide its "
|
|
751
|
+
+ "sectionPath (DOORS object id, e.g. '2.1') and sourceAnchor (back-ref into the PRD). Pass "
|
|
752
|
+
+ "moduleId to contain it under a spec module (so it joins the ordered chain). The requirement "
|
|
753
|
+
+ "is board-excluded and carries no DoR/DoD — it is a link TARGET for `realises` (an "
|
|
754
|
+
+ "implementing story/epic → this requirement) and `tests`. Compact response by default.",
|
|
755
|
+
inputSchema: {
|
|
756
|
+
projectId: z.string(),
|
|
757
|
+
title: z.string(),
|
|
758
|
+
moduleId: z.string().optional(),
|
|
759
|
+
sectionPath: z.string().optional(),
|
|
760
|
+
sourceAnchor: sourceAnchorSchema.optional(),
|
|
761
|
+
description: z.string().optional(),
|
|
762
|
+
verbose: z.boolean().optional()
|
|
763
|
+
}
|
|
764
|
+
}, async (args) => {
|
|
765
|
+
const actor = AI_ACTOR;
|
|
766
|
+
// Capture the created id INSIDE the mutate closure — re-finding it
|
|
767
|
+
// afterwards by title+sectionPath returns the wrong (older) ticket when two
|
|
768
|
+
// requirements share those, silently mis-anchoring downstream trace links.
|
|
769
|
+
let createdId = null;
|
|
770
|
+
const r = await persist(storage, args.projectId, "requirement_create", (cur) => {
|
|
771
|
+
validation.ticketInput({ title: args.title, type: "requirement", description: args.description }, cur.project);
|
|
772
|
+
let next = core.ops.createTicket(cur, {
|
|
773
|
+
type: "requirement", title: args.title, description: args.description,
|
|
774
|
+
sectionPath: args.sectionPath, sourceAnchor: args.sourceAnchor
|
|
775
|
+
}, actor);
|
|
776
|
+
const req = next.tickets[next.tickets.length - 1];
|
|
777
|
+
createdId = req.id;
|
|
778
|
+
if (args.moduleId) {
|
|
779
|
+
next = core.ops.addLink(next, args.moduleId, { linkTypeId: "contains", targetTicketId: req.id }, actor);
|
|
780
|
+
}
|
|
781
|
+
return next;
|
|
782
|
+
}, actor);
|
|
783
|
+
if (r.error) return r.error;
|
|
784
|
+
const ticket = r.saved.snapshot.tickets.find(t => t.id === createdId);
|
|
785
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
786
|
+
ticket: args.verbose ? ticket : { id: ticket.id, ticketKey: ticket.ticketKey, type: ticket.type,
|
|
787
|
+
title: ticket.title, sectionPath: ticket.sectionPath, moduleId: args.moduleId || null } });
|
|
788
|
+
});
|
|
789
|
+
|
|
790
|
+
server.registerTool("requirement_list", {
|
|
791
|
+
description: "List requirement SpecObjects of a project, in document order (by sectionPath). "
|
|
792
|
+
+ "Pass moduleId to scope to one spec module's contained requirements. Returns "
|
|
793
|
+
+ "[{id, ticketKey, sectionPath, title, sourceAnchor}].",
|
|
794
|
+
inputSchema: { projectId: z.string(), moduleId: z.string().optional() }
|
|
795
|
+
}, async ({ projectId, moduleId }) => {
|
|
796
|
+
const snap = await storage.loadProject(projectId);
|
|
797
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
798
|
+
let reqs;
|
|
799
|
+
if (moduleId) {
|
|
800
|
+
reqs = core.tickets.requirementsInModule(snap, moduleId);
|
|
801
|
+
} else {
|
|
802
|
+
reqs = (snap.tickets || []).filter(t => !t.isDeleted && t.type === "requirement")
|
|
803
|
+
.slice().sort((a, b) => core.compareSectionPath(a.sectionPath, b.sectionPath));
|
|
804
|
+
}
|
|
805
|
+
return ok({ requirements: reqs.map(r => ({
|
|
806
|
+
id: r.id, ticketKey: r.ticketKey, sectionPath: r.sectionPath, title: r.title, sourceAnchor: r.sourceAnchor
|
|
807
|
+
})) });
|
|
808
|
+
});
|
|
809
|
+
|
|
810
|
+
server.registerTool("get_trace_coverage", {
|
|
811
|
+
description: "Direction-A coverage report (mirror of generate_product_doc). For each requirement, "
|
|
812
|
+
+ "counts the incoming `realises` (implementing story/epic — DOORS satisfies) and `tests` links "
|
|
813
|
+
+ "and classifies it: covered (exactly 1 realises) / over-covered (>1) / orphan (none) / "
|
|
814
|
+
+ "suspect (tested but not implemented). Returns per-requirement rows (ordered by sectionPath) + "
|
|
815
|
+
+ "a summary. Pass moduleId to scope to one spec module. Use this to answer 'is the PRD fully "
|
|
816
|
+
+ "covered, and what's missing?'.",
|
|
817
|
+
inputSchema: { projectId: z.string(), moduleId: z.string().optional() }
|
|
818
|
+
}, async ({ projectId, moduleId }) => {
|
|
819
|
+
const snap = await storage.loadProject(projectId);
|
|
820
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
821
|
+
const cov = coreGraph.traceCoverage(snap, moduleId ? { moduleId } : {});
|
|
822
|
+
cov.requirements.sort((a, b) => core.compareSectionPath(a.sectionPath, b.sectionPath));
|
|
823
|
+
return ok(cov);
|
|
824
|
+
});
|
|
825
|
+
|
|
826
|
+
server.registerTool("get_drift_report", {
|
|
827
|
+
description: "Round-trip / drift report (SM-182, direction-A Phase 4) — keeps the PRD a living "
|
|
828
|
+
+ "document by surfacing where the doc and the tickets have diverged. Three signals: "
|
|
829
|
+
+ "orphanRequirements (a requirement no ticket `realises`), suspectRequirements (only `tests`, "
|
|
830
|
+
+ "no implementer), danglingLinks (a realises/tests link whose target is deleted or not a "
|
|
831
|
+
+ "requirement), and supersededTrace (a `realises` anchored on a superseded/historical feature "
|
|
832
|
+
+ "version — the implementation moved on but the anchor stayed). Pass moduleId to scope the "
|
|
833
|
+
+ "requirement checks to one spec module. summary.clean=true means no drift. Run it after big "
|
|
834
|
+
+ "ticket changes, then re-generate the product doc (generate_product_doc) to close the loop.",
|
|
835
|
+
inputSchema: { projectId: z.string(), moduleId: z.string().optional() }
|
|
836
|
+
}, async ({ projectId, moduleId }) => {
|
|
837
|
+
const snap = await storage.loadProject(projectId);
|
|
838
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
839
|
+
const report = coreGraph.driftReport(snap, moduleId ? { moduleId } : {});
|
|
840
|
+
return ok(report);
|
|
841
|
+
});
|
|
842
|
+
|
|
843
|
+
server.registerTool("query_tickets", {
|
|
844
|
+
description: "Targeted ticket search (SM-187) with a JQL-angelehnter query language — the precise "
|
|
845
|
+
+ "alternative to list_tickets + manual sieving once a project is large. Same language as the "
|
|
846
|
+
+ "browser smart-bar. Fields: type, status, key, title, description, text (~ full-text over "
|
|
847
|
+
+ "key+title+description+labels), release, processStep, epic (containing epic, by key), label, "
|
|
848
|
+
+ "linkedTo / linkedFrom (by ticket key), acCount, created, updated. Operators: = != ~ IN "
|
|
849
|
+
+ "'NOT IN' < > <= >= ; AND/OR/NOT (AND binds tighter), "
|
|
850
|
+
+ "parentheses, optional 'ORDER BY <field> [ASC|DESC]'. Quote values with spaces. Examples: "
|
|
851
|
+
+ "`type = user-story AND status IN (ready, in-progress)`, "
|
|
852
|
+
+ "`epic = SM-173 AND type != bug ORDER BY updated DESC`, `text ~ login AND label = frontend`. "
|
|
853
|
+
+ "Returns { count, tickets:[{id,ticketKey,type,status,title,position}] } (ordered). A syntax or "
|
|
854
|
+
+ "semantic error (unknown field / disallowed operator) comes back as a structured isError with "
|
|
855
|
+
+ "kind:\"QUERY\" + message + position.",
|
|
856
|
+
inputSchema: { projectId: z.string(), query: z.string() }
|
|
857
|
+
}, async ({ projectId, query }) => {
|
|
858
|
+
const snap = await storage.loadProject(projectId);
|
|
859
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
860
|
+
// SM-260: strictRefs — an unknown release/processStep/epic value comes back
|
|
861
|
+
// as a QUERY error instead of a misleading empty result.
|
|
862
|
+
const r = queryEngine.queryTickets(snap, query, { strictRefs: true });
|
|
863
|
+
if (r.error) {
|
|
864
|
+
return fail(r.error.message, { kind: "QUERY", position: r.error.position, field: r.error.field });
|
|
865
|
+
}
|
|
866
|
+
return ok({ count: r.tickets.length, tickets: r.tickets.map(ticketSummary) });
|
|
867
|
+
});
|
|
868
|
+
|
|
869
|
+
server.registerTool("ticket_get", {
|
|
870
|
+
description: "Get a ticket by id (includes frozen DoR/DoD checklists). Default "
|
|
871
|
+
+ "returns the FULL ticket. Pass compact:true for the {id, ticketKey, type, status, "
|
|
872
|
+
+ "title, position} summary, or fields:[...] to pick only the named top-level fields "
|
|
873
|
+
+ "(id + ticketKey are always included). compact wins over fields if both are passed.",
|
|
874
|
+
inputSchema: {
|
|
875
|
+
projectId: z.string(),
|
|
876
|
+
ticketId: z.string(),
|
|
877
|
+
compact: z.boolean().optional(),
|
|
878
|
+
fields: z.array(z.string()).optional()
|
|
879
|
+
}
|
|
880
|
+
}, async ({ projectId, ticketId, compact, fields }) => {
|
|
881
|
+
const snap = await storage.loadProject(projectId);
|
|
882
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
883
|
+
const t = findEntity(snap, "tickets", ticketId);
|
|
884
|
+
if (!t || t.isDeleted) return fail("ticket not found: " + ticketId);
|
|
885
|
+
if (compact === true) return ok({ ticket: ticketSummary(t) });
|
|
886
|
+
if (Array.isArray(fields) && fields.length > 0) {
|
|
887
|
+
const picked = { id: t.id, ticketKey: t.ticketKey };
|
|
888
|
+
for (const f of fields) {
|
|
889
|
+
if (Object.prototype.hasOwnProperty.call(t, f)) picked[f] = t[f];
|
|
890
|
+
}
|
|
891
|
+
return ok({ ticket: picked });
|
|
892
|
+
}
|
|
893
|
+
return ok({ ticket: t });
|
|
894
|
+
});
|
|
895
|
+
|
|
896
|
+
server.registerTool("ticket_create", {
|
|
897
|
+
description: "Create a ticket. Pass acceptanceCriteria as [{text}] to set them at creation (no more create-then-update). The DoR/DoD checklists are resolved from the project's definitions for this ticket type and FROZEN onto the ticket. Compact response by default ({revision, savedAt, ticket:{id, ticketKey, type, status, title, position}}); pass verbose:true for the full ticket.",
|
|
898
|
+
inputSchema: {
|
|
899
|
+
projectId: z.string(),
|
|
900
|
+
type: z.string(),
|
|
901
|
+
title: z.string(),
|
|
902
|
+
description: z.string().optional(),
|
|
903
|
+
position: positionSchema,
|
|
904
|
+
labels: z.array(z.string()).optional(),
|
|
905
|
+
acceptanceCriteria: acceptanceCriteriaSchema,
|
|
906
|
+
verbose: z.boolean().optional()
|
|
907
|
+
}
|
|
908
|
+
}, async (args) => {
|
|
909
|
+
const actor = AI_ACTOR;
|
|
910
|
+
const r = await persist(storage, args.projectId, "ticket_create", (cur) => {
|
|
911
|
+
validation.ticketInput(args, cur.project);
|
|
912
|
+
return core.ops.createTicket(cur, args, actor);
|
|
913
|
+
}, actor);
|
|
914
|
+
if (r.error) return r.error;
|
|
915
|
+
const ticket = r.saved.snapshot.tickets[r.saved.snapshot.tickets.length - 1];
|
|
916
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
917
|
+
ticket: args.verbose ? ticket : ticketSummary(ticket) });
|
|
918
|
+
});
|
|
919
|
+
|
|
920
|
+
server.registerTool("ticket_update", {
|
|
921
|
+
description: "Patch a ticket (title, description, type, position, labels, acceptanceCriteria). "
|
|
922
|
+
+ "SM-94 HARD gate: published test-definitions reject step/prereq edits (use "
|
|
923
|
+
+ "`update_test_definition_metadata` for title/description/labels OR create a "
|
|
924
|
+
+ "`modifies`-ticket on the target story to evolve the spec). SOFT gate: editing "
|
|
925
|
+
+ "acceptanceCriteria/description/definitionOfReady on a ready+ ticket surfaces "
|
|
926
|
+
+ "`warnings:[{kind:'SPEC_FROZEN_EDIT', ...}]` in the response but doesn't block — "
|
|
927
|
+
+ "cosmetic edits go through; behavioural changes should be modelled via `modifies`. "
|
|
928
|
+
+ "A `status` in the patch is rejected for epics (kind=EPIC_STATUS_DERIVED) — epic status is derived from its stories.",
|
|
929
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), patch: ticketPatchSchema, verbose: z.boolean().optional() }
|
|
930
|
+
}, async ({ projectId, ticketId, patch, verbose }) => {
|
|
931
|
+
const actor = AI_ACTOR;
|
|
932
|
+
// SM-94 hard gate: published test-definition rejects step/prereq edits.
|
|
933
|
+
const snap0 = await storage.loadProject(projectId);
|
|
934
|
+
if (!snap0) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
935
|
+
const ticket0 = findEntity(snap0, "tickets", ticketId);
|
|
936
|
+
if (!ticket0) return fail("ticket not found: " + ticketId, { statusCode: 404 });
|
|
937
|
+
const p = patch || {};
|
|
938
|
+
if (ticket0.type === "test-definition" && ticket0.lifecycle === "published") {
|
|
939
|
+
const forbidden = ["steps", "prerequisites"].filter(k =>
|
|
940
|
+
Object.prototype.hasOwnProperty.call(p, k));
|
|
941
|
+
if (forbidden.length > 0) {
|
|
942
|
+
return fail("steps/prerequisites are frozen on a published test-definition", {
|
|
943
|
+
kind: "DEFINITION_FROZEN",
|
|
944
|
+
message: {
|
|
945
|
+
title: "Definition is frozen",
|
|
946
|
+
reason: "Definition " + (ticket0.ticketKey || ticket0.id) + " is published. The "
|
|
947
|
+
+ "fields " + forbidden.join(", ") + " are part of the spec and cannot be edited.",
|
|
948
|
+
suggestion: "Use `update_test_definition_metadata` for title/description/labels, OR "
|
|
949
|
+
+ "create a `modifies`-ticket on the target story (link_create with "
|
|
950
|
+
+ "linkTypeId='modifies') to evolve the spec, then reopen the definition.",
|
|
951
|
+
skillRef: "Published test-definitions are immutable spec records. Evolve via modifies-tickets."
|
|
952
|
+
},
|
|
953
|
+
statusCode: 422
|
|
954
|
+
});
|
|
955
|
+
}
|
|
956
|
+
}
|
|
957
|
+
// SM-94 soft warn: editing spec fields on a ready+ ticket.
|
|
958
|
+
const SPEC_FIELDS = ["acceptanceCriteria", "description", "definitionOfReady"];
|
|
959
|
+
let specFrozenWarning = null;
|
|
960
|
+
if (ticket0.status && ticket0.status !== "backlog" && ticket0.status !== "ready") {
|
|
961
|
+
// status > backlog OR ready is technically also ≥ ready (statuses ordered).
|
|
962
|
+
}
|
|
963
|
+
// For simplicity: ANY non-backlog status is "frozen" for spec edits.
|
|
964
|
+
if (ticket0.status && ticket0.status !== "backlog") {
|
|
965
|
+
const touched = SPEC_FIELDS.filter(k => Object.prototype.hasOwnProperty.call(p, k));
|
|
966
|
+
if (touched.length > 0) {
|
|
967
|
+
specFrozenWarning = {
|
|
968
|
+
kind: "SPEC_FROZEN_EDIT",
|
|
969
|
+
context: { fields: touched, ticketKey: ticket0.ticketKey, status: ticket0.status },
|
|
970
|
+
message: {
|
|
971
|
+
title: "Editing spec on a ready+ ticket",
|
|
972
|
+
reason: "Ticket " + (ticket0.ticketKey || ticket0.id) + " is in status='" + ticket0.status
|
|
973
|
+
+ "'. Editing spec fields (" + touched.join(", ") + ") after the spec is frozen "
|
|
974
|
+
+ "is allowed but discouraged for behavioural changes.",
|
|
975
|
+
suggestion: "For cosmetic edits (typos, formatting), proceed. For behavioural changes, "
|
|
976
|
+
+ "consider creating a `modifies`-ticket pointing at this ticket so the change is "
|
|
977
|
+
+ "auditable and linked test-definitions can detect staleness.",
|
|
978
|
+
skillRef: "Specs frieren ein. Aenderungen werden zu Tickets."
|
|
979
|
+
}
|
|
980
|
+
};
|
|
981
|
+
}
|
|
982
|
+
}
|
|
983
|
+
const r = await persist(storage, projectId, "ticket_update", (cur) => {
|
|
984
|
+
const existing = findEntity(cur, "tickets", ticketId);
|
|
985
|
+
if (!existing) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
|
|
986
|
+
validation.ticketInput(Object.assign({}, existing, p), cur.project);
|
|
987
|
+
return core.ops.updateTicket(cur, ticketId, p, actor);
|
|
988
|
+
}, actor);
|
|
989
|
+
if (r.error) return r.error;
|
|
990
|
+
const full = findEntity(r.saved.snapshot, "tickets", ticketId);
|
|
991
|
+
const response = {
|
|
992
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
993
|
+
ticket: verbose ? full : ticketSummary(full)
|
|
994
|
+
};
|
|
995
|
+
if (specFrozenWarning) response.warnings = [specFrozenWarning];
|
|
996
|
+
return ok(response);
|
|
997
|
+
});
|
|
998
|
+
|
|
999
|
+
server.registerTool("ticket_delete", {
|
|
1000
|
+
description: "Soft-delete a ticket",
|
|
1001
|
+
inputSchema: { projectId: z.string(), ticketId: z.string() }
|
|
1002
|
+
}, async ({ projectId, ticketId }) => {
|
|
1003
|
+
const actor = AI_ACTOR;
|
|
1004
|
+
const r = await persist(storage, projectId, "ticket_delete",
|
|
1005
|
+
(cur) => core.ops.softDeleteTicket(cur, ticketId, actor), actor);
|
|
1006
|
+
if (r.error) return r.error;
|
|
1007
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, deleted: ticketId });
|
|
1008
|
+
});
|
|
1009
|
+
|
|
1010
|
+
server.registerTool("change_ticket_status", {
|
|
1011
|
+
description: "Change a ticket's status. Forward transitions crossing → ready check DoR; crossing → done check DoD. A transition INTO the done category ALSO fires the complete_ticket governance gates (SM-299: MISSING_TEST_DEFINITION for user-story/bug, STALE_LINKED_DEF, OPEN_MODIFIES) — there is no bypass around complete_ticket. Failures come back as isError with kind/missing. NOT applicable to epics — an epic's status is DERIVED (roll-up) from its contained stories; an attempt returns kind=EPIC_STATUS_DERIVED. Move the stories instead. Compact response by default ({…, ticket: summary}); pass verbose:true for the full ticket.",
|
|
1012
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), status: z.string(), verbose: z.boolean().optional() }
|
|
1013
|
+
}, async ({ projectId, ticketId, status, verbose }) => {
|
|
1014
|
+
const actor = AI_ACTOR;
|
|
1015
|
+
// SM-299: a status change INTO the done category must fire the same
|
|
1016
|
+
// governance gates as complete_ticket — otherwise change_ticket_status:done
|
|
1017
|
+
// is a one-call bypass of MISSING_TEST_DEFINITION / STALE_LINKED_DEF /
|
|
1018
|
+
// OPEN_MODIFIES (review finding F1 on a0c6dde).
|
|
1019
|
+
const snap0 = await storage.loadProject(projectId);
|
|
1020
|
+
if (!snap0) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
1021
|
+
const ticket0 = findEntity(snap0, "tickets", ticketId);
|
|
1022
|
+
if (!ticket0) return fail("ticket not found: " + ticketId, { statusCode: 404 });
|
|
1023
|
+
if (core.statusCategoryOf(snap0.project, status) === "done") {
|
|
1024
|
+
const gateFail = runGovernanceGates(snap0, "complete_ticket", ticket0, {
|
|
1025
|
+
targetKey: ticket0.ticketKey || ticketId, targetType: ticket0.type
|
|
1026
|
+
});
|
|
1027
|
+
if (gateFail) return gateFail;
|
|
1028
|
+
}
|
|
1029
|
+
const r = await persist(storage, projectId, "ticket_change_status", (cur) => {
|
|
1030
|
+
const t = findEntity(cur, "tickets", ticketId);
|
|
1031
|
+
if (!t) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
|
|
1032
|
+
validation.changeStatusTransition(t, status, cur.project);
|
|
1033
|
+
return core.ops.changeStatus(cur, ticketId, status, actor);
|
|
1034
|
+
}, actor);
|
|
1035
|
+
if (r.error) return r.error;
|
|
1036
|
+
const full = findEntity(r.saved.snapshot, "tickets", ticketId);
|
|
1037
|
+
return ok({
|
|
1038
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1039
|
+
ticket: verbose ? full : ticketSummary(full)
|
|
1040
|
+
});
|
|
1041
|
+
});
|
|
1042
|
+
|
|
1043
|
+
server.registerTool("cancel_ticket", {
|
|
1044
|
+
description: "Cancel a ticket — record a deliberate non-implementation (scope reduction), gate-free. Cancel ≠ Delete: cancel keeps the ticket VISIBLE with its full history + links (use ticket_delete only for mistakes). Cancelling an EPIC cascades: every contained non-terminal story is cancelled in one revision and the epic rolls up to cancelled (or done, if some stories had already shipped). Cancelled tickets drop out of release progress and never block completing a release; reopen by moving them to any earlier status (ungated). Spec types (requirement/spec-module) have their own lifecycle and are rejected. Compact response by default; verbose:true for the full ticket.",
|
|
1045
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), verbose: z.boolean().optional() }
|
|
1046
|
+
}, async ({ projectId, ticketId, verbose }) => {
|
|
1047
|
+
const actor = AI_ACTOR;
|
|
1048
|
+
// op-tag distinguishes a single cancel from the epic cascade in the history.
|
|
1049
|
+
const snap0 = await storage.loadProject(projectId);
|
|
1050
|
+
if (!snap0) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
1051
|
+
const t0 = findEntity(snap0, "tickets", ticketId);
|
|
1052
|
+
const opTag = (t0 && t0.type === "epic") ? "epic_cancel" : "ticket_cancel";
|
|
1053
|
+
const r = await persist(storage, projectId, opTag, (cur) => {
|
|
1054
|
+
const t = findEntity(cur, "tickets", ticketId);
|
|
1055
|
+
if (!t) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
|
|
1056
|
+
return core.ops.cancelTicket(cur, ticketId, actor);
|
|
1057
|
+
}, actor);
|
|
1058
|
+
if (r.error) return r.error;
|
|
1059
|
+
const full = findEntity(r.saved.snapshot, "tickets", ticketId);
|
|
1060
|
+
return ok({
|
|
1061
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1062
|
+
ticket: verbose ? full : ticketSummary(full)
|
|
1063
|
+
});
|
|
1064
|
+
});
|
|
1065
|
+
|
|
1066
|
+
// -------------------- DoR/DoD definitions + item checks (E7) ------------
|
|
1067
|
+
|
|
1068
|
+
// SM-164: get_config / set_config replace the ten get/set_{definitions,
|
|
1069
|
+
// workflow, kanban_columns, governance, link_types} tools. See the
|
|
1070
|
+
// readConfigSection / applyConfigSection dispatchers up top.
|
|
1071
|
+
server.registerTool("get_config", {
|
|
1072
|
+
description: "Read a project config section. section ∈ definitions | workflow | "
|
|
1073
|
+
+ "kanban_columns | governance | link_types. Returns {section, value}. For "
|
|
1074
|
+
+ "section='workflow', pass optional `type` to get the EFFECTIVE per-ticket-type "
|
|
1075
|
+
+ "workflow (byType overrides merged in).",
|
|
1076
|
+
inputSchema: {
|
|
1077
|
+
projectId: z.string(),
|
|
1078
|
+
section: z.enum(CONFIG_SECTIONS),
|
|
1079
|
+
type: z.string().optional()
|
|
1080
|
+
}
|
|
1081
|
+
}, async ({ projectId, section, type }) => {
|
|
1082
|
+
const snap = await storage.loadProject(projectId);
|
|
1083
|
+
if (!snap) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
1084
|
+
return ok({ section, value: readConfigSection(snap.project, section, type) });
|
|
1085
|
+
});
|
|
1086
|
+
|
|
1087
|
+
server.registerTool("set_config", {
|
|
1088
|
+
description: "Replace a project config section (full replace, no merge). section ∈ "
|
|
1089
|
+
+ "definitions | workflow | kanban_columns | governance | link_types. `value` is the "
|
|
1090
|
+
+ "new block: an object for definitions/workflow/governance, an array for "
|
|
1091
|
+
+ "kanban_columns/link_types (JSON-string tolerated for the MCP bridge). Per-section "
|
|
1092
|
+
+ "validation is preserved: workflow needs ≥1 status; governance gate predicates must "
|
|
1093
|
+
+ "be in the closed set (else kind=UNKNOWN_PREDICATE); definitions only affect tickets "
|
|
1094
|
+
+ "created AFTER this call (existing keep their frozen checklists). Returns "
|
|
1095
|
+
+ "{revision, savedAt, section, value}.",
|
|
1096
|
+
inputSchema: {
|
|
1097
|
+
projectId: z.string(),
|
|
1098
|
+
section: z.enum(CONFIG_SECTIONS),
|
|
1099
|
+
value: configValueSchema
|
|
1100
|
+
}
|
|
1101
|
+
}, async ({ projectId, section, value }) => {
|
|
1102
|
+
const actor = AI_ACTOR;
|
|
1103
|
+
// Governance: validate predicate names up-front so we can return the rich
|
|
1104
|
+
// {unknown, knownPredicates} body (persist's catch only forwards kind/missing).
|
|
1105
|
+
if (section === "governance") {
|
|
1106
|
+
const predicateSet = new Set(Object.keys(core.GOVERNANCE_PREDICATES));
|
|
1107
|
+
const incoming = (value && typeof value === "object") ? value : {};
|
|
1108
|
+
const gates = incoming.gates || {};
|
|
1109
|
+
const unknown = [];
|
|
1110
|
+
for (const errorKind of Object.keys(gates)) {
|
|
1111
|
+
const cfg = gates[errorKind];
|
|
1112
|
+
if (!cfg || typeof cfg.predicate !== "string") continue;
|
|
1113
|
+
if (!predicateSet.has(cfg.predicate)) unknown.push({ errorKind, predicate: cfg.predicate });
|
|
1114
|
+
}
|
|
1115
|
+
if (unknown.length > 0) {
|
|
1116
|
+
return fail("governance references unknown predicate(s)", {
|
|
1117
|
+
kind: "UNKNOWN_PREDICATE", statusCode: 400,
|
|
1118
|
+
unknown, knownPredicates: Array.from(predicateSet)
|
|
1119
|
+
});
|
|
1120
|
+
}
|
|
1121
|
+
}
|
|
1122
|
+
const r = await persist(storage, projectId, section + "_update",
|
|
1123
|
+
(cur) => applyConfigSection(section, cur, value, actor), actor);
|
|
1124
|
+
if (r.error) return r.error;
|
|
1125
|
+
return ok({
|
|
1126
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1127
|
+
section, value: readConfigSection(r.saved.snapshot.project, section)
|
|
1128
|
+
});
|
|
1129
|
+
});
|
|
1130
|
+
|
|
1131
|
+
server.registerTool("resolve_definitions_for_ticket", {
|
|
1132
|
+
description: "Return the EFFECTIVE DoR/DoD checklists for a ticket — i.e. the per-ticket frozen items with their current checked-state. Use this to see what's left to do for mark_ready / complete_ticket.",
|
|
1133
|
+
inputSchema: { projectId: z.string(), ticketId: z.string() }
|
|
1134
|
+
}, async ({ projectId, ticketId }) => {
|
|
1135
|
+
const snap = await storage.loadProject(projectId);
|
|
1136
|
+
if (!snap) return fail("project not found: " + projectId);
|
|
1137
|
+
const t = findEntity(snap, "tickets", ticketId);
|
|
1138
|
+
if (!t || t.isDeleted) return fail("ticket not found: " + ticketId);
|
|
1139
|
+
return ok({
|
|
1140
|
+
ready: t.definitionOfReady.items,
|
|
1141
|
+
done: t.definitionOfDone.items
|
|
1142
|
+
});
|
|
1143
|
+
});
|
|
1144
|
+
|
|
1145
|
+
// SM-124: one tool replaces the former four (check/uncheck × DoR/DoD).
|
|
1146
|
+
// `gate` selects the checklist, `checked` selects check vs uncheck.
|
|
1147
|
+
// Compact-by-default: returns just the toggled item's id + checked-state.
|
|
1148
|
+
// The full ticket (with both frozen checklists) is large and rarely needed
|
|
1149
|
+
// by the caller right after a toggle — pass verbose:true to get it.
|
|
1150
|
+
server.registerTool("set_checklist_item", {
|
|
1151
|
+
description: "Check or uncheck a single DoR/DoD item on a ticket. gate ∈ 'dor'|'dod' "
|
|
1152
|
+
+ "selects the checklist; checked:true marks the item, checked:false clears it. "
|
|
1153
|
+
+ "Compact response by default: {revision, savedAt, item:{id, checked}}. Pass "
|
|
1154
|
+
+ "verbose:true for the full ticket (both frozen checklists, AC, links, …).",
|
|
1155
|
+
inputSchema: {
|
|
1156
|
+
projectId: z.string(),
|
|
1157
|
+
ticketId: z.string(),
|
|
1158
|
+
gate: z.enum(["dor", "dod"]),
|
|
1159
|
+
itemId: z.string(),
|
|
1160
|
+
checked: z.boolean(),
|
|
1161
|
+
verbose: z.boolean().optional()
|
|
1162
|
+
}
|
|
1163
|
+
}, async ({ projectId, ticketId, gate, itemId, checked, verbose }) => {
|
|
1164
|
+
const actor = AI_ACTOR;
|
|
1165
|
+
const opName = (gate === "dor")
|
|
1166
|
+
? (checked ? "checkDorItem" : "uncheckDorItem")
|
|
1167
|
+
: (checked ? "checkDodItem" : "uncheckDodItem");
|
|
1168
|
+
const op = "ticket_" + gate + "_" + (checked ? "check" : "uncheck");
|
|
1169
|
+
const r = await persist(storage, projectId, op,
|
|
1170
|
+
(cur) => core.ops[opName](cur, ticketId, itemId, actor), actor);
|
|
1171
|
+
if (r.error) return r.error;
|
|
1172
|
+
const ticket = findEntity(r.saved.snapshot, "tickets", ticketId);
|
|
1173
|
+
if (verbose) {
|
|
1174
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, ticket });
|
|
1175
|
+
}
|
|
1176
|
+
const items = (gate === "dor")
|
|
1177
|
+
? ((ticket.definitionOfReady && ticket.definitionOfReady.items) || [])
|
|
1178
|
+
: ((ticket.definitionOfDone && ticket.definitionOfDone.items) || []);
|
|
1179
|
+
const item = items.find(i => i.id === itemId);
|
|
1180
|
+
return ok({
|
|
1181
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1182
|
+
item: item ? { id: item.id, checked: item.checked } : { id: itemId, checked: checked }
|
|
1183
|
+
});
|
|
1184
|
+
});
|
|
1185
|
+
|
|
1186
|
+
server.registerTool("mark_ready", {
|
|
1187
|
+
description: "Shortcut for change_ticket_status → 'ready'. Fails with kind=DoR + missing[] if the DoR is not satisfied. NOT applicable to epics (kind=EPIC_STATUS_DERIVED) — epic status is derived from its stories. Compact response by default; pass verbose:true for the full ticket.",
|
|
1188
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), verbose: z.boolean().optional() }
|
|
1189
|
+
}, async ({ projectId, ticketId, verbose }) => {
|
|
1190
|
+
const actor = AI_ACTOR;
|
|
1191
|
+
const r = await persist(storage, projectId, "ticket_mark_ready", (cur) => {
|
|
1192
|
+
const t = findEntity(cur, "tickets", ticketId);
|
|
1193
|
+
if (!t) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
|
|
1194
|
+
validation.changeStatusTransition(t, "ready", cur.project);
|
|
1195
|
+
return core.ops.changeStatus(cur, ticketId, "ready", actor);
|
|
1196
|
+
}, actor);
|
|
1197
|
+
if (r.error) return r.error;
|
|
1198
|
+
const full = findEntity(r.saved.snapshot, "tickets", ticketId);
|
|
1199
|
+
return ok({
|
|
1200
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1201
|
+
ticket: verbose ? full : ticketSummary(full)
|
|
1202
|
+
});
|
|
1203
|
+
});
|
|
1204
|
+
|
|
1205
|
+
server.registerTool("complete_ticket", {
|
|
1206
|
+
description: "Shortcut for change_ticket_status → 'done'. Coding agents MUST call this so the DoD gate fires explicitly. NOT applicable to epics — an epic's status is DERIVED from its contained stories (kind=EPIC_STATUS_DERIVED); complete the stories and the epic rolls up to done on its own. Pass checkDoD:true to auto-check every required DoD item first (one call instead of N× set_checklist_item + complete). Fails with kind=DoD + missing[] if any required DoD item is unchecked. SM-94: also runs SM-93 governance gates. SM-299: a user-story/bug is blocked with kind=MISSING_TEST_DEFINITION until a PUBLISHED test-definition is linked to it via a `tests`-link (create the test-definition, add steps, link_create tests→ticket, publish_test_definition, THEN complete). STALE_LINKED_DEF + OPEN_MODIFIES still apply (open `modifies`-tickets or a stale/failing linked test-definition also block).",
|
|
1207
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), checkDoD: z.boolean().optional(), verbose: z.boolean().optional() }
|
|
1208
|
+
}, async ({ projectId, ticketId, checkDoD, verbose }) => {
|
|
1209
|
+
const actor = AI_ACTOR;
|
|
1210
|
+
// SM-94: run governance gates first (STALE_LINKED_DEF + OPEN_MODIFIES).
|
|
1211
|
+
const snap0 = await storage.loadProject(projectId);
|
|
1212
|
+
if (!snap0) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
1213
|
+
const ticket0 = findEntity(snap0, "tickets", ticketId);
|
|
1214
|
+
if (!ticket0) return fail("ticket not found: " + ticketId, { statusCode: 404 });
|
|
1215
|
+
const gateFail = runGovernanceGates(snap0, "complete_ticket", ticket0, {
|
|
1216
|
+
targetKey: ticket0.ticketKey || ticketId,
|
|
1217
|
+
targetType: ticket0.type // SM-299: for the MISSING_TEST_DEFINITION message
|
|
1218
|
+
});
|
|
1219
|
+
if (gateFail) return gateFail;
|
|
1220
|
+
// DoD gate runs inside persist via validation.changeStatusTransition.
|
|
1221
|
+
const r = await persist(storage, projectId, "ticket_complete", (cur) => {
|
|
1222
|
+
let next = cur;
|
|
1223
|
+
let t = findEntity(next, "tickets", ticketId);
|
|
1224
|
+
if (!t) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
|
|
1225
|
+
// SM-260: checkDoD — tick every required DoD item before the gate, so a
|
|
1226
|
+
// completion is one call instead of N× set_checklist_item + complete.
|
|
1227
|
+
if (checkDoD) {
|
|
1228
|
+
const items = (t.definitionOfDone && t.definitionOfDone.items) || [];
|
|
1229
|
+
for (const it of items) {
|
|
1230
|
+
if (it.required && !it.checked) next = core.ops.checkDodItem(next, ticketId, it.id, actor);
|
|
1231
|
+
}
|
|
1232
|
+
t = findEntity(next, "tickets", ticketId);
|
|
1233
|
+
}
|
|
1234
|
+
validation.changeStatusTransition(t, "done", next.project);
|
|
1235
|
+
return core.ops.changeStatus(next, ticketId, "done", actor);
|
|
1236
|
+
}, actor);
|
|
1237
|
+
if (r.error) return r.error;
|
|
1238
|
+
const full = findEntity(r.saved.snapshot, "tickets", ticketId);
|
|
1239
|
+
return ok({
|
|
1240
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1241
|
+
ticket: verbose ? full : ticketSummary(full)
|
|
1242
|
+
});
|
|
1243
|
+
});
|
|
1244
|
+
|
|
1245
|
+
// -------------------- releases -----------------------------------------
|
|
1246
|
+
|
|
1247
|
+
server.registerTool("list_releases", {
|
|
1248
|
+
description: "List all non-deleted releases of a project. Returns an array of release objects sorted by sortOrder.",
|
|
1249
|
+
inputSchema: { projectId: z.string() }
|
|
1250
|
+
}, async ({ projectId }) => {
|
|
1251
|
+
const snap = await storage.loadProject(projectId);
|
|
1252
|
+
if (!snap) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
1253
|
+
const list = (snap.releases || []).filter(r => !r.isDeleted)
|
|
1254
|
+
.slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
1255
|
+
return ok(list);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
server.registerTool("release_create", {
|
|
1259
|
+
description: "Create a new release on the project. Returns the created release plus revision/savedAt.",
|
|
1260
|
+
inputSchema: {
|
|
1261
|
+
projectId: z.string(),
|
|
1262
|
+
name: z.string(),
|
|
1263
|
+
description: z.string().optional(),
|
|
1264
|
+
status: z.string().optional(),
|
|
1265
|
+
startDate: z.string().optional(),
|
|
1266
|
+
endDate: z.string().optional()
|
|
1267
|
+
}
|
|
1268
|
+
}, async ({ projectId, ...partial }) => {
|
|
1269
|
+
const actor = AI_ACTOR;
|
|
1270
|
+
const r = await persist(storage, projectId, "release_create", (cur) => {
|
|
1271
|
+
validation.releaseInput(partial);
|
|
1272
|
+
return core.ops.createRelease(cur, partial, actor);
|
|
1273
|
+
}, actor);
|
|
1274
|
+
if (r.error) return r.error;
|
|
1275
|
+
const release = r.saved.snapshot.releases[r.saved.snapshot.releases.length - 1];
|
|
1276
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, release });
|
|
1277
|
+
});
|
|
1278
|
+
|
|
1279
|
+
server.registerTool("release_update", {
|
|
1280
|
+
description: "Patch fields on a release (name, description, status, startDate, endDate, sortOrder).",
|
|
1281
|
+
inputSchema: {
|
|
1282
|
+
projectId: z.string(),
|
|
1283
|
+
releaseId: z.string(),
|
|
1284
|
+
patch: passthroughObjectSchema
|
|
1285
|
+
}
|
|
1286
|
+
}, async ({ projectId, releaseId, patch }) => {
|
|
1287
|
+
const actor = AI_ACTOR;
|
|
1288
|
+
const r = await persist(storage, projectId, "release_update", (cur) => {
|
|
1289
|
+
const ex = findEntity(cur, "releases", releaseId);
|
|
1290
|
+
if (!ex) throw Object.assign(new Error("release not found"), { statusCode: 404 });
|
|
1291
|
+
return core.ops.updateRelease(cur, releaseId, patch || {}, actor);
|
|
1292
|
+
}, actor);
|
|
1293
|
+
if (r.error) return r.error;
|
|
1294
|
+
return ok({
|
|
1295
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1296
|
+
release: findEntity(r.saved.snapshot, "releases", releaseId)
|
|
1297
|
+
});
|
|
1298
|
+
});
|
|
1299
|
+
|
|
1300
|
+
server.registerTool("release_delete", {
|
|
1301
|
+
description: "Soft-delete a release (recoverable via history). Refuses the LAST remaining release (422 kind=LAST_RELEASE) — a project always keeps ≥1 release.",
|
|
1302
|
+
inputSchema: { projectId: z.string(), releaseId: z.string() }
|
|
1303
|
+
}, async ({ projectId, releaseId }) => {
|
|
1304
|
+
const actor = AI_ACTOR;
|
|
1305
|
+
const r = await persist(storage, projectId, "release_delete",
|
|
1306
|
+
(cur) => {
|
|
1307
|
+
validation.releaseDelete(cur, releaseId); // SM-255: 422 on last release
|
|
1308
|
+
return core.ops.softDeleteRelease(cur, releaseId, actor);
|
|
1309
|
+
},
|
|
1310
|
+
actor);
|
|
1311
|
+
if (r.error) return r.error;
|
|
1312
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, deleted: releaseId });
|
|
1313
|
+
});
|
|
1314
|
+
|
|
1315
|
+
// release reorder moved to the unified `reorder` tool (entity='releases', SM-165).
|
|
1316
|
+
|
|
1317
|
+
// -------------------- process steps ------------------------------------
|
|
1318
|
+
|
|
1319
|
+
server.registerTool("list_process_steps", {
|
|
1320
|
+
description: "List all non-deleted process steps of a project, sorted by sortOrder.",
|
|
1321
|
+
inputSchema: { projectId: z.string() }
|
|
1322
|
+
}, async ({ projectId }) => {
|
|
1323
|
+
const snap = await storage.loadProject(projectId);
|
|
1324
|
+
if (!snap) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
1325
|
+
const list = (snap.processSteps || []).filter(p => !p.isDeleted)
|
|
1326
|
+
.slice().sort((a, b) => (a.sortOrder || 0) - (b.sortOrder || 0));
|
|
1327
|
+
return ok(list);
|
|
1328
|
+
});
|
|
1329
|
+
|
|
1330
|
+
server.registerTool("process_step_create", {
|
|
1331
|
+
description: "Create a new process step (a column on the story-map backbone).",
|
|
1332
|
+
inputSchema: {
|
|
1333
|
+
projectId: z.string(),
|
|
1334
|
+
name: z.string(),
|
|
1335
|
+
description: z.string().optional(),
|
|
1336
|
+
epicId: z.string().optional()
|
|
1337
|
+
}
|
|
1338
|
+
}, async ({ projectId, ...partial }) => {
|
|
1339
|
+
const actor = AI_ACTOR;
|
|
1340
|
+
const r = await persist(storage, projectId, "process_step_create", (cur) => {
|
|
1341
|
+
validation.processStepInput(partial);
|
|
1342
|
+
return core.ops.createProcessStep(cur, partial, actor);
|
|
1343
|
+
}, actor);
|
|
1344
|
+
if (r.error) return r.error;
|
|
1345
|
+
const processStep = r.saved.snapshot.processSteps[r.saved.snapshot.processSteps.length - 1];
|
|
1346
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, processStep });
|
|
1347
|
+
});
|
|
1348
|
+
|
|
1349
|
+
server.registerTool("process_step_update", {
|
|
1350
|
+
description: "Patch fields on a process step (name, description, epicId, sortOrder).",
|
|
1351
|
+
inputSchema: {
|
|
1352
|
+
projectId: z.string(),
|
|
1353
|
+
processStepId: z.string(),
|
|
1354
|
+
patch: passthroughObjectSchema
|
|
1355
|
+
}
|
|
1356
|
+
}, async ({ projectId, processStepId, patch }) => {
|
|
1357
|
+
const actor = AI_ACTOR;
|
|
1358
|
+
const r = await persist(storage, projectId, "process_step_update", (cur) => {
|
|
1359
|
+
const ex = findEntity(cur, "processSteps", processStepId);
|
|
1360
|
+
if (!ex) throw Object.assign(new Error("process step not found"), { statusCode: 404 });
|
|
1361
|
+
return core.ops.updateProcessStep(cur, processStepId, patch || {}, actor);
|
|
1362
|
+
}, actor);
|
|
1363
|
+
if (r.error) return r.error;
|
|
1364
|
+
return ok({
|
|
1365
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1366
|
+
processStep: findEntity(r.saved.snapshot, "processSteps", processStepId)
|
|
1367
|
+
});
|
|
1368
|
+
});
|
|
1369
|
+
|
|
1370
|
+
server.registerTool("process_step_delete", {
|
|
1371
|
+
description: "Soft-delete a process step.",
|
|
1372
|
+
inputSchema: { projectId: z.string(), processStepId: z.string() }
|
|
1373
|
+
}, async ({ projectId, processStepId }) => {
|
|
1374
|
+
const actor = AI_ACTOR;
|
|
1375
|
+
const r = await persist(storage, projectId, "process_step_delete",
|
|
1376
|
+
(cur) => core.ops.softDeleteProcessStep(cur, processStepId, actor),
|
|
1377
|
+
actor);
|
|
1378
|
+
if (r.error) return r.error;
|
|
1379
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, deleted: processStepId });
|
|
1380
|
+
});
|
|
1381
|
+
|
|
1382
|
+
server.registerTool("split_process_step", {
|
|
1383
|
+
description: "Split a process step (SM-247): create a NEW step directly after it and move the chosen epics (with their contained stories) into the new step. Loose stories stay with the original. epicIds must reference epics currently in this step (else 422 with `missing`). Empty/omitted epicIds inserts an empty step after the original. One revision.",
|
|
1384
|
+
inputSchema: {
|
|
1385
|
+
projectId: z.string(),
|
|
1386
|
+
processStepId: z.string(),
|
|
1387
|
+
name: z.string(),
|
|
1388
|
+
description: z.string().optional(),
|
|
1389
|
+
// tolerateJsonString: Claude Code's MCP bridge may serialise the array
|
|
1390
|
+
// as a JSON string when the schema isn't an explicit object/array (E20.F).
|
|
1391
|
+
epicIds: tolerateJsonString(z.array(z.string())).optional()
|
|
1392
|
+
}
|
|
1393
|
+
}, async ({ projectId, processStepId, name, description, epicIds }) => {
|
|
1394
|
+
const actor = AI_ACTOR;
|
|
1395
|
+
const r = await persist(storage, projectId, "process_step_split",
|
|
1396
|
+
(cur) => core.ops.splitProcessStep(cur, processStepId,
|
|
1397
|
+
{ name, description, epicIds: epicIds || [] }, actor),
|
|
1398
|
+
actor);
|
|
1399
|
+
if (r.error) return r.error;
|
|
1400
|
+
const before = new Set((r.before && r.before.processSteps || []).map(p => p.id));
|
|
1401
|
+
const newStep = (r.saved.snapshot.processSteps || []).find(p => !before.has(p.id));
|
|
1402
|
+
return ok({
|
|
1403
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1404
|
+
processStep: newStep || null,
|
|
1405
|
+
movedEpics: (epicIds || []).length
|
|
1406
|
+
});
|
|
1407
|
+
});
|
|
1408
|
+
|
|
1409
|
+
// process-step reorder moved to the unified `reorder` tool (entity='process_steps', SM-165).
|
|
1410
|
+
|
|
1411
|
+
// -------------------- SM-165: unified reorder --------------------------
|
|
1412
|
+
//
|
|
1413
|
+
// One tool replaces reorder_tickets / reorder_releases / reorder_process_steps.
|
|
1414
|
+
// `entity` dispatches; `scope` is honoured only for entity='tickets'. Each
|
|
1415
|
+
// path keeps its existing response shape (no behaviour change).
|
|
1416
|
+
server.registerTool("reorder", {
|
|
1417
|
+
description: "Reorder entities by full id list. entity ∈ 'tickets' | 'releases' | "
|
|
1418
|
+
+ "'process_steps'. orderedIds is the new order (sortOrder = index). For "
|
|
1419
|
+
+ "entity='tickets', an optional `scope` {releaseId, processStepId, epicId} is ALSO "
|
|
1420
|
+
+ "applied to every listed ticket (clean cross-container move); scope is IGNORED for "
|
|
1421
|
+
+ "releases/process_steps. Compact response: tickets → {revision, savedAt, "
|
|
1422
|
+
+ "reordered:[{id, ticketKey, position}]} (only tickets whose position actually "
|
|
1423
|
+
+ "changed, incl. stories cascaded by an epic move; the browser still gets the full "
|
|
1424
|
+
+ "snapshot via the WebSocket bus). releases → {…, releases:[…]}; process_steps → "
|
|
1425
|
+
+ "{…, processSteps:[…]} (the reordered non-deleted list).",
|
|
1426
|
+
inputSchema: {
|
|
1427
|
+
projectId: z.string(),
|
|
1428
|
+
entity: z.enum(["tickets", "releases", "process_steps"]),
|
|
1429
|
+
orderedIds: z.array(z.string()),
|
|
1430
|
+
scope: reorderScopeSchema
|
|
1431
|
+
}
|
|
1432
|
+
}, async ({ projectId, entity, orderedIds, scope }) => {
|
|
1433
|
+
const actor = AI_ACTOR;
|
|
1434
|
+
if (entity === "releases") {
|
|
1435
|
+
const r = await persist(storage, projectId, "release_reorder",
|
|
1436
|
+
(cur) => core.ops.reorderReleases(cur, orderedIds, actor), actor);
|
|
1437
|
+
if (r.error) return r.error;
|
|
1438
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1439
|
+
releases: (r.saved.snapshot.releases || []).filter(x => !x.isDeleted) });
|
|
1440
|
+
}
|
|
1441
|
+
if (entity === "process_steps") {
|
|
1442
|
+
const r = await persist(storage, projectId, "process_step_reorder",
|
|
1443
|
+
(cur) => core.ops.reorderProcessSteps(cur, orderedIds, actor), actor);
|
|
1444
|
+
if (r.error) return r.error;
|
|
1445
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
1446
|
+
processSteps: (r.saved.snapshot.processSteps || []).filter(x => !x.isDeleted) });
|
|
1447
|
+
}
|
|
1448
|
+
// entity === "tickets"
|
|
1449
|
+
const r = await persist(storage, projectId, "tickets_reorder",
|
|
1450
|
+
(cur) => core.ops.reorderTickets(cur, orderedIds, scope || null, actor), actor);
|
|
1451
|
+
if (r.error) return r.error;
|
|
1452
|
+
const beforeById = new Map((r.before.tickets || []).map(t => [t.id, t.position]));
|
|
1453
|
+
const reordered = [];
|
|
1454
|
+
for (const t of (r.saved.snapshot.tickets || [])) {
|
|
1455
|
+
if (t.isDeleted) continue;
|
|
1456
|
+
const prev = beforeById.get(t.id);
|
|
1457
|
+
if (!prev) continue;
|
|
1458
|
+
if (prev.releaseId === t.position.releaseId
|
|
1459
|
+
&& prev.processStepId === t.position.processStepId
|
|
1460
|
+
&& prev.epicId === t.position.epicId
|
|
1461
|
+
&& prev.sortOrder === t.position.sortOrder) continue;
|
|
1462
|
+
reordered.push({ id: t.id, ticketKey: t.ticketKey, position: t.position });
|
|
1463
|
+
}
|
|
1464
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, reordered });
|
|
1465
|
+
});
|
|
1466
|
+
|
|
1467
|
+
// -------------------- SM-22/SM-23/SM-24: Bulk operations --------------
|
|
1468
|
+
//
|
|
1469
|
+
// Three bulk tools, one consistent contract:
|
|
1470
|
+
//
|
|
1471
|
+
// {
|
|
1472
|
+
// revision, savedAt,
|
|
1473
|
+
// successful: [{id, ticketKey, ...}],
|
|
1474
|
+
// failed: [{id, ticketKey?, kind, message:{title,reason,suggestion}}],
|
|
1475
|
+
// warnings: [{kind, ticketId, ticketKey, context, message:{...}}] // optional
|
|
1476
|
+
// }
|
|
1477
|
+
//
|
|
1478
|
+
// Semantics:
|
|
1479
|
+
// - Default = best-effort. Failed items are reported but the call
|
|
1480
|
+
// persists the successful ones in a single revision. Total atomicity
|
|
1481
|
+
// at SQLite-tx level: either all successful mutations land or none do.
|
|
1482
|
+
// - `opts.atomic: true` → all-or-nothing. ANY per-item failure aborts
|
|
1483
|
+
// the whole call. The response carries `kind: BULK_ABORTED` and the
|
|
1484
|
+
// full failed[] list. No write happens.
|
|
1485
|
+
//
|
|
1486
|
+
// Per-item gate-checking respects SM-93 governance: bulk_change_status
|
|
1487
|
+
// runs evaluateGates for the target action (mapped from targetStatus),
|
|
1488
|
+
// bulk_ticket_update runs the SM-94 DEFINITION_FROZEN block and collects
|
|
1489
|
+
// SPEC_FROZEN_EDIT warnings, bulk_link_create defers to core.ops.addLink
|
|
1490
|
+
// which already does cycle/dup/self validation.
|
|
1491
|
+
|
|
1492
|
+
/**
|
|
1493
|
+
* Generic runner. Three phases:
|
|
1494
|
+
* 1. Validate each item via `perItemPlan`. Collect plan or failed entry.
|
|
1495
|
+
* 2. Sequentially apply each plan.mutate against a working snapshot.
|
|
1496
|
+
* Mutations that throw (e.g. addLink hitting LINK_DUPLICATE or
|
|
1497
|
+
* LINK_CYCLE) get moved from successful[] to failed[].
|
|
1498
|
+
* 3. Single saveProject for the resulting snapshot (one revision).
|
|
1499
|
+
*
|
|
1500
|
+
* `opts.atomic:true` aborts at the first failure (validation or apply).
|
|
1501
|
+
* Default = best-effort: persist whatever survives.
|
|
1502
|
+
*/
|
|
1503
|
+
async function runBulkOp(projectId, op, ticketIds, perItemPlan, opts) {
|
|
1504
|
+
const actor = AI_ACTOR;
|
|
1505
|
+
const atomic = !!(opts && opts.atomic);
|
|
1506
|
+
|
|
1507
|
+
// Accumulators populated inside the atomic mutate below (closure).
|
|
1508
|
+
let failed = [];
|
|
1509
|
+
let warnings = [];
|
|
1510
|
+
let successful = [];
|
|
1511
|
+
// Sentinels to short-circuit storage.mutate WITHOUT writing a revision:
|
|
1512
|
+
// either nothing survived (no-op) or an atomic run hit a failure.
|
|
1513
|
+
const NO_WRITE = "__bulk_no_write__";
|
|
1514
|
+
const ATOMIC_ABORT = "__bulk_atomic_abort__";
|
|
1515
|
+
|
|
1516
|
+
let saved;
|
|
1517
|
+
try {
|
|
1518
|
+
// SM-154: the whole plan → apply → save now runs inside ONE storage
|
|
1519
|
+
// mutex acquisition, so a concurrent writer can't slip a save in between
|
|
1520
|
+
// the snapshot we planned against and the snapshot we persist.
|
|
1521
|
+
saved = await storage.mutate(projectId, { actor, op }, (snap0) => {
|
|
1522
|
+
// mutate() runs the callback exactly once; reset accumulators anyway
|
|
1523
|
+
// so a hypothetical retry can't double-count.
|
|
1524
|
+
failed = []; warnings = []; successful = [];
|
|
1525
|
+
const planned = [];
|
|
1526
|
+
|
|
1527
|
+
// Phase 1: validate / plan against the freshly-locked snapshot.
|
|
1528
|
+
for (const ticketId of ticketIds) {
|
|
1529
|
+
const t = findEntity(snap0, "tickets", ticketId);
|
|
1530
|
+
if (!t || t.isDeleted) {
|
|
1531
|
+
failed.push({ id: ticketId, kind: "NOT_FOUND", message: {
|
|
1532
|
+
title: "Ticket not found",
|
|
1533
|
+
reason: "Ticket " + ticketId + " is not present in the snapshot (or is soft-deleted).",
|
|
1534
|
+
suggestion: "Verify the id and that it has not been deleted; re-fetch via list_tickets.",
|
|
1535
|
+
skillRef: ""
|
|
1536
|
+
}});
|
|
1537
|
+
continue;
|
|
1538
|
+
}
|
|
1539
|
+
const plan = perItemPlan(snap0, t);
|
|
1540
|
+
if (plan.failed) {
|
|
1541
|
+
failed.push(Object.assign({ id: t.id, ticketKey: t.ticketKey }, plan.failed));
|
|
1542
|
+
} else {
|
|
1543
|
+
planned.push({ ticketId: t.id, ticketKey: t.ticketKey, mutate: plan.mutate });
|
|
1544
|
+
if (plan.warning) warnings.push(Object.assign({ ticketId: t.id, ticketKey: t.ticketKey }, plan.warning));
|
|
1545
|
+
}
|
|
1546
|
+
}
|
|
1547
|
+
if (atomic && failed.length > 0) throw new Error(ATOMIC_ABORT);
|
|
1548
|
+
|
|
1549
|
+
// Phase 2: apply mutations sequentially. Late failures (e.g. addLink
|
|
1550
|
+
// throws on dup/cycle) get moved to failed[].
|
|
1551
|
+
let workingSnap = snap0;
|
|
1552
|
+
for (const p of planned) {
|
|
1553
|
+
try {
|
|
1554
|
+
workingSnap = p.mutate(workingSnap);
|
|
1555
|
+
successful.push({ id: p.ticketId, ticketKey: p.ticketKey });
|
|
1556
|
+
} catch (err) {
|
|
1557
|
+
failed.push({ id: p.ticketId, ticketKey: p.ticketKey,
|
|
1558
|
+
kind: err.kind || "MUTATE_ERROR",
|
|
1559
|
+
message: {
|
|
1560
|
+
title: "Apply failed",
|
|
1561
|
+
reason: err.message || "mutate threw",
|
|
1562
|
+
suggestion: err.kind === "LINK_DUPLICATE" ? "Link already exists; nothing to add."
|
|
1563
|
+
: err.kind === "LINK_CYCLE" ? "Adding this link would close a cycle in the linkType graph."
|
|
1564
|
+
: "Inspect the per-item state and retry individually.",
|
|
1565
|
+
skillRef: ""
|
|
1566
|
+
}
|
|
1567
|
+
});
|
|
1568
|
+
}
|
|
1569
|
+
}
|
|
1570
|
+
if (atomic && failed.length > 0) throw new Error(ATOMIC_ABORT);
|
|
1571
|
+
if (successful.length === 0) throw new Error(NO_WRITE);
|
|
1572
|
+
|
|
1573
|
+
// Phase 3: hand the working snapshot to mutate() for the single write.
|
|
1574
|
+
validation.snapshotLimits(workingSnap);
|
|
1575
|
+
return workingSnap;
|
|
1576
|
+
});
|
|
1577
|
+
} catch (err) {
|
|
1578
|
+
// No-op: nothing survived — return the failure breakdown, no revision.
|
|
1579
|
+
if (err && err.message === NO_WRITE) {
|
|
1580
|
+
return ok({ revision: null, savedAt: null, successful: [], failed: failed,
|
|
1581
|
+
warnings: warnings.length > 0 ? warnings : undefined });
|
|
1582
|
+
}
|
|
1583
|
+
// Atomic abort: caller asked all-or-nothing and something failed.
|
|
1584
|
+
if (err && err.message === ATOMIC_ABORT) {
|
|
1585
|
+
return fail("atomic bulk aborted: " + failed.length + " item(s) failed", {
|
|
1586
|
+
kind: "BULK_ABORTED", statusCode: 422, failed: failed
|
|
1587
|
+
});
|
|
1588
|
+
}
|
|
1589
|
+
// Project missing (storage.mutate 404) or a save-time error.
|
|
1590
|
+
return fail(err.message || "bulk save failed", {
|
|
1591
|
+
statusCode: err.statusCode || 500, kind: err.kind
|
|
1592
|
+
});
|
|
1593
|
+
}
|
|
1594
|
+
|
|
1595
|
+
const enriched = successful.map(s => {
|
|
1596
|
+
const after = findEntity(saved.snapshot, "tickets", s.id);
|
|
1597
|
+
if (!after) return s;
|
|
1598
|
+
return { id: s.id, ticketKey: s.ticketKey,
|
|
1599
|
+
status: after.status, position: after.position };
|
|
1600
|
+
});
|
|
1601
|
+
const payload = { revision: saved.revision, savedAt: saved.savedAt,
|
|
1602
|
+
successful: enriched, failed: failed };
|
|
1603
|
+
if (warnings.length > 0) payload.warnings = warnings;
|
|
1604
|
+
return ok(payload);
|
|
1605
|
+
}
|
|
1606
|
+
|
|
1607
|
+
server.registerTool("bulk_change_status", {
|
|
1608
|
+
description: "Change the status of multiple tickets in one revision. Each ticket "
|
|
1609
|
+
+ "is independently validated (workflow transition + SM-93 governance gates). "
|
|
1610
|
+
+ "Best-effort by default — items that fail their gate land in `failed[]`, the "
|
|
1611
|
+
+ "rest persist in a single revision. Pass `opts.atomic:true` for all-or-nothing.",
|
|
1612
|
+
inputSchema: {
|
|
1613
|
+
projectId: z.string(),
|
|
1614
|
+
ticketIds: z.array(z.string()),
|
|
1615
|
+
targetStatus: z.string(),
|
|
1616
|
+
opts: passthroughObjectSchema.optional()
|
|
1617
|
+
}
|
|
1618
|
+
}, async ({ projectId, ticketIds, targetStatus, opts }) => {
|
|
1619
|
+
const actor = AI_ACTOR;
|
|
1620
|
+
return runBulkOp(projectId, "bulk_change_status", ticketIds, (snap, t) => {
|
|
1621
|
+
// Per-item validation: workflow transition (DoR/DoD via existing
|
|
1622
|
+
// validation), then SM-93 governance gates for the *resulting* tool
|
|
1623
|
+
// action when status === "done" (mirrors complete_ticket).
|
|
1624
|
+
try {
|
|
1625
|
+
validation.changeStatusTransition(t, targetStatus, snap.project);
|
|
1626
|
+
} catch (err) {
|
|
1627
|
+
return { failed: {
|
|
1628
|
+
kind: err.kind || "TRANSITION", missing: err.missing,
|
|
1629
|
+
message: {
|
|
1630
|
+
title: "Status transition refused",
|
|
1631
|
+
reason: (err.message || "transition denied") + " (target=" + targetStatus + ")",
|
|
1632
|
+
suggestion: err.kind === "DoR" || err.kind === "DoD"
|
|
1633
|
+
? "Check the missing items via set_checklist_item (gate dor/dod, checked:true), then retry."
|
|
1634
|
+
: "Inspect get_workflow for the allowed transitions.",
|
|
1635
|
+
skillRef: ""
|
|
1636
|
+
}
|
|
1637
|
+
}};
|
|
1638
|
+
}
|
|
1639
|
+
if (targetStatus === "done") {
|
|
1640
|
+
const gateFail = runGovernanceGates(snap, "complete_ticket", t,
|
|
1641
|
+
{ targetKey: t.ticketKey || t.id, targetType: t.type }); // SM-299 F2: name the type in the message
|
|
1642
|
+
if (gateFail) {
|
|
1643
|
+
const parsed = JSON.parse(gateFail.content[0].text);
|
|
1644
|
+
// Preserve the full per-gate detail array — single complete_ticket
|
|
1645
|
+
// returns parsed.errors, bulk must not drop it. SM-148.
|
|
1646
|
+
return { failed: { kind: parsed.kind, message: parsed.message, errors: parsed.errors } };
|
|
1647
|
+
}
|
|
1648
|
+
}
|
|
1649
|
+
return { mutate: (cur) => core.ops.changeStatus(cur, t.id, targetStatus, actor) };
|
|
1650
|
+
}, opts);
|
|
1651
|
+
});
|
|
1652
|
+
|
|
1653
|
+
server.registerTool("bulk_ticket_update", {
|
|
1654
|
+
description: "Apply the same `patch` to multiple tickets in one revision. Per-item: "
|
|
1655
|
+
+ "the SM-94 DEFINITION_FROZEN block fires on published test-definitions whose "
|
|
1656
|
+
+ "patch touches steps/prereqs (such items land in `failed[]`); editing spec "
|
|
1657
|
+
+ "fields on a ready+ ticket surfaces SPEC_FROZEN_EDIT in `warnings[]` but "
|
|
1658
|
+
+ "still goes through. Default is best-effort; pass `opts.atomic:true` for "
|
|
1659
|
+
+ "all-or-nothing.",
|
|
1660
|
+
inputSchema: {
|
|
1661
|
+
projectId: z.string(),
|
|
1662
|
+
ticketIds: z.array(z.string()),
|
|
1663
|
+
patch: ticketPatchSchema,
|
|
1664
|
+
opts: passthroughObjectSchema.optional()
|
|
1665
|
+
}
|
|
1666
|
+
}, async ({ projectId, ticketIds, patch, opts }) => {
|
|
1667
|
+
const actor = AI_ACTOR;
|
|
1668
|
+
const p = patch || {};
|
|
1669
|
+
return runBulkOp(projectId, "bulk_ticket_update", ticketIds, (snap, t) => {
|
|
1670
|
+
// SM-94 hard gate: published test-definition rejects step/prereq edits.
|
|
1671
|
+
if (t.type === "test-definition" && t.lifecycle === "published") {
|
|
1672
|
+
const forbidden = ["steps", "prerequisites"].filter(k =>
|
|
1673
|
+
Object.prototype.hasOwnProperty.call(p, k));
|
|
1674
|
+
if (forbidden.length > 0) {
|
|
1675
|
+
return { failed: { kind: "DEFINITION_FROZEN", message: {
|
|
1676
|
+
title: "Definition is frozen",
|
|
1677
|
+
reason: "Definition " + t.ticketKey + " is published; cannot patch " + forbidden.join(", ") + ".",
|
|
1678
|
+
suggestion: "Use update_test_definition_metadata for cosmetics, or reopen the definition first.",
|
|
1679
|
+
skillRef: "Published test-definitions are immutable spec records."
|
|
1680
|
+
}}};
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
// SM-94 soft warn: ready+-status + spec-patch.
|
|
1684
|
+
let warning = null;
|
|
1685
|
+
const SPEC_FIELDS = ["acceptanceCriteria", "description", "definitionOfReady"];
|
|
1686
|
+
if (t.status && t.status !== "backlog") {
|
|
1687
|
+
const touched = SPEC_FIELDS.filter(k => Object.prototype.hasOwnProperty.call(p, k));
|
|
1688
|
+
if (touched.length > 0) {
|
|
1689
|
+
warning = { kind: "SPEC_FROZEN_EDIT",
|
|
1690
|
+
context: { fields: touched, status: t.status },
|
|
1691
|
+
message: {
|
|
1692
|
+
title: "Editing spec on a ready+ ticket",
|
|
1693
|
+
reason: "Spec fields (" + touched.join(", ") + ") edited on " + t.ticketKey + " (status=" + t.status + ").",
|
|
1694
|
+
suggestion: "For behavioural changes, consider a `modifies`-ticket linked to " + t.ticketKey + ".",
|
|
1695
|
+
skillRef: "Specs frieren ein. Aenderungen werden zu Tickets."
|
|
1696
|
+
}};
|
|
1697
|
+
}
|
|
1698
|
+
}
|
|
1699
|
+
try {
|
|
1700
|
+
validation.ticketInput(Object.assign({}, t, p), snap.project);
|
|
1701
|
+
} catch (err) {
|
|
1702
|
+
return { failed: { kind: err.kind || "VALIDATION", message: {
|
|
1703
|
+
title: "Patch invalid", reason: err.message || "validation failed",
|
|
1704
|
+
suggestion: "Inspect the input and retry per item.", skillRef: ""
|
|
1705
|
+
}}};
|
|
1706
|
+
}
|
|
1707
|
+
return {
|
|
1708
|
+
mutate: (cur) => core.ops.updateTicket(cur, t.id, p, actor),
|
|
1709
|
+
warning: warning
|
|
1710
|
+
};
|
|
1711
|
+
}, opts);
|
|
1712
|
+
});
|
|
1713
|
+
|
|
1714
|
+
server.registerTool("bulk_link_create", {
|
|
1715
|
+
description: "Create the same-typed link from each ticket in sourceTicketIds[] to "
|
|
1716
|
+
+ "the single targetTicketId. Use case: bulk-tag a refactor — many tickets all "
|
|
1717
|
+
+ "`modifies` one feature. Each link is validated via core.ops.addLink (no self, "
|
|
1718
|
+
+ "no duplicate, cycle-check per linkType). Best-effort default.",
|
|
1719
|
+
inputSchema: {
|
|
1720
|
+
projectId: z.string(),
|
|
1721
|
+
sourceTicketIds: z.array(z.string()),
|
|
1722
|
+
targetTicketId: z.string(),
|
|
1723
|
+
linkTypeId: z.string(),
|
|
1724
|
+
opts: passthroughObjectSchema.optional()
|
|
1725
|
+
}
|
|
1726
|
+
}, async ({ projectId, sourceTicketIds, targetTicketId, linkTypeId, opts }) => {
|
|
1727
|
+
const actor = AI_ACTOR;
|
|
1728
|
+
return runBulkOp(projectId, "bulk_link_create", sourceTicketIds, (snap, src) => {
|
|
1729
|
+
if (src.id === targetTicketId) {
|
|
1730
|
+
return { failed: { kind: "LINK_SELF", message: {
|
|
1731
|
+
title: "Self-link refused", reason: "A ticket cannot link to itself.",
|
|
1732
|
+
suggestion: "Drop this id from sourceTicketIds.", skillRef: ""
|
|
1733
|
+
}}};
|
|
1734
|
+
}
|
|
1735
|
+
return { mutate: (cur) => {
|
|
1736
|
+
try {
|
|
1737
|
+
return core.ops.addLink(cur, src.id, {
|
|
1738
|
+
linkTypeId: linkTypeId, targetTicketId: targetTicketId
|
|
1739
|
+
}, actor);
|
|
1740
|
+
} catch (err) {
|
|
1741
|
+
// Re-throw with structured kind so the persist catch path can
|
|
1742
|
+
// bubble it. Since runBulkOp pre-plans, we shouldn't reach here
|
|
1743
|
+
// unless the snapshot drifted — but be defensive.
|
|
1744
|
+
throw err;
|
|
1745
|
+
}
|
|
1746
|
+
}};
|
|
1747
|
+
}, opts);
|
|
1748
|
+
});
|
|
1749
|
+
|
|
1750
|
+
// workflow + kanban_columns config moved to get_config/set_config (SM-164).
|
|
1751
|
+
|
|
1752
|
+
// -------------------- links (SM-46) ------------------------------------
|
|
1753
|
+
|
|
1754
|
+
server.registerTool("link_create", {
|
|
1755
|
+
description: "Add a typed link from one ticket to another. linkTypeId references a project.linkTypes entry (predecessor-of / blocks / follows-on / contains / relates-to by default — get_link_types for the catalogue). Throws LINK_SELF for self-links, LINK_TARGET_MISSING for unknown target, LINK_DUPLICATE for duplicate (linkTypeId, targetTicketId), LINK_CYCLE if the new edge would close a cycle in a cycle-checked semantic.",
|
|
1756
|
+
inputSchema: {
|
|
1757
|
+
projectId: z.string(),
|
|
1758
|
+
sourceTicketId: z.string(),
|
|
1759
|
+
linkTypeId: z.string(),
|
|
1760
|
+
targetTicketId: z.string(),
|
|
1761
|
+
label: z.string().optional()
|
|
1762
|
+
}
|
|
1763
|
+
}, async ({ projectId, sourceTicketId, linkTypeId, targetTicketId, label }) => {
|
|
1764
|
+
const actor = AI_ACTOR;
|
|
1765
|
+
const linkPatch = { linkTypeId: linkTypeId, targetTicketId: targetTicketId };
|
|
1766
|
+
if (typeof label === "string" && label.length > 0) linkPatch.label = label;
|
|
1767
|
+
const r = await persist(storage, projectId, "link_create",
|
|
1768
|
+
(cur) => core.ops.addLink(cur, sourceTicketId, linkPatch, actor), actor);
|
|
1769
|
+
if (r.error) return r.error;
|
|
1770
|
+
const ticket = r.saved.snapshot.tickets.find(t => t.id === sourceTicketId);
|
|
1771
|
+
const newLink = ticket && ticket.links[ticket.links.length - 1];
|
|
1772
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, link: newLink });
|
|
1773
|
+
});
|
|
1774
|
+
|
|
1775
|
+
server.registerTool("link_delete", {
|
|
1776
|
+
description: "Remove a single link from a ticket by linkId. No-op if the linkId is not on the source ticket.",
|
|
1777
|
+
inputSchema: {
|
|
1778
|
+
projectId: z.string(),
|
|
1779
|
+
sourceTicketId: z.string(),
|
|
1780
|
+
linkId: z.string()
|
|
1781
|
+
}
|
|
1782
|
+
}, async ({ projectId, sourceTicketId, linkId }) => {
|
|
1783
|
+
const actor = AI_ACTOR;
|
|
1784
|
+
const r = await persist(storage, projectId, "link_delete",
|
|
1785
|
+
(cur) => core.ops.removeLink(cur, sourceTicketId, linkId, actor), actor);
|
|
1786
|
+
if (r.error) return r.error;
|
|
1787
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt });
|
|
1788
|
+
});
|
|
1789
|
+
|
|
1790
|
+
server.registerTool("list_links_for_ticket", {
|
|
1791
|
+
description: "List links involving a ticket. direction: 'forward' = links this ticket owns; 'backward' = links from OTHER tickets pointing here (resolved via project.linkTypes.inverseLabel); 'both' (default) = combined. Each entry has linkTypeId, label (project-resolved or the link's stored label), direction, target { id, ticketKey, title, type }. Use this to power a Ticket-Detail-Modal's Links-section.",
|
|
1792
|
+
inputSchema: {
|
|
1793
|
+
projectId: z.string(),
|
|
1794
|
+
ticketId: z.string(),
|
|
1795
|
+
direction: z.enum(["forward", "backward", "both"]).optional()
|
|
1796
|
+
}
|
|
1797
|
+
}, async ({ projectId, ticketId, direction }) => {
|
|
1798
|
+
const dir = direction || "both";
|
|
1799
|
+
const current = await storage.loadProject(projectId);
|
|
1800
|
+
if (!current) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
1801
|
+
const ticket = current.tickets.find(t => t.id === ticketId);
|
|
1802
|
+
if (!ticket) return fail("ticket not found: " + ticketId, { statusCode: 404 });
|
|
1803
|
+
const linkTypes = (current.project && current.project.linkTypes) || [];
|
|
1804
|
+
const ltById = new Map(linkTypes.map(lt => [lt.id, lt]));
|
|
1805
|
+
function summary(t) {
|
|
1806
|
+
return t ? { id: t.id, ticketKey: t.ticketKey, title: t.title, type: t.type } : null;
|
|
1807
|
+
}
|
|
1808
|
+
function resolveLabel(linkTypeId, fallback, inverse) {
|
|
1809
|
+
const lt = ltById.get(linkTypeId);
|
|
1810
|
+
if (lt) return inverse ? lt.inverseLabel : lt.label;
|
|
1811
|
+
return fallback || linkTypeId;
|
|
1812
|
+
}
|
|
1813
|
+
const out = [];
|
|
1814
|
+
if (dir === "forward" || dir === "both") {
|
|
1815
|
+
for (const l of ticket.links || []) {
|
|
1816
|
+
const target = current.tickets.find(t => t.id === l.targetTicketId);
|
|
1817
|
+
out.push({
|
|
1818
|
+
linkId: l.id,
|
|
1819
|
+
linkTypeId: l.linkTypeId,
|
|
1820
|
+
label: l.label || resolveLabel(l.linkTypeId, null, false),
|
|
1821
|
+
direction: "forward",
|
|
1822
|
+
target: summary(target)
|
|
1823
|
+
});
|
|
1824
|
+
}
|
|
1825
|
+
}
|
|
1826
|
+
if (dir === "backward" || dir === "both") {
|
|
1827
|
+
for (const other of current.tickets) {
|
|
1828
|
+
if (other.id === ticketId) continue;
|
|
1829
|
+
for (const l of other.links || []) {
|
|
1830
|
+
if (l.targetTicketId !== ticketId) continue;
|
|
1831
|
+
out.push({
|
|
1832
|
+
linkId: l.id,
|
|
1833
|
+
sourceTicketId: other.id,
|
|
1834
|
+
linkTypeId: l.linkTypeId,
|
|
1835
|
+
label: resolveLabel(l.linkTypeId, l.label, true),
|
|
1836
|
+
direction: "backward",
|
|
1837
|
+
source: summary(other)
|
|
1838
|
+
});
|
|
1839
|
+
}
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
return ok({ ticketId: ticketId, direction: dir, links: out });
|
|
1843
|
+
});
|
|
1844
|
+
|
|
1845
|
+
// link_types config (get/set) moved to get_config/set_config (SM-164).
|
|
1846
|
+
// list_links_for_ticket stays here — it's a per-ticket read, not config.
|
|
1847
|
+
|
|
1848
|
+
// -------------------- revisions / history ------------------------------
|
|
1849
|
+
|
|
1850
|
+
server.registerTool("list_revisions", {
|
|
1851
|
+
description: "List the revision history of a project (reverse-chrono). Returns up to `limit` entries (default 100); pass `before` (a revision id) to page older entries.",
|
|
1852
|
+
inputSchema: { projectId: z.string(), limit: z.number().int().positive().optional(), before: z.string().optional() }
|
|
1853
|
+
}, async ({ projectId, limit, before }) => {
|
|
1854
|
+
// SM-212: limit/before run as SQL LIMIT + cursor inside storage.
|
|
1855
|
+
const opts = {};
|
|
1856
|
+
if (limit != null) opts.limit = limit;
|
|
1857
|
+
if (before) opts.beforeRevision = before;
|
|
1858
|
+
return ok(await storage.listRevisions(projectId, opts));
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
server.registerTool("get_revision", {
|
|
1862
|
+
description: "Fetch a single revision (full snapshot at that point in time). Returns 404-error if the revision does not exist.",
|
|
1863
|
+
inputSchema: { projectId: z.string(), revision: z.string() }
|
|
1864
|
+
}, async ({ projectId, revision }) => {
|
|
1865
|
+
const r = await storage.getRevision(projectId, revision);
|
|
1866
|
+
if (!r) return fail("revision not found: " + revision, { statusCode: 404 });
|
|
1867
|
+
return ok(r);
|
|
1868
|
+
});
|
|
1869
|
+
|
|
1870
|
+
// -------------------- request_switch_project (E20.B) -------------------
|
|
1871
|
+
server.registerTool("request_switch_project", {
|
|
1872
|
+
description: "Ask every connected browser to switch to the named project. The browser shows a confirm dialog; the tool waits up to wait_seconds (default 30) for the user's verdict and returns { requested, requestId, response:{accepted}, timedOut? }. Pass wait_seconds:0 for fire-and-forget. Use this AFTER preparing a project so the user lands on the right place and can verify your work.",
|
|
1873
|
+
inputSchema: {
|
|
1874
|
+
workspace: z.string().describe("The project id to switch to."),
|
|
1875
|
+
reason: z.string().optional().describe("Short rationale shown in the confirm dialog."),
|
|
1876
|
+
wait_seconds: z.number().int().min(0).max(120).optional().describe("Seconds to wait for the user's verdict (default 30, max 120). 0 = fire-and-forget.")
|
|
1877
|
+
}
|
|
1878
|
+
}, async ({ workspace, reason, wait_seconds }) => {
|
|
1879
|
+
const requestId = genRequestId();
|
|
1880
|
+
const waitSec = (typeof wait_seconds === "number")
|
|
1881
|
+
? Math.max(0, Math.min(120, wait_seconds | 0))
|
|
1882
|
+
: 30;
|
|
1883
|
+
|
|
1884
|
+
// Start the listener BEFORE broadcasting so a fast browser response
|
|
1885
|
+
// can't race past us. The two paths (in-process bus vs standalone
|
|
1886
|
+
// long-poll) resolve to the same shape.
|
|
1887
|
+
let waitPromise = null;
|
|
1888
|
+
if (waitSec > 0) {
|
|
1889
|
+
if (httpUrl && fetchImpl) {
|
|
1890
|
+
// Standalone: server holds the cache + bus, we poll it. Add a
|
|
1891
|
+
// safety margin to the AbortController so the HTTP timeout fires
|
|
1892
|
+
// before the AbortController kicks in (cleaner error messages).
|
|
1893
|
+
const ctrl = new AbortController();
|
|
1894
|
+
const safety = setTimeout(() => ctrl.abort(), (waitSec + 5) * 1000);
|
|
1895
|
+
waitPromise = (async () => {
|
|
1896
|
+
try {
|
|
1897
|
+
const url = new URL("/api/internal/switch-response", httpUrl);
|
|
1898
|
+
url.searchParams.set("requestId", requestId);
|
|
1899
|
+
url.searchParams.set("wait", String(waitSec));
|
|
1900
|
+
const res = await fetchImpl(url, { signal: ctrl.signal });
|
|
1901
|
+
clearTimeout(safety);
|
|
1902
|
+
if (!res.ok) return { accepted: null, timedOut: true };
|
|
1903
|
+
return await res.json();
|
|
1904
|
+
} catch (e) {
|
|
1905
|
+
clearTimeout(safety);
|
|
1906
|
+
return { accepted: null, timedOut: true };
|
|
1907
|
+
}
|
|
1908
|
+
})();
|
|
1909
|
+
} else {
|
|
1910
|
+
// In-process: same bus that the WS layer emits on (useful for tests
|
|
1911
|
+
// that share the process).
|
|
1912
|
+
waitPromise = new Promise((resolve) => {
|
|
1913
|
+
let settled = false;
|
|
1914
|
+
const settle = (val) => { if (settled) return; settled = true; clearTimeout(t); bus.off("switch_response", onResp); resolve(val); };
|
|
1915
|
+
const onResp = (ev) => { if (!ev || ev.requestId !== requestId) return; settle({ accepted: !!ev.accepted, timedOut: false }); };
|
|
1916
|
+
const t = setTimeout(() => settle({ accepted: null, timedOut: true }), waitSec * 1000);
|
|
1917
|
+
bus.on("switch_response", onResp);
|
|
1918
|
+
});
|
|
1919
|
+
}
|
|
1920
|
+
}
|
|
1921
|
+
|
|
1922
|
+
// Broadcast. Local bus first (in-process tests rely on it), then HTTP
|
|
1923
|
+
// bridge (standalone subprocess relies on it).
|
|
1924
|
+
bus.emit("switch_request", { workspace, reason: reason || null, requestId });
|
|
1925
|
+
if (httpUrl && fetchImpl) {
|
|
1926
|
+
try {
|
|
1927
|
+
const ctrl = new AbortController();
|
|
1928
|
+
const t = setTimeout(() => ctrl.abort(), 2000);
|
|
1929
|
+
const res = await fetchImpl(new URL("/api/internal/switch-request", httpUrl), {
|
|
1930
|
+
method: "POST",
|
|
1931
|
+
headers: { "Content-Type": "application/json" },
|
|
1932
|
+
body: JSON.stringify({ workspace, reason: reason || null, requestId }),
|
|
1933
|
+
signal: ctrl.signal
|
|
1934
|
+
});
|
|
1935
|
+
clearTimeout(t);
|
|
1936
|
+
if (!res.ok) {
|
|
1937
|
+
process.stderr.write(`[storymap-mcp] switch-request POST failed: ${res.status}\n`);
|
|
1938
|
+
}
|
|
1939
|
+
} catch (e) {
|
|
1940
|
+
process.stderr.write(`[storymap-mcp] switch-request POST error: ${e.message}\n`);
|
|
1941
|
+
}
|
|
1942
|
+
}
|
|
1943
|
+
|
|
1944
|
+
const out = { requested: workspace, reason: reason || null, requestId };
|
|
1945
|
+
if (waitPromise) {
|
|
1946
|
+
const r = await waitPromise;
|
|
1947
|
+
out.response = { accepted: r.accepted };
|
|
1948
|
+
out.timedOut = !!r.timedOut;
|
|
1949
|
+
}
|
|
1950
|
+
return ok(out);
|
|
1951
|
+
});
|
|
1952
|
+
|
|
1953
|
+
// -------------------- SM-58: test-types tooling -----------------------
|
|
1954
|
+
//
|
|
1955
|
+
// Definition-side: step + prereq CRUD. Execution-side: start a run,
|
|
1956
|
+
// record per-step results, set/auto-reset the outcome, list history.
|
|
1957
|
+
// Object-args use passthroughObjectSchema so the Claude Code MCP bridge's
|
|
1958
|
+
// JSON-string serialisation survives the round trip (SM-31 pattern).
|
|
1959
|
+
|
|
1960
|
+
server.registerTool("test_def_step_add", {
|
|
1961
|
+
description: "Append a step to a test-definition. patch = { step: string, data?: string, expectedResult: string, position?: number }. Returns the created step.",
|
|
1962
|
+
inputSchema: {
|
|
1963
|
+
projectId: z.string(),
|
|
1964
|
+
ticketId: z.string(),
|
|
1965
|
+
patch: passthroughObjectSchema
|
|
1966
|
+
}
|
|
1967
|
+
}, async ({ projectId, ticketId, patch }) => {
|
|
1968
|
+
const actor = AI_ACTOR;
|
|
1969
|
+
const r = await persist(storage, projectId, "test_def_step_add",
|
|
1970
|
+
(cur) => core.ops.addTestStep(cur, ticketId, patch || {}, actor), actor);
|
|
1971
|
+
if (r.error) return r.error;
|
|
1972
|
+
const ticket = r.saved.snapshot.tickets.find(t => t.id === ticketId);
|
|
1973
|
+
const newStep = ticket && ticket.steps[ticket.steps.length - 1];
|
|
1974
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, step: newStep });
|
|
1975
|
+
});
|
|
1976
|
+
|
|
1977
|
+
server.registerTool("test_def_step_update", {
|
|
1978
|
+
description: "Update a step on a test-definition. patch = { step?, data?, expectedResult? } (subset).",
|
|
1979
|
+
inputSchema: {
|
|
1980
|
+
projectId: z.string(),
|
|
1981
|
+
ticketId: z.string(),
|
|
1982
|
+
stepId: z.string(),
|
|
1983
|
+
patch: passthroughObjectSchema
|
|
1984
|
+
}
|
|
1985
|
+
}, async ({ projectId, ticketId, stepId, patch }) => {
|
|
1986
|
+
const actor = AI_ACTOR;
|
|
1987
|
+
const r = await persist(storage, projectId, "test_def_step_update",
|
|
1988
|
+
(cur) => core.ops.updateTestStep(cur, ticketId, stepId, patch || {}, actor), actor);
|
|
1989
|
+
if (r.error) return r.error;
|
|
1990
|
+
const ticket = r.saved.snapshot.tickets.find(t => t.id === ticketId);
|
|
1991
|
+
const step = ticket && (ticket.steps || []).find(x => x.id === stepId);
|
|
1992
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, step: step });
|
|
1993
|
+
});
|
|
1994
|
+
|
|
1995
|
+
server.registerTool("test_def_step_remove", {
|
|
1996
|
+
description: "Remove a step from a test-definition.",
|
|
1997
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), stepId: z.string() }
|
|
1998
|
+
}, async ({ projectId, ticketId, stepId }) => {
|
|
1999
|
+
const actor = AI_ACTOR;
|
|
2000
|
+
const r = await persist(storage, projectId, "test_def_step_remove",
|
|
2001
|
+
(cur) => core.ops.removeTestStep(cur, ticketId, stepId, actor), actor);
|
|
2002
|
+
if (r.error) return r.error;
|
|
2003
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt });
|
|
2004
|
+
});
|
|
2005
|
+
|
|
2006
|
+
server.registerTool("test_def_step_reorder", {
|
|
2007
|
+
description: "Reorder steps on a test-definition by full ID list. orderedStepIds is the new sequence; missing steps are appended at the end (defensive). Pass tolerated JSON-string for Claude Code MCP bridge.",
|
|
2008
|
+
inputSchema: {
|
|
2009
|
+
projectId: z.string(),
|
|
2010
|
+
ticketId: z.string(),
|
|
2011
|
+
orderedStepIds: tolerateJsonString(z.array(z.string()))
|
|
2012
|
+
}
|
|
2013
|
+
}, async ({ projectId, ticketId, orderedStepIds }) => {
|
|
2014
|
+
const actor = AI_ACTOR;
|
|
2015
|
+
const r = await persist(storage, projectId, "test_def_step_reorder",
|
|
2016
|
+
(cur) => core.ops.reorderTestSteps(cur, ticketId, orderedStepIds, actor), actor);
|
|
2017
|
+
if (r.error) return r.error;
|
|
2018
|
+
const ticket = r.saved.snapshot.tickets.find(t => t.id === ticketId);
|
|
2019
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, steps: ticket && ticket.steps });
|
|
2020
|
+
});
|
|
2021
|
+
|
|
2022
|
+
server.registerTool("test_def_prereq_add", {
|
|
2023
|
+
description: "Append a prerequisite to a test-definition. patch = { label: string, required?: boolean }. Default required=true.",
|
|
2024
|
+
inputSchema: {
|
|
2025
|
+
projectId: z.string(),
|
|
2026
|
+
ticketId: z.string(),
|
|
2027
|
+
patch: passthroughObjectSchema
|
|
2028
|
+
}
|
|
2029
|
+
}, async ({ projectId, ticketId, patch }) => {
|
|
2030
|
+
const actor = AI_ACTOR;
|
|
2031
|
+
const r = await persist(storage, projectId, "test_def_prereq_add",
|
|
2032
|
+
(cur) => core.ops.addTestPrereq(cur, ticketId, patch || {}, actor), actor);
|
|
2033
|
+
if (r.error) return r.error;
|
|
2034
|
+
const ticket = r.saved.snapshot.tickets.find(t => t.id === ticketId);
|
|
2035
|
+
const newPre = ticket && ticket.prerequisites[ticket.prerequisites.length - 1];
|
|
2036
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, prerequisite: newPre });
|
|
2037
|
+
});
|
|
2038
|
+
|
|
2039
|
+
server.registerTool("test_def_prereq_update", {
|
|
2040
|
+
description: "Update a prerequisite. patch = { label?, required? }.",
|
|
2041
|
+
inputSchema: {
|
|
2042
|
+
projectId: z.string(),
|
|
2043
|
+
ticketId: z.string(),
|
|
2044
|
+
prereqId: z.string(),
|
|
2045
|
+
patch: passthroughObjectSchema
|
|
2046
|
+
}
|
|
2047
|
+
}, async ({ projectId, ticketId, prereqId, patch }) => {
|
|
2048
|
+
const actor = AI_ACTOR;
|
|
2049
|
+
const r = await persist(storage, projectId, "test_def_prereq_update",
|
|
2050
|
+
(cur) => core.ops.updateTestPrereq(cur, ticketId, prereqId, patch || {}, actor), actor);
|
|
2051
|
+
if (r.error) return r.error;
|
|
2052
|
+
const ticket = r.saved.snapshot.tickets.find(t => t.id === ticketId);
|
|
2053
|
+
const item = ticket && (ticket.prerequisites || []).find(x => x.id === prereqId);
|
|
2054
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, prerequisite: item });
|
|
2055
|
+
});
|
|
2056
|
+
|
|
2057
|
+
server.registerTool("test_def_prereq_remove", {
|
|
2058
|
+
description: "Remove a prerequisite from a test-definition.",
|
|
2059
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), prereqId: z.string() }
|
|
2060
|
+
}, async ({ projectId, ticketId, prereqId }) => {
|
|
2061
|
+
const actor = AI_ACTOR;
|
|
2062
|
+
const r = await persist(storage, projectId, "test_def_prereq_remove",
|
|
2063
|
+
(cur) => core.ops.removeTestPrereq(cur, ticketId, prereqId, actor), actor);
|
|
2064
|
+
if (r.error) return r.error;
|
|
2065
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt });
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
server.registerTool("test_def_prereq_check", {
|
|
2069
|
+
description: "Mark a prerequisite as checked.",
|
|
2070
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), prereqId: z.string() }
|
|
2071
|
+
}, async ({ projectId, ticketId, prereqId }) => {
|
|
2072
|
+
const actor = AI_ACTOR;
|
|
2073
|
+
const r = await persist(storage, projectId, "test_def_prereq_check",
|
|
2074
|
+
(cur) => core.ops.checkTestPrereq(cur, ticketId, prereqId, actor), actor);
|
|
2075
|
+
if (r.error) return r.error;
|
|
2076
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt });
|
|
2077
|
+
});
|
|
2078
|
+
|
|
2079
|
+
server.registerTool("test_def_prereq_uncheck", {
|
|
2080
|
+
description: "Mark a prerequisite as unchecked.",
|
|
2081
|
+
inputSchema: { projectId: z.string(), ticketId: z.string(), prereqId: z.string() }
|
|
2082
|
+
}, async ({ projectId, ticketId, prereqId }) => {
|
|
2083
|
+
const actor = AI_ACTOR;
|
|
2084
|
+
const r = await persist(storage, projectId, "test_def_prereq_uncheck",
|
|
2085
|
+
(cur) => core.ops.uncheckTestPrereq(cur, ticketId, prereqId, actor), actor);
|
|
2086
|
+
if (r.error) return r.error;
|
|
2087
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt });
|
|
2088
|
+
});
|
|
2089
|
+
|
|
2090
|
+
server.registerTool("test_exec_start", {
|
|
2091
|
+
description: "Start a test-execution run. Creates a new test-execution ticket, clones the definition's steps (snapshot semantics — later definition edits don't bleed back), opens an 'executes' link to the definition, sets runAt=now and runBy=actor (or opts.runBy). opts = { env?, runBy? }. Returns the new execution ticket. SM-94: definition must be in lifecycle='published' — fails with kind=DEFINITION_DRAFT otherwise.",
|
|
2092
|
+
inputSchema: {
|
|
2093
|
+
projectId: z.string(),
|
|
2094
|
+
definitionId: z.string(),
|
|
2095
|
+
opts: passthroughObjectSchema.optional()
|
|
2096
|
+
}
|
|
2097
|
+
}, async ({ projectId, definitionId, opts: startOpts }) => {
|
|
2098
|
+
const actor = AI_ACTOR;
|
|
2099
|
+
// SM-94: only published test-definitions can be executed. Drafts are not
|
|
2100
|
+
// runnable — promote them via publish_test_definition first.
|
|
2101
|
+
const snap0 = await storage.loadProject(projectId);
|
|
2102
|
+
if (!snap0) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
2103
|
+
const def = findEntity(snap0, "tickets", definitionId);
|
|
2104
|
+
if (!def) return fail("test-definition not found: " + definitionId, { statusCode: 404 });
|
|
2105
|
+
if (def.type !== "test-definition") {
|
|
2106
|
+
return fail("not a test-definition: " + definitionId, { statusCode: 422, kind: "WRONG_TYPE" });
|
|
2107
|
+
}
|
|
2108
|
+
if (def.lifecycle !== "published") {
|
|
2109
|
+
return fail("test-definition is in draft, not runnable", {
|
|
2110
|
+
kind: "DEFINITION_DRAFT",
|
|
2111
|
+
message: {
|
|
2112
|
+
title: "Test-definition is still a draft",
|
|
2113
|
+
reason: "Definition " + (def.ticketKey || definitionId) + " has lifecycle='"
|
|
2114
|
+
+ (def.lifecycle || "draft") + "'. Executions can only be spawned from PUBLISHED "
|
|
2115
|
+
+ "definitions so the spec is locked-in at run time.",
|
|
2116
|
+
suggestion: "Call publish_test_definition({projectId, ticketId:'" + definitionId
|
|
2117
|
+
+ "'}) first — that runs the publish-gates and flips the lifecycle to 'published'. "
|
|
2118
|
+
+ "Then retry test_exec_start.",
|
|
2119
|
+
skillRef: "Test-definitions live in draft while you author them. Publish locks them in for execution."
|
|
2120
|
+
},
|
|
2121
|
+
statusCode: 422
|
|
2122
|
+
});
|
|
2123
|
+
}
|
|
2124
|
+
const r = await persist(storage, projectId, "test_exec_start",
|
|
2125
|
+
(cur) => core.ops.startTestExecution(cur, definitionId, startOpts || {}, actor), actor);
|
|
2126
|
+
if (r.error) return r.error;
|
|
2127
|
+
const exec = r.saved.snapshot.tickets
|
|
2128
|
+
.filter(t => t.type === "test-execution" && t.referencedTestDefinitionId === definitionId)
|
|
2129
|
+
.sort((a, b) => (b.runAt || 0) - (a.runAt || 0))[0];
|
|
2130
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt, execution: exec });
|
|
2131
|
+
});
|
|
2132
|
+
|
|
2133
|
+
// -------------------- SM-94: test-definition lifecycle ----------------
|
|
2134
|
+
|
|
2135
|
+
server.registerTool("publish_test_definition", {
|
|
2136
|
+
description: "Promote a test-definition from lifecycle='draft' to 'published'. Runs the SM-93 governance publish-gates: ≥1 step, ≥1 outgoing 'tests'-link, every step has expectedResult, target ticket has reached 'ready'+. On gate failure returns isError with kind/errors/message{title,reason,suggestion,skillRef}.",
|
|
2137
|
+
inputSchema: { projectId: z.string(), ticketId: z.string() }
|
|
2138
|
+
}, async ({ projectId, ticketId }) => {
|
|
2139
|
+
const actor = AI_ACTOR;
|
|
2140
|
+
const snap0 = await storage.loadProject(projectId);
|
|
2141
|
+
if (!snap0) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
2142
|
+
const def = findEntity(snap0, "tickets", ticketId);
|
|
2143
|
+
if (!def) return fail("ticket not found: " + ticketId, { statusCode: 404 });
|
|
2144
|
+
if (def.type !== "test-definition") {
|
|
2145
|
+
return fail("not a test-definition: " + ticketId, { statusCode: 400 });
|
|
2146
|
+
}
|
|
2147
|
+
if (def.lifecycle === "published") {
|
|
2148
|
+
return ok({ revision: snap0.revision || null, savedAt: snap0.savedAt || null,
|
|
2149
|
+
ticket: def, alreadyPublished: true });
|
|
2150
|
+
}
|
|
2151
|
+
const gateFail = runGovernanceGates(snap0, "publish_test_definition", def, {
|
|
2152
|
+
definitionKey: def.ticketKey || ticketId
|
|
2153
|
+
});
|
|
2154
|
+
if (gateFail) return gateFail;
|
|
2155
|
+
const r = await persist(storage, projectId, "publish_test_definition", (cur) => {
|
|
2156
|
+
const t = findEntity(cur, "tickets", ticketId);
|
|
2157
|
+
if (!t) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
|
|
2158
|
+
return core.ops.updateTicket(cur, ticketId, { lifecycle: "published" }, actor);
|
|
2159
|
+
}, actor);
|
|
2160
|
+
if (r.error) return r.error;
|
|
2161
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
2162
|
+
ticket: findEntity(r.saved.snapshot, "tickets", ticketId) });
|
|
2163
|
+
});
|
|
2164
|
+
|
|
2165
|
+
server.registerTool("derive_test_definition", {
|
|
2166
|
+
description: "SM-301: derive a test-definition from a ticket's ACCEPTANCE CRITERIA — one step per AC (step = the criterion, expectedResult = its observable outcome), linked back via a `tests`-link. The acceptance criteria ARE the test plan: set them first, then call this immediately as the north-star plan (refine steps during implementation). Draft by default; pass publish:true to also publish (the target must be `ready`+, else the response comes back published:false with the TARGET_NOT_READY reason — promote the target, then publish_test_definition). Errors with kind=NO_ACCEPTANCE_CRITERIA (422) if the ticket has no AC. Returns { definition, stepCount, published }.",
|
|
2167
|
+
inputSchema: {
|
|
2168
|
+
projectId: z.string(),
|
|
2169
|
+
ticketId: z.string(),
|
|
2170
|
+
title: z.string().optional(),
|
|
2171
|
+
publish: z.boolean().optional(),
|
|
2172
|
+
verbose: z.boolean().optional()
|
|
2173
|
+
}
|
|
2174
|
+
}, async ({ projectId, ticketId, title, publish, verbose }) => {
|
|
2175
|
+
const actor = AI_ACTOR;
|
|
2176
|
+
let createdId = null;
|
|
2177
|
+
const r = await persist(storage, projectId, "derive_test_definition", (cur) => {
|
|
2178
|
+
const next = core.ops.deriveTestDefinition(cur, ticketId, { title: title }, actor);
|
|
2179
|
+
createdId = next.tickets[next.tickets.length - 1].id;
|
|
2180
|
+
return next;
|
|
2181
|
+
}, actor);
|
|
2182
|
+
if (r.error) return r.error;
|
|
2183
|
+
let def = findEntity(r.saved.snapshot, "tickets", createdId);
|
|
2184
|
+
let revision = r.saved.revision, savedAt = r.saved.savedAt;
|
|
2185
|
+
const stepCount = (def.steps || []).length;
|
|
2186
|
+
|
|
2187
|
+
if (publish) {
|
|
2188
|
+
const snap1 = await storage.loadProject(projectId);
|
|
2189
|
+
const def1 = findEntity(snap1, "tickets", createdId);
|
|
2190
|
+
const gateFail = runGovernanceGates(snap1, "publish_test_definition", def1, {
|
|
2191
|
+
definitionKey: def1.ticketKey || createdId
|
|
2192
|
+
});
|
|
2193
|
+
if (gateFail) {
|
|
2194
|
+
// derive succeeded (draft persisted) — report the block, don't lose it.
|
|
2195
|
+
const parsed = JSON.parse(gateFail.content[0].text);
|
|
2196
|
+
return ok({ revision: revision, savedAt: savedAt, stepCount: stepCount,
|
|
2197
|
+
definition: verbose ? def : ticketSummary(def),
|
|
2198
|
+
published: false, publishBlocked: { kind: parsed.kind, message: parsed.message } });
|
|
2199
|
+
}
|
|
2200
|
+
const pr = await persist(storage, projectId, "publish_test_definition", (cur) =>
|
|
2201
|
+
core.ops.updateTicket(cur, createdId, { lifecycle: "published" }, actor), actor);
|
|
2202
|
+
if (pr.error) return pr.error;
|
|
2203
|
+
def = findEntity(pr.saved.snapshot, "tickets", createdId);
|
|
2204
|
+
revision = pr.saved.revision; savedAt = pr.saved.savedAt;
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
return ok({ revision: revision, savedAt: savedAt, stepCount: stepCount,
|
|
2208
|
+
definition: verbose ? def : ticketSummary(def),
|
|
2209
|
+
published: def.lifecycle === "published" });
|
|
2210
|
+
});
|
|
2211
|
+
|
|
2212
|
+
server.registerTool("reopen_test_definition", {
|
|
2213
|
+
description: "Revert a test-definition from lifecycle='published' to 'draft' so its spec can be edited again. Blocks with kind=ACTIVE_EXECUTION if any associated test-execution is currently in 'in-progress' — finish or cancel the run first.",
|
|
2214
|
+
inputSchema: { projectId: z.string(), ticketId: z.string() }
|
|
2215
|
+
}, async ({ projectId, ticketId }) => {
|
|
2216
|
+
const actor = AI_ACTOR;
|
|
2217
|
+
const snap0 = await storage.loadProject(projectId);
|
|
2218
|
+
if (!snap0) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
2219
|
+
const def = findEntity(snap0, "tickets", ticketId);
|
|
2220
|
+
if (!def) return fail("ticket not found: " + ticketId, { statusCode: 404 });
|
|
2221
|
+
if (def.type !== "test-definition") {
|
|
2222
|
+
return fail("not a test-definition: " + ticketId, { statusCode: 400 });
|
|
2223
|
+
}
|
|
2224
|
+
if (def.lifecycle === "draft") {
|
|
2225
|
+
return ok({ revision: snap0.revision || null, savedAt: snap0.savedAt || null,
|
|
2226
|
+
ticket: def, alreadyDraft: true });
|
|
2227
|
+
}
|
|
2228
|
+
const activeExecs = (snap0.tickets || []).filter(t =>
|
|
2229
|
+
t.type === "test-execution" && !t.isDeleted
|
|
2230
|
+
&& t.referencedTestDefinitionId === ticketId
|
|
2231
|
+
&& t.status === "in-progress");
|
|
2232
|
+
if (activeExecs.length > 0) {
|
|
2233
|
+
return fail("cannot reopen — active execution(s) running", {
|
|
2234
|
+
kind: "ACTIVE_EXECUTION",
|
|
2235
|
+
message: {
|
|
2236
|
+
title: "Test-definition has active executions",
|
|
2237
|
+
reason: "Definition " + (def.ticketKey || ticketId) + " is referenced by " + activeExecs.length
|
|
2238
|
+
+ " in-progress test-execution(s): " + activeExecs.map(e => e.ticketKey).join(", ") + ".",
|
|
2239
|
+
suggestion: "Finish (record all steps) or cancel those executions first, then retry reopen_test_definition.",
|
|
2240
|
+
skillRef: "Reopening a published definition would diverge its spec from the running executions."
|
|
2241
|
+
},
|
|
2242
|
+
statusCode: 422
|
|
2243
|
+
});
|
|
2244
|
+
}
|
|
2245
|
+
const r = await persist(storage, projectId, "reopen_test_definition", (cur) => {
|
|
2246
|
+
return core.ops.updateTicket(cur, ticketId, { lifecycle: "draft" }, actor);
|
|
2247
|
+
}, actor);
|
|
2248
|
+
if (r.error) return r.error;
|
|
2249
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
2250
|
+
ticket: findEntity(r.saved.snapshot, "tickets", ticketId) });
|
|
2251
|
+
});
|
|
2252
|
+
|
|
2253
|
+
server.registerTool("update_test_definition_metadata", {
|
|
2254
|
+
description: "Edit cosmetic metadata on a test-definition (title, description, labels). Deliberately small surface — does NOT touch steps, prerequisites, or lifecycle. Safe to call on both draft and published definitions: the spec stays frozen, only the human-facing labelling changes.",
|
|
2255
|
+
inputSchema: {
|
|
2256
|
+
projectId: z.string(),
|
|
2257
|
+
ticketId: z.string(),
|
|
2258
|
+
patch: tolerateJsonString(z.object({}).passthrough())
|
|
2259
|
+
}
|
|
2260
|
+
}, async ({ projectId, ticketId, patch }) => {
|
|
2261
|
+
const actor = AI_ACTOR;
|
|
2262
|
+
const p = (patch && typeof patch === "object") ? patch : {};
|
|
2263
|
+
const allowed = ["title", "description", "labels"];
|
|
2264
|
+
const filtered = {};
|
|
2265
|
+
for (const k of allowed) {
|
|
2266
|
+
if (Object.prototype.hasOwnProperty.call(p, k)) filtered[k] = p[k];
|
|
2267
|
+
}
|
|
2268
|
+
if (Object.keys(filtered).length === 0) {
|
|
2269
|
+
return fail("patch must contain at least one of: title, description, labels", {
|
|
2270
|
+
statusCode: 400, kind: "EMPTY_PATCH"
|
|
2271
|
+
});
|
|
2272
|
+
}
|
|
2273
|
+
const r = await persist(storage, projectId, "update_test_definition_metadata", (cur) => {
|
|
2274
|
+
const existing = findEntity(cur, "tickets", ticketId);
|
|
2275
|
+
if (!existing) throw Object.assign(new Error("ticket not found"), { statusCode: 404 });
|
|
2276
|
+
if (existing.type !== "test-definition") {
|
|
2277
|
+
throw Object.assign(new Error("not a test-definition: " + ticketId), { statusCode: 400 });
|
|
2278
|
+
}
|
|
2279
|
+
return core.ops.updateTicket(cur, ticketId, filtered, actor);
|
|
2280
|
+
}, actor);
|
|
2281
|
+
if (r.error) return r.error;
|
|
2282
|
+
return ok({ revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
2283
|
+
ticket: findEntity(r.saved.snapshot, "tickets", ticketId) });
|
|
2284
|
+
});
|
|
2285
|
+
|
|
2286
|
+
// -------------------- SM-94: governance config ------------------------
|
|
2287
|
+
|
|
2288
|
+
// governance config (get/set) moved to get_config/set_config (SM-164).
|
|
2289
|
+
|
|
2290
|
+
server.registerTool("test_exec_record", {
|
|
2291
|
+
description: "Record per-step result on a test-execution. stepId may be either the definition-step id (clone source) or the execution-step's own id. patch = { actualResult?, status?, note? } where status ∈ pending|passed|failed|blocked|skipped.",
|
|
2292
|
+
inputSchema: {
|
|
2293
|
+
projectId: z.string(),
|
|
2294
|
+
executionId: z.string(),
|
|
2295
|
+
stepId: z.string(),
|
|
2296
|
+
patch: passthroughObjectSchema
|
|
2297
|
+
}
|
|
2298
|
+
}, async ({ projectId, executionId, stepId, patch }) => {
|
|
2299
|
+
const actor = AI_ACTOR;
|
|
2300
|
+
const r = await persist(storage, projectId, "test_exec_record",
|
|
2301
|
+
(cur) => core.ops.recordTestExecStep(cur, executionId, stepId, patch || {}, actor), actor);
|
|
2302
|
+
if (r.error) return r.error;
|
|
2303
|
+
const exec = r.saved.snapshot.tickets.find(t => t.id === executionId);
|
|
2304
|
+
const step = exec && (exec.executionSteps || [])
|
|
2305
|
+
.find(x => x.stepId === stepId || x.id === stepId);
|
|
2306
|
+
return ok({
|
|
2307
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
2308
|
+
step: step,
|
|
2309
|
+
effectiveOutcome: exec && core.getEffectiveOutcome(exec)
|
|
2310
|
+
});
|
|
2311
|
+
});
|
|
2312
|
+
|
|
2313
|
+
server.registerTool("test_exec_set_outcome", {
|
|
2314
|
+
description: "Set the manual outcome override on a test-execution. outcome ∈ pending|passed|failed|blocked|skipped or 'auto' to reset (let deriveOutcome take over). The 'auto' form clears outcomeOverride.",
|
|
2315
|
+
inputSchema: {
|
|
2316
|
+
projectId: z.string(),
|
|
2317
|
+
executionId: z.string(),
|
|
2318
|
+
outcome: z.string()
|
|
2319
|
+
}
|
|
2320
|
+
}, async ({ projectId, executionId, outcome }) => {
|
|
2321
|
+
const actor = AI_ACTOR;
|
|
2322
|
+
const r = await persist(storage, projectId, "test_exec_set_outcome",
|
|
2323
|
+
(cur) => core.ops.setTestExecOutcome(cur, executionId, outcome, actor), actor);
|
|
2324
|
+
if (r.error) return r.error;
|
|
2325
|
+
const exec = r.saved.snapshot.tickets.find(t => t.id === executionId);
|
|
2326
|
+
return ok({
|
|
2327
|
+
revision: r.saved.revision, savedAt: r.saved.savedAt,
|
|
2328
|
+
outcomeOverride: exec && exec.outcomeOverride,
|
|
2329
|
+
effectiveOutcome: exec && core.getEffectiveOutcome(exec)
|
|
2330
|
+
});
|
|
2331
|
+
});
|
|
2332
|
+
|
|
2333
|
+
server.registerTool("test_exec_history", {
|
|
2334
|
+
description: "List test-execution tickets for a given test-definition, sorted by runAt descending. Use the resulting executions[].id to recordTestExecStep / setTestExecOutcome.",
|
|
2335
|
+
inputSchema: {
|
|
2336
|
+
projectId: z.string(),
|
|
2337
|
+
definitionId: z.string(),
|
|
2338
|
+
limit: z.number().optional()
|
|
2339
|
+
}
|
|
2340
|
+
}, async ({ projectId, definitionId, limit }) => {
|
|
2341
|
+
const current = await storage.loadProject(projectId);
|
|
2342
|
+
if (!current) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
2343
|
+
const history = core.tickets.testExecHistory(current, definitionId,
|
|
2344
|
+
typeof limit === "number" ? { limit: limit } : undefined);
|
|
2345
|
+
// Compact form to keep the response small for large histories.
|
|
2346
|
+
const summary = history.map(t => ({
|
|
2347
|
+
id: t.id,
|
|
2348
|
+
ticketKey: t.ticketKey,
|
|
2349
|
+
title: t.title,
|
|
2350
|
+
status: t.status,
|
|
2351
|
+
runAt: t.runAt,
|
|
2352
|
+
env: t.env || null,
|
|
2353
|
+
effectiveOutcome: core.getEffectiveOutcome(t)
|
|
2354
|
+
}));
|
|
2355
|
+
return ok({ executions: summary });
|
|
2356
|
+
});
|
|
2357
|
+
|
|
2358
|
+
server.registerTool("restore_revision", {
|
|
2359
|
+
description: "Restore a project to a previous revision. The restore creates a NEW revision with op='project_restore', so the restore itself is undoable.",
|
|
2360
|
+
inputSchema: { projectId: z.string(), revision: z.string() }
|
|
2361
|
+
}, async ({ projectId, revision }) => {
|
|
2362
|
+
const actor = AI_ACTOR;
|
|
2363
|
+
try {
|
|
2364
|
+
const result = await storage.restoreRevision(projectId, revision, { actor });
|
|
2365
|
+
return ok({ revision: result.revision, savedAt: result.savedAt, snapshot: result.snapshot });
|
|
2366
|
+
} catch (err) {
|
|
2367
|
+
if (/not found/i.test(err.message || "")) {
|
|
2368
|
+
return fail(err.message, { statusCode: 404 });
|
|
2369
|
+
}
|
|
2370
|
+
return fail(err.message || "restore failed", { statusCode: err.statusCode || 500 });
|
|
2371
|
+
}
|
|
2372
|
+
});
|
|
2373
|
+
|
|
2374
|
+
// ---- SM-290/291: Datenaustausch — bulk ticket import + project export ----
|
|
2375
|
+
|
|
2376
|
+
server.registerTool("import_tickets", {
|
|
2377
|
+
description: "Bulk-import tickets from CSV (header row = field keys: key, type, title, description, "
|
|
2378
|
+
+ "status, release, processStep, epic, labels — the export_tickets/SM-285 format) or `rows` (array "
|
|
2379
|
+
+ "of ticket objects, same fields). Replaces N × ticket_create for mass creates. mode 'create-only' "
|
|
2380
|
+
+ "(default): rows with an EXISTING ticket key error; 'upsert': existing keys are PATCHED with only "
|
|
2381
|
+
+ "the present, actually-changed fields (empty cells never clear). Refs resolve by name OR id; epic "
|
|
2382
|
+
+ "by ticketKey. ALWAYS run dryRun:true first and show the human the plan ({creates, updates, "
|
|
2383
|
+
+ "errors} with line numbers) before applying. Apply writes everything in ONE revision; error rows "
|
|
2384
|
+
+ "are never written. When both csv and rows are given, rows wins. POLICY: import is a migration "
|
|
2385
|
+
+ "surface — statuses apply WITHOUT DoR/DoD gates.",
|
|
2386
|
+
inputSchema: {
|
|
2387
|
+
projectId: z.string(),
|
|
2388
|
+
csv: z.string().optional(),
|
|
2389
|
+
rows: tolerateJsonString(z.array(z.object({}).passthrough())).optional(),
|
|
2390
|
+
mode: z.enum(["create-only", "upsert"]).optional(),
|
|
2391
|
+
dryRun: z.boolean().optional(),
|
|
2392
|
+
// SM-296: {csvHeader → field | null=ignore} — per-column override of the
|
|
2393
|
+
// built-in header resolution (foreign exports: Jira, Excel, …).
|
|
2394
|
+
headerMapping: tolerateJsonString(z.record(z.string().nullable())).optional(),
|
|
2395
|
+
// SM-297: {field: {sourceValue → target | null=drop}} for status/type/
|
|
2396
|
+
// release/processStep — the dryRun plan lists unknownValues to map.
|
|
2397
|
+
valueMapping: tolerateJsonString(z.record(z.record(z.string().nullable()))).optional()
|
|
2398
|
+
}
|
|
2399
|
+
}, async (args) => {
|
|
2400
|
+
const actor = actorFromArgs(args);
|
|
2401
|
+
const mode = args.mode || "create-only";
|
|
2402
|
+
if (!args.csv && !args.rows) return fail("either csv or rows is required", { statusCode: 400 });
|
|
2403
|
+
// Belt+braces vs. the bridge footgun: tolerateJsonString covers the zod
|
|
2404
|
+
// path, but in-process callers (tests, embedding) hit the handler raw —
|
|
2405
|
+
// a string `rows` is treated as its JSON text directly (same for
|
|
2406
|
+
// headerMapping).
|
|
2407
|
+
const rowsText = args.rows == null ? null
|
|
2408
|
+
: (typeof args.rows === "string" ? args.rows : JSON.stringify(args.rows));
|
|
2409
|
+
let headerMapping = args.headerMapping || null;
|
|
2410
|
+
if (typeof headerMapping === "string") {
|
|
2411
|
+
// Loud, not silent: a typo'd mapping string must not degrade to
|
|
2412
|
+
// heuristic-only (review finding on aac77aa).
|
|
2413
|
+
try { headerMapping = JSON.parse(headerMapping); }
|
|
2414
|
+
catch (_e) { return fail("headerMapping is not valid JSON", { statusCode: 400 }); }
|
|
2415
|
+
}
|
|
2416
|
+
let valueMapping = args.valueMapping || null;
|
|
2417
|
+
if (typeof valueMapping === "string") {
|
|
2418
|
+
try { valueMapping = JSON.parse(valueMapping); }
|
|
2419
|
+
catch (_e) { return fail("valueMapping is not valid JSON", { statusCode: 400 }); }
|
|
2420
|
+
}
|
|
2421
|
+
const planOpts = { valueMapping: valueMapping };
|
|
2422
|
+
const parsed = rowsText != null
|
|
2423
|
+
? ticketImport.parseTicketImport(rowsText, "json")
|
|
2424
|
+
: ticketImport.parseTicketImport(args.csv, "csv", { headerMapping: headerMapping });
|
|
2425
|
+
if (parsed.error) return fail(parsed.error, { kind: "IMPORT_PARSE", statusCode: 400 });
|
|
2426
|
+
|
|
2427
|
+
const planSummary = (plan) => ({
|
|
2428
|
+
creates: plan.creates.map(c => ({ line: c.line, title: c.ticket.title, type: c.ticket.type })),
|
|
2429
|
+
updates: plan.updates.map(u => ({ line: u.line, ticketKey: u.ticketKey, fields: Object.keys(u.patch) })),
|
|
2430
|
+
errors: plan.errors,
|
|
2431
|
+
// SM-297: what an agent still needs to map (distinct source values)
|
|
2432
|
+
unknownValues: plan.unknownValues
|
|
2433
|
+
});
|
|
2434
|
+
|
|
2435
|
+
if (args.dryRun) {
|
|
2436
|
+
const snap = await storage.loadProject(args.projectId);
|
|
2437
|
+
if (!snap) return fail("project not found: " + args.projectId, { statusCode: 404 });
|
|
2438
|
+
const plan = ticketImport.planTicketImport(snap, parsed.rows, mode, planOpts);
|
|
2439
|
+
return ok({ dryRun: true, mode: mode, plan: planSummary(plan) });
|
|
2440
|
+
}
|
|
2441
|
+
|
|
2442
|
+
// Plan + apply INSIDE the storage mutex (SM-154 pattern) so a concurrent
|
|
2443
|
+
// writer can't invalidate the plan between planning and persisting.
|
|
2444
|
+
// storage.mutate directly (not persist()) so the no-write sentinel is
|
|
2445
|
+
// identified by its FLAG — a counts-based inference could mask a real
|
|
2446
|
+
// mid-apply error as "nothing to write" (review finding on a14c8ae).
|
|
2447
|
+
let plan = null, counts = null;
|
|
2448
|
+
let saved;
|
|
2449
|
+
try {
|
|
2450
|
+
saved = await storage.mutate(args.projectId, { actor, op: "import_tickets" }, (cur) => {
|
|
2451
|
+
plan = ticketImport.planTicketImport(cur, parsed.rows, mode, planOpts);
|
|
2452
|
+
const applied = ticketImport.applyImportPlan(cur, plan, actor);
|
|
2453
|
+
counts = { created: applied.created, updated: applied.updated };
|
|
2454
|
+
if (applied.created + applied.updated === 0) {
|
|
2455
|
+
const e = new Error("__import_no_write__");
|
|
2456
|
+
e.__noWrite = true;
|
|
2457
|
+
throw e; // abort the mutate — no empty revision
|
|
2458
|
+
}
|
|
2459
|
+
validation.snapshotLimits(applied.snapshot);
|
|
2460
|
+
return applied.snapshot;
|
|
2461
|
+
});
|
|
2462
|
+
} catch (err) {
|
|
2463
|
+
if (err && err.__noWrite) {
|
|
2464
|
+
return ok({ applied: counts, mode: mode, plan: planSummary(plan),
|
|
2465
|
+
note: "nothing to write (errors only or all no-op) — no revision created" });
|
|
2466
|
+
}
|
|
2467
|
+
return fail(err.message || "import failed",
|
|
2468
|
+
{ statusCode: err.statusCode || 500, kind: err.kind, missing: err.missing });
|
|
2469
|
+
}
|
|
2470
|
+
return ok({
|
|
2471
|
+
revision: saved.revision, savedAt: saved.savedAt,
|
|
2472
|
+
applied: counts, mode: mode,
|
|
2473
|
+
errors: plan.errors,
|
|
2474
|
+
// SM-297: what an agent still needs to map (distinct source values)
|
|
2475
|
+
unknownValues: plan.unknownValues
|
|
2476
|
+
});
|
|
2477
|
+
});
|
|
2478
|
+
|
|
2479
|
+
server.registerTool("export_project", {
|
|
2480
|
+
description: "Export a whole project as the re-importable storymap-project JSON envelope "
|
|
2481
|
+
+ "({format, version, exportedAt, snapshot}) — the SAME format as the browser's Export… → "
|
|
2482
|
+
+ "Projekt (JSON). Use for post-processing, backups and transfers (the envelope re-imports "
|
|
2483
|
+
+ "via the browser Import… dialog). Prefer project_get when you only need to READ data.",
|
|
2484
|
+
inputSchema: { projectId: z.string() }
|
|
2485
|
+
}, async ({ projectId }) => {
|
|
2486
|
+
const snap = await storage.loadProject(projectId);
|
|
2487
|
+
if (!snap) return fail("project not found: " + projectId, { statusCode: 404 });
|
|
2488
|
+
const envelope = projectIO.serializeProject(snap, { exportedAt: new Date().toISOString() });
|
|
2489
|
+
return ok({
|
|
2490
|
+
filename: projectIO.exportFilename(snap),
|
|
2491
|
+
envelope: JSON.parse(envelope) // structured, not double-encoded
|
|
2492
|
+
});
|
|
2493
|
+
});
|
|
2494
|
+
|
|
2495
|
+
return server;
|
|
2496
|
+
}
|
|
2497
|
+
|
|
2498
|
+
async function runStdio(storage, opts) {
|
|
2499
|
+
opts = opts || {};
|
|
2500
|
+
const httpUrl = (typeof opts.httpUrl === "string")
|
|
2501
|
+
? opts.httpUrl
|
|
2502
|
+
: (process.env.STORYMAP_HTTP_URL || "http://localhost:8770");
|
|
2503
|
+
const server = buildServer(storage, { httpUrl, fetchImpl: opts.fetchImpl });
|
|
2504
|
+
// SM-309/310: the native stdio frontend replaces StdioServerTransport +
|
|
2505
|
+
// server.connect(). Returns { close } — index.js drives transport.close().
|
|
2506
|
+
const transport = createStdioServer(server, { input: process.stdin, output: process.stdout });
|
|
2507
|
+
return { server, transport };
|
|
2508
|
+
}
|
|
2509
|
+
|
|
2510
|
+
module.exports = { buildServer, runStdio };
|