omegon 0.6.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.gitattributes +3 -0
- package/AGENTS.md +16 -0
- package/LICENSE +15 -0
- package/README.md +289 -0
- package/bin/pi.mjs +30 -0
- package/extensions/00-secrets/index.ts +1126 -0
- package/extensions/01-auth/auth.ts +401 -0
- package/extensions/01-auth/index.ts +289 -0
- package/extensions/auto-compact.ts +42 -0
- package/extensions/bootstrap/deps.ts +291 -0
- package/extensions/bootstrap/index.ts +811 -0
- package/extensions/chronos/chronos.sh +487 -0
- package/extensions/chronos/index.ts +148 -0
- package/extensions/cleave/assessment.ts +754 -0
- package/extensions/cleave/bridge.ts +31 -0
- package/extensions/cleave/conflicts.ts +250 -0
- package/extensions/cleave/dispatcher.ts +808 -0
- package/extensions/cleave/guardrails.ts +426 -0
- package/extensions/cleave/index.ts +3121 -0
- package/extensions/cleave/lifecycle-emitter.ts +20 -0
- package/extensions/cleave/openspec.ts +811 -0
- package/extensions/cleave/planner.ts +260 -0
- package/extensions/cleave/review.ts +579 -0
- package/extensions/cleave/skills.ts +355 -0
- package/extensions/cleave/types.ts +261 -0
- package/extensions/cleave/workspace.ts +861 -0
- package/extensions/cleave/worktree.ts +243 -0
- package/extensions/core-renderers.ts +253 -0
- package/extensions/dashboard/context-gauge.ts +58 -0
- package/extensions/dashboard/file-watch.ts +14 -0
- package/extensions/dashboard/footer.ts +1145 -0
- package/extensions/dashboard/git.ts +185 -0
- package/extensions/dashboard/index.ts +478 -0
- package/extensions/dashboard/memory-audit.ts +34 -0
- package/extensions/dashboard/overlay-data.ts +705 -0
- package/extensions/dashboard/overlay.ts +365 -0
- package/extensions/dashboard/render-utils.ts +54 -0
- package/extensions/dashboard/types.ts +191 -0
- package/extensions/dashboard/uri-helper.ts +45 -0
- package/extensions/debug.ts +69 -0
- package/extensions/defaults.ts +282 -0
- package/extensions/design-tree/dashboard-state.ts +161 -0
- package/extensions/design-tree/design-card.ts +362 -0
- package/extensions/design-tree/index.ts +2130 -0
- package/extensions/design-tree/lifecycle-emitter.ts +41 -0
- package/extensions/design-tree/tree.ts +1607 -0
- package/extensions/design-tree/types.ts +163 -0
- package/extensions/distill.ts +127 -0
- package/extensions/effort/index.ts +395 -0
- package/extensions/effort/tiers.ts +146 -0
- package/extensions/effort/types.ts +105 -0
- package/extensions/lib/git-state.ts +227 -0
- package/extensions/lib/local-models.ts +157 -0
- package/extensions/lib/model-preferences.ts +51 -0
- package/extensions/lib/model-routing.ts +720 -0
- package/extensions/lib/operator-fallback.ts +205 -0
- package/extensions/lib/operator-profile.ts +360 -0
- package/extensions/lib/slash-command-bridge.ts +253 -0
- package/extensions/lib/typebox-helpers.ts +16 -0
- package/extensions/local-inference/index.ts +727 -0
- package/extensions/mcp-bridge/README.md +220 -0
- package/extensions/mcp-bridge/index.ts +951 -0
- package/extensions/mcp-bridge/lib.ts +365 -0
- package/extensions/mcp-bridge/mcp.json +3 -0
- package/extensions/mcp-bridge/package.json +11 -0
- package/extensions/model-budget.ts +752 -0
- package/extensions/offline-driver.ts +403 -0
- package/extensions/openspec/archive-gate.ts +164 -0
- package/extensions/openspec/branch-cleanup.ts +64 -0
- package/extensions/openspec/dashboard-state.ts +50 -0
- package/extensions/openspec/index.ts +1917 -0
- package/extensions/openspec/lifecycle-emitter.ts +65 -0
- package/extensions/openspec/lifecycle-files.ts +70 -0
- package/extensions/openspec/lifecycle.ts +50 -0
- package/extensions/openspec/reconcile.ts +187 -0
- package/extensions/openspec/spec.ts +1385 -0
- package/extensions/openspec/types.ts +98 -0
- package/extensions/project-memory/DESIGN-global-mind.md +198 -0
- package/extensions/project-memory/README.md +202 -0
- package/extensions/project-memory/api-types.ts +382 -0
- package/extensions/project-memory/compaction-policy.ts +29 -0
- package/extensions/project-memory/core.ts +164 -0
- package/extensions/project-memory/embeddings.ts +230 -0
- package/extensions/project-memory/extraction-v2.ts +861 -0
- package/extensions/project-memory/factstore.ts +2177 -0
- package/extensions/project-memory/index.ts +3459 -0
- package/extensions/project-memory/injection-metrics.ts +91 -0
- package/extensions/project-memory/jsonl-io.ts +12 -0
- package/extensions/project-memory/lifecycle.ts +331 -0
- package/extensions/project-memory/migration.ts +293 -0
- package/extensions/project-memory/package.json +9 -0
- package/extensions/project-memory/sci-renderers.ts +7 -0
- package/extensions/project-memory/template.ts +103 -0
- package/extensions/project-memory/triggers.ts +52 -0
- package/extensions/project-memory/types.ts +102 -0
- package/extensions/render/composition/fonts/Inter-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Inter-Regular.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Bold.ttf +0 -0
- package/extensions/render/composition/fonts/Tomorrow-Regular.ttf +0 -0
- package/extensions/render/composition/package-lock.json +534 -0
- package/extensions/render/composition/package.json +22 -0
- package/extensions/render/composition/render.mjs +246 -0
- package/extensions/render/composition/test-comp.tsx +87 -0
- package/extensions/render/composition/types.ts +24 -0
- package/extensions/render/excalidraw/UPSTREAM.md +81 -0
- package/extensions/render/excalidraw/elements.ts +764 -0
- package/extensions/render/excalidraw/index.ts +66 -0
- package/extensions/render/excalidraw/types.ts +223 -0
- package/extensions/render/excalidraw-renderer/pyproject.toml +8 -0
- package/extensions/render/excalidraw-renderer/render_excalidraw.py +182 -0
- package/extensions/render/excalidraw-renderer/render_template.html +59 -0
- package/extensions/render/index.ts +830 -0
- package/extensions/render/native-diagrams/index.ts +57 -0
- package/extensions/render/native-diagrams/motifs.ts +542 -0
- package/extensions/render/native-diagrams/raster.ts +8 -0
- package/extensions/render/native-diagrams/scene.ts +75 -0
- package/extensions/render/native-diagrams/spec.ts +204 -0
- package/extensions/render/native-diagrams/svg.ts +116 -0
- package/extensions/sci-ui.ts +304 -0
- package/extensions/session-log.ts +174 -0
- package/extensions/shared-state.ts +146 -0
- package/extensions/spinner-verbs.ts +91 -0
- package/extensions/style.ts +281 -0
- package/extensions/terminal-title.ts +191 -0
- package/extensions/tool-profile/index.ts +291 -0
- package/extensions/tool-profile/profiles.ts +290 -0
- package/extensions/types.d.ts +9 -0
- package/extensions/vault/index.ts +185 -0
- package/extensions/version-check.ts +90 -0
- package/extensions/view/index.ts +859 -0
- package/extensions/view/uri-resolver.ts +148 -0
- package/extensions/web-search/index.ts +182 -0
- package/extensions/web-search/providers.ts +121 -0
- package/extensions/web-ui/index.ts +110 -0
- package/extensions/web-ui/server.ts +265 -0
- package/extensions/web-ui/state.ts +462 -0
- package/extensions/web-ui/static/index.html +145 -0
- package/extensions/web-ui/types.ts +284 -0
- package/package.json +76 -0
- package/prompts/init.md +75 -0
- package/prompts/new-repo.md +54 -0
- package/prompts/oci-login.md +56 -0
- package/prompts/status.md +50 -0
- package/settings.json +4 -0
- package/skills/cleave/SKILL.md +218 -0
- package/skills/git/SKILL.md +209 -0
- package/skills/git/_reference/ci-validation.md +204 -0
- package/skills/oci/SKILL.md +338 -0
- package/skills/openspec/SKILL.md +346 -0
- package/skills/pi-extensions/SKILL.md +191 -0
- package/skills/pi-tui/SKILL.md +517 -0
- package/skills/python/SKILL.md +189 -0
- package/skills/rust/SKILL.md +268 -0
- package/skills/security/SKILL.md +206 -0
- package/skills/style/SKILL.md +264 -0
- package/skills/typescript/SKILL.md +225 -0
- package/skills/vault/SKILL.md +102 -0
- package/themes/alpharius-legacy.json +85 -0
- package/themes/alpharius.conf +59 -0
- package/themes/alpharius.json +88 -0
|
@@ -0,0 +1,2130 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Design Tree Extension
|
|
3
|
+
*
|
|
4
|
+
* Codifies the interactive design paradigm:
|
|
5
|
+
* EXPLORE → RESEARCH → CRYSTALLIZE → BRANCH → RECURSE
|
|
6
|
+
*
|
|
7
|
+
* Provides two tools for agent autonomy:
|
|
8
|
+
* - design_tree (queries: list, node, frontier, dependencies, children)
|
|
9
|
+
* - design_tree_update (mutations: create, set_status, add_question,
|
|
10
|
+
* remove_question, add_research, add_decision,
|
|
11
|
+
* add_dependency, branch, focus, unfocus, implement)
|
|
12
|
+
*
|
|
13
|
+
* Commands for interactive use:
|
|
14
|
+
* /design list|focus|unfocus|decide|explore|block|defer|branch|frontier|new|update|implement
|
|
15
|
+
*
|
|
16
|
+
* Documents use YAML frontmatter + structured body sections:
|
|
17
|
+
* ## Overview | ## Research | ## Decisions | ## Open Questions | ## Implementation Notes
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
import type { ExtensionAPI, ExtensionContext } from "@cwilson613/pi-coding-agent";
|
|
21
|
+
import { Type } from "@sinclair/typebox";
|
|
22
|
+
import { StringEnum } from "../lib/typebox-helpers.ts";
|
|
23
|
+
import { Text } from "@cwilson613/pi-tui";
|
|
24
|
+
import * as fs from "node:fs";
|
|
25
|
+
import * as path from "node:path";
|
|
26
|
+
import { execFileSync } from "node:child_process";
|
|
27
|
+
import { shouldRefreshDesignTreeForPath } from "../dashboard/file-watch.ts";
|
|
28
|
+
import { sharedState } from "../shared-state.ts";
|
|
29
|
+
|
|
30
|
+
import { emitDesignTreeState } from "./dashboard-state.ts";
|
|
31
|
+
import { sciCall, sciLoading, sciOk, sciErr, sciExpanded, sciBanner } from "../sci-ui.ts";
|
|
32
|
+
import { SciDesignCard, buildCardDetails } from "./design-card.ts";
|
|
33
|
+
import type { DesignCardDetails } from "./design-card.ts";
|
|
34
|
+
import { emitConstraintCandidates, emitDecisionCandidates } from "./lifecycle-emitter.ts";
|
|
35
|
+
import { resolveNodeOpenSpecBinding, resolveDesignSpecBinding } from "../openspec/archive-gate.ts";
|
|
36
|
+
import { resolveLifecycleSummary, getAssessmentStatus, getChange, getOpenSpecDir } from "../openspec/spec.ts";
|
|
37
|
+
import { evaluateLifecycleReconciliation } from "../openspec/reconcile.ts";
|
|
38
|
+
import type { LifecycleSummary } from "../openspec/spec.ts";
|
|
39
|
+
|
|
40
|
+
import type { DesignNode, DesignTree, NodeStatus, IssueType, Priority } from "./types.ts";
|
|
41
|
+
import { VALID_STATUSES, STATUS_ICONS, STATUS_COLORS, VALID_ISSUE_TYPES, PRIORITY_LABELS } from "./types.ts";
|
|
42
|
+
import {
|
|
43
|
+
scanDesignDocs,
|
|
44
|
+
getChildren,
|
|
45
|
+
getAllOpenQuestions,
|
|
46
|
+
getDocBody,
|
|
47
|
+
getNodeSections,
|
|
48
|
+
createNode,
|
|
49
|
+
setNodeStatus,
|
|
50
|
+
addOpenQuestion,
|
|
51
|
+
removeOpenQuestion,
|
|
52
|
+
addResearch,
|
|
53
|
+
addDecision,
|
|
54
|
+
addDependency,
|
|
55
|
+
addRelated,
|
|
56
|
+
addImplementationNotes,
|
|
57
|
+
branchFromQuestion,
|
|
58
|
+
toSlug,
|
|
59
|
+
validateNodeId,
|
|
60
|
+
scaffoldOpenSpecChange,
|
|
61
|
+
scaffoldDesignOpenSpecChange,
|
|
62
|
+
mirrorOpenQuestionsToDesignSpec,
|
|
63
|
+
matchBranchToNode,
|
|
64
|
+
appendBranch,
|
|
65
|
+
readGitBranch,
|
|
66
|
+
sanitizeBranchName,
|
|
67
|
+
writeNodeDocument,
|
|
68
|
+
parseFrontmatter,
|
|
69
|
+
countAcceptanceCriteria,
|
|
70
|
+
} from "./tree.ts";
|
|
71
|
+
import { getSharedBridge, buildSlashCommandResult } from "../lib/slash-command-bridge.ts";
|
|
72
|
+
|
|
73
|
+
// ─── Extension ───────────────────────────────────────────────────────────────
|
|
74
|
+
|
|
75
|
+
export default function designTreeExtension(pi: ExtensionAPI): void {
|
|
76
|
+
let tree: DesignTree = { nodes: new Map(), docsDir: "" };
|
|
77
|
+
let focusedNode: string | null = null;
|
|
78
|
+
let docsWatcher: fs.FSWatcher | null = null;
|
|
79
|
+
let docsRefreshTimer: NodeJS.Timeout | null = null;
|
|
80
|
+
|
|
81
|
+
function reload(cwd: string): void {
|
|
82
|
+
const docsDir = path.join(cwd, "docs");
|
|
83
|
+
tree = scanDesignDocs(docsDir);
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function docsDir(cwd: string): string {
|
|
87
|
+
return path.join(cwd, "docs");
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function emitCurrentState(): void {
|
|
91
|
+
if (tree.nodes.size === 0) return;
|
|
92
|
+
emitDesignTreeState(pi, tree, focusedNode ? tree.nodes.get(focusedNode) ?? null : null);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function scheduleDocsRefresh(filePath?: string): void {
|
|
96
|
+
if (filePath && !shouldRefreshDesignTreeForPath(filePath, tree.docsDir || docsDir(process.cwd()))) {
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
if (docsRefreshTimer) clearTimeout(docsRefreshTimer);
|
|
100
|
+
docsRefreshTimer = setTimeout(() => {
|
|
101
|
+
docsRefreshTimer = null;
|
|
102
|
+
if (!tree.docsDir) return;
|
|
103
|
+
tree = scanDesignDocs(tree.docsDir);
|
|
104
|
+
emitCurrentState();
|
|
105
|
+
}, 75);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
function startDocsWatcher(cwd: string): void {
|
|
109
|
+
const dir = docsDir(cwd);
|
|
110
|
+
if (!fs.existsSync(dir)) return;
|
|
111
|
+
docsWatcher?.close();
|
|
112
|
+
docsWatcher = null;
|
|
113
|
+
try {
|
|
114
|
+
docsWatcher = fs.watch(dir, { recursive: true }, (_eventType, filename) => {
|
|
115
|
+
const filePath = typeof filename === "string" && filename.length > 0
|
|
116
|
+
? path.join(dir, filename)
|
|
117
|
+
: undefined;
|
|
118
|
+
scheduleDocsRefresh(filePath);
|
|
119
|
+
});
|
|
120
|
+
} catch {
|
|
121
|
+
// Best effort only — unsupported platforms simply fall back to command/tool-driven emits.
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
// ─── Canonical lifecycle summary helper ──────────────────────────────
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Compute a normalized LifecycleSummary for a design node when it is bound
|
|
129
|
+
* to an OpenSpec change. Returns null when the node has no binding.
|
|
130
|
+
*
|
|
131
|
+
* Routes through resolveLifecycleSummary so all callers share a single
|
|
132
|
+
* lifecycle truth rather than deriving stage/binding/readiness independently.
|
|
133
|
+
*/
|
|
134
|
+
function resolveNodeLifecycleSummary(cwd: string, node: DesignNode): LifecycleSummary | null {
|
|
135
|
+
const binding = resolveNodeOpenSpecBinding(cwd, node);
|
|
136
|
+
if (!binding.bound || !binding.changeName) return null;
|
|
137
|
+
|
|
138
|
+
try {
|
|
139
|
+
const assessment = getAssessmentStatus(cwd, binding.changeName);
|
|
140
|
+
const reconciliation = evaluateLifecycleReconciliation(cwd, binding.changeName);
|
|
141
|
+
const archiveBlocked = reconciliation.issues.length > 0;
|
|
142
|
+
const archiveBlockedReason = archiveBlocked
|
|
143
|
+
? reconciliation.issues.map((i) => i.message).join("; ")
|
|
144
|
+
: null;
|
|
145
|
+
const archiveBlockedIssueCodes = reconciliation.issues.map((i) => i.code);
|
|
146
|
+
|
|
147
|
+
const change = getChange(cwd, binding.changeName);
|
|
148
|
+
if (!change) return null;
|
|
149
|
+
|
|
150
|
+
return resolveLifecycleSummary({
|
|
151
|
+
change,
|
|
152
|
+
record: assessment.record,
|
|
153
|
+
freshness: assessment.freshness,
|
|
154
|
+
archiveBlocked,
|
|
155
|
+
archiveBlockedReason,
|
|
156
|
+
archiveBlockedIssueCodes,
|
|
157
|
+
boundNodeIds: [node.id],
|
|
158
|
+
});
|
|
159
|
+
} catch {
|
|
160
|
+
// Non-fatal — return null if OpenSpec data is unavailable
|
|
161
|
+
return null;
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ─── Implement Logic (shared between tool and command) ───────────────
|
|
166
|
+
|
|
167
|
+
interface ImplementResult {
|
|
168
|
+
ok: boolean;
|
|
169
|
+
message: string;
|
|
170
|
+
branch?: string;
|
|
171
|
+
changePath?: string;
|
|
172
|
+
files?: string[];
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function executeImplement(cwd: string, node: DesignNode, branchPrefix: string = "feature"): ImplementResult {
|
|
176
|
+
// Scaffold OpenSpec change
|
|
177
|
+
const result = scaffoldOpenSpecChange(cwd, tree, node);
|
|
178
|
+
|
|
179
|
+
// Bail if scaffold failed (e.g. change directory already exists)
|
|
180
|
+
if (result.files.length === 0) {
|
|
181
|
+
return { ok: false, message: result.message };
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// D1: Explicit `branch` frontmatter field overrides derived name.
|
|
185
|
+
// Otherwise derive from prefix + node ID. Never read branches[] as override —
|
|
186
|
+
// that array is a historical record, not an intent.
|
|
187
|
+
const branchName = node.branch ?? `${branchPrefix}/${node.id}`;
|
|
188
|
+
|
|
189
|
+
// Validate branch name before any shell or fs operations
|
|
190
|
+
const safeBranch = sanitizeBranchName(branchName);
|
|
191
|
+
if (!safeBranch) {
|
|
192
|
+
return { ok: false, message: `Invalid branch name: '${branchName}' — contains disallowed characters` };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
// Write all frontmatter fields in one pass to avoid partial state on failure.
|
|
196
|
+
// setNodeStatus and appendBranch each do a full file rewrite; we consolidate
|
|
197
|
+
// by writing the final intended state directly.
|
|
198
|
+
const existingBranches = node.branches ?? [];
|
|
199
|
+
const updatedNode: DesignNode = {
|
|
200
|
+
...node,
|
|
201
|
+
status: "implementing",
|
|
202
|
+
branches: existingBranches.includes(safeBranch)
|
|
203
|
+
? existingBranches
|
|
204
|
+
: [...existingBranches, safeBranch],
|
|
205
|
+
openspec_change: node.id,
|
|
206
|
+
};
|
|
207
|
+
// Use writeNodeDocument to emit all fields in one write
|
|
208
|
+
const sections = getNodeSections(node);
|
|
209
|
+
writeNodeDocument(updatedNode, sections);
|
|
210
|
+
|
|
211
|
+
// Create git branch — execFileSync with array args, no shell interpolation
|
|
212
|
+
try {
|
|
213
|
+
execFileSync("git", ["checkout", "-b", safeBranch], { cwd, stdio: "pipe" });
|
|
214
|
+
} catch {
|
|
215
|
+
try {
|
|
216
|
+
// Branch already exists — switch to it
|
|
217
|
+
execFileSync("git", ["checkout", safeBranch], { cwd, stdio: "pipe" });
|
|
218
|
+
} catch {
|
|
219
|
+
// Non-fatal: branch ops may fail in worktrees or detached HEAD
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
return {
|
|
224
|
+
ok: true,
|
|
225
|
+
message: result.message + `\n\nStatus: implementing\nBranch: ${safeBranch}\nOpenSpec change: ${node.id}`,
|
|
226
|
+
branch: safeBranch,
|
|
227
|
+
changePath: result.changePath,
|
|
228
|
+
files: result.files,
|
|
229
|
+
};
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
// ─── Tool: design_tree (queries) ─────────────────────────────────────
|
|
233
|
+
|
|
234
|
+
pi.registerTool({
|
|
235
|
+
name: "design_tree",
|
|
236
|
+
label: "Design Tree",
|
|
237
|
+
description:
|
|
238
|
+
"Query the design tree: list nodes, get node details with structured sections, " +
|
|
239
|
+
"find open questions (frontier), check dependencies, list children. " +
|
|
240
|
+
"Documents have structured sections: Overview, Research, Decisions, Open Questions, Implementation Notes.",
|
|
241
|
+
promptSnippet: "Query the design exploration tree — nodes, status, open questions, dependencies, structured content",
|
|
242
|
+
promptGuidelines: [
|
|
243
|
+
"Use design_tree to check the state of design documents before creating or modifying them",
|
|
244
|
+
"When the user says 'let's explore X', use design_tree to find the relevant node and its open questions",
|
|
245
|
+
"After a design discussion converges, use design_tree_update with action 'set_status' to mark the node as decided",
|
|
246
|
+
"When discussion reveals new sub-topics, use design_tree_update with action 'branch' to create child nodes",
|
|
247
|
+
"Use action 'node' to read the full structured content (research, decisions, implementation notes)",
|
|
248
|
+
"Use action 'ready' to find nodes that are decided and have all dependencies implemented — work queue for sprint planning",
|
|
249
|
+
"Use action 'blocked' to find nodes that are explicitly blocked or have unresolved dependency blockers — shows exactly which dep is blocking each node",
|
|
250
|
+
],
|
|
251
|
+
parameters: Type.Object({
|
|
252
|
+
action: StringEnum(["list", "node", "frontier", "dependencies", "children", "ready", "blocked"] as const),
|
|
253
|
+
node_id: Type.Optional(
|
|
254
|
+
Type.String({ description: "Node ID (required for node, dependencies, children)" }),
|
|
255
|
+
),
|
|
256
|
+
}),
|
|
257
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
258
|
+
reload(ctx.cwd);
|
|
259
|
+
|
|
260
|
+
switch (params.action) {
|
|
261
|
+
case "list": {
|
|
262
|
+
const nodes = Array.from(tree.nodes.values()).map((n) => {
|
|
263
|
+
const binding = resolveNodeOpenSpecBinding(ctx.cwd, n);
|
|
264
|
+
const lifecycleSummary = resolveNodeLifecycleSummary(ctx.cwd, n);
|
|
265
|
+
return {
|
|
266
|
+
id: n.id,
|
|
267
|
+
title: n.title,
|
|
268
|
+
status: n.status,
|
|
269
|
+
parent: n.parent || null,
|
|
270
|
+
tags: n.tags,
|
|
271
|
+
open_questions: n.open_questions.length,
|
|
272
|
+
dependencies: n.dependencies,
|
|
273
|
+
branches: n.branches,
|
|
274
|
+
openspec_change: n.openspec_change ?? null,
|
|
275
|
+
priority: n.priority ?? null,
|
|
276
|
+
issue_type: n.issue_type ?? null,
|
|
277
|
+
acceptance_criteria_summary: countAcceptanceCriteria(n),
|
|
278
|
+
lifecycle: {
|
|
279
|
+
// Normalized binding status from canonical resolver when available.
|
|
280
|
+
// The fallback (binding.bound ? "bound" : "unbound") is an explicit safety
|
|
281
|
+
// guard for the error paths where resolveNodeLifecycleSummary returns null
|
|
282
|
+
// (e.g. getChange() fails or throws). For successfully bound nodes,
|
|
283
|
+
// resolveLifecycleSummary(bound:true) now returns "bound" directly.
|
|
284
|
+
boundToOpenSpec: binding.bound,
|
|
285
|
+
bindingStatus: lifecycleSummary?.bindingStatus ?? (binding.bound ? "bound" : "unbound"),
|
|
286
|
+
implementationPhase: n.status === "implementing" || n.status === "implemented",
|
|
287
|
+
archiveReady: lifecycleSummary?.archiveReady ?? null,
|
|
288
|
+
nextAction: lifecycleSummary?.nextAction ?? null,
|
|
289
|
+
},
|
|
290
|
+
};
|
|
291
|
+
});
|
|
292
|
+
return {
|
|
293
|
+
content: [{ type: "text", text: JSON.stringify(nodes, null, 2) }],
|
|
294
|
+
details: { nodes },
|
|
295
|
+
};
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
case "node": {
|
|
299
|
+
if (!params.node_id) {
|
|
300
|
+
return { content: [{ type: "text", text: "Error: node_id required" }], details: {}, isError: true };
|
|
301
|
+
}
|
|
302
|
+
const node = tree.nodes.get(params.node_id);
|
|
303
|
+
if (!node) {
|
|
304
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
305
|
+
}
|
|
306
|
+
const sections = getNodeSections(node);
|
|
307
|
+
const children = getChildren(tree, node.id).map((c) => ({ id: c.id, title: c.title, status: c.status }));
|
|
308
|
+
|
|
309
|
+
const binding = resolveNodeOpenSpecBinding(ctx.cwd, node);
|
|
310
|
+
const lifecycleSummary = resolveNodeLifecycleSummary(ctx.cwd, node);
|
|
311
|
+
const result = {
|
|
312
|
+
id: node.id,
|
|
313
|
+
title: node.title,
|
|
314
|
+
status: node.status,
|
|
315
|
+
parent: node.parent || null,
|
|
316
|
+
dependencies: node.dependencies,
|
|
317
|
+
related: node.related,
|
|
318
|
+
tags: node.tags,
|
|
319
|
+
branches: node.branches,
|
|
320
|
+
openspecChange: node.openspec_change ?? null,
|
|
321
|
+
priority: node.priority ?? null,
|
|
322
|
+
issue_type: node.issue_type ?? null,
|
|
323
|
+
children,
|
|
324
|
+
sections: {
|
|
325
|
+
overview: sections.overview,
|
|
326
|
+
research: sections.research,
|
|
327
|
+
decisions: sections.decisions,
|
|
328
|
+
openQuestions: sections.openQuestions,
|
|
329
|
+
implementationNotes: {
|
|
330
|
+
fileScope: sections.implementationNotes.fileScope,
|
|
331
|
+
constraints: sections.implementationNotes.constraints,
|
|
332
|
+
},
|
|
333
|
+
acceptanceCriteria: sections.acceptanceCriteria,
|
|
334
|
+
extraSections: sections.extraSections.map((s) => s.heading),
|
|
335
|
+
},
|
|
336
|
+
lifecycle: {
|
|
337
|
+
// Backward-compatible boolean for existing callers
|
|
338
|
+
boundToOpenSpec: binding.bound,
|
|
339
|
+
// Normalized binding status from canonical resolver
|
|
340
|
+
bindingStatus: lifecycleSummary?.bindingStatus ?? (binding.bound ? "bound" : "unbound"),
|
|
341
|
+
canImplement: node.status === "decided",
|
|
342
|
+
isImplementationPhase: node.status === "implementing" || node.status === "implemented",
|
|
343
|
+
reopenSignalTarget: binding.changeName ?? node.openspec_change ?? node.id,
|
|
344
|
+
// Canonical lifecycle fields from resolveLifecycleSummary when available
|
|
345
|
+
archiveReady: lifecycleSummary?.archiveReady ?? null,
|
|
346
|
+
verificationSubstate: lifecycleSummary?.verificationSubstate ?? null,
|
|
347
|
+
nextAction: lifecycleSummary?.nextAction ?? null,
|
|
348
|
+
openspecStage: lifecycleSummary?.stage ?? null,
|
|
349
|
+
implementationNoteCounts: {
|
|
350
|
+
fileScope: sections.implementationNotes.fileScope.length,
|
|
351
|
+
constraints: sections.implementationNotes.constraints.length,
|
|
352
|
+
},
|
|
353
|
+
},
|
|
354
|
+
};
|
|
355
|
+
|
|
356
|
+
// Also include the raw body for the LLM to reference
|
|
357
|
+
const body = getDocBody(node.filePath, 8000);
|
|
358
|
+
|
|
359
|
+
return {
|
|
360
|
+
content: [{
|
|
361
|
+
type: "text",
|
|
362
|
+
text: JSON.stringify(result, null, 2) + "\n\n--- Document Content ---\n\n" + body,
|
|
363
|
+
}],
|
|
364
|
+
details: { node: result },
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
case "frontier": {
|
|
369
|
+
const questions = getAllOpenQuestions(tree);
|
|
370
|
+
const grouped: Record<string, string[]> = {};
|
|
371
|
+
for (const { node, question } of questions) {
|
|
372
|
+
if (!grouped[node.id]) grouped[node.id] = [];
|
|
373
|
+
grouped[node.id].push(question);
|
|
374
|
+
}
|
|
375
|
+
return {
|
|
376
|
+
content: [{
|
|
377
|
+
type: "text",
|
|
378
|
+
text:
|
|
379
|
+
`${questions.length} open questions across ${Object.keys(grouped).length} nodes:\n\n` +
|
|
380
|
+
Object.entries(grouped)
|
|
381
|
+
.map(
|
|
382
|
+
([id, qs]) =>
|
|
383
|
+
`## ${tree.nodes.get(id)?.title || id}\n${qs.map((q, i) => ` ${i + 1}. ${q}`).join("\n")}`,
|
|
384
|
+
)
|
|
385
|
+
.join("\n\n"),
|
|
386
|
+
}],
|
|
387
|
+
details: { questions: grouped },
|
|
388
|
+
};
|
|
389
|
+
}
|
|
390
|
+
|
|
391
|
+
case "dependencies": {
|
|
392
|
+
if (!params.node_id) {
|
|
393
|
+
return { content: [{ type: "text", text: "Error: node_id required" }], details: {}, isError: true };
|
|
394
|
+
}
|
|
395
|
+
const node = tree.nodes.get(params.node_id);
|
|
396
|
+
if (!node) {
|
|
397
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
398
|
+
}
|
|
399
|
+
const deps = node.dependencies
|
|
400
|
+
.map((id) => tree.nodes.get(id))
|
|
401
|
+
.filter(Boolean)
|
|
402
|
+
.map((n) => ({ id: n!.id, title: n!.title, status: n!.status }));
|
|
403
|
+
const dependents = Array.from(tree.nodes.values())
|
|
404
|
+
.filter((n) => n.dependencies.includes(params.node_id!))
|
|
405
|
+
.map((n) => ({ id: n.id, title: n.title, status: n.status }));
|
|
406
|
+
|
|
407
|
+
return {
|
|
408
|
+
content: [{
|
|
409
|
+
type: "text",
|
|
410
|
+
text: `Dependencies of ${node.title}:\n` +
|
|
411
|
+
JSON.stringify({ depends_on: deps, depended_by: dependents }, null, 2),
|
|
412
|
+
}],
|
|
413
|
+
details: { depends_on: deps, depended_by: dependents },
|
|
414
|
+
};
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
case "children": {
|
|
418
|
+
if (!params.node_id) {
|
|
419
|
+
return { content: [{ type: "text", text: "Error: node_id required" }], details: {}, isError: true };
|
|
420
|
+
}
|
|
421
|
+
const children = getChildren(tree, params.node_id).map((c) => ({
|
|
422
|
+
id: c.id,
|
|
423
|
+
title: c.title,
|
|
424
|
+
status: c.status,
|
|
425
|
+
open_questions: c.open_questions.length,
|
|
426
|
+
}));
|
|
427
|
+
return {
|
|
428
|
+
content: [{ type: "text", text: `Children of ${params.node_id}:\n${JSON.stringify(children, null, 2)}` }],
|
|
429
|
+
details: { children },
|
|
430
|
+
};
|
|
431
|
+
}
|
|
432
|
+
|
|
433
|
+
case "ready": {
|
|
434
|
+
// Nodes with status='decided' where every dependency is 'implemented'
|
|
435
|
+
// AND design-phase OpenSpec change is archived.
|
|
436
|
+
const readyNodes = Array.from(tree.nodes.values())
|
|
437
|
+
.filter((n) => {
|
|
438
|
+
if (n.status !== "decided") return false;
|
|
439
|
+
// Hard gate: design spec must be archived
|
|
440
|
+
const specBinding = resolveDesignSpecBinding(ctx.cwd, n.id);
|
|
441
|
+
if (!specBinding.archived) return false;
|
|
442
|
+
return n.dependencies.every((depId) => {
|
|
443
|
+
const dep = tree.nodes.get(depId);
|
|
444
|
+
return dep?.status === "implemented";
|
|
445
|
+
});
|
|
446
|
+
})
|
|
447
|
+
.sort((a, b) => {
|
|
448
|
+
// Sort by urgency descending: priority 1 (critical) first, 5 (trivial) last.
|
|
449
|
+
// "priority desc" in the spec means "most-urgent first", which is
|
|
450
|
+
// ascending numeric order because 1 = highest urgency.
|
|
451
|
+
// Nodes without priority sort last (treated as 5).
|
|
452
|
+
const pa = a.priority ?? 5;
|
|
453
|
+
const pb = b.priority ?? 5;
|
|
454
|
+
return pa - pb;
|
|
455
|
+
})
|
|
456
|
+
.map((n) => ({
|
|
457
|
+
id: n.id,
|
|
458
|
+
title: n.title,
|
|
459
|
+
status: n.status,
|
|
460
|
+
priority: n.priority ?? null,
|
|
461
|
+
issue_type: n.issue_type ?? null,
|
|
462
|
+
tags: n.tags,
|
|
463
|
+
openspec_change: n.openspec_change ?? null,
|
|
464
|
+
}));
|
|
465
|
+
|
|
466
|
+
return {
|
|
467
|
+
content: [{ type: "text", text: `${readyNodes.length} node(s) ready to implement:\n\n${JSON.stringify(readyNodes, null, 2)}` }],
|
|
468
|
+
details: { ready: readyNodes },
|
|
469
|
+
};
|
|
470
|
+
}
|
|
471
|
+
|
|
472
|
+
case "blocked": {
|
|
473
|
+
// Nodes explicitly blocked OR whose dependencies are not yet 'implemented'
|
|
474
|
+
// OR whose design-phase OpenSpec change is missing/not-archived.
|
|
475
|
+
|
|
476
|
+
// Pre-compute spec bindings once for all decided nodes to avoid repeated I/O.
|
|
477
|
+
// Scan openspec/design-archive/ once to build a set of archived node IDs,
|
|
478
|
+
// avoiding O(n) readdirSync calls (one per decided node) on the same dir.
|
|
479
|
+
const designArchiveDir = path.join(ctx.cwd, "openspec", "design-archive");
|
|
480
|
+
const archivedDesignIds = new Set<string>();
|
|
481
|
+
if (fs.existsSync(designArchiveDir)) {
|
|
482
|
+
for (const entry of fs.readdirSync(designArchiveDir, { withFileTypes: true })) {
|
|
483
|
+
if (!entry.isDirectory()) continue;
|
|
484
|
+
const m = entry.name.match(/^\d{4}-\d{2}-\d{2}-(.+)$/);
|
|
485
|
+
if (m) archivedDesignIds.add(m[1]);
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
|
|
489
|
+
const specBindingCache = new Map<string, ReturnType<typeof resolveDesignSpecBinding>>();
|
|
490
|
+
for (const n of tree.nodes.values()) {
|
|
491
|
+
if (n.status === "decided") {
|
|
492
|
+
// Use pre-scanned archivedDesignIds to avoid a second readdirSync per node.
|
|
493
|
+
const designDir = path.join(ctx.cwd, "openspec", "design", n.id);
|
|
494
|
+
const active =
|
|
495
|
+
fs.existsSync(designDir) &&
|
|
496
|
+
fs.statSync(designDir).isDirectory() &&
|
|
497
|
+
fs.readdirSync(designDir).length > 0;
|
|
498
|
+
const archivedInSet = archivedDesignIds.has(n.id);
|
|
499
|
+
specBindingCache.set(n.id, {
|
|
500
|
+
active,
|
|
501
|
+
archived: archivedInSet && !active,
|
|
502
|
+
missing: !active && !archivedInSet,
|
|
503
|
+
});
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
const blockedNodes = Array.from(tree.nodes.values())
|
|
508
|
+
.filter((n) => {
|
|
509
|
+
if (n.status === "implemented") return false;
|
|
510
|
+
if (n.status === "blocked") return true;
|
|
511
|
+
// Only surface dep-blocked signal for actively-worked nodes.
|
|
512
|
+
// seed/deferred nodes are intentionally parked — flagging them as
|
|
513
|
+
// blocked would be misleading noise.
|
|
514
|
+
if (n.status === "seed" || n.status === "deferred") return false;
|
|
515
|
+
// decided nodes: also block if design spec is not archived
|
|
516
|
+
if (n.status === "decided") {
|
|
517
|
+
const specBinding = specBindingCache.get(n.id)!;
|
|
518
|
+
if (!specBinding.archived) return true;
|
|
519
|
+
}
|
|
520
|
+
// exploring/deciding nodes with at least one non-implemented dependency
|
|
521
|
+
return n.dependencies.some((depId) => {
|
|
522
|
+
const dep = tree.nodes.get(depId);
|
|
523
|
+
return !dep || dep.status !== "implemented";
|
|
524
|
+
});
|
|
525
|
+
})
|
|
526
|
+
.map((n) => {
|
|
527
|
+
const blockingDeps = n.dependencies
|
|
528
|
+
.filter((depId) => {
|
|
529
|
+
const dep = tree.nodes.get(depId);
|
|
530
|
+
return !dep || dep.status !== "implemented";
|
|
531
|
+
})
|
|
532
|
+
.map((depId) => {
|
|
533
|
+
const dep = tree.nodes.get(depId);
|
|
534
|
+
return {
|
|
535
|
+
id: depId,
|
|
536
|
+
title: dep?.title ?? "(unknown)",
|
|
537
|
+
status: dep?.status ?? "missing",
|
|
538
|
+
};
|
|
539
|
+
});
|
|
540
|
+
|
|
541
|
+
// Determine blocking_reason and inject synthetic design-spec dep when needed
|
|
542
|
+
let blockingReason: "design-spec-not-archived" | "dependencies" | "explicit";
|
|
543
|
+
let allBlockingDeps = [...blockingDeps];
|
|
544
|
+
|
|
545
|
+
if (n.status === "blocked") {
|
|
546
|
+
blockingReason = "explicit";
|
|
547
|
+
} else if (n.status === "decided") {
|
|
548
|
+
const specBinding = specBindingCache.get(n.id)!;
|
|
549
|
+
if (!specBinding.archived) {
|
|
550
|
+
blockingReason = "design-spec-not-archived";
|
|
551
|
+
allBlockingDeps = [
|
|
552
|
+
{
|
|
553
|
+
id: "design-spec-missing",
|
|
554
|
+
title: "Design spec not archived",
|
|
555
|
+
status: "missing",
|
|
556
|
+
},
|
|
557
|
+
...allBlockingDeps,
|
|
558
|
+
];
|
|
559
|
+
} else {
|
|
560
|
+
blockingReason = "dependencies";
|
|
561
|
+
}
|
|
562
|
+
} else {
|
|
563
|
+
blockingReason = "dependencies";
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
return {
|
|
567
|
+
id: n.id,
|
|
568
|
+
title: n.title,
|
|
569
|
+
status: n.status,
|
|
570
|
+
priority: n.priority ?? null,
|
|
571
|
+
issue_type: n.issue_type ?? null,
|
|
572
|
+
tags: n.tags,
|
|
573
|
+
openspec_change: n.openspec_change ?? null,
|
|
574
|
+
blocking_reason: blockingReason,
|
|
575
|
+
blocking_deps: allBlockingDeps,
|
|
576
|
+
};
|
|
577
|
+
});
|
|
578
|
+
|
|
579
|
+
return {
|
|
580
|
+
content: [{ type: "text", text: `${blockedNodes.length} node(s) blocked:\n\n${JSON.stringify(blockedNodes, null, 2)}` }],
|
|
581
|
+
details: { blocked: blockedNodes },
|
|
582
|
+
};
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
|
|
586
|
+
return { content: [{ type: "text", text: "Unknown action" }], details: {} };
|
|
587
|
+
},
|
|
588
|
+
|
|
589
|
+
renderCall(args, theme) {
|
|
590
|
+
const summary = args.action + (args.node_id ? ":" + args.node_id : "");
|
|
591
|
+
return sciCall("design_tree", summary, theme);
|
|
592
|
+
},
|
|
593
|
+
|
|
594
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
595
|
+
if (isPartial) {
|
|
596
|
+
return sciLoading("design_tree", theme);
|
|
597
|
+
}
|
|
598
|
+
if ((result as any).isError) {
|
|
599
|
+
const first = result.content?.[0];
|
|
600
|
+
const errLine = (first && 'text' in first ? first.text : "Error") ?? "Error";
|
|
601
|
+
return sciErr(errLine.split("\n")[0].slice(0, 80), theme);
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
const details = (result.details || {}) as Record<string, any>;
|
|
605
|
+
|
|
606
|
+
// Rich card for single node results (expanded)
|
|
607
|
+
if (expanded && details.node) {
|
|
608
|
+
const n = details.node as Record<string, any>;
|
|
609
|
+
const cardDetails: DesignCardDetails = {
|
|
610
|
+
id: n.id ?? "",
|
|
611
|
+
title: n.title ?? "",
|
|
612
|
+
status: n.status ?? "seed",
|
|
613
|
+
priority: n.priority ?? undefined,
|
|
614
|
+
issue_type: n.issue_type ?? undefined,
|
|
615
|
+
overview: n.sections?.overview ?? "",
|
|
616
|
+
decisions: n.sections?.decisions?.map((d: any) => ({ title: d.title, status: d.status })) ?? [],
|
|
617
|
+
openQuestions: n.sections?.openQuestions ?? [],
|
|
618
|
+
dependencies: (n.dependencies ?? []).map((id: string) => {
|
|
619
|
+
const dep = tree.nodes.get(id);
|
|
620
|
+
return dep ? { id: dep.id, title: dep.title, status: dep.status } : { id, title: id, status: "seed" as NodeStatus };
|
|
621
|
+
}),
|
|
622
|
+
children: n.children ?? [],
|
|
623
|
+
fileScope: n.sections?.implementationNotes?.fileScope ?? [],
|
|
624
|
+
constraints: n.sections?.implementationNotes?.constraints ?? [],
|
|
625
|
+
openspec_change: n.openspecChange ?? undefined,
|
|
626
|
+
branches: n.branches ?? [],
|
|
627
|
+
};
|
|
628
|
+
return new SciDesignCard(`design_tree:node → ${cardDetails.id}`, cardDetails, theme);
|
|
629
|
+
}
|
|
630
|
+
|
|
631
|
+
if (expanded) {
|
|
632
|
+
const first = result.content?.[0];
|
|
633
|
+
const fullText = (first && 'text' in first ? first.text : null) ?? "";
|
|
634
|
+
const lines = fullText.split("\n");
|
|
635
|
+
return sciExpanded(lines, `${lines.length} lines`, theme);
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
let summary = "";
|
|
639
|
+
|
|
640
|
+
if (details.nodes) {
|
|
641
|
+
const nodes = details.nodes as Array<{ id: string; status: string; open_questions: number }>;
|
|
642
|
+
summary = `${nodes.length} nodes`;
|
|
643
|
+
} else if (details.node) {
|
|
644
|
+
const n = details.node as { title: string; status: NodeStatus; sections?: { openQuestions?: string[] } };
|
|
645
|
+
const qCount = n.sections?.openQuestions?.length || 0;
|
|
646
|
+
summary = `${STATUS_ICONS[n.status]} ${n.title} (${n.status})` + (qCount > 0 ? ` — ${qCount} questions` : "");
|
|
647
|
+
} else if (details.questions) {
|
|
648
|
+
const q = details.questions as Record<string, string[]>;
|
|
649
|
+
const total = Object.values(q).flat().length;
|
|
650
|
+
summary = `${total} open questions`;
|
|
651
|
+
} else {
|
|
652
|
+
const first = result.content?.[0];
|
|
653
|
+
summary = ((first && 'text' in first ? first.text : null) ?? "Done").split("\n")[0].slice(0, 80);
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
return sciOk(summary, theme);
|
|
657
|
+
},
|
|
658
|
+
});
|
|
659
|
+
|
|
660
|
+
// ─── Tool: design_tree_update (mutations) ────────────────────────────
|
|
661
|
+
|
|
662
|
+
pi.registerTool({
|
|
663
|
+
name: "design_tree_update",
|
|
664
|
+
label: "Design Tree Update",
|
|
665
|
+
description:
|
|
666
|
+
"Mutate the design tree: create nodes, change status, add/remove questions, " +
|
|
667
|
+
"add research findings, record decisions, add dependencies, branch from questions, " +
|
|
668
|
+
"set focus, or bridge to OpenSpec for implementation.\n\n" +
|
|
669
|
+
"Actions:\n" +
|
|
670
|
+
"- create: Create a new design node (id, title required; parent, status, tags, overview optional)\n" +
|
|
671
|
+
"- set_status: Change node status (seed/exploring/decided/blocked/deferred)\n" +
|
|
672
|
+
"- add_question: Add an open question to a node\n" +
|
|
673
|
+
"- remove_question: Remove an open question by text\n" +
|
|
674
|
+
"- add_research: Add a research entry (heading + content)\n" +
|
|
675
|
+
"- add_decision: Record a design decision (title, status, rationale)\n" +
|
|
676
|
+
"- add_dependency: Add a dependency between nodes\n" +
|
|
677
|
+
"- add_related: Add a related node reference\n" +
|
|
678
|
+
"- add_impl_notes: Add implementation notes (file_scope, constraints)\n" +
|
|
679
|
+
"- branch: Create a child node from a parent's open question\n" +
|
|
680
|
+
"- focus: Set the focused design node for context injection\n" +
|
|
681
|
+
"- unfocus: Clear the focused node\n" +
|
|
682
|
+
"- implement: Bridge a decided node to OpenSpec — scaffold a change directory\n" +
|
|
683
|
+
"- set_priority: Set the priority (1-5) on a node\n" +
|
|
684
|
+
"- set_issue_type: Set the issue type (epic/feature/task/bug/chore) on a node",
|
|
685
|
+
promptSnippet:
|
|
686
|
+
"Mutate the design tree — create nodes, set status, add research/decisions/questions, branch, implement",
|
|
687
|
+
promptGuidelines: [
|
|
688
|
+
"Use 'create' to start a new design exploration. Status defaults to 'seed'.",
|
|
689
|
+
"Use 'set_status' to transition nodes: seed → exploring → decided. Use 'blocked' or 'deferred' as needed.",
|
|
690
|
+
"Use 'add_question' when discussion reveals unknowns. Use 'remove_question' when questions are answered.",
|
|
691
|
+
"Use 'add_research' to record findings with a heading and content.",
|
|
692
|
+
"Use 'add_decision' to crystallize choices with title, status (exploring/decided/rejected), and rationale.",
|
|
693
|
+
"Use 'branch' to spawn a child node from a parent's open question — this removes the question from the parent.",
|
|
694
|
+
"Use 'focus' to set which node's context gets injected into the conversation.",
|
|
695
|
+
"Use 'implement' on a decided node to generate an OpenSpec change directory for cleave execution.",
|
|
696
|
+
"When an OpenSpec change exists for a decided node, suggest `/cleave` to parallelize the implementation.",
|
|
697
|
+
"Use 'set_priority' to assign a priority 1 (critical) to 5 (trivial) to a node.",
|
|
698
|
+
"Use 'set_issue_type' to classify a node as epic/feature/task/bug/chore.",
|
|
699
|
+
],
|
|
700
|
+
parameters: Type.Object({
|
|
701
|
+
action: StringEnum([
|
|
702
|
+
"create", "set_status", "add_question", "remove_question",
|
|
703
|
+
"add_research", "add_decision", "add_dependency", "add_related",
|
|
704
|
+
"add_impl_notes", "branch", "focus", "unfocus", "implement",
|
|
705
|
+
"set_priority", "set_issue_type",
|
|
706
|
+
] as const),
|
|
707
|
+
node_id: Type.Optional(Type.String({ description: "Target node ID (required for most actions)" })),
|
|
708
|
+
// create params
|
|
709
|
+
title: Type.Optional(Type.String({ description: "Node title (for create)" })),
|
|
710
|
+
parent: Type.Optional(Type.String({ description: "Parent node ID (for create)" })),
|
|
711
|
+
status: Type.Optional(Type.String({ description: "Node status (for create, set_status)" })),
|
|
712
|
+
tags: Type.Optional(Type.Array(Type.String(), { description: "Tags (for create)" })),
|
|
713
|
+
overview: Type.Optional(Type.String({ description: "Overview text (for create)" })),
|
|
714
|
+
// question params
|
|
715
|
+
question: Type.Optional(Type.String({ description: "Question text (for add_question, remove_question, branch)" })),
|
|
716
|
+
// research params
|
|
717
|
+
heading: Type.Optional(Type.String({ description: "Research heading (for add_research)" })),
|
|
718
|
+
content: Type.Optional(Type.String({ description: "Content text (for add_research)" })),
|
|
719
|
+
// decision params
|
|
720
|
+
decision_title: Type.Optional(Type.String({ description: "Decision title (for add_decision)" })),
|
|
721
|
+
decision_status: Type.Optional(Type.String({ description: "exploring|decided|rejected (for add_decision)" })),
|
|
722
|
+
rationale: Type.Optional(Type.String({ description: "Decision rationale (for add_decision)" })),
|
|
723
|
+
// dependency / related
|
|
724
|
+
target_id: Type.Optional(Type.String({ description: "Target node ID (for add_dependency, add_related)" })),
|
|
725
|
+
// branch params
|
|
726
|
+
child_id: Type.Optional(Type.String({ description: "Child node ID (for branch)" })),
|
|
727
|
+
child_title: Type.Optional(Type.String({ description: "Child node title (for branch)" })),
|
|
728
|
+
// impl notes
|
|
729
|
+
file_scope: Type.Optional(
|
|
730
|
+
Type.Array(
|
|
731
|
+
Type.Object({
|
|
732
|
+
path: Type.String(),
|
|
733
|
+
description: Type.String(),
|
|
734
|
+
action: Type.Optional(StringEnum(["new", "modified", "deleted"] as const)),
|
|
735
|
+
}),
|
|
736
|
+
{ description: "File scope entries (for add_impl_notes)" },
|
|
737
|
+
),
|
|
738
|
+
),
|
|
739
|
+
constraints: Type.Optional(Type.Array(Type.String(), { description: "Constraints (for add_impl_notes)" })),
|
|
740
|
+
// set_priority params
|
|
741
|
+
priority: Type.Optional(Type.Number({ description: "Priority 1 (critical) to 5 (trivial) (for set_priority)" })),
|
|
742
|
+
// set_issue_type params
|
|
743
|
+
issue_type: Type.Optional(Type.String({ description: "epic|feature|task|bug|chore (for set_issue_type)" })),
|
|
744
|
+
}),
|
|
745
|
+
|
|
746
|
+
async execute(_toolCallId, params, _signal, _onUpdate, ctx) {
|
|
747
|
+
reload(ctx.cwd);
|
|
748
|
+
const dd = docsDir(ctx.cwd);
|
|
749
|
+
|
|
750
|
+
switch (params.action) {
|
|
751
|
+
// ── create ────────────────────────────────────────────────
|
|
752
|
+
case "create": {
|
|
753
|
+
if (!params.node_id || !params.title) {
|
|
754
|
+
return { content: [{ type: "text", text: "Error: node_id and title required for create" }], details: {}, isError: true };
|
|
755
|
+
}
|
|
756
|
+
const idError = validateNodeId(params.node_id);
|
|
757
|
+
if (idError) {
|
|
758
|
+
return { content: [{ type: "text", text: `Error: ${idError}` }], details: {}, isError: true };
|
|
759
|
+
}
|
|
760
|
+
if (tree.nodes.has(params.node_id)) {
|
|
761
|
+
return { content: [{ type: "text", text: `Error: node '${params.node_id}' already exists` }], details: {}, isError: true };
|
|
762
|
+
}
|
|
763
|
+
const validStatus = params.status && VALID_STATUSES.includes(params.status as NodeStatus)
|
|
764
|
+
? params.status as NodeStatus
|
|
765
|
+
: "seed";
|
|
766
|
+
|
|
767
|
+
const node = createNode(dd, {
|
|
768
|
+
id: params.node_id,
|
|
769
|
+
title: params.title,
|
|
770
|
+
parent: params.parent,
|
|
771
|
+
status: validStatus,
|
|
772
|
+
tags: params.tags,
|
|
773
|
+
overview: params.overview,
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
reload(ctx.cwd);
|
|
777
|
+
focusedNode = params.node_id;
|
|
778
|
+
emitCurrentState();
|
|
779
|
+
|
|
780
|
+
return {
|
|
781
|
+
content: [{ type: "text", text: `Created design node '${node.title}' (${node.status}) at ${node.filePath}` }],
|
|
782
|
+
details: { node: { id: node.id, title: node.title, status: node.status, filePath: node.filePath } },
|
|
783
|
+
};
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
// ── set_status ────────────────────────────────────────────
|
|
787
|
+
case "set_status": {
|
|
788
|
+
if (!params.node_id) {
|
|
789
|
+
return { content: [{ type: "text", text: "Error: node_id required" }], details: {}, isError: true };
|
|
790
|
+
}
|
|
791
|
+
const node = tree.nodes.get(params.node_id);
|
|
792
|
+
if (!node) {
|
|
793
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
794
|
+
}
|
|
795
|
+
const newStatus = params.status as NodeStatus;
|
|
796
|
+
if (!newStatus || !VALID_STATUSES.includes(newStatus)) {
|
|
797
|
+
return { content: [{ type: "text", text: `Invalid status '${params.status}'. Valid: ${VALID_STATUSES.join(", ")}` }], details: {}, isError: true };
|
|
798
|
+
}
|
|
799
|
+
const oldStatus = node.status;
|
|
800
|
+
|
|
801
|
+
// Hard gate: set_status(decided) requires archived design spec.
|
|
802
|
+
if (newStatus === "decided") {
|
|
803
|
+
const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
|
|
804
|
+
if (designSpec.missing) {
|
|
805
|
+
return {
|
|
806
|
+
content: [{ type: "text", text: `Cannot mark '${node.title}' decided: scaffold design spec first via set_status(exploring).` }],
|
|
807
|
+
details: { id: node.id, blockedBy: "design-openspec-missing" },
|
|
808
|
+
isError: true,
|
|
809
|
+
};
|
|
810
|
+
}
|
|
811
|
+
if (designSpec.active && !designSpec.archived) {
|
|
812
|
+
return {
|
|
813
|
+
content: [{ type: "text", text: `Cannot mark '${node.title}' decided: run /assess design then archive the design change before marking decided.` }],
|
|
814
|
+
details: { id: node.id, blockedBy: "design-openspec-not-archived" },
|
|
815
|
+
isError: true,
|
|
816
|
+
};
|
|
817
|
+
}
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
const updated = setNodeStatus(node, newStatus);
|
|
821
|
+
tree.nodes.set(updated.id, updated);
|
|
822
|
+
emitCurrentState();
|
|
823
|
+
|
|
824
|
+
let text = `${STATUS_ICONS[newStatus]} '${node.title}': ${oldStatus} → ${newStatus}`;
|
|
825
|
+
|
|
826
|
+
// If transitioning to exploring, scaffold design OpenSpec change (idempotent)
|
|
827
|
+
if (newStatus === "exploring") {
|
|
828
|
+
const scaffoldResult = scaffoldDesignOpenSpecChange(ctx.cwd, updated);
|
|
829
|
+
if (scaffoldResult.created) {
|
|
830
|
+
text += `\n\nScaffolded design spec at openspec/design/${node.id}/\n` +
|
|
831
|
+
` - proposal.md, spec.md, tasks.md\n\n` +
|
|
832
|
+
`Fill in ## Acceptance Criteria in the node doc (Scenarios / Falsifiability / Constraints) before running /assess design.`;
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
// If transitioning to decided, check for OpenSpec bridge opportunity
|
|
837
|
+
if (newStatus === "decided") {
|
|
838
|
+
const sections = getNodeSections(node);
|
|
839
|
+
const hasDecisions = sections.decisions.length > 0;
|
|
840
|
+
const hasImplNotes = sections.implementationNotes.fileScope.length > 0 ||
|
|
841
|
+
sections.implementationNotes.constraints.length > 0;
|
|
842
|
+
|
|
843
|
+
if (hasDecisions || hasImplNotes) {
|
|
844
|
+
text += "\n\nThis node has decisions and/or implementation notes. " +
|
|
845
|
+
"Use design_tree_update with action 'implement' to scaffold an OpenSpec change, " +
|
|
846
|
+
"then `/cleave` to parallelize the implementation.";
|
|
847
|
+
} else {
|
|
848
|
+
text += "\n\nConsider adding decisions and implementation notes before implementing.";
|
|
849
|
+
}
|
|
850
|
+
}
|
|
851
|
+
|
|
852
|
+
return {
|
|
853
|
+
content: [{ type: "text", text }],
|
|
854
|
+
details: { id: node.id, oldStatus, newStatus },
|
|
855
|
+
};
|
|
856
|
+
}
|
|
857
|
+
|
|
858
|
+
// ── add_question ──────────────────────────────────────────
|
|
859
|
+
case "add_question": {
|
|
860
|
+
if (!params.node_id || !params.question) {
|
|
861
|
+
return { content: [{ type: "text", text: "Error: node_id and question required" }], details: {}, isError: true };
|
|
862
|
+
}
|
|
863
|
+
const node = tree.nodes.get(params.node_id);
|
|
864
|
+
if (!node) {
|
|
865
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
866
|
+
}
|
|
867
|
+
const updated = addOpenQuestion(node, params.question);
|
|
868
|
+
tree.nodes.set(updated.id, updated);
|
|
869
|
+
// Emit memory fact for the open question
|
|
870
|
+
(sharedState.lifecycleCandidateQueue ??= []).push({
|
|
871
|
+
source: "design-tree",
|
|
872
|
+
context: `Open question added to node '${node.id}'`,
|
|
873
|
+
candidates: [{
|
|
874
|
+
sourceKind: "design-decision",
|
|
875
|
+
authority: "explicit",
|
|
876
|
+
section: "Specs",
|
|
877
|
+
content: `OPEN [${node.id}]: ${params.question}`,
|
|
878
|
+
artifactRef: {
|
|
879
|
+
type: "design-node",
|
|
880
|
+
path: node.filePath,
|
|
881
|
+
subRef: node.id,
|
|
882
|
+
},
|
|
883
|
+
}],
|
|
884
|
+
});
|
|
885
|
+
mirrorOpenQuestionsToDesignSpec(ctx.cwd, updated);
|
|
886
|
+
emitCurrentState();
|
|
887
|
+
return {
|
|
888
|
+
content: [{ type: "text", text: `Added question to '${node.title}': ${params.question}` }],
|
|
889
|
+
details: { id: node.id, question: params.question, totalQuestions: updated.open_questions.length },
|
|
890
|
+
};
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
// ── remove_question ───────────────────────────────────────
|
|
894
|
+
case "remove_question": {
|
|
895
|
+
if (!params.node_id || !params.question) {
|
|
896
|
+
return { content: [{ type: "text", text: "Error: node_id and question required" }], details: {}, isError: true };
|
|
897
|
+
}
|
|
898
|
+
const node = tree.nodes.get(params.node_id);
|
|
899
|
+
if (!node) {
|
|
900
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
901
|
+
}
|
|
902
|
+
const updated = removeOpenQuestion(node, params.question);
|
|
903
|
+
tree.nodes.set(updated.id, updated);
|
|
904
|
+
// Schedule archival of the corresponding memory fact by content prefix.
|
|
905
|
+
// Check whether add_question ever emitted a fact for this question so the
|
|
906
|
+
// caller gets explicit feedback when no matching fact exists (e.g. question
|
|
907
|
+
// was added before this extension version was deployed).
|
|
908
|
+
const factContentPrefix = `OPEN [${node.id}]: ${params.question}`;
|
|
909
|
+
const emittedFacts = (sharedState.lifecycleCandidateQueue ?? [])
|
|
910
|
+
.flatMap((m) => m.candidates)
|
|
911
|
+
.filter((c) => c.section === "Specs" && c.content === factContentPrefix);
|
|
912
|
+
const factWasEmitted = emittedFacts.length > 0;
|
|
913
|
+
(sharedState.factArchiveQueue ??= []).push(factContentPrefix);
|
|
914
|
+
mirrorOpenQuestionsToDesignSpec(ctx.cwd, updated);
|
|
915
|
+
emitCurrentState();
|
|
916
|
+
return {
|
|
917
|
+
content: [
|
|
918
|
+
{
|
|
919
|
+
type: "text",
|
|
920
|
+
text: factWasEmitted
|
|
921
|
+
? `Removed question from '${node.title}'`
|
|
922
|
+
: `Removed question from '${node.title}' (note: no corresponding memory fact found — question may have been added before fact-emission was deployed)`,
|
|
923
|
+
},
|
|
924
|
+
],
|
|
925
|
+
details: { id: node.id, remainingQuestions: updated.open_questions.length, factWasEmitted },
|
|
926
|
+
};
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
// ── add_research ──────────────────────────────────────────
|
|
930
|
+
case "add_research": {
|
|
931
|
+
if (!params.node_id || !params.heading || !params.content) {
|
|
932
|
+
return { content: [{ type: "text", text: "Error: node_id, heading, and content required" }], details: {}, isError: true };
|
|
933
|
+
}
|
|
934
|
+
const node = tree.nodes.get(params.node_id);
|
|
935
|
+
if (!node) {
|
|
936
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
937
|
+
}
|
|
938
|
+
addResearch(node, params.heading, params.content);
|
|
939
|
+
emitCurrentState();
|
|
940
|
+
return {
|
|
941
|
+
content: [{ type: "text", text: `Added research '${params.heading}' to '${node.title}'` }],
|
|
942
|
+
details: { id: node.id, heading: params.heading },
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
// ── add_decision ──────────────────────────────────────────
|
|
947
|
+
case "add_decision": {
|
|
948
|
+
if (!params.node_id || !params.decision_title) {
|
|
949
|
+
return { content: [{ type: "text", text: "Error: node_id and decision_title required" }], details: {}, isError: true };
|
|
950
|
+
}
|
|
951
|
+
const node = tree.nodes.get(params.node_id);
|
|
952
|
+
if (!node) {
|
|
953
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
954
|
+
}
|
|
955
|
+
const validDecisionStatuses = ["exploring", "decided", "rejected"];
|
|
956
|
+
const rawDStatus = params.decision_status || "exploring";
|
|
957
|
+
if (!validDecisionStatuses.includes(rawDStatus)) {
|
|
958
|
+
return { content: [{ type: "text", text: `Invalid decision_status '${rawDStatus}'. Valid: ${validDecisionStatuses.join(", ")}` }], details: {}, isError: true };
|
|
959
|
+
}
|
|
960
|
+
const dStatus = rawDStatus as "exploring" | "decided" | "rejected";
|
|
961
|
+
addDecision(node, {
|
|
962
|
+
title: params.decision_title,
|
|
963
|
+
status: dStatus,
|
|
964
|
+
rationale: params.rationale || "",
|
|
965
|
+
});
|
|
966
|
+
const decisionCandidates = emitDecisionCandidates(node, params.decision_title, dStatus);
|
|
967
|
+
if (decisionCandidates.length > 0) {
|
|
968
|
+
(sharedState.lifecycleCandidateQueue ??= []).push({
|
|
969
|
+
source: "design-tree",
|
|
970
|
+
context: `Decided design decision in '${node.id}'`,
|
|
971
|
+
candidates: decisionCandidates,
|
|
972
|
+
});
|
|
973
|
+
}
|
|
974
|
+
emitCurrentState();
|
|
975
|
+
return {
|
|
976
|
+
content: [{ type: "text", text: `Added decision '${params.decision_title}' (${dStatus}) to '${node.title}'` }],
|
|
977
|
+
details: { id: node.id, decision: params.decision_title, status: dStatus, emittedCandidates: decisionCandidates.length },
|
|
978
|
+
};
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// ── add_dependency ────────────────────────────────────────
|
|
982
|
+
case "add_dependency": {
|
|
983
|
+
if (!params.node_id || !params.target_id) {
|
|
984
|
+
return { content: [{ type: "text", text: "Error: node_id and target_id required" }], details: {}, isError: true };
|
|
985
|
+
}
|
|
986
|
+
const node = tree.nodes.get(params.node_id);
|
|
987
|
+
if (!node) {
|
|
988
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
989
|
+
}
|
|
990
|
+
if (!tree.nodes.has(params.target_id)) {
|
|
991
|
+
return { content: [{ type: "text", text: `Target node '${params.target_id}' not found` }], details: {}, isError: true };
|
|
992
|
+
}
|
|
993
|
+
const updated = addDependency(node, params.target_id);
|
|
994
|
+
tree.nodes.set(updated.id, updated);
|
|
995
|
+
emitCurrentState();
|
|
996
|
+
return {
|
|
997
|
+
content: [{ type: "text", text: `Added dependency: '${node.title}' depends on '${params.target_id}'` }],
|
|
998
|
+
details: { id: node.id, dependency: params.target_id },
|
|
999
|
+
};
|
|
1000
|
+
}
|
|
1001
|
+
|
|
1002
|
+
// ── add_related ───────────────────────────────────────────
|
|
1003
|
+
case "add_related": {
|
|
1004
|
+
if (!params.node_id || !params.target_id) {
|
|
1005
|
+
return { content: [{ type: "text", text: "Error: node_id and target_id required" }], details: {}, isError: true };
|
|
1006
|
+
}
|
|
1007
|
+
const node = tree.nodes.get(params.node_id);
|
|
1008
|
+
if (!node) {
|
|
1009
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
1010
|
+
}
|
|
1011
|
+
const targetNode = tree.nodes.get(params.target_id);
|
|
1012
|
+
if (!targetNode) {
|
|
1013
|
+
return { content: [{ type: "text", text: `Target node '${params.target_id}' not found` }], details: {}, isError: true };
|
|
1014
|
+
}
|
|
1015
|
+
const updated = addRelated(node, params.target_id, targetNode);
|
|
1016
|
+
tree.nodes.set(updated.id, updated);
|
|
1017
|
+
emitCurrentState();
|
|
1018
|
+
return {
|
|
1019
|
+
content: [{ type: "text", text: `Added related: '${node.title}' ↔ '${targetNode.title}' (bidirectional)` }],
|
|
1020
|
+
details: { id: node.id, related: params.target_id },
|
|
1021
|
+
};
|
|
1022
|
+
}
|
|
1023
|
+
|
|
1024
|
+
// ── add_impl_notes ────────────────────────────────────────
|
|
1025
|
+
case "add_impl_notes": {
|
|
1026
|
+
if (!params.node_id) {
|
|
1027
|
+
return { content: [{ type: "text", text: "Error: node_id required" }], details: {}, isError: true };
|
|
1028
|
+
}
|
|
1029
|
+
const node = tree.nodes.get(params.node_id);
|
|
1030
|
+
if (!node) {
|
|
1031
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
1032
|
+
}
|
|
1033
|
+
if (!params.file_scope && !params.constraints) {
|
|
1034
|
+
return { content: [{ type: "text", text: "Error: at least one of file_scope or constraints required" }], details: {}, isError: true };
|
|
1035
|
+
}
|
|
1036
|
+
addImplementationNotes(node, {
|
|
1037
|
+
fileScope: params.file_scope,
|
|
1038
|
+
constraints: params.constraints,
|
|
1039
|
+
});
|
|
1040
|
+
const added: string[] = [];
|
|
1041
|
+
if (params.file_scope) added.push(`${params.file_scope.length} file scope entries`);
|
|
1042
|
+
if (params.constraints) added.push(`${params.constraints.length} constraints`);
|
|
1043
|
+
const constraintCandidates = emitConstraintCandidates(node, params.constraints);
|
|
1044
|
+
if (constraintCandidates.length > 0) {
|
|
1045
|
+
(sharedState.lifecycleCandidateQueue ??= []).push({
|
|
1046
|
+
source: "design-tree",
|
|
1047
|
+
context: `Implementation constraints recorded for '${node.id}'`,
|
|
1048
|
+
candidates: constraintCandidates,
|
|
1049
|
+
});
|
|
1050
|
+
}
|
|
1051
|
+
emitCurrentState();
|
|
1052
|
+
return {
|
|
1053
|
+
content: [{ type: "text", text: `Added implementation notes to '${node.title}': ${added.join(", ")}` }],
|
|
1054
|
+
details: { id: node.id, emittedCandidates: constraintCandidates.length },
|
|
1055
|
+
};
|
|
1056
|
+
}
|
|
1057
|
+
|
|
1058
|
+
// ── branch ───────────────────────────────────────────────
|
|
1059
|
+
case "branch": {
|
|
1060
|
+
if (!params.node_id || !params.question) {
|
|
1061
|
+
return { content: [{ type: "text", text: "Error: node_id and question required for branch" }], details: {}, isError: true };
|
|
1062
|
+
}
|
|
1063
|
+
const childId = params.child_id || toSlug(params.question);
|
|
1064
|
+
const childTitle = params.child_title || params.question.slice(0, 60);
|
|
1065
|
+
|
|
1066
|
+
const child = branchFromQuestion(tree, params.node_id, params.question, childId, childTitle);
|
|
1067
|
+
if (!child) {
|
|
1068
|
+
return { content: [{ type: "text", text: `Could not branch: node '${params.node_id}' not found or question not present` }], details: {}, isError: true };
|
|
1069
|
+
}
|
|
1070
|
+
|
|
1071
|
+
reload(ctx.cwd);
|
|
1072
|
+
focusedNode = childId;
|
|
1073
|
+
emitCurrentState();
|
|
1074
|
+
|
|
1075
|
+
return {
|
|
1076
|
+
content: [{
|
|
1077
|
+
type: "text",
|
|
1078
|
+
text: `Branched '${childTitle}' from '${params.node_id}' — question moved to child node.\n` +
|
|
1079
|
+
`File: ${child.filePath}\n` +
|
|
1080
|
+
`Focus set to new node. Use design_tree with action 'node' to see its content.`,
|
|
1081
|
+
}],
|
|
1082
|
+
details: { child: { id: child.id, title: child.title, parent: params.node_id } },
|
|
1083
|
+
};
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
// ── focus / unfocus ──────────────────────────────────────
|
|
1087
|
+
case "focus": {
|
|
1088
|
+
if (!params.node_id) {
|
|
1089
|
+
return { content: [{ type: "text", text: "Error: node_id required for focus" }], details: {}, isError: true };
|
|
1090
|
+
}
|
|
1091
|
+
const node = tree.nodes.get(params.node_id);
|
|
1092
|
+
if (!node) {
|
|
1093
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
1094
|
+
}
|
|
1095
|
+
focusedNode = params.node_id;
|
|
1096
|
+
|
|
1097
|
+
// Auto-transition seed → exploring
|
|
1098
|
+
if (node.status === "seed") {
|
|
1099
|
+
const updated = setNodeStatus(node, "exploring");
|
|
1100
|
+
tree.nodes.set(updated.id, updated);
|
|
1101
|
+
}
|
|
1102
|
+
|
|
1103
|
+
emitCurrentState();
|
|
1104
|
+
return {
|
|
1105
|
+
content: [{ type: "text", text: `Focused on '${node.title}'. Context will be injected on next turn.` }],
|
|
1106
|
+
details: { focusedNode: params.node_id },
|
|
1107
|
+
};
|
|
1108
|
+
}
|
|
1109
|
+
|
|
1110
|
+
case "unfocus": {
|
|
1111
|
+
focusedNode = null;
|
|
1112
|
+
emitCurrentState();
|
|
1113
|
+
return {
|
|
1114
|
+
content: [{ type: "text", text: "Design focus cleared." }],
|
|
1115
|
+
details: { focusedNode: null },
|
|
1116
|
+
};
|
|
1117
|
+
}
|
|
1118
|
+
|
|
1119
|
+
// ── implement ────────────────────────────────────────────
|
|
1120
|
+
case "implement": {
|
|
1121
|
+
if (!params.node_id) {
|
|
1122
|
+
return { content: [{ type: "text", text: "Error: node_id required for implement" }], details: {}, isError: true };
|
|
1123
|
+
}
|
|
1124
|
+
const node = tree.nodes.get(params.node_id);
|
|
1125
|
+
if (!node) {
|
|
1126
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
1127
|
+
}
|
|
1128
|
+
if (node.status !== "decided") {
|
|
1129
|
+
return {
|
|
1130
|
+
content: [{
|
|
1131
|
+
type: "text",
|
|
1132
|
+
text: `Node '${node.title}' is '${node.status}', not 'decided'. ` +
|
|
1133
|
+
`Resolve open questions and set status to 'decided' before implementing.`,
|
|
1134
|
+
}],
|
|
1135
|
+
details: {},
|
|
1136
|
+
isError: true,
|
|
1137
|
+
};
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
// Hard gate: design-phase spec must be archived before implementation.
|
|
1141
|
+
{
|
|
1142
|
+
const designSpec = resolveDesignSpecBinding(ctx.cwd, node.id);
|
|
1143
|
+
if (designSpec.missing) {
|
|
1144
|
+
return {
|
|
1145
|
+
content: [{ type: "text", text: "Scaffold design spec first via set_status(exploring)" }],
|
|
1146
|
+
details: { id: node.id, blockedBy: "design-openspec-missing" },
|
|
1147
|
+
isError: true,
|
|
1148
|
+
};
|
|
1149
|
+
}
|
|
1150
|
+
if (designSpec.active && !designSpec.archived) {
|
|
1151
|
+
return {
|
|
1152
|
+
content: [{ type: "text", text: `Cannot implement '${node.title}': archive the design change first (/opsx:archive on the design change).` }],
|
|
1153
|
+
details: { id: node.id, blockedBy: "design-openspec-not-archived" },
|
|
1154
|
+
isError: true,
|
|
1155
|
+
};
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
|
|
1159
|
+
const implResult = executeImplement(ctx.cwd, node);
|
|
1160
|
+
reload(ctx.cwd);
|
|
1161
|
+
emitCurrentState();
|
|
1162
|
+
|
|
1163
|
+
return {
|
|
1164
|
+
content: [{ type: "text", text: implResult.message }],
|
|
1165
|
+
details: implResult.ok
|
|
1166
|
+
? { changePath: implResult.changePath, files: implResult.files, branch: implResult.branch }
|
|
1167
|
+
: {},
|
|
1168
|
+
isError: !implResult.ok,
|
|
1169
|
+
};
|
|
1170
|
+
}
|
|
1171
|
+
|
|
1172
|
+
// ── set_priority ──────────────────────────────────────────
|
|
1173
|
+
case "set_priority": {
|
|
1174
|
+
if (!params.node_id) {
|
|
1175
|
+
return { content: [{ type: "text", text: "Error: node_id required" }], details: {}, isError: true };
|
|
1176
|
+
}
|
|
1177
|
+
const node = tree.nodes.get(params.node_id);
|
|
1178
|
+
if (!node) {
|
|
1179
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
1180
|
+
}
|
|
1181
|
+
const p = params.priority !== undefined ? Math.round(params.priority) : undefined;
|
|
1182
|
+
if (p === undefined || p < 1 || p > 5) {
|
|
1183
|
+
return { content: [{ type: "text", text: "Error: priority must be an integer 1–5" }], details: {}, isError: true };
|
|
1184
|
+
}
|
|
1185
|
+
const updatedNode = { ...node, priority: p as Priority };
|
|
1186
|
+
const sections = getNodeSections(node);
|
|
1187
|
+
writeNodeDocument(updatedNode, sections);
|
|
1188
|
+
tree.nodes.set(updatedNode.id, updatedNode);
|
|
1189
|
+
reload(ctx.cwd);
|
|
1190
|
+
emitCurrentState();
|
|
1191
|
+
return {
|
|
1192
|
+
content: [{ type: "text", text: `Priority set to ${p} on '${node.title}'` }],
|
|
1193
|
+
details: { node_id: node.id, priority: p },
|
|
1194
|
+
};
|
|
1195
|
+
}
|
|
1196
|
+
|
|
1197
|
+
// ── set_issue_type ────────────────────────────────────────
|
|
1198
|
+
case "set_issue_type": {
|
|
1199
|
+
if (!params.node_id) {
|
|
1200
|
+
return { content: [{ type: "text", text: "Error: node_id required" }], details: {}, isError: true };
|
|
1201
|
+
}
|
|
1202
|
+
const node = tree.nodes.get(params.node_id);
|
|
1203
|
+
if (!node) {
|
|
1204
|
+
return { content: [{ type: "text", text: `Node '${params.node_id}' not found` }], details: {}, isError: true };
|
|
1205
|
+
}
|
|
1206
|
+
const it = params.issue_type as IssueType | undefined;
|
|
1207
|
+
if (!it || !VALID_ISSUE_TYPES.includes(it)) {
|
|
1208
|
+
return {
|
|
1209
|
+
content: [{ type: "text", text: `Error: issue_type must be one of: ${VALID_ISSUE_TYPES.join(", ")}` }],
|
|
1210
|
+
details: {},
|
|
1211
|
+
isError: true,
|
|
1212
|
+
};
|
|
1213
|
+
}
|
|
1214
|
+
const updatedNode = { ...node, issue_type: it };
|
|
1215
|
+
const sections = getNodeSections(node);
|
|
1216
|
+
writeNodeDocument(updatedNode, sections);
|
|
1217
|
+
tree.nodes.set(updatedNode.id, updatedNode);
|
|
1218
|
+
reload(ctx.cwd);
|
|
1219
|
+
emitCurrentState();
|
|
1220
|
+
return {
|
|
1221
|
+
content: [{ type: "text", text: `Issue type set to '${it}' on '${node.title}'` }],
|
|
1222
|
+
details: { node_id: node.id, issue_type: it },
|
|
1223
|
+
};
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
|
|
1227
|
+
return { content: [{ type: "text", text: "Unknown action" }], details: {} };
|
|
1228
|
+
},
|
|
1229
|
+
|
|
1230
|
+
renderCall(args, theme) {
|
|
1231
|
+
let summary = args.action;
|
|
1232
|
+
if (args.node_id) summary += ":" + args.node_id;
|
|
1233
|
+
|
|
1234
|
+
switch (args.action) {
|
|
1235
|
+
case "set_status":
|
|
1236
|
+
if (args.status) summary += " → " + args.status;
|
|
1237
|
+
break;
|
|
1238
|
+
case "add_question":
|
|
1239
|
+
case "remove_question":
|
|
1240
|
+
if (args.question) summary += " " + `"${String(args.question).slice(0, 50)}"`;
|
|
1241
|
+
break;
|
|
1242
|
+
case "add_decision":
|
|
1243
|
+
if (args.decision_title) summary += " " + `"${String(args.decision_title).slice(0, 45)}"`;
|
|
1244
|
+
break;
|
|
1245
|
+
case "add_research":
|
|
1246
|
+
if (args.heading) summary += " " + `"${String(args.heading).slice(0, 45)}"`;
|
|
1247
|
+
break;
|
|
1248
|
+
case "create":
|
|
1249
|
+
if (args.title) summary += " " + `"${String(args.title).slice(0, 45)}"`;
|
|
1250
|
+
break;
|
|
1251
|
+
}
|
|
1252
|
+
|
|
1253
|
+
return sciCall("design_tree_update", summary, theme);
|
|
1254
|
+
},
|
|
1255
|
+
|
|
1256
|
+
renderResult(result, { expanded, isPartial }, theme) {
|
|
1257
|
+
if (isPartial) {
|
|
1258
|
+
return sciLoading("design_tree_update", theme);
|
|
1259
|
+
}
|
|
1260
|
+
|
|
1261
|
+
const isErr = (result as any).isError;
|
|
1262
|
+
const first = result.content?.[0];
|
|
1263
|
+
const firstLine = ((first && 'text' in first ? first.text : null) ?? "Done").split("\n")[0];
|
|
1264
|
+
|
|
1265
|
+
if (isErr || firstLine.startsWith("Error") || firstLine.startsWith("Cannot")) {
|
|
1266
|
+
return sciErr(firstLine.slice(0, 80), theme);
|
|
1267
|
+
}
|
|
1268
|
+
|
|
1269
|
+
if (expanded) {
|
|
1270
|
+
const fullText = (first && 'text' in first ? first.text : null) ?? "";
|
|
1271
|
+
const lines = fullText.split("\n");
|
|
1272
|
+
return sciExpanded(lines, `${lines.length} lines`, theme);
|
|
1273
|
+
}
|
|
1274
|
+
|
|
1275
|
+
// Collapsed: action-specific one-liners from result.details
|
|
1276
|
+
const details = (result.details ?? {}) as Record<string, unknown>;
|
|
1277
|
+
|
|
1278
|
+
// Determine action from details fields
|
|
1279
|
+
if ("newStatus" in details && "id" in details) {
|
|
1280
|
+
// set_status result
|
|
1281
|
+
const ns = details.newStatus as string;
|
|
1282
|
+
return sciOk(`→ ${ns} ${String(details.id)}`, theme);
|
|
1283
|
+
}
|
|
1284
|
+
if ("totalQuestions" in details && "question" in details) {
|
|
1285
|
+
// add_question
|
|
1286
|
+
const q = String(details.question).slice(0, 50);
|
|
1287
|
+
const total = String(details.totalQuestions);
|
|
1288
|
+
return sciOk(`+ question "${q}" (${total} total)`, theme);
|
|
1289
|
+
}
|
|
1290
|
+
if ("remainingQuestions" in details && "question" in details) {
|
|
1291
|
+
// remove_question
|
|
1292
|
+
const q = String(details.question).slice(0, 50);
|
|
1293
|
+
const rem = String(details.remainingQuestions);
|
|
1294
|
+
return sciOk(`− question "${q}" (${rem} remaining)`, theme);
|
|
1295
|
+
}
|
|
1296
|
+
if ("decision" in details && "status" in details) {
|
|
1297
|
+
// add_decision
|
|
1298
|
+
const d = String(details.decision).slice(0, 45);
|
|
1299
|
+
const ds = String(details.status);
|
|
1300
|
+
return sciOk(`+ decision "${d}" ${ds}`, theme);
|
|
1301
|
+
}
|
|
1302
|
+
if ("heading" in details) {
|
|
1303
|
+
// add_research
|
|
1304
|
+
const h = String(details.heading).slice(0, 45);
|
|
1305
|
+
return sciOk(`+ research "${h}"`, theme);
|
|
1306
|
+
}
|
|
1307
|
+
if ("changePath" in details) {
|
|
1308
|
+
// implement
|
|
1309
|
+
const cp = String(details.changePath ?? "").replace(/^.*openspec\//, "openspec/");
|
|
1310
|
+
return sciOk(`✓ scaffolded ${cp}`, theme);
|
|
1311
|
+
}
|
|
1312
|
+
if ("node" in details && typeof details.node === "object" && details.node !== null) {
|
|
1313
|
+
// create
|
|
1314
|
+
const n = details.node as { id: string; status: string };
|
|
1315
|
+
return sciOk(`✓ created ${n.id} ${n.status}`, theme);
|
|
1316
|
+
}
|
|
1317
|
+
if ("focusedNode" in details) {
|
|
1318
|
+
const fid = details.focusedNode as string | null;
|
|
1319
|
+
return sciOk(fid ? `→ focused ${fid}` : "focus cleared", theme);
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
// Fallback: first line of content text
|
|
1323
|
+
return sciOk(firstLine.slice(0, 80), theme);
|
|
1324
|
+
},
|
|
1325
|
+
});
|
|
1326
|
+
|
|
1327
|
+
// ─── Commands (interactive) ──────────────────────────────────────────
|
|
1328
|
+
|
|
1329
|
+
pi.registerCommand("design", {
|
|
1330
|
+
description:
|
|
1331
|
+
"Design tree: list | focus [id] | unfocus | decide [id] | explore [id] | " +
|
|
1332
|
+
"block [id] | defer [id] | branch | frontier | new <id> <title> | " +
|
|
1333
|
+
"update [id] | implement [id]",
|
|
1334
|
+
getArgumentCompletions: (prefix: string) => {
|
|
1335
|
+
const subcommands = [
|
|
1336
|
+
"list", "focus", "unfocus", "decide", "explore",
|
|
1337
|
+
"block", "defer", "branch", "frontier", "new", "update",
|
|
1338
|
+
"implement",
|
|
1339
|
+
];
|
|
1340
|
+
const parts = prefix.split(" ");
|
|
1341
|
+
if (parts.length <= 1) {
|
|
1342
|
+
return subcommands
|
|
1343
|
+
.filter((s) => s.startsWith(prefix))
|
|
1344
|
+
.map((s) => ({ value: s, label: s }));
|
|
1345
|
+
}
|
|
1346
|
+
const sub = parts[0];
|
|
1347
|
+
if (["focus", "decide", "explore", "block", "defer", "update", "implement"].includes(sub) && parts.length === 2) {
|
|
1348
|
+
const partial = parts[1] || "";
|
|
1349
|
+
return Array.from(tree.nodes.keys())
|
|
1350
|
+
.filter((id) => id.startsWith(partial))
|
|
1351
|
+
.map((id) => {
|
|
1352
|
+
const node = tree.nodes.get(id)!;
|
|
1353
|
+
return { value: `${sub} ${id}`, label: `${id} — ${node.title} (${node.status})` };
|
|
1354
|
+
});
|
|
1355
|
+
}
|
|
1356
|
+
return null;
|
|
1357
|
+
},
|
|
1358
|
+
handler: async (args, ctx) => {
|
|
1359
|
+
reload(ctx.cwd);
|
|
1360
|
+
const parts = (args || "list").trim().split(/\s+/);
|
|
1361
|
+
const subcommand = parts[0];
|
|
1362
|
+
|
|
1363
|
+
switch (subcommand) {
|
|
1364
|
+
case "list": {
|
|
1365
|
+
if (tree.nodes.size === 0) {
|
|
1366
|
+
ctx.ui.notify("No design documents found in docs/. Create one with /design new <id> <title>", "info");
|
|
1367
|
+
return;
|
|
1368
|
+
}
|
|
1369
|
+
const total = tree.nodes.size;
|
|
1370
|
+
const decided = Array.from(tree.nodes.values()).filter((n) => n.status === "decided").length;
|
|
1371
|
+
const exploring = Array.from(tree.nodes.values()).filter(
|
|
1372
|
+
(n) => n.status === "exploring" || n.status === "seed",
|
|
1373
|
+
).length;
|
|
1374
|
+
const blocked = Array.from(tree.nodes.values()).filter((n) => n.status === "blocked").length;
|
|
1375
|
+
const openQ = getAllOpenQuestions(tree).length;
|
|
1376
|
+
|
|
1377
|
+
const lines = [`${decided}/${total} decided, ${exploring} exploring, ${openQ} open questions`];
|
|
1378
|
+
if (blocked > 0) lines[0] += `, ${blocked} blocked`;
|
|
1379
|
+
|
|
1380
|
+
const byStatus = new Map<string, DesignNode[]>();
|
|
1381
|
+
for (const node of tree.nodes.values()) {
|
|
1382
|
+
const list = byStatus.get(node.status) || [];
|
|
1383
|
+
list.push(node);
|
|
1384
|
+
byStatus.set(node.status, list);
|
|
1385
|
+
}
|
|
1386
|
+
for (const [status, nodes] of byStatus) {
|
|
1387
|
+
const icon = STATUS_ICONS[status as NodeStatus];
|
|
1388
|
+
const names = nodes.map((n) => n.title).join(", ");
|
|
1389
|
+
lines.push(`${icon} ${status}: ${names}`);
|
|
1390
|
+
}
|
|
1391
|
+
|
|
1392
|
+
if (focusedNode) {
|
|
1393
|
+
const node = tree.nodes.get(focusedNode);
|
|
1394
|
+
if (node) lines.push(`▸ Focused: ${node.title}`);
|
|
1395
|
+
}
|
|
1396
|
+
|
|
1397
|
+
ctx.ui.notify(lines.join("\n"), "info");
|
|
1398
|
+
break;
|
|
1399
|
+
}
|
|
1400
|
+
|
|
1401
|
+
case "focus": {
|
|
1402
|
+
const id = parts[1];
|
|
1403
|
+
if (!id) {
|
|
1404
|
+
const ids = Array.from(tree.nodes.keys());
|
|
1405
|
+
if (ids.length === 0) {
|
|
1406
|
+
ctx.ui.notify("No design nodes to focus on", "info");
|
|
1407
|
+
return;
|
|
1408
|
+
}
|
|
1409
|
+
const labels = ids.map((nid) => {
|
|
1410
|
+
const n = tree.nodes.get(nid)!;
|
|
1411
|
+
const icon = STATUS_ICONS[n.status];
|
|
1412
|
+
return `${icon} ${nid} — ${n.title} (${n.open_questions.length}?)`;
|
|
1413
|
+
});
|
|
1414
|
+
const choice = await ctx.ui.select("Focus on which node?", labels);
|
|
1415
|
+
if (!choice) return;
|
|
1416
|
+
focusedNode = choice.split(" — ")[0].replace(/^[◌◐●✕◑]\s*/, "");
|
|
1417
|
+
} else {
|
|
1418
|
+
const node = tree.nodes.get(id);
|
|
1419
|
+
if (!node) {
|
|
1420
|
+
ctx.ui.notify(`Node '${id}' not found`, "error");
|
|
1421
|
+
return;
|
|
1422
|
+
}
|
|
1423
|
+
focusedNode = id;
|
|
1424
|
+
if (node.status === "seed") {
|
|
1425
|
+
setNodeStatus(node, "exploring");
|
|
1426
|
+
ctx.ui.notify(`${node.title}: seed → exploring`, "info");
|
|
1427
|
+
}
|
|
1428
|
+
}
|
|
1429
|
+
emitCurrentState();
|
|
1430
|
+
|
|
1431
|
+
const node = tree.nodes.get(focusedNode!)!;
|
|
1432
|
+
const sections = getNodeSections(node);
|
|
1433
|
+
const cardDetails = buildCardDetails(node, sections, tree);
|
|
1434
|
+
|
|
1435
|
+
const openQ = node.open_questions.length > 0
|
|
1436
|
+
? `\n\nOpen questions:\n${node.open_questions.map((q, i) => `${i + 1}. ${q}`).join("\n")}`
|
|
1437
|
+
: "";
|
|
1438
|
+
|
|
1439
|
+
pi.sendMessage(
|
|
1440
|
+
{
|
|
1441
|
+
customType: "design-focus",
|
|
1442
|
+
content: `[Design Focus: ${node.title} (${node.status})]${openQ}\n\nLet's explore this design space.`,
|
|
1443
|
+
display: true,
|
|
1444
|
+
details: cardDetails,
|
|
1445
|
+
},
|
|
1446
|
+
{ triggerTurn: false },
|
|
1447
|
+
);
|
|
1448
|
+
break;
|
|
1449
|
+
}
|
|
1450
|
+
|
|
1451
|
+
case "unfocus": {
|
|
1452
|
+
focusedNode = null;
|
|
1453
|
+
emitCurrentState();
|
|
1454
|
+
ctx.ui.notify("Design focus cleared", "info");
|
|
1455
|
+
break;
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
case "decide":
|
|
1459
|
+
case "explore":
|
|
1460
|
+
case "block":
|
|
1461
|
+
case "defer": {
|
|
1462
|
+
const statusMap: Record<string, NodeStatus> = {
|
|
1463
|
+
decide: "decided", explore: "exploring", block: "blocked", defer: "deferred",
|
|
1464
|
+
};
|
|
1465
|
+
const id = parts[1] || focusedNode;
|
|
1466
|
+
if (!id) {
|
|
1467
|
+
ctx.ui.notify(`Usage: /design ${subcommand} <node-id>`, "warning");
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
const node = tree.nodes.get(id);
|
|
1471
|
+
if (!node) {
|
|
1472
|
+
ctx.ui.notify(`Node '${id}' not found`, "error");
|
|
1473
|
+
return;
|
|
1474
|
+
}
|
|
1475
|
+
const newStatus = statusMap[subcommand];
|
|
1476
|
+
setNodeStatus(node, newStatus);
|
|
1477
|
+
if (subcommand === "explore") focusedNode = id;
|
|
1478
|
+
reload(ctx.cwd);
|
|
1479
|
+
emitCurrentState();
|
|
1480
|
+
ctx.ui.notify(`${STATUS_ICONS[newStatus]} '${node.title}' → ${newStatus}`, "info");
|
|
1481
|
+
break;
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
case "frontier": {
|
|
1485
|
+
const questions = getAllOpenQuestions(tree);
|
|
1486
|
+
if (questions.length === 0) {
|
|
1487
|
+
ctx.ui.notify("No open questions in the design tree", "info");
|
|
1488
|
+
return;
|
|
1489
|
+
}
|
|
1490
|
+
const items = questions.map(({ node, question }) => `[${node.id}] ${question}`);
|
|
1491
|
+
const choice = await ctx.ui.select(`Open Questions (${questions.length}):`, items);
|
|
1492
|
+
if (choice) {
|
|
1493
|
+
const match = choice.match(/^\[([^\]]+)\]/);
|
|
1494
|
+
if (match) {
|
|
1495
|
+
focusedNode = match[1];
|
|
1496
|
+
emitCurrentState();
|
|
1497
|
+
const node = tree.nodes.get(match[1])!;
|
|
1498
|
+
const question = choice.replace(/^\[[^\]]+\]\s*/, "");
|
|
1499
|
+
pi.sendMessage(
|
|
1500
|
+
{
|
|
1501
|
+
customType: "design-frontier",
|
|
1502
|
+
content: `[Exploring open question from ${node.title}]\n\nQuestion: ${question}\n\nLet's dig into this.`,
|
|
1503
|
+
display: true,
|
|
1504
|
+
},
|
|
1505
|
+
{ triggerTurn: true },
|
|
1506
|
+
);
|
|
1507
|
+
}
|
|
1508
|
+
}
|
|
1509
|
+
break;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
case "branch": {
|
|
1513
|
+
let nodeId = focusedNode;
|
|
1514
|
+
if (!nodeId) {
|
|
1515
|
+
const ids = Array.from(tree.nodes.keys());
|
|
1516
|
+
const labels = ids.map((id) => {
|
|
1517
|
+
const n = tree.nodes.get(id)!;
|
|
1518
|
+
return `${id} — ${n.title} (${n.open_questions.length} questions)`;
|
|
1519
|
+
});
|
|
1520
|
+
const choice = await ctx.ui.select("Branch from which node?", labels);
|
|
1521
|
+
if (!choice) return;
|
|
1522
|
+
nodeId = choice.split(" — ")[0];
|
|
1523
|
+
}
|
|
1524
|
+
const node = tree.nodes.get(nodeId);
|
|
1525
|
+
if (!node) return;
|
|
1526
|
+
if (node.open_questions.length === 0) {
|
|
1527
|
+
ctx.ui.notify(`${node.title} has no open questions to branch from`, "info");
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
const selected = await ctx.ui.select(
|
|
1531
|
+
`Branch from '${node.title}' — select a question:`,
|
|
1532
|
+
node.open_questions,
|
|
1533
|
+
);
|
|
1534
|
+
if (!selected) return;
|
|
1535
|
+
|
|
1536
|
+
const suggestedId = toSlug(selected);
|
|
1537
|
+
const newId = await ctx.ui.input("Node ID:", suggestedId);
|
|
1538
|
+
if (!newId) return;
|
|
1539
|
+
const newTitle = await ctx.ui.input("Title:", selected.slice(0, 60));
|
|
1540
|
+
if (!newTitle) return;
|
|
1541
|
+
|
|
1542
|
+
branchFromQuestion(tree, nodeId, selected, newId, newTitle);
|
|
1543
|
+
reload(ctx.cwd);
|
|
1544
|
+
focusedNode = newId;
|
|
1545
|
+
emitCurrentState();
|
|
1546
|
+
ctx.ui.notify(`Created ${newId}.md — branched from ${node.title}`, "info");
|
|
1547
|
+
break;
|
|
1548
|
+
}
|
|
1549
|
+
|
|
1550
|
+
case "new": {
|
|
1551
|
+
const id = parts[1];
|
|
1552
|
+
const title = parts.slice(2).join(" ");
|
|
1553
|
+
if (!id || !title) {
|
|
1554
|
+
ctx.ui.notify("Usage: /design new <id> <title>", "warning");
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
const idErr = validateNodeId(id);
|
|
1558
|
+
if (idErr) {
|
|
1559
|
+
ctx.ui.notify(`Invalid node ID '${id}': ${idErr}`, "error");
|
|
1560
|
+
return;
|
|
1561
|
+
}
|
|
1562
|
+
createNode(docsDir(ctx.cwd), { id, title });
|
|
1563
|
+
reload(ctx.cwd);
|
|
1564
|
+
focusedNode = id;
|
|
1565
|
+
emitCurrentState();
|
|
1566
|
+
ctx.ui.notify(`Created ${id}.md`, "info");
|
|
1567
|
+
break;
|
|
1568
|
+
}
|
|
1569
|
+
|
|
1570
|
+
case "update": {
|
|
1571
|
+
const id = parts[1] || focusedNode;
|
|
1572
|
+
if (!id) {
|
|
1573
|
+
ctx.ui.notify("Usage: /design update <node-id>", "warning");
|
|
1574
|
+
return;
|
|
1575
|
+
}
|
|
1576
|
+
const node = tree.nodes.get(id);
|
|
1577
|
+
if (!node) {
|
|
1578
|
+
ctx.ui.notify(`Node '${id}' not found`, "error");
|
|
1579
|
+
return;
|
|
1580
|
+
}
|
|
1581
|
+
|
|
1582
|
+
const action = await ctx.ui.select(`Update '${node.title}':`, [
|
|
1583
|
+
"Add open question",
|
|
1584
|
+
"Remove open question",
|
|
1585
|
+
"Add dependency",
|
|
1586
|
+
"Add related node",
|
|
1587
|
+
]);
|
|
1588
|
+
if (!action) return;
|
|
1589
|
+
|
|
1590
|
+
if (action === "Add open question") {
|
|
1591
|
+
const question = await ctx.ui.input("New open question:");
|
|
1592
|
+
if (!question) return;
|
|
1593
|
+
addOpenQuestion(node, question);
|
|
1594
|
+
reload(ctx.cwd);
|
|
1595
|
+
emitCurrentState();
|
|
1596
|
+
ctx.ui.notify(`Added question to ${node.title}`, "info");
|
|
1597
|
+
} else if (action === "Remove open question") {
|
|
1598
|
+
if (node.open_questions.length === 0) {
|
|
1599
|
+
ctx.ui.notify("No open questions to remove", "info");
|
|
1600
|
+
return;
|
|
1601
|
+
}
|
|
1602
|
+
const toRemove = await ctx.ui.select("Remove which question?", node.open_questions);
|
|
1603
|
+
if (!toRemove) return;
|
|
1604
|
+
removeOpenQuestion(node, toRemove);
|
|
1605
|
+
reload(ctx.cwd);
|
|
1606
|
+
emitCurrentState();
|
|
1607
|
+
ctx.ui.notify(`Removed question from ${node.title}`, "info");
|
|
1608
|
+
} else if (action === "Add dependency") {
|
|
1609
|
+
const otherNodes = Array.from(tree.nodes.keys()).filter(
|
|
1610
|
+
(nid) => nid !== id && !node.dependencies.includes(nid),
|
|
1611
|
+
);
|
|
1612
|
+
if (otherNodes.length === 0) {
|
|
1613
|
+
ctx.ui.notify("No available nodes to add as dependency", "info");
|
|
1614
|
+
return;
|
|
1615
|
+
}
|
|
1616
|
+
const labels = otherNodes.map((nid) => {
|
|
1617
|
+
const n = tree.nodes.get(nid)!;
|
|
1618
|
+
return `${nid} — ${n.title}`;
|
|
1619
|
+
});
|
|
1620
|
+
const choice = await ctx.ui.select("Add dependency:", labels);
|
|
1621
|
+
if (!choice) return;
|
|
1622
|
+
addDependency(node, choice.split(" — ")[0]);
|
|
1623
|
+
reload(ctx.cwd);
|
|
1624
|
+
emitCurrentState();
|
|
1625
|
+
ctx.ui.notify(`Added dependency: ${choice.split(" — ")[0]}`, "info");
|
|
1626
|
+
} else if (action === "Add related node") {
|
|
1627
|
+
const otherNodes = Array.from(tree.nodes.keys()).filter(
|
|
1628
|
+
(nid) => nid !== id && !node.related.includes(nid),
|
|
1629
|
+
);
|
|
1630
|
+
if (otherNodes.length === 0) {
|
|
1631
|
+
ctx.ui.notify("No available nodes to add as related", "info");
|
|
1632
|
+
return;
|
|
1633
|
+
}
|
|
1634
|
+
const labels = otherNodes.map((nid) => {
|
|
1635
|
+
const n = tree.nodes.get(nid)!;
|
|
1636
|
+
return `${nid} — ${n.title}`;
|
|
1637
|
+
});
|
|
1638
|
+
const choice = await ctx.ui.select("Add related:", labels);
|
|
1639
|
+
if (!choice) return;
|
|
1640
|
+
const relatedId = choice.split(" — ")[0];
|
|
1641
|
+
const targetNode = tree.nodes.get(relatedId);
|
|
1642
|
+
addRelated(node, relatedId, targetNode);
|
|
1643
|
+
reload(ctx.cwd);
|
|
1644
|
+
emitCurrentState();
|
|
1645
|
+
ctx.ui.notify(`Added related: ${relatedId} (bidirectional)`, "info");
|
|
1646
|
+
}
|
|
1647
|
+
break;
|
|
1648
|
+
}
|
|
1649
|
+
|
|
1650
|
+
case "implement": {
|
|
1651
|
+
const id = parts[1] || focusedNode;
|
|
1652
|
+
if (!id) {
|
|
1653
|
+
ctx.ui.notify("Usage: /design implement <node-id>", "warning");
|
|
1654
|
+
return;
|
|
1655
|
+
}
|
|
1656
|
+
const node = tree.nodes.get(id);
|
|
1657
|
+
if (!node) {
|
|
1658
|
+
ctx.ui.notify(`Node '${id}' not found`, "error");
|
|
1659
|
+
return;
|
|
1660
|
+
}
|
|
1661
|
+
if (node.status !== "decided") {
|
|
1662
|
+
ctx.ui.notify(
|
|
1663
|
+
`'${node.title}' is '${node.status}', not 'decided'. Resolve questions first.`,
|
|
1664
|
+
"warning",
|
|
1665
|
+
);
|
|
1666
|
+
return;
|
|
1667
|
+
}
|
|
1668
|
+
|
|
1669
|
+
const implResult = executeImplement(ctx.cwd, node);
|
|
1670
|
+
reload(ctx.cwd);
|
|
1671
|
+
emitCurrentState();
|
|
1672
|
+
ctx.ui.notify(implResult.message, implResult.ok ? "info" : "error");
|
|
1673
|
+
break;
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
default:
|
|
1677
|
+
ctx.ui.notify(
|
|
1678
|
+
"Subcommands: list, focus, unfocus, decide, explore, block, defer, branch, frontier, new, update, implement",
|
|
1679
|
+
"info",
|
|
1680
|
+
);
|
|
1681
|
+
}
|
|
1682
|
+
},
|
|
1683
|
+
});
|
|
1684
|
+
|
|
1685
|
+
// ─── Context Injection ───────────────────────────────────────────────
|
|
1686
|
+
|
|
1687
|
+
pi.on("before_agent_start", async (_event, ctx) => {
|
|
1688
|
+
reload(ctx.cwd);
|
|
1689
|
+
|
|
1690
|
+
// Auto-associate branch on every turn (catches branch switches)
|
|
1691
|
+
tryAssociateBranch(ctx);
|
|
1692
|
+
|
|
1693
|
+
if (tree.nodes.size === 0) return;
|
|
1694
|
+
|
|
1695
|
+
if (focusedNode) {
|
|
1696
|
+
const node = tree.nodes.get(focusedNode);
|
|
1697
|
+
if (node) {
|
|
1698
|
+
const sections = getNodeSections(node);
|
|
1699
|
+
const cardDetails = buildCardDetails(node, sections, tree);
|
|
1700
|
+
|
|
1701
|
+
// Build structured content for the model
|
|
1702
|
+
const contentParts: string[] = [];
|
|
1703
|
+
contentParts.push(`[Design Tree — Focused on: ${node.title} (${node.status})]`);
|
|
1704
|
+
|
|
1705
|
+
if (node.priority != null || node.issue_type) {
|
|
1706
|
+
const meta: string[] = [];
|
|
1707
|
+
if (node.priority != null) meta.push(`P${node.priority} ${PRIORITY_LABELS[node.priority as Priority] ?? ""}`);
|
|
1708
|
+
if (node.issue_type) meta.push(node.issue_type);
|
|
1709
|
+
contentParts.push(`Type: ${meta.join(" · ")}`);
|
|
1710
|
+
}
|
|
1711
|
+
|
|
1712
|
+
if (sections.overview?.trim()) {
|
|
1713
|
+
contentParts.push(`\nOverview: ${sections.overview.trim().slice(0, 300)}`);
|
|
1714
|
+
}
|
|
1715
|
+
|
|
1716
|
+
if (node.dependencies.length > 0) {
|
|
1717
|
+
const deps = node.dependencies
|
|
1718
|
+
.map((id) => { const d = tree.nodes.get(id); return d ? `- ${d.title} (${d.status})` : null; })
|
|
1719
|
+
.filter(Boolean);
|
|
1720
|
+
if (deps.length > 0) contentParts.push(`\nDependencies:\n${deps.join("\n")}`);
|
|
1721
|
+
}
|
|
1722
|
+
|
|
1723
|
+
// Children
|
|
1724
|
+
const children = Array.from(tree.nodes.values()).filter((n) => n.parent === node.id);
|
|
1725
|
+
if (children.length > 0) {
|
|
1726
|
+
contentParts.push(`\nChildren:\n${children.map((c) => `- ${c.title} (${c.status})`).join("\n")}`);
|
|
1727
|
+
}
|
|
1728
|
+
|
|
1729
|
+
if (sections.decisions.length > 0) {
|
|
1730
|
+
contentParts.push(`\nDecisions:\n${sections.decisions.map((d) => `- ${d.title} (${d.status})`).join("\n")}`);
|
|
1731
|
+
}
|
|
1732
|
+
|
|
1733
|
+
if (node.open_questions.length > 0) {
|
|
1734
|
+
contentParts.push(`\nOpen questions:\n${node.open_questions.map((q, i) => `${i + 1}. ${q}`).join("\n")}`);
|
|
1735
|
+
}
|
|
1736
|
+
|
|
1737
|
+
if (sections.implementationNotes.fileScope.length > 0) {
|
|
1738
|
+
const scope = sections.implementationNotes.fileScope
|
|
1739
|
+
.map((f) => `- ${f.action ? `[${f.action}] ` : ""}${f.path}`)
|
|
1740
|
+
.join("\n");
|
|
1741
|
+
contentParts.push(`\nFile scope:\n${scope}`);
|
|
1742
|
+
}
|
|
1743
|
+
|
|
1744
|
+
if (sections.implementationNotes.constraints.length > 0) {
|
|
1745
|
+
contentParts.push(`\nConstraints:\n${sections.implementationNotes.constraints.map((c) => `- ${c}`).join("\n")}`);
|
|
1746
|
+
}
|
|
1747
|
+
|
|
1748
|
+
contentParts.push(
|
|
1749
|
+
`\nUse design_tree(action='node', node_id='${node.id}') to read the full document including research sections. ` +
|
|
1750
|
+
`Use design_tree_update to modify it. ` +
|
|
1751
|
+
`When this discussion reaches a conclusion, use design_tree_update to set_status to 'decided'. ` +
|
|
1752
|
+
`If new sub-topics emerge, use design_tree_update to branch child nodes.`,
|
|
1753
|
+
);
|
|
1754
|
+
|
|
1755
|
+
return {
|
|
1756
|
+
message: {
|
|
1757
|
+
customType: "design-context",
|
|
1758
|
+
content: contentParts.join("\n"),
|
|
1759
|
+
display: false,
|
|
1760
|
+
details: cardDetails,
|
|
1761
|
+
},
|
|
1762
|
+
};
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
|
|
1766
|
+
const implemented = Array.from(tree.nodes.values()).filter((n) => n.status === "implemented").length;
|
|
1767
|
+
const implementing = Array.from(tree.nodes.values()).filter((n) => n.status === "implementing").length;
|
|
1768
|
+
const decided = Array.from(tree.nodes.values()).filter((n) => n.status === "decided").length;
|
|
1769
|
+
const exploring = Array.from(tree.nodes.values()).filter(
|
|
1770
|
+
(n) => n.status === "exploring" || n.status === "seed",
|
|
1771
|
+
).length;
|
|
1772
|
+
const blocked = Array.from(tree.nodes.values()).filter((n) => n.status === "blocked").length;
|
|
1773
|
+
const deferred = Array.from(tree.nodes.values()).filter((n) => n.status === "deferred").length;
|
|
1774
|
+
const totalQ = getAllOpenQuestions(tree).length;
|
|
1775
|
+
const summaryParts = [
|
|
1776
|
+
`${tree.nodes.size} nodes`,
|
|
1777
|
+
`${implemented} implemented`,
|
|
1778
|
+
`${implementing} implementing`,
|
|
1779
|
+
`${decided} decided`,
|
|
1780
|
+
`${exploring} exploring`,
|
|
1781
|
+
`${totalQ} open questions`,
|
|
1782
|
+
];
|
|
1783
|
+
if (blocked > 0) summaryParts.push(`${blocked} blocked`);
|
|
1784
|
+
if (deferred > 0) summaryParts.push(`${deferred} deferred`);
|
|
1785
|
+
|
|
1786
|
+
return {
|
|
1787
|
+
message: {
|
|
1788
|
+
customType: "design-context",
|
|
1789
|
+
content:
|
|
1790
|
+
`[Design Tree: ${summaryParts.join(" — ")}]\n` +
|
|
1791
|
+
`Use the design_tree tool to query the design space and design_tree_update to modify it.`,
|
|
1792
|
+
display: false,
|
|
1793
|
+
},
|
|
1794
|
+
};
|
|
1795
|
+
});
|
|
1796
|
+
|
|
1797
|
+
// Filter stale design-context messages
|
|
1798
|
+
pi.on("context", async (event) => {
|
|
1799
|
+
let foundLatest = false;
|
|
1800
|
+
const keep = new Array(event.messages.length).fill(true);
|
|
1801
|
+
for (let i = event.messages.length - 1; i >= 0; i--) {
|
|
1802
|
+
const msg = event.messages[i] as { customType?: string };
|
|
1803
|
+
if (msg.customType === "design-context") {
|
|
1804
|
+
if (!foundLatest) {
|
|
1805
|
+
foundLatest = true;
|
|
1806
|
+
} else {
|
|
1807
|
+
keep[i] = false;
|
|
1808
|
+
}
|
|
1809
|
+
}
|
|
1810
|
+
}
|
|
1811
|
+
if (foundLatest) {
|
|
1812
|
+
const filtered = event.messages.filter((_, i) => keep[i]);
|
|
1813
|
+
if (filtered.length !== event.messages.length) {
|
|
1814
|
+
return { messages: filtered };
|
|
1815
|
+
}
|
|
1816
|
+
}
|
|
1817
|
+
});
|
|
1818
|
+
|
|
1819
|
+
// ─── Message Renderers ───────────────────────────────────────────────
|
|
1820
|
+
|
|
1821
|
+
pi.registerMessageRenderer("design-focus", (message, _options, theme) => {
|
|
1822
|
+
const details = message.details as DesignCardDetails | undefined;
|
|
1823
|
+
|
|
1824
|
+
// Rich card when details are available
|
|
1825
|
+
if (details?.id) {
|
|
1826
|
+
return new SciDesignCard(`design:focus → ${details.id}`, details, theme);
|
|
1827
|
+
}
|
|
1828
|
+
|
|
1829
|
+
// Fallback for legacy messages without details
|
|
1830
|
+
const titleMatch = (message.content as string).match(/\[Design Focus: (.+?)\]/);
|
|
1831
|
+
const title = titleMatch ? titleMatch[1] : "Unknown";
|
|
1832
|
+
|
|
1833
|
+
const questionsMatch = (message.content as string).match(/Open questions:\n([\s\S]*?)(?:\n\n|$)/);
|
|
1834
|
+
const questionLines = questionsMatch
|
|
1835
|
+
? questionsMatch[1].split("\n").filter(Boolean)
|
|
1836
|
+
: [];
|
|
1837
|
+
|
|
1838
|
+
return sciBanner("◈", "design:focus → " + title, questionLines, theme);
|
|
1839
|
+
});
|
|
1840
|
+
|
|
1841
|
+
pi.registerMessageRenderer("design-frontier", (message, _options, theme) => {
|
|
1842
|
+
const questionMatch = (message.content as string).match(/Question: (.+)/);
|
|
1843
|
+
const question = questionMatch ? questionMatch[1] : "Unknown";
|
|
1844
|
+
return sciBanner("◈", "design:frontier", [question], theme);
|
|
1845
|
+
});
|
|
1846
|
+
|
|
1847
|
+
pi.registerMessageRenderer("design-context", (message, _options, theme) => {
|
|
1848
|
+
const details = message.details as DesignCardDetails | undefined;
|
|
1849
|
+
|
|
1850
|
+
// Rich card when details are available (focused node context)
|
|
1851
|
+
if (details?.id) {
|
|
1852
|
+
return new SciDesignCard(`design:context → ${details.id}`, details, theme);
|
|
1853
|
+
}
|
|
1854
|
+
|
|
1855
|
+
// Summary context (no focused node) — show as a thin banner
|
|
1856
|
+
const content = (message.content as string) || "";
|
|
1857
|
+
const firstLine = content.split("\n")[0] || "";
|
|
1858
|
+
return sciBanner("◈", "design:context", [firstLine], theme);
|
|
1859
|
+
});
|
|
1860
|
+
|
|
1861
|
+
// ─── Branch Auto-Association ─────────────────────────────────────────
|
|
1862
|
+
// Note: pi's onBranchChange callback (ReadonlyFooterDataProvider) is only
|
|
1863
|
+
// accessible inside setFooter(), which conflicts with the dashboard extension.
|
|
1864
|
+
// We use before_agent_start polling with a dedup guard instead — readGitBranch
|
|
1865
|
+
// reads .git/HEAD which is a trivial stat+read, and the lastAssociatedBranch
|
|
1866
|
+
// guard ensures we only process actual changes.
|
|
1867
|
+
|
|
1868
|
+
let lastAssociatedBranch: string | null = null;
|
|
1869
|
+
|
|
1870
|
+
function tryAssociateBranch(ctx: ExtensionContext): void {
|
|
1871
|
+
const branch = readGitBranch(ctx.cwd);
|
|
1872
|
+
if (!branch || branch === lastAssociatedBranch) return;
|
|
1873
|
+
lastAssociatedBranch = branch;
|
|
1874
|
+
|
|
1875
|
+
reload(ctx.cwd);
|
|
1876
|
+
const matched = matchBranchToNode(tree, branch);
|
|
1877
|
+
if (matched && !matched.branches.includes(branch)) {
|
|
1878
|
+
const updated = appendBranch(matched, branch);
|
|
1879
|
+
tree.nodes.set(updated.id, updated);
|
|
1880
|
+
emitCurrentState();
|
|
1881
|
+
}
|
|
1882
|
+
}
|
|
1883
|
+
|
|
1884
|
+
// ─── Session Lifecycle ───────────────────────────────────────────────
|
|
1885
|
+
|
|
1886
|
+
pi.on("session_start", async (_event, ctx) => {
|
|
1887
|
+
reload(ctx.cwd);
|
|
1888
|
+
|
|
1889
|
+
const entries = ctx.sessionManager.getEntries();
|
|
1890
|
+
const focusEntry = entries
|
|
1891
|
+
.filter(
|
|
1892
|
+
(e: { type: string; customType?: string }) =>
|
|
1893
|
+
e.type === "custom" && e.customType === "design-tree-focus",
|
|
1894
|
+
)
|
|
1895
|
+
.pop() as { data?: { focusedNode: string | null } } | undefined;
|
|
1896
|
+
|
|
1897
|
+
if (focusEntry?.data?.focusedNode) {
|
|
1898
|
+
focusedNode = focusEntry.data.focusedNode;
|
|
1899
|
+
}
|
|
1900
|
+
|
|
1901
|
+
emitCurrentState();
|
|
1902
|
+
startDocsWatcher(ctx.cwd);
|
|
1903
|
+
|
|
1904
|
+
// Check for migrateable design docs and hint
|
|
1905
|
+
const { toMigrate } = detectMigratableDesignDocs(ctx.cwd);
|
|
1906
|
+
if (toMigrate.length > 0) {
|
|
1907
|
+
ctx.ui.notify(
|
|
1908
|
+
`📦 ${toMigrate.length} completed design doc(s) can be archived to docs/design/. Run /migrate to clean up.`,
|
|
1909
|
+
"info",
|
|
1910
|
+
);
|
|
1911
|
+
}
|
|
1912
|
+
|
|
1913
|
+
// Auto-associate current branch on session start
|
|
1914
|
+
tryAssociateBranch(ctx);
|
|
1915
|
+
});
|
|
1916
|
+
|
|
1917
|
+
// ─── /migrate command ────────────────────────────────────────────────
|
|
1918
|
+
|
|
1919
|
+
/**
|
|
1920
|
+
* Detect design docs in docs/ that should be archived to docs/design/
|
|
1921
|
+
* and offer to move them. Returns { migrated, skipped, errors }.
|
|
1922
|
+
*/
|
|
1923
|
+
function detectMigratableDesignDocs(cwd: string): {
|
|
1924
|
+
toMigrate: Array<{ file: string; id: string; status: string }>;
|
|
1925
|
+
activeExplorations: Array<{ file: string; id: string; status: string }>;
|
|
1926
|
+
} {
|
|
1927
|
+
const docsDir = path.join(cwd, "docs");
|
|
1928
|
+
if (!fs.existsSync(docsDir)) return { toMigrate: [], activeExplorations: [] };
|
|
1929
|
+
|
|
1930
|
+
const designSubdir = path.join(docsDir, "design");
|
|
1931
|
+
const alreadyArchived = new Set<string>();
|
|
1932
|
+
if (fs.existsSync(designSubdir)) {
|
|
1933
|
+
for (const f of fs.readdirSync(designSubdir)) {
|
|
1934
|
+
if (f.endsWith(".md")) alreadyArchived.add(f);
|
|
1935
|
+
}
|
|
1936
|
+
}
|
|
1937
|
+
|
|
1938
|
+
const toMigrate: Array<{ file: string; id: string; status: string }> = [];
|
|
1939
|
+
const activeExplorations: Array<{ file: string; id: string; status: string }> = [];
|
|
1940
|
+
|
|
1941
|
+
for (const file of fs.readdirSync(docsDir)) {
|
|
1942
|
+
if (!file.endsWith(".md")) continue;
|
|
1943
|
+
if (alreadyArchived.has(file)) continue;
|
|
1944
|
+
|
|
1945
|
+
const filePath = path.join(docsDir, file);
|
|
1946
|
+
const stat = fs.statSync(filePath);
|
|
1947
|
+
if (!stat.isFile()) continue;
|
|
1948
|
+
|
|
1949
|
+
const content = fs.readFileSync(filePath, "utf-8");
|
|
1950
|
+
const fm = parseFrontmatter(content);
|
|
1951
|
+
if (!fm || !fm.id || !fm.status) continue; // Not a design doc
|
|
1952
|
+
|
|
1953
|
+
const status = fm.status as string;
|
|
1954
|
+
const entry = { file, id: fm.id as string, status };
|
|
1955
|
+
|
|
1956
|
+
if (status === "implemented" || status === "deferred") {
|
|
1957
|
+
toMigrate.push(entry);
|
|
1958
|
+
} else {
|
|
1959
|
+
// seed, exploring, decided, blocked — leave in docs/
|
|
1960
|
+
activeExplorations.push(entry);
|
|
1961
|
+
}
|
|
1962
|
+
}
|
|
1963
|
+
|
|
1964
|
+
return { toMigrate, activeExplorations };
|
|
1965
|
+
}
|
|
1966
|
+
|
|
1967
|
+
function executeDocsMigration(cwd: string, files: Array<{ file: string }>): {
|
|
1968
|
+
migrated: string[];
|
|
1969
|
+
errors: Array<{ file: string; error: string }>;
|
|
1970
|
+
} {
|
|
1971
|
+
const docsDir = path.join(cwd, "docs");
|
|
1972
|
+
const designDir = path.join(docsDir, "design");
|
|
1973
|
+
fs.mkdirSync(designDir, { recursive: true });
|
|
1974
|
+
|
|
1975
|
+
const migrated: string[] = [];
|
|
1976
|
+
const errors: Array<{ file: string; error: string }> = [];
|
|
1977
|
+
|
|
1978
|
+
// Detect if we're in a git repo
|
|
1979
|
+
let useGitMv = false;
|
|
1980
|
+
try {
|
|
1981
|
+
execFileSync("git", ["rev-parse", "--git-dir"], { cwd, stdio: "pipe" });
|
|
1982
|
+
useGitMv = true;
|
|
1983
|
+
} catch {
|
|
1984
|
+
// Not a git repo — use plain fs.rename
|
|
1985
|
+
}
|
|
1986
|
+
|
|
1987
|
+
for (const { file } of files) {
|
|
1988
|
+
const src = path.join(docsDir, file);
|
|
1989
|
+
const dst = path.join(designDir, file);
|
|
1990
|
+
|
|
1991
|
+
try {
|
|
1992
|
+
if (useGitMv) {
|
|
1993
|
+
execFileSync("git", ["mv", src, dst], { cwd, stdio: "pipe" });
|
|
1994
|
+
} else {
|
|
1995
|
+
fs.renameSync(src, dst);
|
|
1996
|
+
}
|
|
1997
|
+
migrated.push(file);
|
|
1998
|
+
} catch (e: any) {
|
|
1999
|
+
errors.push({ file, error: e.message?.slice(0, 200) ?? "unknown error" });
|
|
2000
|
+
}
|
|
2001
|
+
}
|
|
2002
|
+
|
|
2003
|
+
return { migrated, errors };
|
|
2004
|
+
}
|
|
2005
|
+
|
|
2006
|
+
pi.registerCommand("migrate", {
|
|
2007
|
+
description: "Migrate design docs: archive implemented/deferred explorations to docs/design/",
|
|
2008
|
+
handler: async (_args, ctx) => {
|
|
2009
|
+
const cwd = ctx.cwd;
|
|
2010
|
+
const { toMigrate, activeExplorations } = detectMigratableDesignDocs(cwd);
|
|
2011
|
+
|
|
2012
|
+
if (toMigrate.length === 0) {
|
|
2013
|
+
const msg = activeExplorations.length > 0
|
|
2014
|
+
? `No design docs to migrate. ${activeExplorations.length} active exploration(s) remain in docs/ (correct).`
|
|
2015
|
+
: "No design docs found in docs/. Nothing to migrate.";
|
|
2016
|
+
ctx.ui.notify(msg, "info");
|
|
2017
|
+
return;
|
|
2018
|
+
}
|
|
2019
|
+
|
|
2020
|
+
// Show what will be migrated
|
|
2021
|
+
const lines = [
|
|
2022
|
+
`Found ${toMigrate.length} design doc(s) to archive to docs/design/:`,
|
|
2023
|
+
"",
|
|
2024
|
+
...toMigrate.map((d) => ` ${STATUS_ICONS[d.status as NodeStatus] ?? "○"} ${d.file} (${d.status})`),
|
|
2025
|
+
];
|
|
2026
|
+
if (activeExplorations.length > 0) {
|
|
2027
|
+
lines.push(
|
|
2028
|
+
"",
|
|
2029
|
+
`${activeExplorations.length} active exploration(s) will stay in docs/:`,
|
|
2030
|
+
...activeExplorations.map((d) => ` ${STATUS_ICONS[d.status as NodeStatus] ?? "○"} ${d.file} (${d.status})`),
|
|
2031
|
+
);
|
|
2032
|
+
}
|
|
2033
|
+
|
|
2034
|
+
const confirmed = await ctx.ui.confirm(
|
|
2035
|
+
"Migrate design docs",
|
|
2036
|
+
lines.join("\n") + "\n\nProceed with migration?",
|
|
2037
|
+
);
|
|
2038
|
+
if (!confirmed) {
|
|
2039
|
+
ctx.ui.notify("Migration cancelled.", "info");
|
|
2040
|
+
return;
|
|
2041
|
+
}
|
|
2042
|
+
|
|
2043
|
+
const { migrated, errors } = executeDocsMigration(cwd, toMigrate);
|
|
2044
|
+
|
|
2045
|
+
// Reload the tree to pick up new locations
|
|
2046
|
+
reload(cwd);
|
|
2047
|
+
emitCurrentState();
|
|
2048
|
+
|
|
2049
|
+
const summary = [
|
|
2050
|
+
`✅ Migrated ${migrated.length} design doc(s) to docs/design/`,
|
|
2051
|
+
];
|
|
2052
|
+
if (errors.length > 0) {
|
|
2053
|
+
summary.push(`⚠️ ${errors.length} error(s):`);
|
|
2054
|
+
for (const e of errors) summary.push(` ${e.file}: ${e.error}`);
|
|
2055
|
+
}
|
|
2056
|
+
if (activeExplorations.length > 0) {
|
|
2057
|
+
summary.push(`ℹ️ ${activeExplorations.length} active exploration(s) unchanged in docs/`);
|
|
2058
|
+
}
|
|
2059
|
+
|
|
2060
|
+
pi.sendMessage({
|
|
2061
|
+
customType: "design-tree-migrate",
|
|
2062
|
+
content: summary.join("\n"),
|
|
2063
|
+
display: true,
|
|
2064
|
+
});
|
|
2065
|
+
},
|
|
2066
|
+
});
|
|
2067
|
+
|
|
2068
|
+
// Bridge /migrate for agent access
|
|
2069
|
+
const bridge = getSharedBridge();
|
|
2070
|
+
bridge.register(pi, {
|
|
2071
|
+
name: "migrate",
|
|
2072
|
+
description: "Migrate design docs: archive implemented/deferred explorations to docs/design/",
|
|
2073
|
+
bridge: {
|
|
2074
|
+
agentCallable: true,
|
|
2075
|
+
sideEffectClass: "git-write",
|
|
2076
|
+
requiresConfirmation: true,
|
|
2077
|
+
summary: "Archive completed design docs from docs/ to docs/design/",
|
|
2078
|
+
},
|
|
2079
|
+
structuredExecutor: async (_args, ctx) => {
|
|
2080
|
+
const cwd = (ctx as ExtensionContext).cwd;
|
|
2081
|
+
const { toMigrate, activeExplorations } = detectMigratableDesignDocs(cwd);
|
|
2082
|
+
|
|
2083
|
+
if (toMigrate.length === 0) {
|
|
2084
|
+
return buildSlashCommandResult("migrate", [], {
|
|
2085
|
+
ok: true,
|
|
2086
|
+
summary: "Nothing to migrate",
|
|
2087
|
+
humanText: activeExplorations.length > 0
|
|
2088
|
+
? `No completed design docs to migrate. ${activeExplorations.length} active exploration(s) remain in docs/.`
|
|
2089
|
+
: "No design docs found in docs/.",
|
|
2090
|
+
effects: { sideEffectClass: "read" },
|
|
2091
|
+
});
|
|
2092
|
+
}
|
|
2093
|
+
|
|
2094
|
+
const { migrated, errors } = executeDocsMigration(cwd, toMigrate);
|
|
2095
|
+
|
|
2096
|
+
reload(cwd);
|
|
2097
|
+
emitCurrentState();
|
|
2098
|
+
|
|
2099
|
+
const humanLines = [
|
|
2100
|
+
`Migrated ${migrated.length} design doc(s) to docs/design/`,
|
|
2101
|
+
...migrated.map((f) => ` ✓ ${f}`),
|
|
2102
|
+
];
|
|
2103
|
+
if (errors.length > 0) {
|
|
2104
|
+
humanLines.push(`${errors.length} error(s):`);
|
|
2105
|
+
for (const e of errors) humanLines.push(` ✗ ${e.file}: ${e.error}`);
|
|
2106
|
+
}
|
|
2107
|
+
|
|
2108
|
+
return buildSlashCommandResult("migrate", [], {
|
|
2109
|
+
ok: errors.length === 0,
|
|
2110
|
+
summary: `Migrated ${migrated.length} doc(s)${errors.length > 0 ? `, ${errors.length} error(s)` : ""}`,
|
|
2111
|
+
humanText: humanLines.join("\n"),
|
|
2112
|
+
effects: {
|
|
2113
|
+
sideEffectClass: "git-write",
|
|
2114
|
+
filesChanged: migrated.map((f) => `docs/design/${f}`),
|
|
2115
|
+
},
|
|
2116
|
+
data: { migrated, errors, activeExplorations: activeExplorations.map((a) => a.file) },
|
|
2117
|
+
});
|
|
2118
|
+
},
|
|
2119
|
+
});
|
|
2120
|
+
|
|
2121
|
+
// ─── Session lifecycle ───────────────────────────────────────────────
|
|
2122
|
+
|
|
2123
|
+
pi.on("agent_end", async () => {
|
|
2124
|
+
if (tree.nodes.size > 0) {
|
|
2125
|
+
pi.appendEntry("design-tree-focus", { focusedNode });
|
|
2126
|
+
}
|
|
2127
|
+
});
|
|
2128
|
+
}
|
|
2129
|
+
|
|
2130
|
+
|