stagent 0.10.0 → 0.11.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.
Files changed (170) hide show
  1. package/README.md +15 -2
  2. package/dist/cli.js +24 -0
  3. package/docs/.coverage-gaps.json +154 -24
  4. package/docs/.last-generated +1 -1
  5. package/docs/features/agent-intelligence.md +12 -2
  6. package/docs/features/chat.md +40 -5
  7. package/docs/features/cost-usage.md +1 -1
  8. package/docs/features/documents.md +5 -2
  9. package/docs/features/inbox-notifications.md +10 -2
  10. package/docs/features/keyboard-navigation.md +12 -3
  11. package/docs/features/provider-runtimes.md +16 -2
  12. package/docs/features/settings.md +2 -2
  13. package/docs/features/shared-components.md +7 -3
  14. package/docs/features/tables.md +3 -1
  15. package/docs/features/tool-permissions.md +6 -2
  16. package/docs/features/workflows.md +6 -2
  17. package/docs/index.md +1 -1
  18. package/docs/journeys/developer.md +25 -2
  19. package/docs/journeys/personal-use.md +12 -5
  20. package/docs/journeys/power-user.md +45 -14
  21. package/docs/journeys/work-use.md +17 -8
  22. package/docs/manifest.json +15 -15
  23. package/docs/superpowers/plans/2026-04-14-chat-command-namespace-refactor.md +1390 -0
  24. package/docs/superpowers/plans/2026-04-14-chat-environment-integration.md +1561 -0
  25. package/docs/superpowers/plans/2026-04-14-chat-polish-bundle-v1.md +1219 -0
  26. package/docs/superpowers/plans/2026-04-14-chat-session-persistence-provider-closeout.md +399 -0
  27. package/next.config.mjs +1 -0
  28. package/package.json +1 -1
  29. package/src/app/api/chat/conversations/[id]/skills/__tests__/activate.test.ts +141 -0
  30. package/src/app/api/chat/conversations/[id]/skills/activate/route.ts +74 -0
  31. package/src/app/api/chat/conversations/[id]/skills/deactivate/route.ts +33 -0
  32. package/src/app/api/chat/export/route.ts +52 -0
  33. package/src/app/api/chat/files/search/route.ts +50 -0
  34. package/src/app/api/environment/rescan-if-stale/__tests__/route.test.ts +45 -0
  35. package/src/app/api/environment/rescan-if-stale/route.ts +23 -0
  36. package/src/app/api/environment/skills/route.ts +13 -0
  37. package/src/app/api/schedules/[id]/execute/route.ts +2 -2
  38. package/src/app/api/settings/chat/pins/route.ts +94 -0
  39. package/src/app/api/settings/chat/saved-searches/__tests__/route.test.ts +119 -0
  40. package/src/app/api/settings/chat/saved-searches/route.ts +79 -0
  41. package/src/app/api/settings/environment/route.ts +26 -0
  42. package/src/app/api/tasks/[id]/execute/route.ts +52 -12
  43. package/src/app/api/tasks/[id]/respond/route.ts +31 -15
  44. package/src/app/api/tasks/[id]/resume/route.ts +24 -3
  45. package/src/app/documents/page.tsx +4 -1
  46. package/src/app/settings/page.tsx +2 -0
  47. package/src/components/chat/__tests__/capability-banner.test.tsx +38 -0
  48. package/src/components/chat/__tests__/chat-session-provider.test.tsx +166 -1
  49. package/src/components/chat/__tests__/skill-row.test.tsx +91 -0
  50. package/src/components/chat/capability-banner.tsx +68 -0
  51. package/src/components/chat/chat-command-popover.tsx +668 -47
  52. package/src/components/chat/chat-input.tsx +103 -8
  53. package/src/components/chat/chat-message.tsx +12 -3
  54. package/src/components/chat/chat-session-provider.tsx +73 -3
  55. package/src/components/chat/chat-shell.tsx +62 -3
  56. package/src/components/chat/command-tab-bar.tsx +68 -0
  57. package/src/components/chat/conversation-template-picker.tsx +421 -0
  58. package/src/components/chat/help-dialog.tsx +39 -0
  59. package/src/components/chat/skill-composition-conflict-dialog.tsx +96 -0
  60. package/src/components/chat/skill-row.tsx +147 -0
  61. package/src/components/documents/document-browser.tsx +37 -19
  62. package/src/components/notifications/__tests__/permission-response-actions.test.tsx +70 -0
  63. package/src/components/notifications/permission-response-actions.tsx +155 -1
  64. package/src/components/settings/environment-section.tsx +102 -0
  65. package/src/components/shared/__tests__/filter-hint.test.tsx +40 -0
  66. package/src/components/shared/__tests__/saved-searches-manager.test.tsx +147 -0
  67. package/src/components/shared/command-palette.tsx +262 -2
  68. package/src/components/shared/filter-hint.tsx +70 -0
  69. package/src/components/shared/filter-input.tsx +59 -0
  70. package/src/components/shared/saved-searches-manager.tsx +199 -0
  71. package/src/components/tasks/task-bento-grid.tsx +12 -2
  72. package/src/components/tasks/task-card.tsx +3 -0
  73. package/src/components/tasks/task-chip-bar.tsx +30 -1
  74. package/src/hooks/__tests__/use-chat-autocomplete-tabs.test.ts +47 -0
  75. package/src/hooks/__tests__/use-saved-searches.test.ts +70 -0
  76. package/src/hooks/use-active-skills.ts +110 -0
  77. package/src/hooks/use-chat-autocomplete.ts +120 -7
  78. package/src/hooks/use-enriched-skills.ts +19 -0
  79. package/src/hooks/use-pinned-entries.ts +104 -0
  80. package/src/hooks/use-recent-user-messages.ts +19 -0
  81. package/src/hooks/use-saved-searches.ts +142 -0
  82. package/src/lib/agents/__tests__/claude-agent-sdk-options.test.ts +56 -0
  83. package/src/lib/agents/__tests__/claude-agent.test.ts +17 -4
  84. package/src/lib/agents/__tests__/task-dispatch.test.ts +166 -0
  85. package/src/lib/agents/__tests__/tool-permissions.test.ts +60 -0
  86. package/src/lib/agents/claude-agent.ts +105 -46
  87. package/src/lib/agents/handoff/bus.ts +2 -2
  88. package/src/lib/agents/profiles/__tests__/list-fused-profiles.test.ts +110 -0
  89. package/src/lib/agents/profiles/__tests__/registry.test.ts +47 -0
  90. package/src/lib/agents/profiles/builtins/upgrade-assistant/SKILL.md +30 -3
  91. package/src/lib/agents/profiles/builtins/upgrade-assistant/profile.yaml +6 -2
  92. package/src/lib/agents/profiles/list-fused-profiles.ts +104 -0
  93. package/src/lib/agents/profiles/registry.ts +18 -0
  94. package/src/lib/agents/profiles/types.ts +7 -1
  95. package/src/lib/agents/router.ts +3 -6
  96. package/src/lib/agents/runtime/__tests__/catalog.test.ts +130 -0
  97. package/src/lib/agents/runtime/__tests__/execution-target.test.ts +183 -0
  98. package/src/lib/agents/runtime/anthropic-direct.ts +8 -0
  99. package/src/lib/agents/runtime/catalog.ts +121 -0
  100. package/src/lib/agents/runtime/claude-sdk.ts +32 -0
  101. package/src/lib/agents/runtime/execution-target.ts +456 -0
  102. package/src/lib/agents/runtime/index.ts +4 -0
  103. package/src/lib/agents/runtime/launch-failure.ts +101 -0
  104. package/src/lib/agents/runtime/openai-codex.ts +35 -0
  105. package/src/lib/agents/runtime/openai-direct.ts +8 -0
  106. package/src/lib/agents/task-dispatch.ts +220 -0
  107. package/src/lib/agents/tool-permissions.ts +16 -1
  108. package/src/lib/chat/__tests__/active-skill-injection.test.ts +261 -0
  109. package/src/lib/chat/__tests__/clean-filter-input.test.ts +68 -0
  110. package/src/lib/chat/__tests__/command-tabs.test.ts +68 -0
  111. package/src/lib/chat/__tests__/context-builder-files.test.ts +112 -0
  112. package/src/lib/chat/__tests__/dismissals.test.ts +65 -0
  113. package/src/lib/chat/__tests__/engine-sdk-options.test.ts +117 -0
  114. package/src/lib/chat/__tests__/skill-conflict.test.ts +35 -0
  115. package/src/lib/chat/__tests__/types.test.ts +28 -0
  116. package/src/lib/chat/active-skills.ts +31 -0
  117. package/src/lib/chat/clean-filter-input.ts +30 -0
  118. package/src/lib/chat/codex-engine.ts +30 -7
  119. package/src/lib/chat/command-tabs.ts +61 -0
  120. package/src/lib/chat/context-builder.ts +141 -1
  121. package/src/lib/chat/dismissals.ts +73 -0
  122. package/src/lib/chat/engine.ts +109 -15
  123. package/src/lib/chat/files/__tests__/search.test.ts +135 -0
  124. package/src/lib/chat/files/expand-mention.ts +76 -0
  125. package/src/lib/chat/files/search.ts +99 -0
  126. package/src/lib/chat/skill-composition.ts +210 -0
  127. package/src/lib/chat/skill-conflict.ts +105 -0
  128. package/src/lib/chat/stagent-tools.ts +6 -19
  129. package/src/lib/chat/stream-telemetry.ts +9 -4
  130. package/src/lib/chat/system-prompt.ts +22 -0
  131. package/src/lib/chat/tool-catalog.ts +33 -3
  132. package/src/lib/chat/tools/__tests__/profile-tools.test.ts +51 -0
  133. package/src/lib/chat/tools/__tests__/settings-tools.test.ts +294 -0
  134. package/src/lib/chat/tools/__tests__/skill-tools.test.ts +474 -0
  135. package/src/lib/chat/tools/__tests__/task-tools.test.ts +47 -0
  136. package/src/lib/chat/tools/__tests__/workflow-tools-dedup.test.ts +134 -0
  137. package/src/lib/chat/tools/blueprint-tools.ts +190 -0
  138. package/src/lib/chat/tools/helpers.ts +2 -0
  139. package/src/lib/chat/tools/profile-tools.ts +120 -23
  140. package/src/lib/chat/tools/skill-tools.ts +183 -0
  141. package/src/lib/chat/tools/task-tools.ts +6 -2
  142. package/src/lib/chat/tools/workflow-tools.ts +61 -20
  143. package/src/lib/chat/types.ts +15 -0
  144. package/src/lib/constants/settings.ts +2 -0
  145. package/src/lib/data/clear.ts +2 -6
  146. package/src/lib/db/bootstrap.ts +17 -0
  147. package/src/lib/db/schema.ts +26 -0
  148. package/src/lib/environment/__tests__/auto-promote.test.ts +132 -0
  149. package/src/lib/environment/__tests__/list-skills-enriched.test.ts +55 -0
  150. package/src/lib/environment/__tests__/skill-enrichment.test.ts +129 -0
  151. package/src/lib/environment/__tests__/skill-recommendations.test.ts +87 -0
  152. package/src/lib/environment/data.ts +9 -0
  153. package/src/lib/environment/list-skills.ts +176 -0
  154. package/src/lib/environment/parsers/__tests__/skill.test.ts +54 -0
  155. package/src/lib/environment/parsers/skill.ts +26 -5
  156. package/src/lib/environment/profile-generator.ts +54 -0
  157. package/src/lib/environment/skill-enrichment.ts +106 -0
  158. package/src/lib/environment/skill-recommendations.ts +66 -0
  159. package/src/lib/filters/__tests__/parse.quoted.test.ts +40 -0
  160. package/src/lib/filters/__tests__/parse.test.ts +135 -0
  161. package/src/lib/filters/parse.ts +86 -0
  162. package/src/lib/instance/__tests__/upgrade-poller.test.ts +50 -0
  163. package/src/lib/instance/fingerprint.ts +7 -9
  164. package/src/lib/instance/upgrade-poller.ts +53 -1
  165. package/src/lib/schedules/scheduler.ts +4 -4
  166. package/src/lib/workflows/blueprints/__tests__/render-prompt.test.ts +124 -0
  167. package/src/lib/workflows/blueprints/render-prompt.ts +71 -0
  168. package/src/lib/workflows/blueprints/types.ts +6 -0
  169. package/src/lib/workflows/engine.ts +5 -3
  170. package/src/test/setup.ts +10 -0
@@ -0,0 +1,1561 @@
1
+ # Chat Environment Integration Implementation Plan
2
+
3
+ > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
4
+
5
+ **Goal:** Thread environment metadata (per-skill health, profile linkage, cross-tool sync status, scope) into the chat Skills tab and add a passive "recommended" flag plus auto-rescan-on-session-start, without changing the SDK's native skill discovery path.
6
+
7
+ **Architecture:** Add pure enrichment + recommendation modules over the existing `listSkills()` output. Extend the `list_skills` MCP tool with an opt-in `enriched: true` flag that is backwards compatible (additive fields only). Skills tab consumes enriched fields and renders inline badges via a new `SkillRow` component. A thin `/api/environment/rescan-if-stale` endpoint is called fire-and-forget from `ChatSessionProvider` on conversation activation. Passive "recommended" star replaces the spec's chip-above-input concept per scope approval.
8
+
9
+ **Tech Stack:** TypeScript, Drizzle, vitest + testing-library, existing scanner/linker/sync modules, shadcn Badge.
10
+
11
+ ---
12
+
13
+ ## NOT in scope
14
+
15
+ | Deferred | Rationale |
16
+ |---|---|
17
+ | Profile suggestion chip above chat input | Replaced with passive "recommended" star in Skills tab (scope decision) |
18
+ | Embeddings-based skill matching | Keyword match is enough for v1 |
19
+ | Editing skill metadata from chat | Env dashboard owns edit flows |
20
+ | Automatic skill activation | Per DD-CE-004 — one click still required |
21
+ | Per-skill usage telemetry | Not tracked today, not needed for health |
22
+ | Real-time sync refresh | Cache only; next rescan updates it |
23
+ | Dashboard route support for `?skill=` deep-link if absent | Ship the link anyway; dashboard may or may not parse it |
24
+ | Skill composition (relaxing single-active) | Belongs to `chat-advanced-ux` |
25
+
26
+ ## What already exists
27
+
28
+ | Asset | Path | Reuse strategy |
29
+ |---|---|---|
30
+ | `listSkills()` + `getSkill()` | `src/lib/environment/list-skills.ts` | Extend `SkillSummary` type; keep existing callers working |
31
+ | Env scanner w/ 5-min cache | `src/lib/environment/scanner.ts` | Read-only consumer |
32
+ | `linkedProfileId` column | `schema.ts:446` | Consume for profile-link badge |
33
+ | Skill MCP tools | `src/lib/chat/tools/skill-tools.ts` | Add `enriched` input flag |
34
+ | Skills tab rendering | `src/components/chat/chat-command-popover.tsx` | Swap in new `SkillRow` when enriched |
35
+ | StatusChip / Badge | existing shadcn Badge + status-colors.ts | Badge rendering |
36
+ | Auto-scan helper | `src/lib/environment/auto-scan.ts` | Call from new thin endpoint |
37
+ | Settings table | `schema.ts` | Dismissals JSON |
38
+ | Chat messages | `chat_messages` table | Source for recommendation keywords |
39
+ | ChatSessionProvider | `src/components/chat/chat-session-provider.tsx` | Hook for session-start rescan |
40
+ | `activeSkillId` on conversations | `chat-ollama-native-skills` | Exclude active skill from recommendations |
41
+
42
+ ## Error & Rescue Registry
43
+
44
+ | # | Error | Trigger | Impact | Rescue |
45
+ |---|---|---|---|---|
46
+ | 1 | Env scan cache empty | Fresh install | `list_skills({enriched})` returns bare summaries | Fallback: `health: "unknown"`, omit sync; don't throw |
47
+ | 2 | `modifiedAt` null | DB upgrade gap | `healthScore` undefined | Default to `"healthy"` |
48
+ | 3 | Concurrent rescans | Rapid convo switch | Duplicate I/O | Guard with in-flight lock in `auto-scan.ts` |
49
+ | 4 | Rescan throws | FS perms | Session hang | Catch + log; stale data continues |
50
+ | 5 | Many candidates for recommendation | Broad keywords | Noise | Cap to 1 recommended item, rank by recency+health |
51
+ | 6 | Active skill shown as recommended | Already activated | Confusing | Exclude `conversations.activeSkillId` |
52
+ | 7 | `sessionStorage`/settings write fails | Quota | Dismissal not sticky | try/catch silent |
53
+ | 8 | Slow enrichment on 1000+ skills | Massive dir | Popover lag | Cache-only path; cap keywords |
54
+ | 9 | Schema mismatch w/ older MCP callers | Additive field | Break clients | Additive only, never rename |
55
+ | 10 | Symlink loops | `.claude → .agents` | "synced" but same file | Dedupe by realpath |
56
+ | 11 | Scope missing | Scanner edge | Wrong badge | Omit badge |
57
+ | 12 | Recommendation race w/ empty conv | New conv no msgs | Match nothing | Short-circuit empty |
58
+ | 13 | False positives (common words) | Stopwords | Dumb recs | Stopword filter; require ≥2 distinct hits |
59
+ | 14 | Dismissal never expires | Bug | Rec gone forever | Timestamp + 7-day check on read |
60
+
61
+ ## File Structure
62
+
63
+ **Create:**
64
+ - `src/lib/environment/skill-enrichment.ts` — pure `computeHealthScore`, `computeSyncStatus`, `enrichSkills`
65
+ - `src/lib/environment/skill-recommendations.ts` — pure `computeRecommendation` (keyword match, ranking)
66
+ - `src/lib/chat/dismissals.ts` — read/write `settings.chat.dismissedSuggestions` with 7-day expiry
67
+ - `src/lib/environment/__tests__/skill-enrichment.test.ts`
68
+ - `src/lib/environment/__tests__/skill-recommendations.test.ts`
69
+ - `src/lib/chat/__tests__/dismissals.test.ts`
70
+ - `src/app/api/environment/rescan-if-stale/route.ts` — thin POST endpoint
71
+ - `src/app/api/environment/rescan-if-stale/__tests__/route.test.ts`
72
+ - `src/components/chat/skill-row.tsx` — extracted per-skill row with badges + recommended star
73
+ - `src/components/chat/__tests__/skill-row.test.tsx`
74
+
75
+ **Modify:**
76
+ - `src/lib/environment/list-skills.ts` — extend `SkillSummary` with optional enrichment fields; add `listSkillsEnriched()`
77
+ - `src/lib/chat/tools/skill-tools.ts` — accept `enriched` flag in `list_skills` tool input schema
78
+ - `src/components/chat/chat-command-popover.tsx` — render `<SkillRow>` for Skills tab entries when metadata available
79
+ - `src/components/chat/chat-session-provider.tsx` — call `/api/environment/rescan-if-stale` on `activeId` change
80
+
81
+ ---
82
+
83
+ ## Task 1: Pure health + sync computation (no I/O)
84
+
85
+ **Files:**
86
+ - Create: `src/lib/environment/skill-enrichment.ts`
87
+ - Test: `src/lib/environment/__tests__/skill-enrichment.test.ts`
88
+
89
+ - [ ] **Step 1: Write the failing test**
90
+
91
+ ```ts
92
+ // src/lib/environment/__tests__/skill-enrichment.test.ts
93
+ import { describe, it, expect } from "vitest";
94
+ import {
95
+ computeHealthScore,
96
+ computeSyncStatus,
97
+ type HealthScore,
98
+ type SyncStatus,
99
+ } from "../skill-enrichment";
100
+
101
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
102
+
103
+ describe("computeHealthScore", () => {
104
+ const NOW = new Date("2026-04-14T00:00:00Z").getTime();
105
+
106
+ it("returns 'healthy' for artifacts modified in the last 6 months", () => {
107
+ expect(computeHealthScore(NOW - 30 * MS_PER_DAY, NOW)).toBe("healthy");
108
+ expect(computeHealthScore(NOW - 179 * MS_PER_DAY, NOW)).toBe("healthy");
109
+ });
110
+
111
+ it("returns 'stale' for artifacts between 6 and 12 months old", () => {
112
+ expect(computeHealthScore(NOW - 200 * MS_PER_DAY, NOW)).toBe("stale");
113
+ expect(computeHealthScore(NOW - 364 * MS_PER_DAY, NOW)).toBe("stale");
114
+ });
115
+
116
+ it("returns 'aging' for artifacts over 12 months old", () => {
117
+ expect(computeHealthScore(NOW - 400 * MS_PER_DAY, NOW)).toBe("aging");
118
+ });
119
+
120
+ it("returns 'unknown' when modifiedAt is null", () => {
121
+ expect(computeHealthScore(null, NOW)).toBe("unknown");
122
+ });
123
+ });
124
+
125
+ describe("computeSyncStatus", () => {
126
+ it("returns 'synced' when both tools have the skill", () => {
127
+ expect(computeSyncStatus(["claude-code", "codex"])).toBe("synced");
128
+ });
129
+
130
+ it("returns 'claude-only' when only claude-code has it", () => {
131
+ expect(computeSyncStatus(["claude-code"])).toBe("claude-only");
132
+ });
133
+
134
+ it("returns 'codex-only' when only codex has it", () => {
135
+ expect(computeSyncStatus(["codex"])).toBe("codex-only");
136
+ });
137
+
138
+ it("returns 'shared' when only shared tool is present", () => {
139
+ expect(computeSyncStatus(["shared"])).toBe("shared");
140
+ });
141
+
142
+ it("returns 'shared' when claude + shared (covers both)", () => {
143
+ expect(computeSyncStatus(["claude-code", "shared"])).toBe("synced");
144
+ });
145
+ });
146
+ ```
147
+
148
+ - [ ] **Step 2: Run — expect FAIL (module missing)**
149
+
150
+ Run: `npx vitest run src/lib/environment/__tests__/skill-enrichment.test.ts`
151
+ Expected: FAIL — "Cannot find module '../skill-enrichment'".
152
+
153
+ - [ ] **Step 3: Implement**
154
+
155
+ ```ts
156
+ // src/lib/environment/skill-enrichment.ts
157
+
158
+ export type HealthScore = "healthy" | "stale" | "aging" | "broken" | "unknown";
159
+
160
+ export type SyncStatus =
161
+ | "synced"
162
+ | "claude-only"
163
+ | "codex-only"
164
+ | "shared";
165
+
166
+ const MS_PER_DAY = 24 * 60 * 60 * 1000;
167
+ const SIX_MONTHS_DAYS = 180;
168
+ const TWELVE_MONTHS_DAYS = 365;
169
+
170
+ export function computeHealthScore(
171
+ modifiedAtMs: number | null,
172
+ nowMs: number = Date.now()
173
+ ): HealthScore {
174
+ if (modifiedAtMs == null) return "unknown";
175
+ const ageDays = (nowMs - modifiedAtMs) / MS_PER_DAY;
176
+ if (ageDays < SIX_MONTHS_DAYS) return "healthy";
177
+ if (ageDays < TWELVE_MONTHS_DAYS) return "stale";
178
+ return "aging";
179
+ }
180
+
181
+ /**
182
+ * Compute sync status from the set of tools that own the skill.
183
+ * - Both claude-code and codex present → "synced"
184
+ * - Only claude-code → "claude-only"
185
+ * - Only codex → "codex-only"
186
+ * - Only shared → "shared" (project-level file, no user peer expected)
187
+ * - claude-code + shared (or codex + shared) → treat as synced
188
+ */
189
+ export function computeSyncStatus(tools: string[]): SyncStatus {
190
+ const set = new Set(tools);
191
+ const hasClaude = set.has("claude-code");
192
+ const hasCodex = set.has("codex");
193
+ const hasShared = set.has("shared");
194
+ if (hasClaude && hasCodex) return "synced";
195
+ if (hasClaude && hasShared) return "synced";
196
+ if (hasCodex && hasShared) return "synced";
197
+ if (hasClaude) return "claude-only";
198
+ if (hasCodex) return "codex-only";
199
+ return "shared";
200
+ }
201
+ ```
202
+
203
+ - [ ] **Step 4: Run — expect PASS**
204
+
205
+ Expected: 8/8 passing.
206
+
207
+ - [ ] **Step 5: Commit**
208
+
209
+ ```bash
210
+ git add src/lib/environment/skill-enrichment.ts src/lib/environment/__tests__/skill-enrichment.test.ts
211
+ git commit -m "feat(env): pure health + sync-status computations"
212
+ ```
213
+
214
+ ---
215
+
216
+ ## Task 2: `enrichSkills` orchestrator
217
+
218
+ **Files:**
219
+ - Modify: `src/lib/environment/skill-enrichment.ts`
220
+ - Modify: `src/lib/environment/__tests__/skill-enrichment.test.ts`
221
+
222
+ - [ ] **Step 1: Write the failing test (appended)**
223
+
224
+ ```ts
225
+ // appended to src/lib/environment/__tests__/skill-enrichment.test.ts
226
+ import { enrichSkills, type EnrichedSkill } from "../skill-enrichment";
227
+ import type { SkillSummary } from "../list-skills";
228
+
229
+ const NOW = new Date("2026-04-14T00:00:00Z").getTime();
230
+ const DAY = 24 * 60 * 60 * 1000;
231
+
232
+ function skill(
233
+ id: string,
234
+ name: string,
235
+ tool: string,
236
+ overrides: Partial<SkillSummary> = {}
237
+ ): SkillSummary {
238
+ return {
239
+ id,
240
+ name,
241
+ tool,
242
+ scope: "user",
243
+ preview: "",
244
+ sizeBytes: 0,
245
+ absPath: `/tmp/${id}`,
246
+ ...overrides,
247
+ };
248
+ }
249
+
250
+ describe("enrichSkills", () => {
251
+ it("groups by name and computes syncStatus across tools", () => {
252
+ const out = enrichSkills(
253
+ [
254
+ skill("a", "research", "claude-code"),
255
+ skill("b", "research", "codex"),
256
+ skill("c", "standalone", "claude-code"),
257
+ ],
258
+ { modifiedAtMsByPath: {}, linkedProfilesByPath: {}, nowMs: NOW }
259
+ );
260
+ const bySkill: Record<string, EnrichedSkill> = {};
261
+ for (const s of out) bySkill[s.name] = s;
262
+ expect(bySkill.research.syncStatus).toBe("synced");
263
+ expect(bySkill.standalone.syncStatus).toBe("claude-only");
264
+ });
265
+
266
+ it("attaches linkedProfileId per artifact absPath", () => {
267
+ const out = enrichSkills(
268
+ [skill("x", "coder", "claude-code", { absPath: "/p/a" })],
269
+ {
270
+ modifiedAtMsByPath: {},
271
+ linkedProfilesByPath: { "/p/a": "code-reviewer" },
272
+ nowMs: NOW,
273
+ }
274
+ );
275
+ expect(out[0].linkedProfileId).toBe("code-reviewer");
276
+ });
277
+
278
+ it("assigns health from modifiedAtMsByPath", () => {
279
+ const out = enrichSkills(
280
+ [skill("x", "aging", "claude-code", { absPath: "/p/a" })],
281
+ {
282
+ modifiedAtMsByPath: { "/p/a": NOW - 400 * DAY },
283
+ linkedProfilesByPath: {},
284
+ nowMs: NOW,
285
+ }
286
+ );
287
+ expect(out[0].healthScore).toBe("aging");
288
+ });
289
+
290
+ it("merges duplicate absPaths (symlink case) to a single entry", () => {
291
+ const out = enrichSkills(
292
+ [
293
+ skill("a", "shared", "claude-code", { absPath: "/same" }),
294
+ skill("b", "shared", "codex", { absPath: "/same" }),
295
+ ],
296
+ { modifiedAtMsByPath: {}, linkedProfilesByPath: {}, nowMs: NOW }
297
+ );
298
+ expect(out).toHaveLength(1);
299
+ });
300
+ });
301
+ ```
302
+
303
+ - [ ] **Step 2: Run — expect FAIL**
304
+
305
+ Expected: `enrichSkills` import fails.
306
+
307
+ - [ ] **Step 3: Implement**
308
+
309
+ Append to `src/lib/environment/skill-enrichment.ts`:
310
+
311
+ ```ts
312
+ import type { SkillSummary } from "./list-skills";
313
+
314
+ export interface EnrichedSkill extends SkillSummary {
315
+ healthScore: HealthScore;
316
+ syncStatus: SyncStatus;
317
+ linkedProfileId: string | null;
318
+ /** All absPaths for the same skill name (for symlink/dup handling). */
319
+ absPaths: string[];
320
+ }
321
+
322
+ export interface EnrichmentContext {
323
+ modifiedAtMsByPath: Record<string, number | null>;
324
+ linkedProfilesByPath: Record<string, string | null>;
325
+ nowMs?: number;
326
+ }
327
+
328
+ export function enrichSkills(
329
+ skills: SkillSummary[],
330
+ ctx: EnrichmentContext
331
+ ): EnrichedSkill[] {
332
+ const nowMs = ctx.nowMs ?? Date.now();
333
+ // Dedupe by absPath first (symlink loops).
334
+ const seen = new Set<string>();
335
+ const deduped: SkillSummary[] = [];
336
+ for (const s of skills) {
337
+ if (seen.has(s.absPath)) continue;
338
+ seen.add(s.absPath);
339
+ deduped.push(s);
340
+ }
341
+ // Group by name.
342
+ const byName = new Map<string, SkillSummary[]>();
343
+ for (const s of deduped) {
344
+ const list = byName.get(s.name) ?? [];
345
+ list.push(s);
346
+ byName.set(s.name, list);
347
+ }
348
+ const out: EnrichedSkill[] = [];
349
+ for (const [, group] of byName) {
350
+ const tools = group.map((g) => g.tool);
351
+ const syncStatus = computeSyncStatus(tools);
352
+ // Use the highest health (most recent modification) across the group.
353
+ const ages = group.map((g) => ctx.modifiedAtMsByPath[g.absPath] ?? null);
354
+ const newest = ages.reduce<number | null>(
355
+ (acc, v) => (v != null && (acc == null || v > acc) ? v : acc),
356
+ null
357
+ );
358
+ const healthScore = computeHealthScore(newest, nowMs);
359
+ const linkedProfileId =
360
+ group
361
+ .map((g) => ctx.linkedProfilesByPath[g.absPath] ?? null)
362
+ .find((v) => v != null) ?? null;
363
+ const primary = group[0];
364
+ out.push({
365
+ ...primary,
366
+ healthScore,
367
+ syncStatus,
368
+ linkedProfileId,
369
+ absPaths: group.map((g) => g.absPath),
370
+ });
371
+ }
372
+ return out;
373
+ }
374
+ ```
375
+
376
+ - [ ] **Step 4: Run — expect PASS (12/12)**
377
+
378
+ - [ ] **Step 5: Commit**
379
+
380
+ ```bash
381
+ git add src/lib/environment/skill-enrichment.ts src/lib/environment/__tests__/skill-enrichment.test.ts
382
+ git commit -m "feat(env): enrichSkills orchestrator with group + dedupe"
383
+ ```
384
+
385
+ ---
386
+
387
+ ## Task 3: Recommendation engine
388
+
389
+ **Files:**
390
+ - Create: `src/lib/environment/skill-recommendations.ts`
391
+ - Test: `src/lib/environment/__tests__/skill-recommendations.test.ts`
392
+
393
+ - [ ] **Step 1: Write the failing test**
394
+
395
+ ```ts
396
+ // src/lib/environment/__tests__/skill-recommendations.test.ts
397
+ import { describe, it, expect } from "vitest";
398
+ import { computeRecommendation } from "../skill-recommendations";
399
+ import type { EnrichedSkill } from "../skill-enrichment";
400
+
401
+ const mkSkill = (
402
+ name: string,
403
+ preview: string,
404
+ overrides: Partial<EnrichedSkill> = {}
405
+ ): EnrichedSkill => ({
406
+ id: name,
407
+ name,
408
+ tool: "claude-code",
409
+ scope: "user",
410
+ preview,
411
+ sizeBytes: 0,
412
+ absPath: `/p/${name}`,
413
+ healthScore: "healthy",
414
+ syncStatus: "claude-only",
415
+ linkedProfileId: null,
416
+ absPaths: [`/p/${name}`],
417
+ ...overrides,
418
+ });
419
+
420
+ describe("computeRecommendation", () => {
421
+ it("recommends a healthy skill whose keywords match 2+ in recent messages", () => {
422
+ const skills = [
423
+ mkSkill("code-reviewer", "Review pull requests for security"),
424
+ mkSkill("researcher", "Search the web for up-to-date information"),
425
+ ];
426
+ const rec = computeRecommendation(skills, [
427
+ "can you review this pull request for security issues?",
428
+ ]);
429
+ expect(rec?.name).toBe("code-reviewer");
430
+ });
431
+
432
+ it("returns null when no strong keyword match exists", () => {
433
+ const skills = [mkSkill("code-reviewer", "Review PRs for security")];
434
+ const rec = computeRecommendation(skills, ["hi there"]);
435
+ expect(rec).toBeNull();
436
+ });
437
+
438
+ it("excludes already-active skill", () => {
439
+ const skills = [mkSkill("code-reviewer", "Review pull requests security")];
440
+ const rec = computeRecommendation(
441
+ skills,
442
+ ["review this pull request for security"],
443
+ { activeSkillId: "code-reviewer" }
444
+ );
445
+ expect(rec).toBeNull();
446
+ });
447
+
448
+ it("excludes dismissed skills", () => {
449
+ const skills = [mkSkill("code-reviewer", "Review pull requests security")];
450
+ const rec = computeRecommendation(
451
+ skills,
452
+ ["review pull request security issues"],
453
+ { dismissedIds: new Set(["code-reviewer"]) }
454
+ );
455
+ expect(rec).toBeNull();
456
+ });
457
+
458
+ it("excludes broken/aging skills", () => {
459
+ const skills = [
460
+ mkSkill("code-reviewer", "Review pull requests security", {
461
+ healthScore: "aging",
462
+ }),
463
+ ];
464
+ const rec = computeRecommendation(skills, [
465
+ "review pull request security issues",
466
+ ]);
467
+ expect(rec).toBeNull();
468
+ });
469
+
470
+ it("ignores stopwords and requires ≥2 distinct meaningful hits", () => {
471
+ const skills = [mkSkill("researcher", "the and for a of in on")];
472
+ const rec = computeRecommendation(skills, ["the and for a of in on"]);
473
+ expect(rec).toBeNull();
474
+ });
475
+
476
+ it("returns null on empty message list", () => {
477
+ const rec = computeRecommendation(
478
+ [mkSkill("code-reviewer", "review pull request security")],
479
+ []
480
+ );
481
+ expect(rec).toBeNull();
482
+ });
483
+ });
484
+ ```
485
+
486
+ - [ ] **Step 2: Run — expect FAIL**
487
+
488
+ Expected: module missing.
489
+
490
+ - [ ] **Step 3: Implement**
491
+
492
+ ```ts
493
+ // src/lib/environment/skill-recommendations.ts
494
+ import type { EnrichedSkill } from "./skill-enrichment";
495
+
496
+ const STOPWORDS = new Set([
497
+ "the","and","for","with","that","this","from","have","your","will","not","but",
498
+ "you","are","was","can","any","all","has","his","her","how","who","why","what",
499
+ "when","where","use","using","used","its","into","new","one","two","get","got",
500
+ "please","help","like","need","want","make","made","made","just","also","some",
501
+ "more","most","very","than","then","them","they","their","out","off","put",
502
+ "got","let","say","said","see","saw","per","via","about","over","under","code",
503
+ "file","files",
504
+ ]);
505
+
506
+ const MIN_KEYWORD_LEN = 4;
507
+ const MIN_DISTINCT_HITS = 2;
508
+
509
+ interface Options {
510
+ activeSkillId?: string | null;
511
+ dismissedIds?: Set<string>;
512
+ }
513
+
514
+ function tokenize(text: string): string[] {
515
+ return text
516
+ .toLowerCase()
517
+ .split(/[^a-z0-9]+/)
518
+ .filter((t) => t.length >= MIN_KEYWORD_LEN && !STOPWORDS.has(t));
519
+ }
520
+
521
+ export function computeRecommendation(
522
+ skills: EnrichedSkill[],
523
+ recentMessages: string[],
524
+ opts: Options = {}
525
+ ): EnrichedSkill | null {
526
+ if (recentMessages.length === 0) return null;
527
+ const messageTokens = new Set(tokenize(recentMessages.join(" ")));
528
+ if (messageTokens.size === 0) return null;
529
+
530
+ const candidates: Array<{ skill: EnrichedSkill; hits: number }> = [];
531
+
532
+ for (const skill of skills) {
533
+ if (opts.activeSkillId && skill.id === opts.activeSkillId) continue;
534
+ if (opts.dismissedIds?.has(skill.id)) continue;
535
+ if (skill.healthScore !== "healthy" && skill.healthScore !== "stale") continue;
536
+
537
+ const skillTokens = new Set(tokenize(`${skill.name} ${skill.preview}`));
538
+ let hits = 0;
539
+ for (const t of skillTokens) {
540
+ if (messageTokens.has(t)) hits++;
541
+ }
542
+ if (hits >= MIN_DISTINCT_HITS) {
543
+ candidates.push({ skill, hits });
544
+ }
545
+ }
546
+
547
+ if (candidates.length === 0) return null;
548
+
549
+ // Rank by hits DESC, then health (healthy > stale), then name for determinism.
550
+ candidates.sort((a, b) => {
551
+ if (a.hits !== b.hits) return b.hits - a.hits;
552
+ if (a.skill.healthScore !== b.skill.healthScore) {
553
+ return a.skill.healthScore === "healthy" ? -1 : 1;
554
+ }
555
+ return a.skill.name.localeCompare(b.skill.name);
556
+ });
557
+
558
+ return candidates[0].skill;
559
+ }
560
+ ```
561
+
562
+ - [ ] **Step 4: Run — expect PASS (7/7)**
563
+
564
+ - [ ] **Step 5: Commit**
565
+
566
+ ```bash
567
+ git add src/lib/environment/skill-recommendations.ts src/lib/environment/__tests__/skill-recommendations.test.ts
568
+ git commit -m "feat(env): keyword-based skill recommendation engine"
569
+ ```
570
+
571
+ ---
572
+
573
+ ## Task 4: Dismissals store
574
+
575
+ **Files:**
576
+ - Create: `src/lib/chat/dismissals.ts`
577
+ - Test: `src/lib/chat/__tests__/dismissals.test.ts`
578
+
579
+ - [ ] **Step 1: Write the failing test**
580
+
581
+ ```ts
582
+ // src/lib/chat/__tests__/dismissals.test.ts
583
+ import { describe, it, expect, beforeEach, vi } from "vitest";
584
+ import {
585
+ loadDismissals,
586
+ saveDismissal,
587
+ activeDismissedIds,
588
+ DISMISSAL_TTL_MS,
589
+ } from "../dismissals";
590
+
591
+ type Store = { read: () => string | null; write: (v: string) => void };
592
+
593
+ function mockStore(initial: string | null = null): Store {
594
+ let v = initial;
595
+ return {
596
+ read: () => v,
597
+ write: (next) => {
598
+ v = next;
599
+ },
600
+ };
601
+ }
602
+
603
+ describe("dismissals", () => {
604
+ const NOW = 1_700_000_000_000;
605
+
606
+ it("returns empty when store is null", () => {
607
+ const store = mockStore();
608
+ const all = loadDismissals(store);
609
+ expect(all).toEqual({});
610
+ });
611
+
612
+ it("saves dismissals keyed by conversation + skill", () => {
613
+ const store = mockStore();
614
+ saveDismissal(store, "conv-1", "skill-a", NOW);
615
+ const all = loadDismissals(store);
616
+ expect(all["conv-1"]["skill-a"]).toBe(NOW);
617
+ });
618
+
619
+ it("activeDismissedIds excludes expired entries", () => {
620
+ const store = mockStore();
621
+ saveDismissal(store, "c1", "fresh", NOW);
622
+ saveDismissal(store, "c1", "old", NOW - DISMISSAL_TTL_MS - 1000);
623
+ const ids = activeDismissedIds(store, "c1", NOW);
624
+ expect(ids.has("fresh")).toBe(true);
625
+ expect(ids.has("old")).toBe(false);
626
+ });
627
+
628
+ it("returns empty set when conversation has no dismissals", () => {
629
+ const store = mockStore();
630
+ expect(activeDismissedIds(store, "never-seen", NOW).size).toBe(0);
631
+ });
632
+
633
+ it("silently tolerates store write errors", () => {
634
+ const store: Store = {
635
+ read: () => null,
636
+ write: () => {
637
+ throw new Error("quota");
638
+ },
639
+ };
640
+ expect(() => saveDismissal(store, "c1", "s1", NOW)).not.toThrow();
641
+ });
642
+
643
+ it("silently tolerates corrupt JSON on read", () => {
644
+ const store = mockStore("not-json");
645
+ expect(loadDismissals(store)).toEqual({});
646
+ });
647
+ });
648
+ ```
649
+
650
+ - [ ] **Step 2: Run — expect FAIL**
651
+
652
+ - [ ] **Step 3: Implement**
653
+
654
+ ```ts
655
+ // src/lib/chat/dismissals.ts
656
+
657
+ export const DISMISSAL_TTL_MS = 7 * 24 * 60 * 60 * 1000;
658
+
659
+ export interface DismissalStore {
660
+ read(): string | null;
661
+ write(value: string): void;
662
+ }
663
+
664
+ export type DismissalMap = Record<string, Record<string, number>>;
665
+
666
+ export function loadDismissals(store: DismissalStore): DismissalMap {
667
+ const raw = store.read();
668
+ if (!raw) return {};
669
+ try {
670
+ const parsed = JSON.parse(raw);
671
+ if (parsed && typeof parsed === "object") return parsed as DismissalMap;
672
+ } catch {
673
+ // corrupt — fall through
674
+ }
675
+ return {};
676
+ }
677
+
678
+ export function saveDismissal(
679
+ store: DismissalStore,
680
+ conversationId: string,
681
+ skillId: string,
682
+ nowMs: number = Date.now()
683
+ ): void {
684
+ const current = loadDismissals(store);
685
+ current[conversationId] = current[conversationId] ?? {};
686
+ current[conversationId][skillId] = nowMs;
687
+ try {
688
+ store.write(JSON.stringify(current));
689
+ } catch {
690
+ // silent — in-memory state won't persist
691
+ }
692
+ }
693
+
694
+ export function activeDismissedIds(
695
+ store: DismissalStore,
696
+ conversationId: string,
697
+ nowMs: number = Date.now()
698
+ ): Set<string> {
699
+ const all = loadDismissals(store);
700
+ const conv = all[conversationId];
701
+ if (!conv) return new Set();
702
+ const out = new Set<string>();
703
+ for (const [skillId, ts] of Object.entries(conv)) {
704
+ if (nowMs - ts < DISMISSAL_TTL_MS) out.add(skillId);
705
+ }
706
+ return out;
707
+ }
708
+
709
+ /** Browser store adapter around localStorage for a given key. */
710
+ export function browserLocalStore(key: string): DismissalStore {
711
+ return {
712
+ read() {
713
+ if (typeof window === "undefined") return null;
714
+ try {
715
+ return window.localStorage.getItem(key);
716
+ } catch {
717
+ return null;
718
+ }
719
+ },
720
+ write(value) {
721
+ if (typeof window === "undefined") return;
722
+ try {
723
+ window.localStorage.setItem(key, value);
724
+ } catch {
725
+ // quota / disabled — silent
726
+ }
727
+ },
728
+ };
729
+ }
730
+ ```
731
+
732
+ - [ ] **Step 4: Run — expect PASS (6/6)**
733
+
734
+ - [ ] **Step 5: Commit**
735
+
736
+ ```bash
737
+ git add src/lib/chat/dismissals.ts src/lib/chat/__tests__/dismissals.test.ts
738
+ git commit -m "feat(chat): skill-recommendation dismissal store (7d TTL)"
739
+ ```
740
+
741
+ ---
742
+
743
+ ## Task 5: `listSkillsEnriched` adapter
744
+
745
+ **Files:**
746
+ - Modify: `src/lib/environment/list-skills.ts`
747
+ - Test: `src/lib/environment/__tests__/list-skills-enriched.test.ts` (new)
748
+
749
+ - [ ] **Step 1: Write the failing test**
750
+
751
+ ```ts
752
+ // src/lib/environment/__tests__/list-skills-enriched.test.ts
753
+ import { describe, it, expect, vi, beforeEach } from "vitest";
754
+
755
+ vi.mock("../scanner", () => ({
756
+ scanEnvironment: () => ({
757
+ artifacts: [
758
+ {
759
+ id: "art-1",
760
+ category: "skill",
761
+ tool: "claude-code",
762
+ scope: "user",
763
+ name: "code-reviewer",
764
+ relPath: ".claude/skills/code-reviewer",
765
+ absPath: "/u/.claude/skills/code-reviewer",
766
+ preview: "Review PRs",
767
+ sizeBytes: 100,
768
+ modifiedAt: new Date("2026-01-01T00:00:00Z").getTime(),
769
+ linkedProfileId: "code-reviewer-profile",
770
+ contentHash: "h",
771
+ metadata: null,
772
+ },
773
+ {
774
+ id: "art-2",
775
+ category: "skill",
776
+ tool: "codex",
777
+ scope: "user",
778
+ name: "code-reviewer",
779
+ relPath: ".agents/skills/code-reviewer",
780
+ absPath: "/u/.agents/skills/code-reviewer",
781
+ preview: "Review PRs",
782
+ sizeBytes: 100,
783
+ modifiedAt: new Date("2026-01-01T00:00:00Z").getTime(),
784
+ linkedProfileId: null,
785
+ contentHash: "h",
786
+ metadata: null,
787
+ },
788
+ ],
789
+ }),
790
+ }));
791
+
792
+ vi.mock("../workspace-context", () => ({ getLaunchCwd: () => "/tmp" }));
793
+
794
+ import { listSkillsEnriched } from "../list-skills";
795
+
796
+ describe("listSkillsEnriched", () => {
797
+ it("returns enriched skills with syncStatus and linkedProfileId populated", () => {
798
+ const enriched = listSkillsEnriched({ nowMs: new Date("2026-04-14T00:00:00Z").getTime() });
799
+ expect(enriched).toHaveLength(1);
800
+ expect(enriched[0].name).toBe("code-reviewer");
801
+ expect(enriched[0].syncStatus).toBe("synced");
802
+ expect(enriched[0].linkedProfileId).toBe("code-reviewer-profile");
803
+ expect(enriched[0].healthScore).toBe("healthy");
804
+ });
805
+ });
806
+ ```
807
+
808
+ - [ ] **Step 2: Run — expect FAIL**
809
+
810
+ - [ ] **Step 3: Modify `list-skills.ts`**
811
+
812
+ Append below `getSkill`:
813
+
814
+ ```ts
815
+ import {
816
+ enrichSkills,
817
+ type EnrichedSkill,
818
+ type EnrichmentContext,
819
+ } from "./skill-enrichment";
820
+
821
+ export function listSkillsEnriched(
822
+ options: { projectDir?: string; nowMs?: number } = {}
823
+ ): EnrichedSkill[] {
824
+ const projectDir = options.projectDir ?? getLaunchCwd();
825
+ const scan = scanEnvironment({ projectDir });
826
+ const skills: SkillSummary[] = [];
827
+ const modifiedAtMsByPath: Record<string, number | null> = {};
828
+ const linkedProfilesByPath: Record<string, string | null> = {};
829
+ for (const a of scan.artifacts) {
830
+ if (a.category !== "skill") continue;
831
+ skills.push(artifactToSummary(a));
832
+ modifiedAtMsByPath[a.absPath] =
833
+ typeof a.modifiedAt === "number"
834
+ ? a.modifiedAt
835
+ : a.modifiedAt instanceof Date
836
+ ? a.modifiedAt.getTime()
837
+ : null;
838
+ linkedProfilesByPath[a.absPath] = a.linkedProfileId ?? null;
839
+ }
840
+ return enrichSkills(skills, {
841
+ modifiedAtMsByPath,
842
+ linkedProfilesByPath,
843
+ nowMs: options.nowMs,
844
+ });
845
+ }
846
+ ```
847
+
848
+ (Also re-export `EnrichedSkill` from this module for consumers.)
849
+
850
+ - [ ] **Step 4: Run — expect PASS**
851
+
852
+ Also re-run Task 1+2 tests to confirm no regression.
853
+
854
+ - [ ] **Step 5: Commit**
855
+
856
+ ```bash
857
+ git add src/lib/environment/list-skills.ts src/lib/environment/__tests__/list-skills-enriched.test.ts
858
+ git commit -m "feat(env): listSkillsEnriched reads cache + enriches"
859
+ ```
860
+
861
+ ---
862
+
863
+ ## Task 6: Extend `list_skills` MCP tool with `enriched` flag
864
+
865
+ **Files:**
866
+ - Modify: `src/lib/chat/tools/skill-tools.ts`
867
+ - Test: add case to existing `src/lib/chat/tools/__tests__/skill-tools.test.ts`
868
+
869
+ - [ ] **Step 1: Find existing `list_skills` tool definition**
870
+
871
+ Read `src/lib/chat/tools/skill-tools.ts` lines 20-50. Note current input schema (likely empty or `{}`).
872
+
873
+ - [ ] **Step 2: Add `enriched` optional boolean**
874
+
875
+ ```ts
876
+ // Inside the list_skills tool definition input schema
877
+ z.object({
878
+ enriched: z.boolean().optional().describe("When true, include healthScore, syncStatus, linkedProfileId, and scope per skill."),
879
+ }),
880
+ ```
881
+
882
+ - [ ] **Step 3: Branch in handler**
883
+
884
+ Replace the existing handler body:
885
+
886
+ ```ts
887
+ async (input) => {
888
+ try {
889
+ if (input.enriched) {
890
+ const { listSkillsEnriched } = await import("@/lib/environment/list-skills");
891
+ return ok(listSkillsEnriched());
892
+ }
893
+ const { listSkills } = await import("@/lib/environment/list-skills");
894
+ return ok(listSkills());
895
+ } catch (e) {
896
+ return err(e instanceof Error ? e.message : "list_skills failed");
897
+ }
898
+ },
899
+ ```
900
+
901
+ - [ ] **Step 4: Add test**
902
+
903
+ Append to `src/lib/chat/tools/__tests__/skill-tools.test.ts`:
904
+
905
+ ```ts
906
+ it("list_skills returns enriched data when enriched:true", async () => {
907
+ // Existing test harness — find and reuse the handler invocation pattern
908
+ const result = await invokeTool("list_skills", { enriched: true });
909
+ const skills = JSON.parse(result);
910
+ // At least one skill should have an `healthScore` field
911
+ if (skills.length > 0) {
912
+ expect(skills[0]).toHaveProperty("healthScore");
913
+ expect(skills[0]).toHaveProperty("syncStatus");
914
+ }
915
+ });
916
+ ```
917
+
918
+ If `invokeTool` helper doesn't exist, skip this step and rely on downstream component-level tests instead.
919
+
920
+ - [ ] **Step 5: Typecheck**
921
+
922
+ ```
923
+ npx tsc --noEmit 2>&1 | grep -E "skill-tools|list-skills" | head
924
+ ```
925
+ Expected: empty.
926
+
927
+ - [ ] **Step 6: Commit**
928
+
929
+ ```bash
930
+ git add src/lib/chat/tools/skill-tools.ts src/lib/chat/tools/__tests__/
931
+ git commit -m "feat(chat): list_skills tool accepts enriched flag"
932
+ ```
933
+
934
+ ---
935
+
936
+ ## Task 7: `/api/environment/rescan-if-stale` endpoint
937
+
938
+ **Files:**
939
+ - Create: `src/app/api/environment/rescan-if-stale/route.ts`
940
+ - Test: `src/app/api/environment/rescan-if-stale/__tests__/route.test.ts`
941
+
942
+ - [ ] **Step 1: Find the auto-scan helper**
943
+
944
+ ```bash
945
+ grep -n "export " src/lib/environment/auto-scan.ts | head
946
+ ```
947
+
948
+ Typical exports include `scheduleAutoScan` or similar. Use whichever entry point exists for fire-and-forget. If none, call `scanEnvironment()` behind an in-memory lock.
949
+
950
+ - [ ] **Step 2: Write the failing test**
951
+
952
+ ```ts
953
+ // src/app/api/environment/rescan-if-stale/__tests__/route.test.ts
954
+ import { describe, it, expect, vi } from "vitest";
955
+
956
+ vi.mock("@/lib/environment/scanner", () => ({
957
+ scanEnvironment: vi.fn(() => ({ scannedAt: new Date() })),
958
+ getLatestScan: vi.fn(() => ({ scannedAt: new Date(Date.now() - 10 * 60 * 1000) })),
959
+ }));
960
+
961
+ import { POST } from "../route";
962
+
963
+ describe("POST /api/environment/rescan-if-stale", () => {
964
+ it("triggers a scan when last scan older than TTL and returns 200", async () => {
965
+ const res = await POST(new Request("http://test/api/environment/rescan-if-stale", { method: "POST" }));
966
+ expect(res.status).toBe(200);
967
+ const json = await res.json();
968
+ expect(json.scanned).toBe(true);
969
+ });
970
+ });
971
+ ```
972
+
973
+ - [ ] **Step 3: Run — expect FAIL**
974
+
975
+ - [ ] **Step 4: Implement**
976
+
977
+ ```ts
978
+ // src/app/api/environment/rescan-if-stale/route.ts
979
+ import { NextResponse } from "next/server";
980
+ import { scanEnvironment } from "@/lib/environment/scanner";
981
+ import { getLaunchCwd } from "@/lib/environment/workspace-context";
982
+
983
+ const FIVE_MIN_MS = 5 * 60 * 1000;
984
+
985
+ // Module-scoped guard to prevent concurrent rescans.
986
+ let rescanInFlight: Promise<void> | null = null;
987
+ let lastScanAt: number | null = null;
988
+
989
+ export async function POST() {
990
+ const now = Date.now();
991
+ const ageMs = lastScanAt == null ? Infinity : now - lastScanAt;
992
+
993
+ if (ageMs < FIVE_MIN_MS) {
994
+ return NextResponse.json({ scanned: false, ageMs });
995
+ }
996
+ if (rescanInFlight) {
997
+ return NextResponse.json({ scanned: false, inFlight: true });
998
+ }
999
+
1000
+ rescanInFlight = (async () => {
1001
+ try {
1002
+ scanEnvironment({ projectDir: getLaunchCwd() });
1003
+ lastScanAt = Date.now();
1004
+ } catch (err) {
1005
+ console.warn("[rescan-if-stale] scan failed:", err);
1006
+ } finally {
1007
+ rescanInFlight = null;
1008
+ }
1009
+ })();
1010
+
1011
+ // Fire-and-forget — return immediately.
1012
+ return NextResponse.json({ scanned: true });
1013
+ }
1014
+ ```
1015
+
1016
+ - [ ] **Step 5: Run — expect PASS**
1017
+
1018
+ - [ ] **Step 6: Commit**
1019
+
1020
+ ```bash
1021
+ git add src/app/api/environment/rescan-if-stale/
1022
+ git commit -m "feat(env): rescan-if-stale fire-and-forget endpoint"
1023
+ ```
1024
+
1025
+ ---
1026
+
1027
+ ## Task 8: `SkillRow` component with badges
1028
+
1029
+ **Files:**
1030
+ - Create: `src/components/chat/skill-row.tsx`
1031
+ - Test: `src/components/chat/__tests__/skill-row.test.tsx`
1032
+
1033
+ - [ ] **Step 1: Write the failing test**
1034
+
1035
+ ```tsx
1036
+ // src/components/chat/__tests__/skill-row.test.tsx
1037
+ import { describe, it, expect } from "vitest";
1038
+ import { render, screen } from "@testing-library/react";
1039
+ import { SkillRow } from "../skill-row";
1040
+ import type { EnrichedSkill } from "@/lib/environment/skill-enrichment";
1041
+
1042
+ const base: EnrichedSkill = {
1043
+ id: "code-reviewer",
1044
+ name: "code-reviewer",
1045
+ tool: "claude-code",
1046
+ scope: "user",
1047
+ preview: "Review PRs for security",
1048
+ sizeBytes: 100,
1049
+ absPath: "/p",
1050
+ absPaths: ["/p"],
1051
+ healthScore: "healthy",
1052
+ syncStatus: "synced",
1053
+ linkedProfileId: "code-reviewer-profile",
1054
+ };
1055
+
1056
+ describe("SkillRow", () => {
1057
+ it("renders skill name and description", () => {
1058
+ render(<SkillRow skill={base} onSelect={() => {}} />);
1059
+ expect(screen.getByText("code-reviewer")).toBeTruthy();
1060
+ expect(screen.getByText(/Review PRs/)).toBeTruthy();
1061
+ });
1062
+
1063
+ it("shows 'Synced' badge when syncStatus is synced", () => {
1064
+ render(<SkillRow skill={base} onSelect={() => {}} />);
1065
+ expect(screen.getByText(/synced/i)).toBeTruthy();
1066
+ });
1067
+
1068
+ it("shows profile linkage badge", () => {
1069
+ render(<SkillRow skill={base} onSelect={() => {}} />);
1070
+ expect(screen.getByText(/code-reviewer-profile/)).toBeTruthy();
1071
+ });
1072
+
1073
+ it("shows 'stale' badge for stale health", () => {
1074
+ render(<SkillRow skill={{ ...base, healthScore: "stale" }} onSelect={() => {}} />);
1075
+ expect(screen.getByText(/stale/i)).toBeTruthy();
1076
+ });
1077
+
1078
+ it("shows a recommended indicator when recommended=true", () => {
1079
+ render(<SkillRow skill={base} recommended onSelect={() => {}} />);
1080
+ expect(screen.getByLabelText(/recommended/i)).toBeTruthy();
1081
+ });
1082
+ });
1083
+ ```
1084
+
1085
+ - [ ] **Step 2: Run — expect FAIL**
1086
+
1087
+ - [ ] **Step 3: Implement**
1088
+
1089
+ ```tsx
1090
+ // src/components/chat/skill-row.tsx
1091
+ "use client";
1092
+ import { Sparkles, Star } from "lucide-react";
1093
+ import { Badge } from "@/components/ui/badge";
1094
+ import { CommandItem } from "@/components/ui/command";
1095
+ import type { EnrichedSkill } from "@/lib/environment/skill-enrichment";
1096
+ import { cn } from "@/lib/utils";
1097
+
1098
+ interface SkillRowProps {
1099
+ skill: EnrichedSkill;
1100
+ recommended?: boolean;
1101
+ onSelect: () => void;
1102
+ }
1103
+
1104
+ function healthVariant(h: EnrichedSkill["healthScore"]) {
1105
+ if (h === "healthy") return "default" as const;
1106
+ if (h === "stale") return "outline" as const;
1107
+ if (h === "aging" || h === "broken") return "destructive" as const;
1108
+ return "secondary" as const;
1109
+ }
1110
+
1111
+ function syncLabel(s: EnrichedSkill["syncStatus"]): string {
1112
+ switch (s) {
1113
+ case "synced": return "synced";
1114
+ case "claude-only": return "claude-only";
1115
+ case "codex-only": return "codex-only";
1116
+ case "shared": return "shared";
1117
+ }
1118
+ }
1119
+
1120
+ export function SkillRow({ skill, recommended, onSelect }: SkillRowProps) {
1121
+ const syncHref =
1122
+ skill.syncStatus !== "synced"
1123
+ ? `/environment?skill=${encodeURIComponent(skill.name)}`
1124
+ : null;
1125
+
1126
+ return (
1127
+ <CommandItem
1128
+ key={skill.id}
1129
+ value={`${skill.name} ${skill.preview} ${skill.tool}`}
1130
+ onSelect={onSelect}
1131
+ >
1132
+ <Sparkles className="h-4 w-4 shrink-0 text-muted-foreground" />
1133
+ <div className="flex flex-col min-w-0 gap-0.5">
1134
+ <div className="flex items-center gap-1.5">
1135
+ <span className="truncate text-sm font-medium">{skill.name}</span>
1136
+ {recommended && (
1137
+ <Star
1138
+ className="h-3 w-3 shrink-0 fill-amber-500 text-amber-500"
1139
+ aria-label="Recommended for this conversation"
1140
+ />
1141
+ )}
1142
+ </div>
1143
+ <span className="truncate text-xs text-muted-foreground">{skill.preview}</span>
1144
+ <div className="flex flex-wrap items-center gap-1 mt-0.5">
1145
+ <Badge variant={healthVariant(skill.healthScore)} className="text-[10px]">
1146
+ {skill.healthScore}
1147
+ </Badge>
1148
+ <Badge variant="outline" className="text-[10px]">
1149
+ {syncLabel(skill.syncStatus)}
1150
+ </Badge>
1151
+ {skill.linkedProfileId && (
1152
+ <Badge variant="secondary" className="text-[10px]">
1153
+ {skill.linkedProfileId}
1154
+ </Badge>
1155
+ )}
1156
+ <Badge variant="outline" className={cn("text-[10px]")}>
1157
+ {skill.scope}
1158
+ </Badge>
1159
+ </div>
1160
+ </div>
1161
+ {syncHref && (
1162
+ <a
1163
+ href={syncHref}
1164
+ aria-label={`Open ${skill.name} in environment dashboard`}
1165
+ className="ml-auto shrink-0 text-muted-foreground hover:text-foreground"
1166
+ onClick={(e) => e.stopPropagation()}
1167
+ >
1168
+
1169
+ </a>
1170
+ )}
1171
+ </CommandItem>
1172
+ );
1173
+ }
1174
+ ```
1175
+
1176
+ - [ ] **Step 4: Run — expect PASS**
1177
+
1178
+ If testing-library doesn't find "synced" due to casing — adjust to match via regex which already handles case. Ensure `@testing-library/jest-dom` matchers are not required; use `toBeTruthy()` on query results.
1179
+
1180
+ - [ ] **Step 5: Commit**
1181
+
1182
+ ```bash
1183
+ git add src/components/chat/skill-row.tsx src/components/chat/__tests__/skill-row.test.tsx
1184
+ git commit -m "feat(chat): SkillRow component with health/sync/profile/scope badges"
1185
+ ```
1186
+
1187
+ ---
1188
+
1189
+ ## Task 9: Wire `SkillRow` into the Skills tab
1190
+
1191
+ **Files:**
1192
+ - Modify: `src/components/chat/chat-command-popover.tsx`
1193
+ - Modify: `src/lib/chat/tool-catalog.ts` (add metadata passthrough)
1194
+
1195
+ - [ ] **Step 1: Fetch enriched skills in popover**
1196
+
1197
+ The popover already receives `projectProfiles`. Add a separate fetch of enriched skills via a React hook so the Skills tab gets the full metadata. Simplest: a new `useEnrichedSkills(open: boolean)` hook that calls `/api/environment/skills-enriched` (new endpoint? or reuse via `list_skills` MCP call?).
1198
+
1199
+ Decision: create a tiny GET endpoint `src/app/api/environment/skills/route.ts` returning `listSkillsEnriched()`. Frontend fetches on popover open.
1200
+
1201
+ Create: `src/app/api/environment/skills/route.ts`
1202
+
1203
+ ```ts
1204
+ import { NextResponse } from "next/server";
1205
+ import { listSkillsEnriched } from "@/lib/environment/list-skills";
1206
+
1207
+ export async function GET() {
1208
+ try {
1209
+ return NextResponse.json(listSkillsEnriched());
1210
+ } catch (err) {
1211
+ return NextResponse.json(
1212
+ { error: err instanceof Error ? err.message : "scan failed" },
1213
+ { status: 500 }
1214
+ );
1215
+ }
1216
+ }
1217
+ ```
1218
+
1219
+ Create: `src/hooks/use-enriched-skills.ts`
1220
+
1221
+ ```ts
1222
+ "use client";
1223
+ import { useEffect, useState } from "react";
1224
+ import type { EnrichedSkill } from "@/lib/environment/skill-enrichment";
1225
+
1226
+ export function useEnrichedSkills(open: boolean): EnrichedSkill[] {
1227
+ const [skills, setSkills] = useState<EnrichedSkill[]>([]);
1228
+ useEffect(() => {
1229
+ if (!open) return;
1230
+ const controller = new AbortController();
1231
+ fetch("/api/environment/skills", { signal: controller.signal })
1232
+ .then((r) => (r.ok ? r.json() : []))
1233
+ .then((data) => {
1234
+ if (Array.isArray(data)) setSkills(data);
1235
+ })
1236
+ .catch(() => {});
1237
+ return () => controller.abort();
1238
+ }, [open]);
1239
+ return skills;
1240
+ }
1241
+ ```
1242
+
1243
+ - [ ] **Step 2: Consume in `chat-command-popover.tsx`**
1244
+
1245
+ At the top of the `ChatCommandPopover` component:
1246
+
1247
+ ```tsx
1248
+ import { useEnrichedSkills } from "@/hooks/use-enriched-skills";
1249
+ import { SkillRow } from "./skill-row";
1250
+ // ...
1251
+
1252
+ const enrichedSkills = useEnrichedSkills(open && mode === "slash");
1253
+ ```
1254
+
1255
+ Inside `ToolCatalogItems`, when `activeTab === "skills"` and `enrichedSkills` is provided, render via `SkillRow` instead of the default catalog row. Pass `enrichedSkills` into `ToolCatalogItems` as a new prop.
1256
+
1257
+ - [ ] **Step 3: Recommended computation**
1258
+
1259
+ In the popover, compute `recommendedId` using:
1260
+
1261
+ ```tsx
1262
+ import { computeRecommendation } from "@/lib/environment/skill-recommendations";
1263
+ import { browserLocalStore, activeDismissedIds } from "@/lib/chat/dismissals";
1264
+ // ...
1265
+ const recentUserMessages = useRecentUserMessages(conversationId, 20); // new hook (Task 10)
1266
+ const dismissedIds = activeDismissedIds(
1267
+ browserLocalStore("stagent.chat.dismissed-suggestions"),
1268
+ conversationId ?? ""
1269
+ );
1270
+ const recommended = computeRecommendation(
1271
+ enrichedSkills,
1272
+ recentUserMessages,
1273
+ { activeSkillId, dismissedIds }
1274
+ );
1275
+ ```
1276
+
1277
+ - [ ] **Step 4: Render Skills tab with `SkillRow`**
1278
+
1279
+ Inside the `activeTab === "skills"` branch of `ToolCatalogItems`, when `enrichedSkills.length > 0`, render:
1280
+
1281
+ ```tsx
1282
+ {enrichedSkills.map((skill) => (
1283
+ <SkillRow
1284
+ key={skill.id}
1285
+ skill={skill}
1286
+ recommended={recommended?.id === skill.id}
1287
+ onSelect={() => onSelect({
1288
+ type: "slash",
1289
+ id: skill.name,
1290
+ label: skill.name,
1291
+ text: `Use the ${skill.name} profile: `,
1292
+ })}
1293
+ />
1294
+ ))}
1295
+ ```
1296
+
1297
+ Fall back to existing catalog rendering if `enrichedSkills` is empty (covers loading state).
1298
+
1299
+ - [ ] **Step 5: Typecheck**
1300
+
1301
+ ```
1302
+ npx tsc --noEmit 2>&1 | grep -E "skill-row|use-enriched-skills|chat-command-popover" | head
1303
+ ```
1304
+
1305
+ - [ ] **Step 6: Commit**
1306
+
1307
+ ```bash
1308
+ git add src/app/api/environment/skills/route.ts src/hooks/use-enriched-skills.ts src/components/chat/chat-command-popover.tsx
1309
+ git commit -m "feat(chat): render enriched skills with badges in Skills tab"
1310
+ ```
1311
+
1312
+ ---
1313
+
1314
+ ## Task 10: Recent-messages hook + recommendation plumbing
1315
+
1316
+ **Files:**
1317
+ - Create: `src/hooks/use-recent-user-messages.ts`
1318
+ - Modify: `src/components/chat/chat-command-popover.tsx` (consume)
1319
+
1320
+ - [ ] **Step 1: Implement the hook**
1321
+
1322
+ ```tsx
1323
+ // src/hooks/use-recent-user-messages.ts
1324
+ "use client";
1325
+ import { useContext, useMemo } from "react";
1326
+ // Use the existing chat session context — import path is the provider file.
1327
+ import { useChatSession } from "@/components/chat/chat-session-provider";
1328
+
1329
+ export function useRecentUserMessages(
1330
+ conversationId: string | null | undefined,
1331
+ limit: number = 20
1332
+ ): string[] {
1333
+ const { messages } = useChatSession();
1334
+ return useMemo(() => {
1335
+ if (!conversationId) return [];
1336
+ return messages
1337
+ .filter((m) => m.role === "user")
1338
+ .slice(-limit)
1339
+ .map((m) => m.content);
1340
+ }, [messages, conversationId, limit]);
1341
+ }
1342
+ ```
1343
+
1344
+ If `useChatSession` isn't exported, add a minimal `export function useChatSession()` in the provider that returns the context value. Short fix, not a refactor.
1345
+
1346
+ - [ ] **Step 2: Verify**
1347
+
1348
+ ```
1349
+ npx tsc --noEmit 2>&1 | grep use-recent-user-messages | head
1350
+ ```
1351
+
1352
+ - [ ] **Step 3: Commit**
1353
+
1354
+ ```bash
1355
+ git add src/hooks/use-recent-user-messages.ts src/components/chat/chat-session-provider.tsx
1356
+ git commit -m "feat(chat): useRecentUserMessages hook"
1357
+ ```
1358
+
1359
+ ---
1360
+
1361
+ ## Task 11: Hook `/api/environment/rescan-if-stale` into session open
1362
+
1363
+ **Files:**
1364
+ - Modify: `src/components/chat/chat-session-provider.tsx`
1365
+
1366
+ - [ ] **Step 1: Add effect**
1367
+
1368
+ Inside `ChatSessionProvider`, alongside the existing effects, add:
1369
+
1370
+ ```tsx
1371
+ useEffect(() => {
1372
+ if (!activeId) return;
1373
+ // Fire-and-forget; endpoint self-guards against stampede.
1374
+ fetch("/api/environment/rescan-if-stale", { method: "POST" }).catch(() => {});
1375
+ }, [activeId]);
1376
+ ```
1377
+
1378
+ - [ ] **Step 2: Typecheck**
1379
+
1380
+ - [ ] **Step 3: Commit**
1381
+
1382
+ ```bash
1383
+ git add src/components/chat/chat-session-provider.tsx
1384
+ git commit -m "feat(chat): rescan environment on conversation activation"
1385
+ ```
1386
+
1387
+ ---
1388
+
1389
+ ## Task 12: Dismissal interaction in SkillRow
1390
+
1391
+ **Files:**
1392
+ - Modify: `src/components/chat/skill-row.tsx`
1393
+
1394
+ - [ ] **Step 1: Add dismiss prop + handler**
1395
+
1396
+ ```tsx
1397
+ interface SkillRowProps {
1398
+ skill: EnrichedSkill;
1399
+ recommended?: boolean;
1400
+ onSelect: () => void;
1401
+ onDismissRecommendation?: () => void;
1402
+ }
1403
+ ```
1404
+
1405
+ Render a small X on hover next to the star when `recommended && onDismissRecommendation`:
1406
+
1407
+ ```tsx
1408
+ {recommended && onDismissRecommendation && (
1409
+ <button
1410
+ type="button"
1411
+ aria-label="Dismiss recommendation"
1412
+ className="opacity-0 group-hover:opacity-100 transition-opacity focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring"
1413
+ onClick={(e) => {
1414
+ e.stopPropagation();
1415
+ onDismissRecommendation();
1416
+ }}
1417
+ >
1418
+ <X className="h-3 w-3" />
1419
+ </button>
1420
+ )}
1421
+ ```
1422
+
1423
+ Wrap the row with `className="group"` on the outer `CommandItem` content so `group-hover` works.
1424
+
1425
+ - [ ] **Step 2: Wire in popover**
1426
+
1427
+ In `chat-command-popover.tsx`:
1428
+
1429
+ ```tsx
1430
+ import { browserLocalStore, saveDismissal } from "@/lib/chat/dismissals";
1431
+ // ...
1432
+ const dismissStore = useMemo(
1433
+ () => browserLocalStore("stagent.chat.dismissed-suggestions"),
1434
+ []
1435
+ );
1436
+
1437
+ <SkillRow
1438
+ ...
1439
+ onDismissRecommendation={
1440
+ recommended?.id === skill.id && conversationId
1441
+ ? () => saveDismissal(dismissStore, conversationId, skill.id)
1442
+ : undefined
1443
+ }
1444
+ />
1445
+ ```
1446
+
1447
+ - [ ] **Step 3: Typecheck + commit**
1448
+
1449
+ ```bash
1450
+ git add src/components/chat/skill-row.tsx src/components/chat/chat-command-popover.tsx
1451
+ git commit -m "feat(chat): dismiss recommendation per conversation (7d)"
1452
+ ```
1453
+
1454
+ ---
1455
+
1456
+ ## Task 13: Full verification
1457
+
1458
+ - [ ] **Step 1: Full typecheck**
1459
+
1460
+ Run: `npx tsc --noEmit 2>&1 | tail -10`
1461
+ Expected: 0 errors.
1462
+
1463
+ - [ ] **Step 2: Full test run**
1464
+
1465
+ Run: `npm test -- --run 2>&1 | tail -10`
1466
+ Expected: no new failures. Only pre-existing e2e suite may fail.
1467
+
1468
+ - [ ] **Step 3: Manual browser smoke (PORT=3010)**
1469
+
1470
+ Start: `PORT=3010 npm run dev`
1471
+
1472
+ Verify:
1473
+ - Open chat; open `/` popover → Skills tab shows badges (health, sync, profile if linked, scope)
1474
+ - Switch conversation → network tab shows POST to `/api/environment/rescan-if-stale`
1475
+ - Send a message matching a skill's keywords (e.g. "review pull request for security") → reopen Skills tab → recommended star appears on `code-reviewer`
1476
+ - Click the X on the recommended item → star disappears
1477
+ - Switch to another conversation, send the same message → recommended star appears (dismissal is per-conversation)
1478
+
1479
+ Stop server: `pkill -f "next dev.*3010"`
1480
+
1481
+ ---
1482
+
1483
+ ## Task 14: Docs
1484
+
1485
+ **Files:**
1486
+ - Modify: `features/chat-environment-integration.md` → `status: completed`
1487
+ - Modify: `features/changelog.md` → entry under today
1488
+ - Modify: `features/roadmap.md` → flip row to `completed`
1489
+
1490
+ - [ ] **Step 1: Update spec with Verification section**
1491
+
1492
+ Append under References:
1493
+
1494
+ ```markdown
1495
+ ## Verification — 2026-04-14
1496
+
1497
+ ### What shipped
1498
+ - `src/lib/environment/skill-enrichment.ts` — pure health / sync-status / enrichSkills
1499
+ - `src/lib/environment/skill-recommendations.ts` — keyword recommendation engine
1500
+ - `src/lib/chat/dismissals.ts` — 7d TTL dismissal store
1501
+ - `src/lib/environment/list-skills.ts` — new `listSkillsEnriched`
1502
+ - `src/app/api/environment/skills/route.ts` — enriched GET
1503
+ - `src/app/api/environment/rescan-if-stale/route.ts` — fire-and-forget POST
1504
+ - `src/components/chat/skill-row.tsx` — SkillRow with badges + recommended star
1505
+ - `src/hooks/use-enriched-skills.ts` + `use-recent-user-messages.ts`
1506
+
1507
+ ### Scope deviations
1508
+ - Profile suggestion chip above input replaced with passive "Recommended" star inside Skills tab (lower UI intrusiveness, simpler state model).
1509
+ - Per-skill `healthScore` derived from `modifiedAt` recency (no usage telemetry in the codebase today).
1510
+ - Deep-link to `/environment?skill=<name>` shipped as a simple ↗ link; dashboard route parsing of the param is a follow-up.
1511
+ ```
1512
+
1513
+ - [ ] **Step 2: Append changelog entry**
1514
+
1515
+ ```markdown
1516
+ ### Completed — chat-environment-integration (P2)
1517
+
1518
+ The chat Skills tab now surfaces per-skill environment metadata: health (based on `modifiedAt` age), cross-tool sync status (claude-only / codex-only / synced / shared), profile linkage (from `environment_artifacts.linked_profile_id`), and scope (user vs project). A passive "Recommended" star appears on healthy skills whose keywords match the conversation's recent user messages, dismissible per-conversation for 7 days. Fire-and-forget `/api/environment/rescan-if-stale` is called on every conversation activation, non-blocking.
1519
+
1520
+ Architecture is strictly read-only over the existing scanner — pure enrichment + recommendation modules with no new writes to the env artifacts layer. The `list_skills` MCP tool now accepts `enriched: true` (additive, backwards compatible); the popover consumes a dedicated `GET /api/environment/skills` endpoint. Passive recommendation replaced the spec's chip-above-input concept per HOLD scope review — lower UI intrusiveness, simpler state.
1521
+
1522
+ Commits: [list SHAs].
1523
+ ```
1524
+
1525
+ - [ ] **Step 3: Flip roadmap**
1526
+
1527
+ ```bash
1528
+ sed -i '' 's|chat-environment-integration.md) | P2 | planned |chat-environment-integration.md) | P2 | completed |' features/roadmap.md
1529
+ ```
1530
+
1531
+ Or edit directly:
1532
+ ```
1533
+ | [chat-environment-integration](...) | P2 | completed | ... |
1534
+ ```
1535
+
1536
+ - [ ] **Step 4: Commit**
1537
+
1538
+ ```bash
1539
+ git add features/
1540
+ git commit -m "docs(features): mark chat-environment-integration complete"
1541
+ ```
1542
+
1543
+ ---
1544
+
1545
+ ## Self-Review
1546
+
1547
+ **Spec coverage:**
1548
+ - AC 1 (`list_skills` enriched) → Task 5 + 6
1549
+ - AC 2 (badges on Skills tab) → Task 8 + 9
1550
+ - AC 3 (auto-rescan on session start, non-blocking) → Task 7 + 11
1551
+ - AC 4 (profile suggestion chip on keyword match, healthy only, once per session) → Task 3 + 10 + 12 (scope-adjusted to passive star)
1552
+ - AC 5 (7-day dismissal) → Task 4 + 12
1553
+ - AC 6 (sync click-through deep-link) → Task 8
1554
+ - AC 7 (no FS I/O on popover open — cache only) → Task 5 reads `scanEnvironment` which is cache-backed
1555
+ - AC 8 (SDK native discovery untouched) → No changes to SDK execution path; only `listSkills*` and UI
1556
+
1557
+ **Gaps:** AC 4 is scope-adjusted (passive star instead of chip). Called out in Task 14 spec update.
1558
+
1559
+ **Placeholder scan:** none. All code blocks are complete. Task 6 has an "if harness absent" skip note for one test helper — acceptable because the unit-level coverage for enriched shape is already in Task 5.
1560
+
1561
+ **Type consistency:** `EnrichedSkill` / `HealthScore` / `SyncStatus` / `DismissalStore` / `DismissalMap` all defined in Task 1-4 and consistently used in Tasks 5-12. `computeRecommendation` signature `(skills, messages, opts)` consistent between Task 3 definition and Task 9 usage.