zelari-code 0.7.5 → 0.7.9

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.
Files changed (44) hide show
  1. package/README.md +27 -1
  2. package/dist/cli/app.js +50 -18
  3. package/dist/cli/app.js.map +1 -1
  4. package/dist/cli/components/Sidebar.js +86 -39
  5. package/dist/cli/components/Sidebar.js.map +1 -1
  6. package/dist/cli/components/SplashScreen.js +204 -0
  7. package/dist/cli/components/SplashScreen.js.map +1 -0
  8. package/dist/cli/components/StatusBar.js +22 -18
  9. package/dist/cli/components/StatusBar.js.map +1 -1
  10. package/dist/cli/councilConfig.js +38 -0
  11. package/dist/cli/councilConfig.js.map +1 -0
  12. package/dist/cli/councilDispatcher.js +38 -32
  13. package/dist/cli/councilDispatcher.js.map +1 -1
  14. package/dist/cli/hooks/useChatTurn.js +80 -61
  15. package/dist/cli/hooks/useChatTurn.js.map +1 -1
  16. package/dist/cli/hooks/useExecutionTimer.js +25 -0
  17. package/dist/cli/hooks/useExecutionTimer.js.map +1 -0
  18. package/dist/cli/hooks/useGitChanges.js +142 -0
  19. package/dist/cli/hooks/useGitChanges.js.map +1 -0
  20. package/dist/cli/hooks/useSlashDispatch.js +9 -2
  21. package/dist/cli/hooks/useSlashDispatch.js.map +1 -1
  22. package/dist/cli/main.bundled.js +2560 -1231
  23. package/dist/cli/main.bundled.js.map +4 -4
  24. package/dist/cli/main.js +9 -2
  25. package/dist/cli/main.js.map +1 -1
  26. package/dist/cli/mcp/mcpClient.js +14 -6
  27. package/dist/cli/mcp/mcpClient.js.map +1 -1
  28. package/dist/cli/updater.js +8 -5
  29. package/dist/cli/updater.js.map +1 -1
  30. package/dist/cli/utils/cmdline.js +26 -0
  31. package/dist/cli/utils/cmdline.js.map +1 -0
  32. package/dist/cli/utils/paths.js +20 -0
  33. package/dist/cli/utils/paths.js.map +1 -0
  34. package/dist/cli/wizard/index.js +1 -1
  35. package/dist/cli/wizard/index.js.map +1 -1
  36. package/dist/cli/workspace/completeDesign.js +128 -0
  37. package/dist/cli/workspace/completeDesign.js.map +1 -0
  38. package/dist/cli/workspace/planDetect.js +17 -0
  39. package/dist/cli/workspace/planDetect.js.map +1 -0
  40. package/dist/cli/workspace/postCouncilHook.js +157 -20
  41. package/dist/cli/workspace/postCouncilHook.js.map +1 -1
  42. package/dist/cli/workspace/stubs.js +406 -225
  43. package/dist/cli/workspace/stubs.js.map +1 -1
  44. package/package.json +71 -71
@@ -1,21 +1,22 @@
1
1
  /**
2
2
  * workspace/stubs.ts — CLI workspace tool stubs.
3
3
  *
4
- * Implements the 9 council workspace tools (`createPhase`, `createTask`,
5
- * `updateTask`, `addIdea`, `createMilestone`, `createDocument`,
6
- * `searchDocuments`, `linkDocuments`, `getDocumentBacklinks`) as
7
- * filesystem-backed stubs that persist to `.zelari/` at the project root.
4
+ * Implements the 10 council workspace tools (`createPlan`, `createPhase`,
5
+ * `createTask`, `updateTask`, `addIdea`, `createMilestone`,
6
+ * `createDocument`, `searchDocuments`, `linkDocuments`,
7
+ * `getDocumentBacklinks`) as filesystem-backed stubs that persist to
8
+ * `.zelari/` at the project root.
8
9
  *
9
10
  * Replaces the AnathemaBrain Electron-only ctx (e.g. `ctx.addIdea(...)`)
10
11
  * with a `WorkspaceContext` that writes Markdown + YAML frontmatter files.
11
12
  *
12
13
  * @see docs/plans/2026-07-01-council-workspace-cli-stubs.md
13
14
  */
14
- import { existsSync, readdirSync, writeFileSync, readFileSync, mkdirSync, renameSync } from 'node:fs';
15
- import { join, basename, dirname, relative } from 'node:path';
16
- import { workspaceMutex } from './storage.js';
17
- import { resolveWorkspaceRoot, workspaceFile, workspaceArtifact, projectName as deriveProjectName, } from './paths.js';
18
- import { Storage } from './storage.js';
15
+ import { existsSync, readdirSync, writeFileSync, readFileSync, mkdirSync, renameSync, } from "node:fs";
16
+ import { join, basename, dirname, relative } from "node:path";
17
+ import { workspaceMutex } from "./storage.js";
18
+ import { resolveWorkspaceRoot, workspaceFile, workspaceArtifact, projectName as deriveProjectName, } from "./paths.js";
19
+ import { Storage } from "./storage.js";
19
20
  // ── Context factory ───────────────────────────────────────────────────
20
21
  /**
21
22
  * Build a WorkspaceContext rooted at the project's `.zelari/` directory.
@@ -31,7 +32,7 @@ export function createWorkspaceContext(projectRoot = process.cwd()) {
31
32
  }
32
33
  /** Path of the machine-readable plan store (source of truth since v0.7.3). */
33
34
  function planJsonPath(ctx) {
34
- return join(ctx.rootDir, 'plan.json');
35
+ return join(ctx.rootDir, "plan.json");
35
36
  }
36
37
  function readPlan(ctx) {
37
38
  // v0.7.3: the plan's source of truth is plan.json. The previous
@@ -44,7 +45,7 @@ function readPlan(ctx) {
44
45
  const jsonPath = planJsonPath(ctx);
45
46
  if (existsSync(jsonPath)) {
46
47
  try {
47
- const parsed = JSON.parse(readFileSync(jsonPath, 'utf8'));
48
+ const parsed = JSON.parse(readFileSync(jsonPath, "utf8"));
48
49
  return {
49
50
  phases: Array.isArray(parsed.phases) ? parsed.phases : [],
50
51
  tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
@@ -57,7 +58,7 @@ function readPlan(ctx) {
57
58
  }
58
59
  // Legacy migration: one best-effort parse of the old plan.md frontmatter
59
60
  // (pre-v0.7.3 files). The next writePlan persists it as plan.json.
60
- const path = workspaceFile(ctx.rootDir, 'plan');
61
+ const path = workspaceFile(ctx.rootDir, "plan");
61
62
  const doc = ctx.storage.readIfExists(path);
62
63
  if (!doc)
63
64
  return { phases: [], tasks: [], milestones: [] };
@@ -77,13 +78,13 @@ function readPlan(ctx) {
77
78
  function writePlan(ctx, summary) {
78
79
  const jsonPath = planJsonPath(ctx);
79
80
  mkdirSync(dirname(jsonPath), { recursive: true });
80
- const tmp = jsonPath + '.tmp-' + process.pid;
81
- writeFileSync(tmp, JSON.stringify(summary, null, 2), 'utf8');
81
+ const tmp = jsonPath + ".tmp-" + process.pid;
82
+ writeFileSync(tmp, JSON.stringify(summary, null, 2), "utf8");
82
83
  renameSync(tmp, jsonPath);
83
- const mdPath = workspaceFile(ctx.rootDir, 'plan');
84
+ const mdPath = workspaceFile(ctx.rootDir, "plan");
84
85
  const summaryMeta = {
85
- kind: 'plan-summary',
86
- id: '_plan',
86
+ kind: "plan-summary",
87
+ id: "_plan",
87
88
  updatedAt: new Date().toISOString(),
88
89
  };
89
90
  ctx.storage.write(mdPath, summaryMeta, renderPlanBody(summary));
@@ -92,77 +93,195 @@ function writePlan(ctx, summary) {
92
93
  function renderPlanBody(summary) {
93
94
  const lines = [];
94
95
  lines.push(`# Execution Plan`);
95
- lines.push('');
96
+ lines.push("");
96
97
  lines.push(`> Auto-generated by Zelari Code council. Do not edit by hand.`);
97
- lines.push('');
98
+ lines.push("");
98
99
  // Phases
99
100
  lines.push(`## Phases`);
100
- lines.push('');
101
+ lines.push("");
101
102
  if (summary.phases.length === 0) {
102
103
  lines.push(`_(none yet)_`);
103
104
  }
104
105
  else {
105
106
  for (const phase of [...summary.phases].sort((a, b) => (a.order ?? 0) - (b.order ?? 0))) {
106
- lines.push(`### ${phase.order ?? '?'}. ${phase.id} \`${phase.color ?? ''}\``);
107
- lines.push('');
107
+ lines.push(`### ${phase.order ?? "?"}. ${phase.id} \`${phase.color ?? ""}\``);
108
+ lines.push("");
108
109
  }
109
110
  }
110
- lines.push('');
111
+ lines.push("");
111
112
  // Tasks grouped by phase
112
113
  lines.push(`## Tasks`);
113
- lines.push('');
114
+ lines.push("");
114
115
  if (summary.tasks.length === 0) {
115
116
  lines.push(`_(none yet)_`);
116
117
  }
117
118
  else {
118
119
  const byPhase = new Map();
119
120
  for (const task of summary.tasks) {
120
- const pid = task.phaseId ?? '_unassigned';
121
+ const pid = task.phaseId ?? "_unassigned";
121
122
  if (!byPhase.has(pid))
122
123
  byPhase.set(pid, []);
123
124
  byPhase.get(pid).push(task);
124
125
  }
125
126
  for (const [phaseId, tasks] of byPhase) {
126
127
  lines.push(`### Phase: \`${phaseId}\``);
127
- lines.push('');
128
+ lines.push("");
128
129
  for (const t of tasks) {
129
- const status = t.status ?? 'pending';
130
- const pri = t.priority ?? 'medium';
130
+ const status = t.status ?? "pending";
131
+ const pri = t.priority ?? "medium";
131
132
  lines.push(`- [ ] **${t.id}** _(${status}, ${pri})_`);
132
133
  }
133
- lines.push('');
134
+ lines.push("");
134
135
  }
135
136
  }
136
- lines.push('');
137
+ lines.push("");
137
138
  // Milestones
138
139
  lines.push(`## Milestones`);
139
- lines.push('');
140
+ lines.push("");
140
141
  if (summary.milestones.length === 0) {
141
142
  lines.push(`_(none yet)_`);
142
143
  }
143
144
  else {
144
145
  for (const m of summary.milestones) {
145
- lines.push(`- **${m.id}** → ${m.targetVersion ?? '?'}`);
146
+ lines.push(`- **${m.id}** → ${m.targetVersion ?? "?"}`);
146
147
  }
147
148
  }
148
- lines.push('');
149
- return lines.join('\n');
149
+ lines.push("");
150
+ return lines.join("\n");
150
151
  }
151
152
  // ── ADR (decisions/) helpers ──────────────────────────────────────────
152
153
  function nextAdrId(ctx) {
153
- const decisionsDir = join(ctx.rootDir, 'decisions');
154
+ const decisionsDir = join(ctx.rootDir, "decisions");
154
155
  if (!existsSync(decisionsDir))
155
- return '001';
156
+ return "001";
156
157
  const existing = readdirSync(decisionsDir)
157
- .filter((f) => f.endsWith('.md'))
158
+ .filter((f) => f.endsWith(".md"))
158
159
  .map((f) => f.match(/^(\d+)-/))
159
160
  .filter((m) => !!m)
160
161
  .map((m) => parseInt(m[1], 10));
161
162
  const max = existing.length === 0 ? 0 : Math.max(...existing);
162
- return String(max + 1).padStart(3, '0');
163
+ return String(max + 1).padStart(3, "0");
163
164
  }
164
165
  function slugify(s) {
165
- return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
166
+ return s
167
+ .toLowerCase()
168
+ .replace(/[^a-z0-9]+/g, "-")
169
+ .replace(/^-|-$/g, "")
170
+ .slice(0, 60);
171
+ }
172
+ /** Add a phase record. Returns the phase id and whether it was newly created. */
173
+ function addPhaseRecord(summary, input) {
174
+ const id = slugify(input.name) || `phase-${input.order}`;
175
+ if (summary.phases.some((p) => p.id === id)) {
176
+ return { id, created: false };
177
+ }
178
+ summary.phases.push({
179
+ kind: "phase",
180
+ id,
181
+ name: input.name,
182
+ description: input.description,
183
+ order: input.order,
184
+ color: input.color,
185
+ });
186
+ return { id, created: true };
187
+ }
188
+ /**
189
+ * Add a task record + its per-task file. With `dedupe: true` a task whose
190
+ * title already exists in the same phase is treated as the same task and
191
+ * NOT re-added (protects the createPlan batch path from duplicating tasks
192
+ * that a partial itemized run already persisted). The itemized createTask
193
+ * stub keeps `dedupe: false` for backward compatibility.
194
+ */
195
+ function addTaskRecord(ctx, summary, phaseId, t, options) {
196
+ const slug = slugify(t.title);
197
+ if (options.dedupe) {
198
+ const existing = summary.tasks.find((k) => k.phaseId === phaseId &&
199
+ k.id.replace(/-\d+$/, "") === `${phaseId}-${slug}`);
200
+ if (existing)
201
+ return { id: existing.id, created: false };
202
+ }
203
+ const id = `${phaseId}-${slug}-${summary.tasks.filter((k) => k.phaseId === phaseId).length + 1}`;
204
+ summary.tasks.push({
205
+ kind: "task",
206
+ id,
207
+ // v0.7.3: keep the human-readable title in plan.json so
208
+ // buildPlanSummary can render it in the system prompt.
209
+ name: t.title,
210
+ phaseId,
211
+ status: "pending",
212
+ priority: t.priority,
213
+ });
214
+ const taskPath = join(ctx.rootDir, "plan-tasks", `${id}.md`);
215
+ const meta = {
216
+ kind: "task",
217
+ id,
218
+ phaseId,
219
+ status: "pending",
220
+ priority: t.priority,
221
+ tags: t.fileRefs,
222
+ };
223
+ const body = [
224
+ `# ${t.title}`,
225
+ "",
226
+ t.description,
227
+ "",
228
+ `## File references`,
229
+ ...t.fileRefs.map((f) => `- \`${f}\``),
230
+ "",
231
+ `## Acceptance criteria`,
232
+ ...t.acceptance.map((a) => `- ${a}`),
233
+ "",
234
+ `## QA scenario`,
235
+ "",
236
+ t.qaScenario || "_(not specified)_",
237
+ "",
238
+ ].join("\n");
239
+ ctx.storage.write(taskPath, meta, body);
240
+ return { id, created: true };
241
+ }
242
+ /** Add a milestone record + its file. Returns id and whether newly created. */
243
+ function addMilestoneRecord(ctx, summary, input) {
244
+ const id = `m-${slugify(input.title)}`;
245
+ if (summary.milestones.some((m) => m.id === id)) {
246
+ return { id, created: false };
247
+ }
248
+ const version = input.targetVersion ?? input.dueDate ?? "TBD";
249
+ summary.milestones.push({
250
+ kind: "milestone",
251
+ id,
252
+ name: input.title,
253
+ description: input.description,
254
+ dueDate: input.dueDate,
255
+ targetVersion: version,
256
+ });
257
+ const path = join(ctx.rootDir, "milestones", `${id}.md`);
258
+ const meta = {
259
+ kind: "milestone",
260
+ id,
261
+ name: input.title,
262
+ description: input.description,
263
+ dueDate: input.dueDate,
264
+ targetVersion: version,
265
+ };
266
+ const body = [
267
+ `# Milestone: ${input.title}`,
268
+ "",
269
+ input.description,
270
+ "",
271
+ `Target version: ${version}`,
272
+ "",
273
+ ].join("\n");
274
+ ctx.storage.write(path, meta, body);
275
+ return { id, created: true };
276
+ }
277
+ /**
278
+ * Read-only view of the current plan (phases + tasks + milestones).
279
+ * Exported for the built-in complete-design fallback (completeDesign.ts)
280
+ * so it can inspect per-phase task coverage without duplicating the
281
+ * plan.json / legacy plan.md read logic.
282
+ */
283
+ export function readPlanSummary(ctx) {
284
+ return readPlan(ctx);
166
285
  }
167
286
  // ── Stub factory ─────────────────────────────────────────────────────
168
287
  /**
@@ -173,6 +292,7 @@ function slugify(s) {
173
292
  */
174
293
  export function createWorkspaceStubs(ctx) {
175
294
  return [
295
+ createPlanStub(ctx),
176
296
  createPhaseStub(ctx),
177
297
  createTaskStub(ctx),
178
298
  updateTaskStub(ctx),
@@ -185,32 +305,106 @@ export function createWorkspaceStubs(ctx) {
185
305
  ];
186
306
  }
187
307
  // ── Individual stubs ──────────────────────────────────────────────────
188
- function createPhaseStub(ctx) {
308
+ /**
309
+ * v0.7.8 — Batch plan creation. One tool call persists the WHOLE plan:
310
+ * every phase, the tasks nested under each phase, and the milestone.
311
+ *
312
+ * Rationale: the itemized contract (4×createPhase + 12×createTask +
313
+ * 1×createMilestone = 17 sequential tool calls) exceeds what mid-tier
314
+ * council models reliably emit in one turn (composer-2.5 persisted ≤7 of
315
+ * 17 in the 2026-07-03 live tests, see HANDOFF.md). A single structured
316
+ * call collapses the whole contract into one emission, which the same
317
+ * models satisfy reliably (proven by the 1-call retry paths of Minosse
318
+ * and Lucifero).
319
+ */
320
+ function createPlanStub(ctx) {
189
321
  return {
190
- name: 'createPhase',
191
- description: 'Create a new execution phase in the project plan. Each phase is a milestone toward shipping a feature.',
192
- category: 'project',
322
+ name: "createPlan",
323
+ description: "Create the FULL project plan in one call: all phases, the tasks nested under each phase, and the target milestone. " +
324
+ "Preferred over itemized createPhase/createTask/createMilestone calls — one createPlan call persists everything atomically.",
325
+ category: "project",
193
326
  parameters: [],
194
327
  execute: async (args) => {
195
328
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
196
- const name = args['name'] ?? 'New Phase';
197
- const description = args['description'] ?? '';
198
- const order = args['order'] ?? 0;
199
- const color = args['color'] ?? '#3b82f6';
200
- const id = slugify(name) || `phase-${order}`;
329
+ const rawPhases = Array.isArray(args["phases"])
330
+ ? args["phases"]
331
+ : [];
332
+ if (rawPhases.length === 0) {
333
+ return 'createPlan requires a non-empty "phases" array (each phase: { name, description, order, color, tasks: [{ title, description, fileRefs, acceptance, qaScenario, priority }] }).';
334
+ }
201
335
  const summary = readPlan(ctx);
202
- // Check uniqueness
203
- if (summary.phases.some((p) => p.id === id)) {
204
- return `Phase "${id}" already exists.`;
336
+ let phasesCreated = 0;
337
+ let tasksCreated = 0;
338
+ const phaseIds = [];
339
+ rawPhases.forEach((rawPhase, idx) => {
340
+ const { id: phaseId, created } = addPhaseRecord(summary, {
341
+ name: rawPhase["name"] ?? `Phase ${idx + 1}`,
342
+ description: rawPhase["description"] ?? "",
343
+ order: rawPhase["order"] ?? idx + 1,
344
+ color: rawPhase["color"] ?? "#3b82f6",
345
+ });
346
+ if (created)
347
+ phasesCreated += 1;
348
+ phaseIds.push(phaseId);
349
+ const rawTasks = Array.isArray(rawPhase["tasks"])
350
+ ? rawPhase["tasks"]
351
+ : [];
352
+ for (const rawTask of rawTasks) {
353
+ const { created: taskCreated } = addTaskRecord(ctx, summary, phaseId, {
354
+ title: rawTask["title"] ?? "New Task",
355
+ description: rawTask["description"] ?? "",
356
+ fileRefs: rawTask["fileRefs"] ??
357
+ rawTask["files"] ??
358
+ [],
359
+ acceptance: rawTask["acceptance"] ?? [],
360
+ qaScenario: rawTask["qaScenario"] ??
361
+ rawTask["qa"] ??
362
+ "",
363
+ priority: (rawTask["priority"] ??
364
+ "medium"),
365
+ }, { dedupe: true });
366
+ if (taskCreated)
367
+ tasksCreated += 1;
368
+ }
369
+ });
370
+ let milestonesCreated = 0;
371
+ const rawMilestone = args["milestone"];
372
+ if (rawMilestone && typeof rawMilestone === "object") {
373
+ const { created } = addMilestoneRecord(ctx, summary, {
374
+ title: rawMilestone["title"] ?? "v0.1.0 design-complete",
375
+ description: rawMilestone["description"] ?? "",
376
+ dueDate: rawMilestone["dueDate"],
377
+ targetVersion: rawMilestone["targetVersion"],
378
+ });
379
+ if (created)
380
+ milestonesCreated += 1;
205
381
  }
206
- summary.phases.push({
207
- kind: 'phase',
208
- id,
382
+ writePlan(ctx, summary);
383
+ return `Plan created: ${phasesCreated} phases, ${tasksCreated} tasks, ${milestonesCreated} milestone(s). Phase ids: ${phaseIds.join(", ")}.`;
384
+ });
385
+ },
386
+ };
387
+ }
388
+ function createPhaseStub(ctx) {
389
+ return {
390
+ name: "createPhase",
391
+ description: "Create a new execution phase in the project plan. Each phase is a milestone toward shipping a feature.",
392
+ category: "project",
393
+ parameters: [],
394
+ execute: async (args) => {
395
+ return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
396
+ const name = args["name"] ?? "New Phase";
397
+ const order = args["order"] ?? 0;
398
+ const summary = readPlan(ctx);
399
+ const { id, created } = addPhaseRecord(summary, {
209
400
  name,
210
- description,
401
+ description: args["description"] ?? "",
211
402
  order,
212
- color,
403
+ color: args["color"] ?? "#3b82f6",
213
404
  });
405
+ if (!created) {
406
+ return `Phase "${id}" already exists.`;
407
+ }
214
408
  writePlan(ctx, summary);
215
409
  return `Phase "${name}" created (id: ${id}, order: ${order}).`;
216
410
  });
@@ -219,64 +413,32 @@ function createPhaseStub(ctx) {
219
413
  }
220
414
  function createTaskStub(ctx) {
221
415
  return {
222
- name: 'createTask',
223
- description: 'Create a task within an existing phase. Tasks have file references, acceptance criteria, and a QA scenario.',
224
- category: 'project',
416
+ name: "createTask",
417
+ description: "Create a task within an existing phase. Tasks have file references, acceptance criteria, and a QA scenario.",
418
+ category: "project",
225
419
  parameters: [],
226
420
  execute: async (args) => {
227
421
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
228
- const phaseId = args['phaseId'] ?? args['phase'];
229
- const title = args['title'] ?? 'New Task';
230
- const description = args['description'] ?? '';
231
- const fileRefs = args['fileRefs'] ?? args['files'] ?? [];
232
- const acceptance = args['acceptance'] ?? [];
233
- const qaScenario = args['qaScenario'] ?? args['qa'] ?? '';
234
- const priority = args['priority'] ?? 'medium';
422
+ const phaseId = args["phaseId"] ?? args["phase"];
423
+ const title = args["title"] ?? "New Task";
235
424
  if (!phaseId)
236
- return 'Task requires a phaseId.';
425
+ return "Task requires a phaseId.";
237
426
  const summary = readPlan(ctx);
238
427
  if (!summary.phases.some((p) => p.id === phaseId)) {
239
428
  return `Phase "${phaseId}" not found. Create it first via /createPhase.`;
240
429
  }
241
- const id = `${phaseId}-${slugify(title)}-${summary.tasks.filter((t) => t.phaseId === phaseId).length + 1}`;
242
- summary.tasks.push({
243
- kind: 'task',
244
- id,
245
- // v0.7.3: keep the human-readable title in plan.json so
246
- // buildPlanSummary can render it in the system prompt.
247
- name: title,
248
- phaseId,
249
- status: 'pending',
250
- priority: priority,
251
- });
430
+ const { id } = addTaskRecord(ctx, summary, phaseId, {
431
+ title,
432
+ description: args["description"] ?? "",
433
+ fileRefs: args["fileRefs"] ??
434
+ args["files"] ??
435
+ [],
436
+ acceptance: args["acceptance"] ?? [],
437
+ qaScenario: args["qaScenario"] ?? args["qa"] ?? "",
438
+ priority: (args["priority"] ??
439
+ "medium"),
440
+ }, { dedupe: false });
252
441
  writePlan(ctx, summary);
253
- // Also write the task body to a per-task file so it doesn't get lost
254
- const taskPath = join(ctx.rootDir, 'plan-tasks', `${id}.md`);
255
- const meta = {
256
- kind: 'task',
257
- id,
258
- phaseId,
259
- status: 'pending',
260
- priority: priority,
261
- tags: fileRefs,
262
- };
263
- const body = [
264
- `# ${title}`,
265
- '',
266
- description,
267
- '',
268
- `## File references`,
269
- ...fileRefs.map((f) => `- \`${f}\``),
270
- '',
271
- `## Acceptance criteria`,
272
- ...acceptance.map((a) => `- ${a}`),
273
- '',
274
- `## QA scenario`,
275
- '',
276
- qaScenario || '_(not specified)_',
277
- '',
278
- ].join('\n');
279
- ctx.storage.write(taskPath, meta, body);
280
442
  return `Task "${title}" created (id: ${id}) under phase "${phaseId}".`;
281
443
  });
282
444
  },
@@ -284,28 +446,44 @@ function createTaskStub(ctx) {
284
446
  }
285
447
  function updateTaskStub(ctx) {
286
448
  return {
287
- name: 'updateTask',
288
- description: 'Update a task\'s status (pending|in_progress|done|blocked).',
289
- category: 'project',
449
+ name: "updateTask",
450
+ description: "Update a task's status (pending|in_progress|done|blocked).",
451
+ category: "project",
290
452
  parameters: [],
291
453
  execute: async (args) => {
292
454
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
293
- const taskId = args['taskId'];
294
- const rawStatus = args['status'];
455
+ const taskId = args["taskId"];
456
+ const rawStatus = args["status"];
295
457
  if (!taskId || !rawStatus)
296
- return 'updateTask requires taskId and status.';
297
- const valid = ['pending', 'in_progress', 'done', 'blocked'];
458
+ return "updateTask requires taskId and status.";
459
+ const valid = ["pending", "in_progress", "done", "blocked"];
298
460
  // v0.7.3: models routinely use kanban vocabulary ("todo", "completed")
299
461
  // — map the common synonyms instead of erroring on each attempt.
300
462
  const aliases = {
301
- 'todo': 'pending', 'to-do': 'pending', 'open': 'pending', 'backlog': 'pending',
302
- 'in-progress': 'in_progress', 'in progress': 'in_progress', 'doing': 'in_progress', 'started': 'in_progress', 'active': 'in_progress',
303
- 'complete': 'done', 'completed': 'done', 'finished': 'done', 'closed': 'done', 'resolved': 'done',
304
- 'stuck': 'blocked', 'waiting': 'blocked', 'on-hold': 'blocked', 'on hold': 'blocked',
463
+ todo: "pending",
464
+ "to-do": "pending",
465
+ open: "pending",
466
+ backlog: "pending",
467
+ "in-progress": "in_progress",
468
+ "in progress": "in_progress",
469
+ doing: "in_progress",
470
+ started: "in_progress",
471
+ active: "in_progress",
472
+ complete: "done",
473
+ completed: "done",
474
+ finished: "done",
475
+ closed: "done",
476
+ resolved: "done",
477
+ stuck: "blocked",
478
+ waiting: "blocked",
479
+ "on-hold": "blocked",
480
+ "on hold": "blocked",
305
481
  };
306
- const status = valid.includes(rawStatus) ? rawStatus : aliases[rawStatus.toLowerCase()];
482
+ const status = valid.includes(rawStatus)
483
+ ? rawStatus
484
+ : aliases[rawStatus.toLowerCase()];
307
485
  if (!status)
308
- return `Invalid status: ${rawStatus}. Must be one of: ${valid.join(', ')}.`;
486
+ return `Invalid status: ${rawStatus}. Must be one of: ${valid.join(", ")}.`;
309
487
  const summary = readPlan(ctx);
310
488
  const task = summary.tasks.find((t) => t.id === taskId);
311
489
  if (!task)
@@ -319,45 +497,45 @@ function updateTaskStub(ctx) {
319
497
  }
320
498
  function addIdeaStub(ctx) {
321
499
  return {
322
- name: 'addIdea',
323
- description: 'Add an Architecture Decision Record (ADR) to the project. ADRs document the context, decision, and consequences of significant choices.',
324
- category: 'project',
500
+ name: "addIdea",
501
+ description: "Add an Architecture Decision Record (ADR) to the project. ADRs document the context, decision, and consequences of significant choices.",
502
+ category: "project",
325
503
  parameters: [],
326
504
  execute: async (args) => {
327
505
  return workspaceMutex.run(`${ctx.rootDir}:adr`, () => {
328
- const title = args['title'] ?? 'New Decision';
329
- const context = args['context'] ?? args['content'] ?? '';
330
- const decision = args['decision'] ?? title;
331
- const consequences = args['consequences'] ?? [];
332
- const tags = args['tags'] ?? [];
333
- const category = args['category'] ?? 'General';
506
+ const title = args["title"] ?? "New Decision";
507
+ const context = args["context"] ?? args["content"] ?? "";
508
+ const decision = args["decision"] ?? title;
509
+ const consequences = args["consequences"] ?? [];
510
+ const tags = args["tags"] ?? [];
511
+ const category = args["category"] ?? "General";
334
512
  const id = `${nextAdrId(ctx)}-${slugify(title)}`;
335
- const path = workspaceArtifact(ctx.rootDir, 'decisions', id);
513
+ const path = workspaceArtifact(ctx.rootDir, "decisions", id);
336
514
  const meta = {
337
- kind: 'adr',
338
- status: 'proposed',
515
+ kind: "adr",
516
+ status: "proposed",
339
517
  id,
340
518
  date: new Date().toISOString().slice(0, 10),
341
519
  tags,
342
520
  };
343
521
  const body = [
344
522
  `# ADR ${id}: ${title}`,
345
- '',
523
+ "",
346
524
  `> Category: ${category} · Status: **proposed** · ${meta.date}`,
347
- '',
525
+ "",
348
526
  `## Context`,
349
- '',
527
+ "",
350
528
  context,
351
- '',
529
+ "",
352
530
  `## Decision`,
353
- '',
531
+ "",
354
532
  decision,
355
- '',
533
+ "",
356
534
  `## Consequences`,
357
- '',
535
+ "",
358
536
  ...consequences.map((c) => `- ${c}`),
359
- '',
360
- ].join('\n');
537
+ "",
538
+ ].join("\n");
361
539
  ctx.storage.write(path, meta, body);
362
540
  return `ADR ${id} created: "${title}". Status: proposed. Promote to accepted via /update ADR or manual edit.`;
363
541
  });
@@ -366,48 +544,24 @@ function addIdeaStub(ctx) {
366
544
  }
367
545
  function createMilestoneStub(ctx) {
368
546
  return {
369
- name: 'createMilestone',
370
- description: 'Create a project milestone (a code version that ships when specific acceptance criteria are met).',
371
- category: 'project',
547
+ name: "createMilestone",
548
+ description: "Create a project milestone (a code version that ships when specific acceptance criteria are met).",
549
+ category: "project",
372
550
  parameters: [],
373
551
  execute: async (args) => {
374
552
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
375
- const title = args['title'] ?? 'New Milestone';
376
- const description = args['description'] ?? '';
377
- const dueDate = args['dueDate'];
378
- const id = `m-${slugify(title)}`;
553
+ const title = args["title"] ?? "New Milestone";
379
554
  const summary = readPlan(ctx);
380
- if (summary.milestones.some((m) => m.id === id))
381
- return `Milestone "${id}" already exists.`;
382
- const version = args['targetVersion'] ?? dueDate ?? 'TBD';
383
- summary.milestones.push({
384
- kind: 'milestone',
385
- id,
386
- name: title,
387
- description,
388
- dueDate,
389
- targetVersion: version,
555
+ const { id, created } = addMilestoneRecord(ctx, summary, {
556
+ title,
557
+ description: args["description"] ?? "",
558
+ dueDate: args["dueDate"],
559
+ targetVersion: args["targetVersion"],
390
560
  });
561
+ if (!created)
562
+ return `Milestone "${id}" already exists.`;
391
563
  writePlan(ctx, summary);
392
- // Also write the body
393
- const path = join(ctx.rootDir, 'milestones', `${id}.md`);
394
- const meta = {
395
- kind: 'milestone',
396
- id,
397
- name: title,
398
- description,
399
- dueDate,
400
- targetVersion: version,
401
- };
402
- const body = [
403
- `# Milestone: ${title}`,
404
- '',
405
- description,
406
- '',
407
- `Target version: ${version}`,
408
- '',
409
- ].join('\n');
410
- ctx.storage.write(path, meta, body);
564
+ const version = summary.milestones.find((m) => m.id === id)?.targetVersion ?? "TBD";
411
565
  return `Milestone "${title}" created (id: ${id}, version: ${version}).`;
412
566
  });
413
567
  },
@@ -415,25 +569,41 @@ function createMilestoneStub(ctx) {
415
569
  }
416
570
  function createDocumentStub(ctx) {
417
571
  return {
418
- name: 'createDocument',
419
- description: 'Create a Markdown document in the project docs (README/CHANGELOG/spec draft produced by the council).',
420
- category: 'vault',
572
+ name: "createDocument",
573
+ description: "Create a Markdown document in the project docs (README/CHANGELOG/spec draft produced by the council).",
574
+ category: "vault",
421
575
  parameters: [],
422
576
  execute: async (args) => {
423
577
  return workspaceMutex.run(`${ctx.rootDir}:doc`, () => {
424
- const title = args['title'] ?? 'New Document';
425
- const content = args['content'] ?? '';
426
- const tags = args['tags'] ?? [];
427
- const id = slugify(title) || `doc-${Date.now()}`;
428
- const path = workspaceArtifact(ctx.rootDir, 'docs', id);
578
+ const title = args["title"] ?? "New Document";
579
+ const content = args["content"] ?? "";
580
+ const tags = args["tags"] ?? [];
581
+ // v0.7.8: models routinely pass the FILENAME as title ("risks.md",
582
+ // "synthesis.md"), which slugified to "risks-md"/"synthesis-md" and
583
+ // produced duplicated artifacts (docs/risks-md.md next to risks.md,
584
+ // HANDOFF 2026-07-03 §5.1). Strip the extension before slugifying.
585
+ const normalizedTitle = title.replace(/\.(md|markdown)\s*$/i, "");
586
+ const slug = slugify(normalizedTitle) || `doc-${Date.now()}`;
587
+ if (slug === "risks") {
588
+ const risksPath = workspaceFile(ctx.rootDir, "risks");
589
+ const riskMeta = {
590
+ kind: "risk",
591
+ id: "risks",
592
+ severity: "medium",
593
+ date: new Date().toISOString().slice(0, 10),
594
+ };
595
+ ctx.storage.write(risksPath, riskMeta, content);
596
+ return `Document "${title}" created at risks.md (workspace root).`;
597
+ }
598
+ const path = workspaceArtifact(ctx.rootDir, "docs", slug);
429
599
  const meta = {
430
- kind: 'doc',
431
- id,
600
+ kind: "doc",
601
+ id: slug,
432
602
  date: new Date().toISOString().slice(0, 10),
433
603
  tags,
434
604
  };
435
605
  ctx.storage.write(path, meta, content);
436
- return `Document "${title}" created at docs/${id}.md.`;
606
+ return `Document "${title}" created at docs/${slug}.md.`;
437
607
  });
438
608
  },
439
609
  };
@@ -449,20 +619,20 @@ const SEARCH_NUDGE_THRESHOLD = 5;
449
619
  function searchDocumentsStub(ctx) {
450
620
  let callCount = 0;
451
621
  return {
452
- name: 'searchDocuments',
453
- description: 'Search all workspace documents (`.zelari/**`) for a keyword. Returns matching files with snippets. ' +
454
- 'Search AT MOST 2-3 times, then act on the results: read the matched files with read_file instead of rephrasing the same search.',
455
- category: 'analysis',
622
+ name: "searchDocuments",
623
+ description: "Search all workspace documents (`.zelari/**`) for a keyword. Returns matching files with snippets. " +
624
+ "Search AT MOST 2-3 times, then act on the results: read the matched files with read_file instead of rephrasing the same search.",
625
+ category: "analysis",
456
626
  parameters: [],
457
627
  execute: async (args) => {
458
628
  callCount++;
459
629
  const nudge = callCount > SEARCH_NUDGE_THRESHOLD
460
630
  ? `[note] This is searchDocuments call #${callCount} in this run — STOP searching. Read the files you already found with read_file and act on them.\n\n`
461
- : '';
462
- const rawQuery = (args['query'] ?? '').trim();
463
- const limit = args['limit'] ?? 5;
631
+ : "";
632
+ const rawQuery = (args["query"] ?? "").trim();
633
+ const limit = args["limit"] ?? 5;
464
634
  if (!rawQuery)
465
- return 'searchDocuments requires a query.';
635
+ return "searchDocuments requires a query.";
466
636
  // v0.7.3: models routinely write boolean-ish queries ("plan OR phase OR
467
637
  // milestone"). The previous implementation substring-matched the WHOLE
468
638
  // query string, so every OR query — and any multi-word query that didn't
@@ -477,21 +647,21 @@ function searchDocumentsStub(ctx) {
477
647
  const words = rawQuery
478
648
  .toLowerCase()
479
649
  .split(/[^a-z0-9_-]+/)
480
- .filter((w) => w.length >= 3 && w !== 'or' && w !== 'and' && w !== 'the');
650
+ .filter((w) => w.length >= 3 && w !== "or" && w !== "and" && w !== "the");
481
651
  const files = [
482
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'decisions')),
483
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'docs')),
484
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'reviews')),
485
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'plan-tasks')),
486
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'milestones')),
487
- workspaceFile(ctx.rootDir, 'plan'),
488
- workspaceFile(ctx.rootDir, 'risks'),
652
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "decisions")),
653
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "docs")),
654
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "reviews")),
655
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "plan-tasks")),
656
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "milestones")),
657
+ workspaceFile(ctx.rootDir, "plan"),
658
+ workspaceFile(ctx.rootDir, "risks"),
489
659
  ];
490
660
  const results = [];
491
661
  for (const file of files) {
492
662
  if (!existsSync(file))
493
663
  continue;
494
- const raw = readFileSync(file, 'utf8');
664
+ const raw = readFileSync(file, "utf8");
495
665
  const content = raw.toLowerCase();
496
666
  let idx = -1;
497
667
  let matchLen = 0;
@@ -517,34 +687,42 @@ function searchDocumentsStub(ctx) {
517
687
  continue;
518
688
  const start = Math.max(0, idx - 40);
519
689
  const end = Math.min(raw.length, idx + matchLen + 40);
520
- const snippet = '' + raw.slice(start, end).replace(/\n/g, ' ').trim() + '';
521
- results.push({ path: relative(ctx.rootDir, file) || basename(file), snippet });
690
+ const snippet = "" + raw.slice(start, end).replace(/\n/g, " ").trim() + "";
691
+ results.push({
692
+ path: relative(ctx.rootDir, file) || basename(file),
693
+ snippet,
694
+ });
522
695
  if (results.length >= limit)
523
696
  break;
524
697
  }
525
698
  if (results.length === 0)
526
699
  return `${nudge}No matches for "${rawQuery}" in workspace.`;
527
- return nudge + results.map((r) => `[${r.path}]\n${r.snippet}`).join('\n\n');
700
+ return (nudge + results.map((r) => `[${r.path}]\n${r.snippet}`).join("\n\n"));
528
701
  },
529
702
  };
530
703
  }
531
704
  function linkDocumentsStub(ctx) {
532
705
  return {
533
- name: 'linkDocuments',
534
- description: 'Link two workspace artifacts. Adds the `related:` field to the source frontmatter so backreferences can be traced.',
535
- category: 'vault',
706
+ name: "linkDocuments",
707
+ description: "Link two workspace artifacts. Adds the `related:` field to the source frontmatter so backreferences can be traced.",
708
+ category: "vault",
536
709
  parameters: [],
537
710
  execute: async (args) => {
538
711
  return workspaceMutex.run(`${ctx.rootDir}:link`, () => {
539
- const fromId = args['fromId'];
540
- const toId = args['toId'];
712
+ // v0.7.8: the provider-facing JSON schema (toolSchemas.ts) advertises
713
+ // sourceId/targetPathOrTitle accept those aliases too, otherwise
714
+ // every schema-conformant call fails with "requires fromId and toId".
715
+ const fromId = args["fromId"] ?? args["sourceId"];
716
+ const toId = args["toId"] ??
717
+ args["targetId"] ??
718
+ args["targetPathOrTitle"];
541
719
  if (!fromId || !toId)
542
- return 'linkDocuments requires fromId and toId.';
720
+ return "linkDocuments requires fromId and toId.";
543
721
  // Find the source file by id
544
722
  const allFiles = [
545
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'decisions')),
546
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'docs')),
547
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'reviews')),
723
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "decisions")),
724
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "docs")),
725
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "reviews")),
548
726
  ];
549
727
  const source = allFiles.find((f) => {
550
728
  try {
@@ -570,18 +748,19 @@ function linkDocumentsStub(ctx) {
570
748
  }
571
749
  function getDocumentBacklinksStub(ctx) {
572
750
  return {
573
- name: 'getDocumentBacklinks',
574
- description: 'List all workspace artifacts that link to the given artifact via their `related:` frontmatter.',
575
- category: 'analysis',
751
+ name: "getDocumentBacklinks",
752
+ description: "List all workspace artifacts that link to the given artifact via their `related:` frontmatter.",
753
+ category: "analysis",
576
754
  parameters: [],
577
755
  execute: async (args) => {
578
- const targetId = args['targetId'];
756
+ // v0.7.8: the provider-facing JSON schema advertises `id` — accept it.
757
+ const targetId = args["targetId"] ?? args["id"];
579
758
  if (!targetId)
580
- return 'getDocumentBacklinks requires targetId.';
759
+ return "getDocumentBacklinks requires targetId.";
581
760
  const allFiles = [
582
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'decisions')),
583
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'docs')),
584
- ...ctx.storage.listMarkdown(join(ctx.rootDir, 'reviews')),
761
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "decisions")),
762
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "docs")),
763
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, "reviews")),
585
764
  ];
586
765
  const backlinks = [];
587
766
  for (const file of allFiles) {
@@ -593,11 +772,13 @@ function getDocumentBacklinksStub(ctx) {
593
772
  backlinks.push(doc.meta.id);
594
773
  }
595
774
  }
596
- catch { /* skip */ }
775
+ catch {
776
+ /* skip */
777
+ }
597
778
  }
598
779
  if (backlinks.length === 0)
599
780
  return `No backlinks to "${targetId}".`;
600
- return backlinks.map((id) => `- ${id}`).join('\n');
781
+ return backlinks.map((id) => `- ${id}`).join("\n");
601
782
  },
602
783
  };
603
784
  }