zelari-code 0.6.2 → 0.7.8

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 (63) hide show
  1. package/dist/cli/app.js +64 -29
  2. package/dist/cli/app.js.map +1 -1
  3. package/dist/cli/components/ChatStream.js +40 -126
  4. package/dist/cli/components/ChatStream.js.map +1 -1
  5. package/dist/cli/components/LiveRegion.js +54 -0
  6. package/dist/cli/components/LiveRegion.js.map +1 -0
  7. package/dist/cli/components/SplashScreen.js +158 -0
  8. package/dist/cli/components/SplashScreen.js.map +1 -0
  9. package/dist/cli/components/StatusBar.js +42 -0
  10. package/dist/cli/components/StatusBar.js.map +1 -0
  11. package/dist/cli/components/ToolOutput.js +68 -0
  12. package/dist/cli/components/ToolOutput.js.map +1 -0
  13. package/dist/cli/components/toolFormat.js +195 -0
  14. package/dist/cli/components/toolFormat.js.map +1 -0
  15. package/dist/cli/councilDispatcher.js +20 -0
  16. package/dist/cli/councilDispatcher.js.map +1 -1
  17. package/dist/cli/grokOAuth.js +93 -75
  18. package/dist/cli/grokOAuth.js.map +1 -1
  19. package/dist/cli/hooks/chatState.js +139 -0
  20. package/dist/cli/hooks/chatState.js.map +1 -0
  21. package/dist/cli/hooks/eventsToMessages.js +1 -1
  22. package/dist/cli/hooks/eventsToMessages.js.map +1 -1
  23. package/dist/cli/hooks/messageHelpers.js +11 -4
  24. package/dist/cli/hooks/messageHelpers.js.map +1 -1
  25. package/dist/cli/hooks/useBatchedMessages.js +14 -10
  26. package/dist/cli/hooks/useBatchedMessages.js.map +1 -1
  27. package/dist/cli/hooks/useChatTurn.js +400 -87
  28. package/dist/cli/hooks/useChatTurn.js.map +1 -1
  29. package/dist/cli/hooks/useSession.js +23 -11
  30. package/dist/cli/hooks/useSession.js.map +1 -1
  31. package/dist/cli/hooks/useSlashDispatch.js +9 -1
  32. package/dist/cli/hooks/useSlashDispatch.js.map +1 -1
  33. package/dist/cli/main.bundled.js +15468 -12961
  34. package/dist/cli/main.bundled.js.map +4 -4
  35. package/dist/cli/main.js +43 -2
  36. package/dist/cli/main.js.map +1 -1
  37. package/dist/cli/mcp/mcpClient.js +152 -0
  38. package/dist/cli/mcp/mcpClient.js.map +1 -0
  39. package/dist/cli/mcp/mcpManager.js +139 -0
  40. package/dist/cli/mcp/mcpManager.js.map +1 -0
  41. package/dist/cli/refreshRegistry.js +9 -6
  42. package/dist/cli/refreshRegistry.js.map +1 -1
  43. package/dist/cli/skillsMd.js +155 -0
  44. package/dist/cli/skillsMd.js.map +1 -0
  45. package/dist/cli/slashHandlers/skills.js +16 -0
  46. package/dist/cli/slashHandlers/skills.js.map +1 -1
  47. package/dist/cli/toolRegistry.js +31 -0
  48. package/dist/cli/toolRegistry.js.map +1 -1
  49. package/dist/cli/wizard/index.js +1 -1
  50. package/dist/cli/wizard/index.js.map +1 -1
  51. package/dist/cli/workspace/completeDesign.js +128 -0
  52. package/dist/cli/workspace/completeDesign.js.map +1 -0
  53. package/dist/cli/workspace/paths.js +20 -13
  54. package/dist/cli/workspace/paths.js.map +1 -1
  55. package/dist/cli/workspace/postCouncilHook.js +146 -20
  56. package/dist/cli/workspace/postCouncilHook.js.map +1 -1
  57. package/dist/cli/workspace/storage.js +7 -3
  58. package/dist/cli/workspace/storage.js.map +1 -1
  59. package/dist/cli/workspace/stubs.js +350 -136
  60. package/dist/cli/workspace/stubs.js.map +1 -1
  61. package/dist/cli/workspace/workspaceSummary.js +255 -0
  62. package/dist/cli/workspace/workspaceSummary.js.map +1 -0
  63. package/package.json +2 -2
@@ -1,18 +1,19 @@
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, readFileSync } from 'node:fs';
15
- import { join } from 'node:path';
15
+ import { existsSync, readdirSync, writeFileSync, readFileSync, mkdirSync, renameSync } from 'node:fs';
16
+ import { join, basename, dirname, relative } from 'node:path';
16
17
  import { workspaceMutex } from './storage.js';
17
18
  import { resolveWorkspaceRoot, workspaceFile, workspaceArtifact, projectName as deriveProjectName, } from './paths.js';
18
19
  import { Storage } from './storage.js';
@@ -29,49 +30,64 @@ export function createWorkspaceContext(projectRoot = process.cwd()) {
29
30
  storage: new Storage(),
30
31
  };
31
32
  }
33
+ /** Path of the machine-readable plan store (source of truth since v0.7.3). */
34
+ function planJsonPath(ctx) {
35
+ return join(ctx.rootDir, 'plan.json');
36
+ }
32
37
  function readPlan(ctx) {
38
+ // v0.7.3: the plan's source of truth is plan.json. The previous
39
+ // implementation round-tripped the whole summary through the handwritten
40
+ // YAML-subset frontmatter of plan.md; free-text names/descriptions with
41
+ // colons, commas, and apostrophes corrupted the flow-map parse, so a phase
42
+ // written by createPhase could be unreadable one call later ("Phase not
43
+ // found" right after "Phase created", live test 2026-07-02). plan.md is
44
+ // now only the human-readable rendering.
45
+ const jsonPath = planJsonPath(ctx);
46
+ if (existsSync(jsonPath)) {
47
+ try {
48
+ const parsed = JSON.parse(readFileSync(jsonPath, 'utf8'));
49
+ return {
50
+ phases: Array.isArray(parsed.phases) ? parsed.phases : [],
51
+ tasks: Array.isArray(parsed.tasks) ? parsed.tasks : [],
52
+ milestones: Array.isArray(parsed.milestones) ? parsed.milestones : [],
53
+ };
54
+ }
55
+ catch {
56
+ // Corrupt plan.json — fall through to the legacy migration read.
57
+ }
58
+ }
59
+ // Legacy migration: one best-effort parse of the old plan.md frontmatter
60
+ // (pre-v0.7.3 files). The next writePlan persists it as plan.json.
33
61
  const path = workspaceFile(ctx.rootDir, 'plan');
34
62
  const doc = ctx.storage.readIfExists(path);
35
63
  if (!doc)
36
64
  return { phases: [], tasks: [], milestones: [] };
37
- // The plan.md body is markdown; the frontmatter is a single "summary" object.
38
- // We treat the file as a single doc with `kind: plan` and a body listing items.
39
- // For our needs we just persist individual items to disk separately.
40
- // But for v1 we serialize the entire plan as one doc with a list of phases/tasks/milestones in the body.
41
- // Simpler: we store ONE phase/task/milestone per file under `.zelari/plan/<id>.md`.
42
- // TODO: refactor for v2 if plan gets large.
43
- const items = [];
44
- // For now, parse the doc.meta as the entire plan state (kind=plan, has children fields).
45
65
  const meta = doc.meta;
46
- if (meta.phases)
47
- items.push(...meta.phases);
48
- return { phases: meta.phases ?? [], tasks: meta.tasks ?? [], milestones: meta.milestones ?? [] };
66
+ return {
67
+ phases: Array.isArray(meta.phases) ? meta.phases : [],
68
+ tasks: Array.isArray(meta.tasks) ? meta.tasks : [],
69
+ milestones: Array.isArray(meta.milestones) ? meta.milestones : [],
70
+ };
49
71
  }
50
72
  /**
51
- * Write the entire plan (phases + tasks + milestones) as one file.
52
- * Simpler than per-item files for v1.
73
+ * Write the entire plan (phases + tasks + milestones):
74
+ * - plan.json lossless machine-readable source of truth (atomic write).
75
+ * - plan.md — human-readable rendering only (no machine-parsed arrays in
76
+ * the frontmatter, so the fragile YAML round-trip is gone).
53
77
  */
54
78
  function writePlan(ctx, summary) {
55
- const path = workspaceFile(ctx.rootDir, 'plan');
56
- const meta = {
57
- kind: 'phase',
58
- id: '_plan_summary',
59
- order: -1,
60
- tags: ['plan-summary'],
61
- };
62
- // We pack phases/tasks/milestones in a synthetic frontmatter using flow-style.
63
- // Easier: just store each item's key fields inline as comments? No — keep them
64
- // parseable. We use a special frontmatter shape with arrays.
79
+ const jsonPath = planJsonPath(ctx);
80
+ mkdirSync(dirname(jsonPath), { recursive: true });
81
+ const tmp = jsonPath + '.tmp-' + process.pid;
82
+ writeFileSync(tmp, JSON.stringify(summary, null, 2), 'utf8');
83
+ renameSync(tmp, jsonPath);
84
+ const mdPath = workspaceFile(ctx.rootDir, 'plan');
65
85
  const summaryMeta = {
66
86
  kind: 'plan-summary',
67
87
  id: '_plan',
68
88
  updatedAt: new Date().toISOString(),
69
- phases: summary.phases,
70
- tasks: summary.tasks,
71
- milestones: summary.milestones,
72
89
  };
73
- const body = renderPlanBody(summary);
74
- ctx.storage.write(path, summaryMeta, body);
90
+ ctx.storage.write(mdPath, summaryMeta, renderPlanBody(summary));
75
91
  }
76
92
  /** Render the plan.md body in human-readable Markdown. */
77
93
  function renderPlanBody(summary) {
@@ -149,6 +165,119 @@ function nextAdrId(ctx) {
149
165
  function slugify(s) {
150
166
  return s.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '').slice(0, 60);
151
167
  }
168
+ /** Add a phase record. Returns the phase id and whether it was newly created. */
169
+ function addPhaseRecord(summary, input) {
170
+ const id = slugify(input.name) || `phase-${input.order}`;
171
+ if (summary.phases.some((p) => p.id === id)) {
172
+ return { id, created: false };
173
+ }
174
+ summary.phases.push({
175
+ kind: 'phase',
176
+ id,
177
+ name: input.name,
178
+ description: input.description,
179
+ order: input.order,
180
+ color: input.color,
181
+ });
182
+ return { id, created: true };
183
+ }
184
+ /**
185
+ * Add a task record + its per-task file. With `dedupe: true` a task whose
186
+ * title already exists in the same phase is treated as the same task and
187
+ * NOT re-added (protects the createPlan batch path from duplicating tasks
188
+ * that a partial itemized run already persisted). The itemized createTask
189
+ * stub keeps `dedupe: false` for backward compatibility.
190
+ */
191
+ function addTaskRecord(ctx, summary, phaseId, t, options) {
192
+ const slug = slugify(t.title);
193
+ if (options.dedupe) {
194
+ const existing = summary.tasks.find((k) => k.phaseId === phaseId && k.id.replace(/-\d+$/, '') === `${phaseId}-${slug}`);
195
+ if (existing)
196
+ return { id: existing.id, created: false };
197
+ }
198
+ const id = `${phaseId}-${slug}-${summary.tasks.filter((k) => k.phaseId === phaseId).length + 1}`;
199
+ summary.tasks.push({
200
+ kind: 'task',
201
+ id,
202
+ // v0.7.3: keep the human-readable title in plan.json so
203
+ // buildPlanSummary can render it in the system prompt.
204
+ name: t.title,
205
+ phaseId,
206
+ status: 'pending',
207
+ priority: t.priority,
208
+ });
209
+ const taskPath = join(ctx.rootDir, 'plan-tasks', `${id}.md`);
210
+ const meta = {
211
+ kind: 'task',
212
+ id,
213
+ phaseId,
214
+ status: 'pending',
215
+ priority: t.priority,
216
+ tags: t.fileRefs,
217
+ };
218
+ const body = [
219
+ `# ${t.title}`,
220
+ '',
221
+ t.description,
222
+ '',
223
+ `## File references`,
224
+ ...t.fileRefs.map((f) => `- \`${f}\``),
225
+ '',
226
+ `## Acceptance criteria`,
227
+ ...t.acceptance.map((a) => `- ${a}`),
228
+ '',
229
+ `## QA scenario`,
230
+ '',
231
+ t.qaScenario || '_(not specified)_',
232
+ '',
233
+ ].join('\n');
234
+ ctx.storage.write(taskPath, meta, body);
235
+ return { id, created: true };
236
+ }
237
+ /** Add a milestone record + its file. Returns id and whether newly created. */
238
+ function addMilestoneRecord(ctx, summary, input) {
239
+ const id = `m-${slugify(input.title)}`;
240
+ if (summary.milestones.some((m) => m.id === id)) {
241
+ return { id, created: false };
242
+ }
243
+ const version = input.targetVersion ?? input.dueDate ?? 'TBD';
244
+ summary.milestones.push({
245
+ kind: 'milestone',
246
+ id,
247
+ name: input.title,
248
+ description: input.description,
249
+ dueDate: input.dueDate,
250
+ targetVersion: version,
251
+ });
252
+ const path = join(ctx.rootDir, 'milestones', `${id}.md`);
253
+ const meta = {
254
+ kind: 'milestone',
255
+ id,
256
+ name: input.title,
257
+ description: input.description,
258
+ dueDate: input.dueDate,
259
+ targetVersion: version,
260
+ };
261
+ const body = [
262
+ `# Milestone: ${input.title}`,
263
+ '',
264
+ input.description,
265
+ '',
266
+ `Target version: ${version}`,
267
+ '',
268
+ ].join('\n');
269
+ ctx.storage.write(path, meta, body);
270
+ return { id, created: true };
271
+ }
272
+ /**
273
+ * Read-only view of the current plan (phases + tasks + milestones).
274
+ * Exported for the built-in complete-design fallback (completeDesign.ts)
275
+ * so it can inspect per-phase task coverage without duplicating the
276
+ * plan.json / legacy plan.md read logic.
277
+ */
278
+ export function readPlanSummary(ctx) {
279
+ return readPlan(ctx);
280
+ }
152
281
  // ── Stub factory ─────────────────────────────────────────────────────
153
282
  /**
154
283
  * Create all 9 workspace stubs bound to the given context.
@@ -158,6 +287,7 @@ function slugify(s) {
158
287
  */
159
288
  export function createWorkspaceStubs(ctx) {
160
289
  return [
290
+ createPlanStub(ctx),
161
291
  createPhaseStub(ctx),
162
292
  createTaskStub(ctx),
163
293
  updateTaskStub(ctx),
@@ -170,6 +300,81 @@ export function createWorkspaceStubs(ctx) {
170
300
  ];
171
301
  }
172
302
  // ── Individual stubs ──────────────────────────────────────────────────
303
+ /**
304
+ * v0.7.8 — Batch plan creation. One tool call persists the WHOLE plan:
305
+ * every phase, the tasks nested under each phase, and the milestone.
306
+ *
307
+ * Rationale: the itemized contract (4×createPhase + 12×createTask +
308
+ * 1×createMilestone = 17 sequential tool calls) exceeds what mid-tier
309
+ * council models reliably emit in one turn (composer-2.5 persisted ≤7 of
310
+ * 17 in the 2026-07-03 live tests, see HANDOFF.md). A single structured
311
+ * call collapses the whole contract into one emission, which the same
312
+ * models satisfy reliably (proven by the 1-call retry paths of Minosse
313
+ * and Lucifero).
314
+ */
315
+ function createPlanStub(ctx) {
316
+ return {
317
+ name: 'createPlan',
318
+ description: 'Create the FULL project plan in one call: all phases, the tasks nested under each phase, and the target milestone. ' +
319
+ 'Preferred over itemized createPhase/createTask/createMilestone calls — one createPlan call persists everything atomically.',
320
+ category: 'project',
321
+ parameters: [],
322
+ execute: async (args) => {
323
+ return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
324
+ const rawPhases = Array.isArray(args['phases'])
325
+ ? args['phases']
326
+ : [];
327
+ if (rawPhases.length === 0) {
328
+ return 'createPlan requires a non-empty "phases" array (each phase: { name, description, order, color, tasks: [{ title, description, fileRefs, acceptance, qaScenario, priority }] }).';
329
+ }
330
+ const summary = readPlan(ctx);
331
+ let phasesCreated = 0;
332
+ let tasksCreated = 0;
333
+ const phaseIds = [];
334
+ rawPhases.forEach((rawPhase, idx) => {
335
+ const { id: phaseId, created } = addPhaseRecord(summary, {
336
+ name: rawPhase['name'] ?? `Phase ${idx + 1}`,
337
+ description: rawPhase['description'] ?? '',
338
+ order: rawPhase['order'] ?? idx + 1,
339
+ color: rawPhase['color'] ?? '#3b82f6',
340
+ });
341
+ if (created)
342
+ phasesCreated += 1;
343
+ phaseIds.push(phaseId);
344
+ const rawTasks = Array.isArray(rawPhase['tasks'])
345
+ ? rawPhase['tasks']
346
+ : [];
347
+ for (const rawTask of rawTasks) {
348
+ const { created: taskCreated } = addTaskRecord(ctx, summary, phaseId, {
349
+ title: rawTask['title'] ?? 'New Task',
350
+ description: rawTask['description'] ?? '',
351
+ fileRefs: rawTask['fileRefs'] ?? rawTask['files'] ?? [],
352
+ acceptance: rawTask['acceptance'] ?? [],
353
+ qaScenario: rawTask['qaScenario'] ?? rawTask['qa'] ?? '',
354
+ priority: (rawTask['priority'] ?? 'medium'),
355
+ }, { dedupe: true });
356
+ if (taskCreated)
357
+ tasksCreated += 1;
358
+ }
359
+ });
360
+ let milestonesCreated = 0;
361
+ const rawMilestone = args['milestone'];
362
+ if (rawMilestone && typeof rawMilestone === 'object') {
363
+ const { created } = addMilestoneRecord(ctx, summary, {
364
+ title: rawMilestone['title'] ?? 'v0.1.0 design-complete',
365
+ description: rawMilestone['description'] ?? '',
366
+ dueDate: rawMilestone['dueDate'],
367
+ targetVersion: rawMilestone['targetVersion'],
368
+ });
369
+ if (created)
370
+ milestonesCreated += 1;
371
+ }
372
+ writePlan(ctx, summary);
373
+ return `Plan created: ${phasesCreated} phases, ${tasksCreated} tasks, ${milestonesCreated} milestone(s). Phase ids: ${phaseIds.join(', ')}.`;
374
+ });
375
+ },
376
+ };
377
+ }
173
378
  function createPhaseStub(ctx) {
174
379
  return {
175
380
  name: 'createPhase',
@@ -179,23 +384,17 @@ function createPhaseStub(ctx) {
179
384
  execute: async (args) => {
180
385
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
181
386
  const name = args['name'] ?? 'New Phase';
182
- const description = args['description'] ?? '';
183
387
  const order = args['order'] ?? 0;
184
- const color = args['color'] ?? '#3b82f6';
185
- const id = slugify(name) || `phase-${order}`;
186
388
  const summary = readPlan(ctx);
187
- // Check uniqueness
188
- if (summary.phases.some((p) => p.id === id)) {
189
- return `Phase "${id}" already exists.`;
190
- }
191
- summary.phases.push({
192
- kind: 'phase',
193
- id,
389
+ const { id, created } = addPhaseRecord(summary, {
194
390
  name,
195
- description,
391
+ description: args['description'] ?? '',
196
392
  order,
197
- color,
393
+ color: args['color'] ?? '#3b82f6',
198
394
  });
395
+ if (!created) {
396
+ return `Phase "${id}" already exists.`;
397
+ }
199
398
  writePlan(ctx, summary);
200
399
  return `Phase "${name}" created (id: ${id}, order: ${order}).`;
201
400
  });
@@ -212,53 +411,21 @@ function createTaskStub(ctx) {
212
411
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
213
412
  const phaseId = args['phaseId'] ?? args['phase'];
214
413
  const title = args['title'] ?? 'New Task';
215
- const description = args['description'] ?? '';
216
- const fileRefs = args['fileRefs'] ?? args['files'] ?? [];
217
- const acceptance = args['acceptance'] ?? [];
218
- const qaScenario = args['qaScenario'] ?? args['qa'] ?? '';
219
- const priority = args['priority'] ?? 'medium';
220
414
  if (!phaseId)
221
415
  return 'Task requires a phaseId.';
222
416
  const summary = readPlan(ctx);
223
417
  if (!summary.phases.some((p) => p.id === phaseId)) {
224
418
  return `Phase "${phaseId}" not found. Create it first via /createPhase.`;
225
419
  }
226
- const id = `${phaseId}-${slugify(title)}-${summary.tasks.filter((t) => t.phaseId === phaseId).length + 1}`;
227
- summary.tasks.push({
228
- kind: 'task',
229
- id,
230
- phaseId,
231
- status: 'pending',
232
- priority: priority,
233
- });
420
+ const { id } = addTaskRecord(ctx, summary, phaseId, {
421
+ title,
422
+ description: args['description'] ?? '',
423
+ fileRefs: args['fileRefs'] ?? args['files'] ?? [],
424
+ acceptance: args['acceptance'] ?? [],
425
+ qaScenario: args['qaScenario'] ?? args['qa'] ?? '',
426
+ priority: (args['priority'] ?? 'medium'),
427
+ }, { dedupe: false });
234
428
  writePlan(ctx, summary);
235
- // Also write the task body to a per-task file so it doesn't get lost
236
- const taskPath = join(ctx.rootDir, 'plan-tasks', `${id}.md`);
237
- const meta = {
238
- kind: 'task',
239
- id,
240
- phaseId,
241
- status: 'pending',
242
- priority: priority,
243
- tags: fileRefs,
244
- };
245
- const body = [
246
- `# ${title}`,
247
- '',
248
- description,
249
- '',
250
- `## File references`,
251
- ...fileRefs.map((f) => `- \`${f}\``),
252
- '',
253
- `## Acceptance criteria`,
254
- ...acceptance.map((a) => `- ${a}`),
255
- '',
256
- `## QA scenario`,
257
- '',
258
- qaScenario || '_(not specified)_',
259
- '',
260
- ].join('\n');
261
- ctx.storage.write(taskPath, meta, body);
262
429
  return `Task "${title}" created (id: ${id}) under phase "${phaseId}".`;
263
430
  });
264
431
  },
@@ -273,12 +440,21 @@ function updateTaskStub(ctx) {
273
440
  execute: async (args) => {
274
441
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
275
442
  const taskId = args['taskId'];
276
- const status = args['status'];
277
- if (!taskId || !status)
443
+ const rawStatus = args['status'];
444
+ if (!taskId || !rawStatus)
278
445
  return 'updateTask requires taskId and status.';
279
446
  const valid = ['pending', 'in_progress', 'done', 'blocked'];
280
- if (!valid.includes(status))
281
- return `Invalid status: ${status}. Must be one of: ${valid.join(', ')}.`;
447
+ // v0.7.3: models routinely use kanban vocabulary ("todo", "completed")
448
+ // map the common synonyms instead of erroring on each attempt.
449
+ const aliases = {
450
+ 'todo': 'pending', 'to-do': 'pending', 'open': 'pending', 'backlog': 'pending',
451
+ 'in-progress': 'in_progress', 'in progress': 'in_progress', 'doing': 'in_progress', 'started': 'in_progress', 'active': 'in_progress',
452
+ 'complete': 'done', 'completed': 'done', 'finished': 'done', 'closed': 'done', 'resolved': 'done',
453
+ 'stuck': 'blocked', 'waiting': 'blocked', 'on-hold': 'blocked', 'on hold': 'blocked',
454
+ };
455
+ const status = valid.includes(rawStatus) ? rawStatus : aliases[rawStatus.toLowerCase()];
456
+ if (!status)
457
+ return `Invalid status: ${rawStatus}. Must be one of: ${valid.join(', ')}.`;
282
458
  const summary = readPlan(ctx);
283
459
  const task = summary.tasks.find((t) => t.id === taskId);
284
460
  if (!task)
@@ -346,41 +522,17 @@ function createMilestoneStub(ctx) {
346
522
  execute: async (args) => {
347
523
  return workspaceMutex.run(`${ctx.rootDir}:plan`, () => {
348
524
  const title = args['title'] ?? 'New Milestone';
349
- const description = args['description'] ?? '';
350
- const dueDate = args['dueDate'];
351
- const id = `m-${slugify(title)}`;
352
525
  const summary = readPlan(ctx);
353
- if (summary.milestones.some((m) => m.id === id))
354
- return `Milestone "${id}" already exists.`;
355
- const version = args['targetVersion'] ?? dueDate ?? 'TBD';
356
- summary.milestones.push({
357
- kind: 'milestone',
358
- id,
359
- name: title,
360
- description,
361
- dueDate,
362
- targetVersion: version,
526
+ const { id, created } = addMilestoneRecord(ctx, summary, {
527
+ title,
528
+ description: args['description'] ?? '',
529
+ dueDate: args['dueDate'],
530
+ targetVersion: args['targetVersion'],
363
531
  });
532
+ if (!created)
533
+ return `Milestone "${id}" already exists.`;
364
534
  writePlan(ctx, summary);
365
- // Also write the body
366
- const path = join(ctx.rootDir, 'milestones', `${id}.md`);
367
- const meta = {
368
- kind: 'milestone',
369
- id,
370
- name: title,
371
- description,
372
- dueDate,
373
- targetVersion: version,
374
- };
375
- const body = [
376
- `# Milestone: ${title}`,
377
- '',
378
- description,
379
- '',
380
- `Target version: ${version}`,
381
- '',
382
- ].join('\n');
383
- ctx.storage.write(path, meta, body);
535
+ const version = summary.milestones.find((m) => m.id === id)?.targetVersion ?? 'TBD';
384
536
  return `Milestone "${title}" created (id: ${id}, version: ${version}).`;
385
537
  });
386
538
  },
@@ -397,7 +549,12 @@ function createDocumentStub(ctx) {
397
549
  const title = args['title'] ?? 'New Document';
398
550
  const content = args['content'] ?? '';
399
551
  const tags = args['tags'] ?? [];
400
- const id = slugify(title) || `doc-${Date.now()}`;
552
+ // v0.7.8: models routinely pass the FILENAME as title ("risks.md",
553
+ // "synthesis.md"), which slugified to "risks-md"/"synthesis-md" and
554
+ // produced duplicated artifacts (docs/risks-md.md next to risks.md,
555
+ // HANDOFF 2026-07-03 §5.1). Strip the extension before slugifying.
556
+ const normalizedTitle = title.replace(/\.(md|markdown)\s*$/i, '');
557
+ const id = slugify(normalizedTitle) || `doc-${Date.now()}`;
401
558
  const path = workspaceArtifact(ctx.rootDir, 'docs', id);
402
559
  const meta = {
403
560
  kind: 'doc',
@@ -411,21 +568,52 @@ function createDocumentStub(ctx) {
411
568
  },
412
569
  };
413
570
  }
571
+ /**
572
+ * After this many searchDocuments calls in one council run, every further
573
+ * result carries a nudge to stop searching and act. Live test 2026-07-02:
574
+ * one member burned ~25 near-identical searches (each phrased slightly
575
+ * differently, so the duplicate-call cache never tripped) before doing any
576
+ * actual work.
577
+ */
578
+ const SEARCH_NUDGE_THRESHOLD = 5;
414
579
  function searchDocumentsStub(ctx) {
580
+ let callCount = 0;
415
581
  return {
416
582
  name: 'searchDocuments',
417
- description: 'Search all workspace documents (`.zelari/**`) for a keyword. Returns matching files with snippets.',
583
+ description: 'Search all workspace documents (`.zelari/**`) for a keyword. Returns matching files with snippets. ' +
584
+ 'Search AT MOST 2-3 times, then act on the results: read the matched files with read_file instead of rephrasing the same search.',
418
585
  category: 'analysis',
419
586
  parameters: [],
420
587
  execute: async (args) => {
421
- const query = (args['query'] ?? '').toLowerCase();
588
+ callCount++;
589
+ const nudge = callCount > SEARCH_NUDGE_THRESHOLD
590
+ ? `[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`
591
+ : '';
592
+ const rawQuery = (args['query'] ?? '').trim();
422
593
  const limit = args['limit'] ?? 5;
423
- if (!query)
594
+ if (!rawQuery)
424
595
  return 'searchDocuments requires a query.';
596
+ // v0.7.3: models routinely write boolean-ish queries ("plan OR phase OR
597
+ // milestone"). The previous implementation substring-matched the WHOLE
598
+ // query string, so every OR query — and any multi-word query that didn't
599
+ // appear verbatim — returned "No matches" even for documents created
600
+ // seconds earlier. Match any OR-phrase first, then fall back to
601
+ // any-word matching per file.
602
+ const phrases = rawQuery
603
+ .toLowerCase()
604
+ .split(/\s+or\s+/i)
605
+ .map((p) => p.trim())
606
+ .filter((p) => p.length > 0);
607
+ const words = rawQuery
608
+ .toLowerCase()
609
+ .split(/[^a-z0-9_-]+/)
610
+ .filter((w) => w.length >= 3 && w !== 'or' && w !== 'and' && w !== 'the');
425
611
  const files = [
426
612
  ...ctx.storage.listMarkdown(join(ctx.rootDir, 'decisions')),
427
613
  ...ctx.storage.listMarkdown(join(ctx.rootDir, 'docs')),
428
614
  ...ctx.storage.listMarkdown(join(ctx.rootDir, 'reviews')),
615
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, 'plan-tasks')),
616
+ ...ctx.storage.listMarkdown(join(ctx.rootDir, 'milestones')),
429
617
  workspaceFile(ctx.rootDir, 'plan'),
430
618
  workspaceFile(ctx.rootDir, 'risks'),
431
619
  ];
@@ -433,20 +621,40 @@ function searchDocumentsStub(ctx) {
433
621
  for (const file of files) {
434
622
  if (!existsSync(file))
435
623
  continue;
436
- const content = readFileSync(file, 'utf8').toLowerCase();
437
- const idx = content.indexOf(query);
624
+ const raw = readFileSync(file, 'utf8');
625
+ const content = raw.toLowerCase();
626
+ let idx = -1;
627
+ let matchLen = 0;
628
+ for (const p of phrases) {
629
+ const i = content.indexOf(p);
630
+ if (i >= 0) {
631
+ idx = i;
632
+ matchLen = p.length;
633
+ break;
634
+ }
635
+ }
636
+ if (idx < 0) {
637
+ for (const w of words) {
638
+ const i = content.indexOf(w);
639
+ if (i >= 0) {
640
+ idx = i;
641
+ matchLen = w.length;
642
+ break;
643
+ }
644
+ }
645
+ }
438
646
  if (idx < 0)
439
647
  continue;
440
648
  const start = Math.max(0, idx - 40);
441
- const end = Math.min(content.length, idx + query.length + 40);
442
- const snippet = '…' + readFileSync(file, 'utf8').slice(start, end).replace(/\n/g, ' ').trim() + '…';
443
- results.push({ path: file, snippet });
649
+ const end = Math.min(raw.length, idx + matchLen + 40);
650
+ const snippet = '…' + raw.slice(start, end).replace(/\n/g, ' ').trim() + '…';
651
+ results.push({ path: relative(ctx.rootDir, file) || basename(file), snippet });
444
652
  if (results.length >= limit)
445
653
  break;
446
654
  }
447
655
  if (results.length === 0)
448
- return `No matches for "${query}" in workspace.`;
449
- return results.map((r) => `[${r.path}]\n${r.snippet}`).join('\n\n');
656
+ return `${nudge}No matches for "${rawQuery}" in workspace.`;
657
+ return nudge + results.map((r) => `[${r.path}]\n${r.snippet}`).join('\n\n');
450
658
  },
451
659
  };
452
660
  }
@@ -458,8 +666,13 @@ function linkDocumentsStub(ctx) {
458
666
  parameters: [],
459
667
  execute: async (args) => {
460
668
  return workspaceMutex.run(`${ctx.rootDir}:link`, () => {
461
- const fromId = args['fromId'];
462
- const toId = args['toId'];
669
+ // v0.7.8: the provider-facing JSON schema (toolSchemas.ts) advertises
670
+ // sourceId/targetPathOrTitle accept those aliases too, otherwise
671
+ // every schema-conformant call fails with "requires fromId and toId".
672
+ const fromId = args['fromId'] ?? args['sourceId'];
673
+ const toId = args['toId']
674
+ ?? args['targetId']
675
+ ?? args['targetPathOrTitle'];
463
676
  if (!fromId || !toId)
464
677
  return 'linkDocuments requires fromId and toId.';
465
678
  // Find the source file by id
@@ -497,7 +710,8 @@ function getDocumentBacklinksStub(ctx) {
497
710
  category: 'analysis',
498
711
  parameters: [],
499
712
  execute: async (args) => {
500
- const targetId = args['targetId'];
713
+ // v0.7.8: the provider-facing JSON schema advertises `id` — accept it.
714
+ const targetId = args['targetId'] ?? args['id'];
501
715
  if (!targetId)
502
716
  return 'getDocumentBacklinks requires targetId.';
503
717
  const allFiles = [