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.
- package/dist/cli/app.js +64 -29
- package/dist/cli/app.js.map +1 -1
- package/dist/cli/components/ChatStream.js +40 -126
- package/dist/cli/components/ChatStream.js.map +1 -1
- package/dist/cli/components/LiveRegion.js +54 -0
- package/dist/cli/components/LiveRegion.js.map +1 -0
- package/dist/cli/components/SplashScreen.js +158 -0
- package/dist/cli/components/SplashScreen.js.map +1 -0
- package/dist/cli/components/StatusBar.js +42 -0
- package/dist/cli/components/StatusBar.js.map +1 -0
- package/dist/cli/components/ToolOutput.js +68 -0
- package/dist/cli/components/ToolOutput.js.map +1 -0
- package/dist/cli/components/toolFormat.js +195 -0
- package/dist/cli/components/toolFormat.js.map +1 -0
- package/dist/cli/councilDispatcher.js +20 -0
- package/dist/cli/councilDispatcher.js.map +1 -1
- package/dist/cli/grokOAuth.js +93 -75
- package/dist/cli/grokOAuth.js.map +1 -1
- package/dist/cli/hooks/chatState.js +139 -0
- package/dist/cli/hooks/chatState.js.map +1 -0
- package/dist/cli/hooks/eventsToMessages.js +1 -1
- package/dist/cli/hooks/eventsToMessages.js.map +1 -1
- package/dist/cli/hooks/messageHelpers.js +11 -4
- package/dist/cli/hooks/messageHelpers.js.map +1 -1
- package/dist/cli/hooks/useBatchedMessages.js +14 -10
- package/dist/cli/hooks/useBatchedMessages.js.map +1 -1
- package/dist/cli/hooks/useChatTurn.js +400 -87
- package/dist/cli/hooks/useChatTurn.js.map +1 -1
- package/dist/cli/hooks/useSession.js +23 -11
- package/dist/cli/hooks/useSession.js.map +1 -1
- package/dist/cli/hooks/useSlashDispatch.js +9 -1
- package/dist/cli/hooks/useSlashDispatch.js.map +1 -1
- package/dist/cli/main.bundled.js +15468 -12961
- package/dist/cli/main.bundled.js.map +4 -4
- package/dist/cli/main.js +43 -2
- package/dist/cli/main.js.map +1 -1
- package/dist/cli/mcp/mcpClient.js +152 -0
- package/dist/cli/mcp/mcpClient.js.map +1 -0
- package/dist/cli/mcp/mcpManager.js +139 -0
- package/dist/cli/mcp/mcpManager.js.map +1 -0
- package/dist/cli/refreshRegistry.js +9 -6
- package/dist/cli/refreshRegistry.js.map +1 -1
- package/dist/cli/skillsMd.js +155 -0
- package/dist/cli/skillsMd.js.map +1 -0
- package/dist/cli/slashHandlers/skills.js +16 -0
- package/dist/cli/slashHandlers/skills.js.map +1 -1
- package/dist/cli/toolRegistry.js +31 -0
- package/dist/cli/toolRegistry.js.map +1 -1
- package/dist/cli/wizard/index.js +1 -1
- package/dist/cli/wizard/index.js.map +1 -1
- package/dist/cli/workspace/completeDesign.js +128 -0
- package/dist/cli/workspace/completeDesign.js.map +1 -0
- package/dist/cli/workspace/paths.js +20 -13
- package/dist/cli/workspace/paths.js.map +1 -1
- package/dist/cli/workspace/postCouncilHook.js +146 -20
- package/dist/cli/workspace/postCouncilHook.js.map +1 -1
- package/dist/cli/workspace/storage.js +7 -3
- package/dist/cli/workspace/storage.js.map +1 -1
- package/dist/cli/workspace/stubs.js +350 -136
- package/dist/cli/workspace/stubs.js.map +1 -1
- package/dist/cli/workspace/workspaceSummary.js +255 -0
- package/dist/cli/workspace/workspaceSummary.js.map +1 -0
- 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
|
|
5
|
-
* `updateTask`, `addIdea`, `createMilestone`,
|
|
6
|
-
* `searchDocuments`, `linkDocuments`,
|
|
7
|
-
* filesystem-backed stubs that persist to
|
|
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
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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)
|
|
52
|
-
*
|
|
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
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
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
|
|
277
|
-
if (!taskId || !
|
|
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
|
-
|
|
281
|
-
|
|
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
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (!
|
|
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
|
|
437
|
-
const
|
|
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(
|
|
442
|
-
const snippet = '…' +
|
|
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
|
|
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
|
-
|
|
462
|
-
|
|
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
|
-
|
|
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 = [
|