pi-ui-extend 0.1.43 → 0.1.44

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.
@@ -18,10 +18,11 @@ This package keeps shared Pi tools as ordinary source folders under `src/` and r
18
18
  - `src/web-search` — `web_search` and `web_fetch` tools migrated from `@ollama/pi-web-search`; calls the local Ollama experimental web search/fetch APIs, honors `OLLAMA_HOST`, supports request timeouts via `timeout_ms` / `PI_WEB_SEARCH_TIMEOUT_MS`, and reports targeted `ollama signin`, unsupported-endpoint, invalid-response, timeout, DNS, and Ollama-not-running errors
19
19
  - `src/dcp` — headless Dynamic Context Pruning ported from `opencode-dynamic-context-pruning` for the Pi SDK: explicit `compress` tool with range and message modes, `/dcp` commands (context, stats, sweep, manual, decompress, recompress, compress), same-call overlap validation, recoverable compressed-block rollups, grouped message-mode skip diagnostics, stable raw-message anchors when available, protected user/tool preservation, deduplication, error purging, and context nudges; visualization is left to `compress` tool responses and the renderer-owned context-percent click dialog
20
20
  - `src/prompt-commands` — user slash-command builder: `/prompt-commands` opens a CRUD menu for saved prompt-backed slash commands, stores them under `promptCommands` in `~/.config/pi/pi-tools-suite.jsonc`, reloads after edits, and runs each saved prompt as a normal user message
21
+ - `src/skill-installer` — `/install-skill [name]` installs a personal skill folder from `~/.agents/local_skills` into the current project's `.pi/skills/` so it activates as a project-local skill, then automatically runs `/reload` so the new skill is picked up without a manual step; `/export-skill [name]` does the reverse, copying a project-local skill back to `~/.agents/local_skills/` for reuse in other projects (no reload, since the library lives outside the project); with no argument either command shows an interactive menu of available skills (folders containing `SKILL.md`), and the `<name>` form installs/exports it directly (headless-safe); existing destinations prompt to overwrite in the UI and are refused in headless mode; `.DS_Store` files are skipped
21
22
 
22
23
  `index.ts` is intentionally only a thin auto-discovery shim that re-exports `src/index.ts`. There is no `pi.extensions` manifest here, so local Pi auto-discovery loads the suite once via `~/.pi/agent/extensions/pi-tools-suite/index.ts` and does not double-register tools.
23
24
 
24
- Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, then prompt-commands. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
25
+ Registration order is preserved in `src/index.ts`: coding-discipline, ast-grep, async-subagents, lsp, comment-checker, session-name, repo-discovery command/tool gate, antigravity-auth provider, todo, model-tools, usage, web-search, dcp, prompt-commands, then skill-installer. Tool metadata and active model-specific tool sets have two modes: standard and repo-aware. When `.indexer-cli` enables `repo_*`, those tools stay active ahead of overlapping lower-level aliases so the indexed discovery surface has priority.
25
26
 
26
27
  ## Disabling modules
27
28
 
@@ -88,7 +88,8 @@ const QUALITY_DISCIPLINE_LINES = [
88
88
  "- for bugs, prefer a failing repro first, then the minimal fix, then verify;",
89
89
  "- high-risk changes (security, data/schema, public APIs, concurrency, irreversible) need a short spec first;",
90
90
  "- handle edge cases, errors, cancellation, and async behavior; do not block UI/event loops;",
91
- "- avoid duplicate state, duplicate prompts, and repeated side effects.",
91
+ "- avoid duplicate state, duplicate prompts, and repeated side effects;",
92
+ "- write code, identifiers, comments, and commit messages in English.",
92
93
  ];
93
94
 
94
95
  const LOOKUP_DISCIPLINE_LINES = [
@@ -57,6 +57,20 @@ export const DEFAULT_PI_TOOLS_SUITE_CONFIG_JSONC = String.raw`{
57
57
  "maxContextPercent": "30%"
58
58
  }
59
59
  },
60
+ // glm-5.2 reports a ~1M-token window. Even zai/* 16%/30% = 160K/300K is
61
+ // above the ~15% (~150K) point where long sessions degrade, and an
62
+ // observed 14h/273K-token session never crossed 16%. Lower ONLY glm-5.2
63
+ // within the zai family: 8%/15% (~80K/150K) so nudging starts early and
64
+ // auto-compress fires at the observed degradation point. Other zai/*
65
+ // models keep 16%/30%.
66
+ "zai/glm-5.2": {
67
+ "compress": {
68
+ "minContextPercent": "8%",
69
+ "maxContextPercent": "15%",
70
+ "autoCandidates": { "minContextPercent": 0.08 },
71
+ "messageMode": { "minContextPercent": 0.08 }
72
+ }
73
+ },
60
74
  "antigravity/*sonnet*": {
61
75
  "compress": {
62
76
  "minContextPercent": "22%",
@@ -25,6 +25,7 @@ const MODULES: Array<{ name: string; load: () => Promise<ExtensionModule> }> = [
25
25
  { name: "web-search", load: () => import("./web-search/index") },
26
26
  { name: "dcp", load: () => import("./dcp/index") },
27
27
  { name: "prompt-commands", load: () => import("./prompt-commands/index") },
28
+ { name: "skill-installer", load: () => import("./skill-installer/index") },
28
29
  { name: "telegram-mirror", load: () => import("./telegram-mirror/index") },
29
30
  ];
30
31
 
@@ -0,0 +1,333 @@
1
+ import { promises as fs } from "node:fs";
2
+ import { homedir } from "node:os";
3
+ import { basename, join, relative } from "node:path";
4
+ import type { ExtensionAPI, ExtensionCommandContext, ExtensionContext } from "@earendil-works/pi-coding-agent";
5
+
6
+ import { ignoreStaleExtensionContextError } from "../context-usage.js";
7
+
8
+ const INSTALL_COMMAND = "install-skill";
9
+ const EXPORT_COMMAND = "export-skill";
10
+ const LOCAL_SKILLS_DIR = join(homedir(), ".agents", "local_skills");
11
+ const SKILL_FILE = "SKILL.md";
12
+ const PROJECT_DIR = ".pi";
13
+ const PROJECT_SKILLS_SUBDIR = "skills";
14
+ const SKIP_NAMES = new Set([".DS_Store"]);
15
+ const DESC_MAX = 90;
16
+
17
+ type SkillEntry = {
18
+ name: string;
19
+ path: string;
20
+ description: string;
21
+ };
22
+
23
+ /** Direction-aware transfer plan shared by install and export. */
24
+ type Transfer = {
25
+ srcDir: string;
26
+ destDir: string;
27
+ /** Human label for the destination root (with trailing slash); name is appended per skill. */
28
+ destRootLabel: string;
29
+ commandName: string;
30
+ direction: "install" | "export";
31
+ };
32
+
33
+ function localSkillsLabel(): string {
34
+ const home = homedir();
35
+ return LOCAL_SKILLS_DIR === home ? LOCAL_SKILLS_DIR : LOCAL_SKILLS_DIR.replace(home, "~");
36
+ }
37
+
38
+ function projectSkillsDir(ctx: ExtensionContext): string {
39
+ return join(ctx.cwd, PROJECT_DIR, PROJECT_SKILLS_SUBDIR);
40
+ }
41
+
42
+ function projectSkillsLabel(ctx: ExtensionContext): string {
43
+ return displayPath(ctx, projectSkillsDir(ctx));
44
+ }
45
+
46
+ function displayPath(ctx: ExtensionContext, absPath: string): string {
47
+ const rel = relative(ctx.cwd, absPath);
48
+ return rel && !rel.startsWith("..") ? rel : absPath;
49
+ }
50
+
51
+ function notify(ctx: ExtensionContext, message: string, type: "info" | "warning" | "error" = "info"): void {
52
+ if (ctx.hasUI) ctx.ui.notify(message, type);
53
+ else console.log(message);
54
+ }
55
+
56
+ function truncate(value: string, maxLength: number): string {
57
+ const collapsed = value.replace(/\s+/g, " ").trim();
58
+ return collapsed.length <= maxLength ? collapsed : `${collapsed.slice(0, Math.max(0, maxLength - 1))}…`;
59
+ }
60
+
61
+ /** Extract the `description` scalar from SKILL.md YAML frontmatter. Returns "" when absent/unparseable. */
62
+ function parseFrontmatterDescription(content: string): string {
63
+ const match = content.match(/^---\r?\n([\s\S]*?)\r?\n---/);
64
+ if (!match) return "";
65
+ const front = match[1] ?? "";
66
+ const lines = front.split(/\r?\n/);
67
+ const idx = lines.findIndex((line) => /^\s*description:\s*/.test(line));
68
+ if (idx === -1) return "";
69
+ const headerLine = lines[idx];
70
+ if (!headerLine) return "";
71
+ const after = headerLine.replace(/^\s*description:\s*/, "");
72
+
73
+ // Folded/literal block scalar (>, >-, |, |-)
74
+ if (/^[>|]/.test(after)) {
75
+ const block: string[] = [];
76
+ for (let i = idx + 1; i < lines.length; i += 1) {
77
+ const line = lines[i] ?? "";
78
+ if (line === "") {
79
+ block.push(" ");
80
+ continue;
81
+ }
82
+ if (/^\s+/.test(line)) {
83
+ block.push(line.replace(/^\s+/, ""));
84
+ continue;
85
+ }
86
+ break;
87
+ }
88
+ return block.join(" ").trim();
89
+ }
90
+
91
+ // Plain or quoted scalar
92
+ let value = after.trim();
93
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) {
94
+ value = value.slice(1, -1);
95
+ }
96
+ return value;
97
+ }
98
+
99
+ async function readSkillDescription(skillDir: string): Promise<string> {
100
+ try {
101
+ const content = await fs.readFile(join(skillDir, SKILL_FILE), "utf-8");
102
+ return parseFrontmatterDescription(content);
103
+ } catch {
104
+ return "";
105
+ }
106
+ }
107
+
108
+ async function pathExists(path: string): Promise<boolean> {
109
+ try {
110
+ await fs.access(path);
111
+ return true;
112
+ } catch {
113
+ return false;
114
+ }
115
+ }
116
+
117
+ /** Scan a skills directory: every subdirectory that contains SKILL.md. Returns ENOENT as empty. */
118
+ async function scanSkills(dir: string): Promise<SkillEntry[]> {
119
+ let entries: import("node:fs").Dirent[];
120
+ try {
121
+ entries = await fs.readdir(dir, { withFileTypes: true });
122
+ } catch (error) {
123
+ if ((error as NodeJS.ErrnoException).code === "ENOENT") return [];
124
+ throw error;
125
+ }
126
+
127
+ const skills: SkillEntry[] = [];
128
+ for (const entry of entries) {
129
+ if (!entry.isDirectory() || SKIP_NAMES.has(entry.name)) continue;
130
+ const skillPath = join(dir, entry.name);
131
+ if (!(await pathExists(join(skillPath, SKILL_FILE)))) continue;
132
+ skills.push({ name: entry.name, path: skillPath, description: await readSkillDescription(skillPath) });
133
+ }
134
+ return skills.sort((a, b) => a.name.localeCompare(b.name));
135
+ }
136
+
137
+ function listLocalSkills(): Promise<SkillEntry[]> {
138
+ return scanSkills(LOCAL_SKILLS_DIR);
139
+ }
140
+
141
+ function listProjectSkills(ctx: ExtensionContext): Promise<SkillEntry[]> {
142
+ return scanSkills(projectSkillsDir(ctx));
143
+ }
144
+
145
+ function skillLabel(skill: SkillEntry): string {
146
+ const description = skill.description.replace(/\s+/g, " ").trim();
147
+ return description ? `${skill.name} — ${truncate(description, DESC_MAX)}` : skill.name;
148
+ }
149
+
150
+ async function copySkill(source: string, dest: string): Promise<void> {
151
+ await fs.mkdir(join(dest, ".."), { recursive: true });
152
+ await fs.cp(source, dest, {
153
+ recursive: true,
154
+ filter: (src) => !SKIP_NAMES.has(basename(src)),
155
+ });
156
+ }
157
+
158
+ /** Copy a skill folder from srcDir/name → destDir/name with overwrite-confirm (UI) / refuse (headless). */
159
+ async function transferSkill(ctx: ExtensionCommandContext, name: string, t: Transfer): Promise<void> {
160
+ const src = join(t.srcDir, name);
161
+ const dest = join(t.destDir, name);
162
+
163
+ if (await pathExists(dest)) {
164
+ if (!ctx.hasUI) {
165
+ notify(ctx, `Skill "${name}" already exists at ${t.destRootLabel}${name}. Remove it first or run /${t.commandName} ${name} interactively to overwrite.`, "error");
166
+ return;
167
+ }
168
+ let confirmed = false;
169
+ try {
170
+ confirmed = await ctx.ui.confirm("Skill already exists", `"${name}" already exists in ${t.destRootLabel}. Overwrite it?`);
171
+ } catch (error) {
172
+ ignoreStaleExtensionContextError(error);
173
+ return;
174
+ }
175
+ if (!confirmed) {
176
+ notify(ctx, `Cancelled. "${name}" left unchanged.`);
177
+ return;
178
+ }
179
+ await fs.rm(dest, { recursive: true, force: true });
180
+ }
181
+
182
+ try {
183
+ await copySkill(src, dest);
184
+ } catch (error) {
185
+ const message = error instanceof Error ? error.message : String(error);
186
+ notify(ctx, `Failed to ${t.direction} skill "${name}": ${message}`, "error");
187
+ return;
188
+ }
189
+
190
+ if (t.direction === "install") {
191
+ notify(ctx, `Installed skill "${name}" → ${t.destRootLabel}${name}. Reloading to activate…`);
192
+ try {
193
+ await ctx.reload();
194
+ } catch (error) {
195
+ ignoreStaleExtensionContextError(error);
196
+ }
197
+ return;
198
+ }
199
+
200
+ notify(ctx, `Exported skill "${name}" → ${t.destRootLabel}${name}. Available for /install-skill in other projects.`);
201
+ }
202
+
203
+ function installTransfer(ctx: ExtensionCommandContext): Transfer {
204
+ return {
205
+ srcDir: LOCAL_SKILLS_DIR,
206
+ destDir: projectSkillsDir(ctx),
207
+ destRootLabel: `${projectSkillsLabel(ctx)}/`,
208
+ commandName: INSTALL_COMMAND,
209
+ direction: "install",
210
+ };
211
+ }
212
+
213
+ function exportTransfer(ctx: ExtensionCommandContext): Transfer {
214
+ return {
215
+ srcDir: projectSkillsDir(ctx),
216
+ destDir: LOCAL_SKILLS_DIR,
217
+ destRootLabel: `${localSkillsLabel()}/`,
218
+ commandName: EXPORT_COMMAND,
219
+ direction: "export",
220
+ };
221
+ }
222
+
223
+ // --- install ---
224
+
225
+ async function showInstallMenu(ctx: ExtensionCommandContext): Promise<void> {
226
+ const skills = await listLocalSkills();
227
+ if (skills.length === 0) {
228
+ notify(ctx, `No skills found in ${localSkillsLabel()} (each skill is a folder containing ${SKILL_FILE}).`, "warning");
229
+ return;
230
+ }
231
+
232
+ if (!ctx.hasUI) {
233
+ notify(ctx, `Available skills in ${localSkillsLabel()}:\n${skills.map((s) => s.name).join("\n")}\n\nUse /${INSTALL_COMMAND} <name> to install one.`, "warning");
234
+ return;
235
+ }
236
+
237
+ const t = installTransfer(ctx);
238
+ const labels = skills.map(skillLabel);
239
+ const labelToName = new Map(labels.map((label, index) => [label, skills[index]!.name]));
240
+ let selected: string | undefined;
241
+ try {
242
+ selected = await ctx.ui.select(`Install skill into ${projectSkillsLabel(ctx)}/ (${localSkillsLabel()})`, labels);
243
+ } catch (error) {
244
+ ignoreStaleExtensionContextError(error);
245
+ return;
246
+ }
247
+ if (!selected) return;
248
+
249
+ const name = labelToName.get(selected);
250
+ if (!name) return;
251
+ await transferSkill(ctx, name, t);
252
+ }
253
+
254
+ async function installByName(ctx: ExtensionCommandContext, name: string): Promise<void> {
255
+ const skills = await listLocalSkills();
256
+ const match = skills.find((entry) => entry.name === name);
257
+ if (!match) {
258
+ const available = skills.length > 0 ? skills.map((s) => s.name).join(", ") : "(none)";
259
+ notify(ctx, `No skill named "${name}" in ${localSkillsLabel()}. Available: ${available}`, "error");
260
+ return;
261
+ }
262
+ await transferSkill(ctx, match.name, installTransfer(ctx));
263
+ }
264
+
265
+ // --- export ---
266
+
267
+ async function showExportMenu(ctx: ExtensionCommandContext): Promise<void> {
268
+ const skills = await listProjectSkills(ctx);
269
+ if (skills.length === 0) {
270
+ notify(ctx, `No skills found in ${projectSkillsLabel(ctx)}/. Install one first with /${INSTALL_COMMAND}.`, "warning");
271
+ return;
272
+ }
273
+
274
+ if (!ctx.hasUI) {
275
+ notify(ctx, `Installed skills in ${projectSkillsLabel(ctx)}/:\n${skills.map((s) => s.name).join("\n")}\n\nUse /${EXPORT_COMMAND} <name> to export one to ${localSkillsLabel()}.`, "warning");
276
+ return;
277
+ }
278
+
279
+ const t = exportTransfer(ctx);
280
+ const labels = skills.map(skillLabel);
281
+ const labelToName = new Map(labels.map((label, index) => [label, skills[index]!.name]));
282
+ let selected: string | undefined;
283
+ try {
284
+ selected = await ctx.ui.select(`Export skill from ${projectSkillsLabel(ctx)}/ to ${localSkillsLabel()}`, labels);
285
+ } catch (error) {
286
+ ignoreStaleExtensionContextError(error);
287
+ return;
288
+ }
289
+ if (!selected) return;
290
+
291
+ const name = labelToName.get(selected);
292
+ if (!name) return;
293
+ await transferSkill(ctx, name, t);
294
+ }
295
+
296
+ async function exportByName(ctx: ExtensionCommandContext, name: string): Promise<void> {
297
+ const skills = await listProjectSkills(ctx);
298
+ const match = skills.find((entry) => entry.name === name);
299
+ if (!match) {
300
+ const available = skills.length > 0 ? skills.map((s) => s.name).join(", ") : "(none)";
301
+ notify(ctx, `No skill named "${name}" in ${projectSkillsLabel(ctx)}/. Installed: ${available}`, "error");
302
+ return;
303
+ }
304
+ await transferSkill(ctx, match.name, exportTransfer(ctx));
305
+ }
306
+
307
+ export default function skillInstaller(pi: ExtensionAPI): void {
308
+ pi.registerCommand(INSTALL_COMMAND, {
309
+ description: `Install a skill from ${localSkillsLabel()} into the current project's ${PROJECT_DIR}/${PROJECT_SKILLS_SUBDIR}`,
310
+ handler: async (args: string, ctx) => {
311
+ const name = args.trim();
312
+ try {
313
+ if (name) await installByName(ctx, name);
314
+ else await showInstallMenu(ctx);
315
+ } catch (error) {
316
+ ignoreStaleExtensionContextError(error);
317
+ }
318
+ },
319
+ });
320
+
321
+ pi.registerCommand(EXPORT_COMMAND, {
322
+ description: `Export a skill from the current project's ${PROJECT_DIR}/${PROJECT_SKILLS_SUBDIR} to ${localSkillsLabel()} for reuse in other projects`,
323
+ handler: async (args: string, ctx) => {
324
+ const name = args.trim();
325
+ try {
326
+ if (name) await exportByName(ctx, name);
327
+ else await showExportMenu(ctx);
328
+ } catch (error) {
329
+ ignoreStaleExtensionContextError(error);
330
+ }
331
+ },
332
+ });
333
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "pi-ui-extend",
3
- "version": "0.1.43",
3
+ "version": "0.1.44",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "bin": {
@@ -0,0 +1,140 @@
1
+ ---
2
+ name: spec-lite
3
+ description: Use this skill for creating, updating, or checking lightweight specs for existing code, risky changes, public behavior, bug fixes, or spec-code drift. Use it before or during non-trivial changes that touch security, data, persistence, public APIs, CLI/UI contracts, integrations, concurrency, architecture, or user-visible behavior with edge cases. Do not use it for trivial local edits.
4
+ argument-hint: "behavior, change, bug, or spec path to document/check"
5
+ ---
6
+
7
+ # Spec Lite
8
+
9
+ Use the lightest spec process that preserves correctness. The goal is to make behavior and risk explicit without turning every change into a heavyweight design process.
10
+
11
+ ## Core rules
12
+
13
+ - Do not change production code unless the user explicitly asks for implementation.
14
+ - Specs describe either current behavior or intended behavior; label which one clearly.
15
+ - Do not invent behavior. If evidence is missing or ambiguous, mark uncertainty explicitly.
16
+ - Prefer compact specs over exhaustive documents.
17
+ - Load only relevant files, tests, schemas, docs, migrations, entrypoints, and existing specs.
18
+ - Do not load all specs by default.
19
+ - Keep the spec close to the codebase convention when a specs directory, ADR folder, or feature-doc pattern already exists.
20
+
21
+ ## When to create or update a spec
22
+
23
+ Create or update a lightweight spec when the work affects any of these areas:
24
+
25
+ - security, privacy, auth, or permissions;
26
+ - data, schemas, migrations, persistence, or file formats;
27
+ - public APIs, CLI behavior, UI contracts, events, or other external contracts;
28
+ - external integrations;
29
+ - payments, destructive operations, or irreversible workflows;
30
+ - background jobs, concurrency, cancellation, retries, or async behavior;
31
+ - cross-cutting architecture;
32
+ - user-visible behavior with non-trivial edge cases;
33
+ - bug fixes where expected behavior, compatibility, or regression risk is unclear;
34
+ - suspected drift between existing specs/docs/tests and code.
35
+
36
+ Do not require a spec for trivial local edits, mechanical renames, formatting-only changes, or obvious one-file fixes with no broader behavior impact.
37
+
38
+ ## As-is spec workflow
39
+
40
+ Use this workflow for existing code or spec-code drift checks:
41
+
42
+ 1. Identify the narrow behavior or contract to document.
43
+ 2. Inspect relevant code, tests, docs, schemas, migrations, and entrypoints.
44
+ 3. Identify current behavior and known gaps.
45
+ 4. Mark claims using evidence labels:
46
+ - confirmed by code;
47
+ - confirmed by tests;
48
+ - confirmed by docs;
49
+ - inferred;
50
+ - unknown.
51
+ 5. Create or update a compact as-is spec.
52
+ 6. If drift is found, report it separately from confirmed behavior.
53
+
54
+ ## Change spec workflow
55
+
56
+ Use this workflow before risky implementation:
57
+
58
+ 1. Define the goal.
59
+ 2. Define scope and non-goals.
60
+ 3. State expected behavior.
61
+ 4. List affected contracts.
62
+ 5. List risks, compatibility concerns, and migration concerns if any.
63
+ 6. Define a verification path.
64
+ 7. Then implement only the scoped change, if implementation was requested.
65
+
66
+ ## Spec template
67
+
68
+ Use this template by default. Remove sections that are genuinely irrelevant, but keep `Type`, `Goal`, `Behavior`, `Related files`, `Verification`, and `Evidence` when possible.
69
+
70
+ ```markdown
71
+ # Spec: <name>
72
+
73
+ ## Type
74
+
75
+ As-is | Change
76
+
77
+ ## Goal
78
+
79
+ ...
80
+
81
+ ## Scope
82
+
83
+ ...
84
+
85
+ ## Non-goals
86
+
87
+ ...
88
+
89
+ ## Behavior
90
+
91
+ ...
92
+
93
+ ## Contracts
94
+
95
+ Inputs, outputs, APIs, CLI, UI, events, files, schemas.
96
+
97
+ ## Invariants
98
+
99
+ ...
100
+
101
+ ## Edge cases
102
+
103
+ ...
104
+
105
+ ## Side effects
106
+
107
+ ...
108
+
109
+ ## Related files
110
+
111
+ ...
112
+
113
+ ## Verification
114
+
115
+ ...
116
+
117
+ ## Risks / unknowns
118
+
119
+ ...
120
+
121
+ ## Evidence
122
+
123
+ - Confirmed by code:
124
+ - Confirmed by tests:
125
+ - Confirmed by docs:
126
+ - Inferred:
127
+ - Unknown:
128
+ ```
129
+
130
+ ## Output guidance
131
+
132
+ When the user asks only for a spec, write or update the spec and summarize:
133
+
134
+ - spec path;
135
+ - whether it is as-is or change-oriented;
136
+ - strongest evidence sources;
137
+ - important unknowns or drift;
138
+ - whether production code was left untouched.
139
+
140
+ When the user asks for implementation too, keep the spec scoped and practical, then implement only after the expected behavior and verification path are clear.