@miphamai/cli 0.68.0 → 0.70.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/package.json +1 -1
- package/src/commands/loop-scaffold.ts +15 -44
- package/src/config/loader.ts +56 -1
- package/src/core/claude-md-fix.ts +28 -0
- package/src/core/crsi-modify.ts +10 -1
- package/src/core/crsi-producer.ts +13 -0
- package/src/core/engine.ts +31 -0
- package/src/core/eval-harness.ts +94 -2
- package/src/core/fix-code.ts +167 -0
- package/src/core/fix.ts +176 -0
- package/src/core/hooks-config.ts +10 -5
- package/src/core/hooks-executor.ts +84 -5
- package/src/core/instructions.ts +2 -2
- package/src/core/memory/memory-manager.ts +205 -19
- package/src/core/post-flight-checker.ts +102 -0
- package/src/core/reward-fn.ts +3 -1
- package/src/core/session-log.ts +3 -1
- package/src/core/working-memory.ts +140 -0
- package/src/i18n-core/locales/en-US.json +30 -2
- package/src/i18n-core/locales/zh-CN.json +30 -2
- package/src/index.tsx +12 -0
- package/src/shared/package-info.ts +1 -1
- package/src/shared/types.ts +2 -0
- package/src/skills/community-registry.json +16 -0
- package/src/skills/marketplace.ts +233 -0
- package/src/skills/registry.ts +77 -63
- package/src/tools/agent/memory.ts +5 -1
- package/src/tools/exec/task.ts +21 -2
- package/src/ui/commands.ts +354 -34
|
@@ -0,0 +1,233 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Marketplace sources — discover and install skills from any public GitHub
|
|
3
|
+
* repository that follows the standard `SKILL.md` (frontmatter name/description)
|
|
4
|
+
* convention. This lifts skill installation out of the hard-coded community
|
|
5
|
+
* registry so users can add any public repo as a source.
|
|
6
|
+
*/
|
|
7
|
+
import { parse as parseYaml } from 'yaml'
|
|
8
|
+
import { homedir } from 'node:os'
|
|
9
|
+
import { join } from 'node:path'
|
|
10
|
+
import { spawn } from 'node:child_process'
|
|
11
|
+
import { URL } from 'node:url'
|
|
12
|
+
|
|
13
|
+
export const MARKETPLACES_PATH = join(homedir(), '.mipham', 'marketplaces.json')
|
|
14
|
+
|
|
15
|
+
export interface SkillFrontmatter {
|
|
16
|
+
name: string
|
|
17
|
+
description: string
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Parse a `SKILL.md` document's frontmatter for `name`/`description`. Returns
|
|
22
|
+
* null when there is no frontmatter, the YAML is invalid, or `name` is missing.
|
|
23
|
+
*/
|
|
24
|
+
export function parseSkillFrontmatter(content: string): SkillFrontmatter | null {
|
|
25
|
+
const src = content.replace(/^/, '')
|
|
26
|
+
const match = src.match(/^---\n([\s\S]*?)\n---/)
|
|
27
|
+
if (!match) return null
|
|
28
|
+
let data: Record<string, unknown>
|
|
29
|
+
try {
|
|
30
|
+
data = parseYaml(match[1] || '') as Record<string, unknown>
|
|
31
|
+
} catch {
|
|
32
|
+
return null
|
|
33
|
+
}
|
|
34
|
+
const name = typeof data.name === 'string' ? data.name.trim() : ''
|
|
35
|
+
if (!name) return null
|
|
36
|
+
const description = typeof data.description === 'string' ? data.description : ''
|
|
37
|
+
return { name, description }
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export interface MarketplaceSource {
|
|
41
|
+
owner: string
|
|
42
|
+
repo: string
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export const DEFAULT_MARKETPLACES: MarketplaceSource[] = [
|
|
46
|
+
{ owner: 'anthropics', repo: 'claude-plugins-community' },
|
|
47
|
+
{ owner: 'anthropics', repo: 'skills' },
|
|
48
|
+
]
|
|
49
|
+
|
|
50
|
+
/** owner/repo must be plain GitHub names (letters, digits, dot, dash, underscore). */
|
|
51
|
+
export function isValidMarketplaceRef(owner: string, repo: string): boolean {
|
|
52
|
+
const valid = (s: string) => /^[a-zA-Z0-9._-]+$/.test(s) && s.length > 0
|
|
53
|
+
return valid(owner) && valid(repo)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* Read the marketplace sources from disk. A missing or corrupt file falls back
|
|
58
|
+
* to the default Anthropic sources.
|
|
59
|
+
*/
|
|
60
|
+
export function readMarketplaces(readFile: (path: string) => string | null): MarketplaceSource[] {
|
|
61
|
+
const raw = readFile(MARKETPLACES_PATH)
|
|
62
|
+
if (raw === null) return [...DEFAULT_MARKETPLACES]
|
|
63
|
+
try {
|
|
64
|
+
const parsed = JSON.parse(raw)
|
|
65
|
+
if (!Array.isArray(parsed)) return [...DEFAULT_MARKETPLACES]
|
|
66
|
+
return parsed.filter(
|
|
67
|
+
(s): s is MarketplaceSource =>
|
|
68
|
+
!!s && typeof s === 'object' && typeof s.owner === 'string' && typeof s.repo === 'string',
|
|
69
|
+
)
|
|
70
|
+
} catch {
|
|
71
|
+
return [...DEFAULT_MARKETPLACES]
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
export function addMarketplace(
|
|
76
|
+
current: MarketplaceSource[],
|
|
77
|
+
owner: string,
|
|
78
|
+
repo: string,
|
|
79
|
+
): { sources: MarketplaceSource[]; added: boolean } {
|
|
80
|
+
if (current.some((s) => s.owner === owner && s.repo === repo)) {
|
|
81
|
+
return { sources: current, added: false }
|
|
82
|
+
}
|
|
83
|
+
return { sources: [...current, { owner, repo }], added: true }
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
export function removeMarketplace(
|
|
87
|
+
current: MarketplaceSource[],
|
|
88
|
+
owner: string,
|
|
89
|
+
repo: string,
|
|
90
|
+
): { sources: MarketplaceSource[]; removed: boolean } {
|
|
91
|
+
const sources = current.filter((s) => !(s.owner === owner && s.repo === repo))
|
|
92
|
+
return { sources, removed: sources.length !== current.length }
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
export interface DiscoveredSkill {
|
|
96
|
+
name: string
|
|
97
|
+
description: string
|
|
98
|
+
path: string
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Minimal fetch shape (ok/json/text) so discovery is testable without real network. */
|
|
102
|
+
export interface FetchLike {
|
|
103
|
+
(url: string): Promise<{ ok: boolean; json: () => Promise<unknown>; text: () => Promise<string> }>
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* List every `SKILL.md` blob path in a repository via the GitHub trees API.
|
|
108
|
+
* Returns [] on any failure (network, rate-limit, non-2xx).
|
|
109
|
+
*/
|
|
110
|
+
export async function listSkillPaths(
|
|
111
|
+
source: MarketplaceSource,
|
|
112
|
+
fetchFn: FetchLike,
|
|
113
|
+
): Promise<string[]> {
|
|
114
|
+
const url = `https://api.github.com/repos/${source.owner}/${source.repo}/git/trees/HEAD?recursive=1`
|
|
115
|
+
try {
|
|
116
|
+
const res = await fetchFn(url)
|
|
117
|
+
if (!res.ok) return []
|
|
118
|
+
const data = (await res.json()) as { tree?: Array<{ path?: string; type?: string }> }
|
|
119
|
+
return (data.tree ?? [])
|
|
120
|
+
.filter((e) => e.type === 'blob' && e.path?.endsWith('SKILL.md'))
|
|
121
|
+
.map((e) => e.path!)
|
|
122
|
+
} catch {
|
|
123
|
+
return []
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
/**
|
|
128
|
+
* Discover every skill in a repository: list `SKILL.md` paths, fetch each from
|
|
129
|
+
* raw.githubusercontent.com, and parse its frontmatter for name/description.
|
|
130
|
+
*/
|
|
131
|
+
export async function discoverSkills(
|
|
132
|
+
source: MarketplaceSource,
|
|
133
|
+
fetchFn: FetchLike,
|
|
134
|
+
download: (url: string) => Promise<string>,
|
|
135
|
+
): Promise<DiscoveredSkill[]> {
|
|
136
|
+
const paths = await listSkillPaths(source, fetchFn)
|
|
137
|
+
const skills = await Promise.all(
|
|
138
|
+
paths.map(async (path) => {
|
|
139
|
+
const rawUrl = `https://raw.githubusercontent.com/${source.owner}/${source.repo}/HEAD/${path}`
|
|
140
|
+
try {
|
|
141
|
+
const fm = parseSkillFrontmatter(await download(rawUrl))
|
|
142
|
+
return fm ? { name: fm.name, description: fm.description, path } : null
|
|
143
|
+
} catch {
|
|
144
|
+
return null
|
|
145
|
+
}
|
|
146
|
+
}),
|
|
147
|
+
)
|
|
148
|
+
return skills.filter((s): s is DiscoveredSkill => s !== null)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
export interface FoundSkill {
|
|
152
|
+
name: string
|
|
153
|
+
description: string
|
|
154
|
+
source: MarketplaceSource
|
|
155
|
+
path: string
|
|
156
|
+
rawUrl: string
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
/**
|
|
160
|
+
* Search every marketplace source for a skill by name (first match wins, in
|
|
161
|
+
* source order). Returns null when no source exposes it.
|
|
162
|
+
*/
|
|
163
|
+
export async function findSkillInMarketplaces(
|
|
164
|
+
skillName: string,
|
|
165
|
+
sources: MarketplaceSource[],
|
|
166
|
+
fetchFn: FetchLike,
|
|
167
|
+
download: (url: string) => Promise<string>,
|
|
168
|
+
): Promise<FoundSkill | null> {
|
|
169
|
+
for (const source of sources) {
|
|
170
|
+
const skills = await discoverSkills(source, fetchFn, download)
|
|
171
|
+
const found = skills.find((s) => s.name === skillName)
|
|
172
|
+
if (found) {
|
|
173
|
+
return {
|
|
174
|
+
name: found.name,
|
|
175
|
+
description: found.description,
|
|
176
|
+
source,
|
|
177
|
+
path: found.path,
|
|
178
|
+
rawUrl: `https://raw.githubusercontent.com/${source.owner}/${source.repo}/HEAD/${found.path}`,
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
return null
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/** Allowed domains for remote skill installation. */
|
|
186
|
+
const ALLOWED_DOMAINS = [
|
|
187
|
+
'raw.githubusercontent.com',
|
|
188
|
+
'github.com',
|
|
189
|
+
'gist.githubusercontent.com',
|
|
190
|
+
'gitlab.com',
|
|
191
|
+
]
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* Download a file over HTTPS via `curl` (spawn, no shell injection), restricted
|
|
195
|
+
* to known-safe domains. Async so multiple downloads can run in parallel.
|
|
196
|
+
*/
|
|
197
|
+
export async function downloadFile(rawUrl: string): Promise<string> {
|
|
198
|
+
let parsed: URL
|
|
199
|
+
try {
|
|
200
|
+
parsed = new URL(rawUrl)
|
|
201
|
+
} catch {
|
|
202
|
+
throw new Error(`Invalid URL: ${rawUrl}`)
|
|
203
|
+
}
|
|
204
|
+
if (parsed.protocol !== 'https:') {
|
|
205
|
+
throw new Error(`Only HTTPS URLs are allowed (got: ${parsed.protocol})`)
|
|
206
|
+
}
|
|
207
|
+
if (!ALLOWED_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith('.' + d))) {
|
|
208
|
+
throw new Error(
|
|
209
|
+
`Domain not allowed: ${parsed.hostname}. Allowed: ${ALLOWED_DOMAINS.join(', ')}`,
|
|
210
|
+
)
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
return new Promise<string>((resolve, reject) => {
|
|
214
|
+
const proc = spawn('curl', ['-fsSL', rawUrl], { stdio: ['pipe', 'pipe', 'pipe'] })
|
|
215
|
+
let stdout = ''
|
|
216
|
+
let stderr = ''
|
|
217
|
+
const timer = setTimeout(() => {
|
|
218
|
+
proc.kill()
|
|
219
|
+
reject(new Error(`Download timed out: ${rawUrl}`))
|
|
220
|
+
}, 15_000)
|
|
221
|
+
proc.stdout.on('data', (c) => (stdout += c))
|
|
222
|
+
proc.stderr.on('data', (c) => (stderr += c))
|
|
223
|
+
proc.on('error', (err) => {
|
|
224
|
+
clearTimeout(timer)
|
|
225
|
+
reject(new Error(`Failed to download: ${err.message}`))
|
|
226
|
+
})
|
|
227
|
+
proc.on('close', (code) => {
|
|
228
|
+
clearTimeout(timer)
|
|
229
|
+
if (code === 0) resolve(stdout)
|
|
230
|
+
else reject(new Error(`Download failed with status ${code}: ${stderr}`))
|
|
231
|
+
})
|
|
232
|
+
})
|
|
233
|
+
}
|
package/src/skills/registry.ts
CHANGED
|
@@ -5,13 +5,19 @@
|
|
|
5
5
|
* from remote sources (GitHub repos, direct URLs).
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
import {
|
|
8
|
+
import {
|
|
9
|
+
existsSync,
|
|
10
|
+
mkdirSync,
|
|
11
|
+
writeFileSync,
|
|
12
|
+
readdirSync,
|
|
13
|
+
unlinkSync,
|
|
14
|
+
readFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
9
16
|
import { join } from 'node:path'
|
|
10
17
|
import { homedir } from 'node:os'
|
|
11
|
-
import { spawnSync } from 'node:child_process'
|
|
12
|
-
import { URL } from 'node:url'
|
|
13
18
|
import type { MiphamConfig } from '../shared/types.js'
|
|
14
19
|
import communitySkills from './community-registry.json'
|
|
20
|
+
import { readMarketplaces, findSkillInMarketplaces, downloadFile } from './marketplace'
|
|
15
21
|
|
|
16
22
|
const SKILLS_DIR = join(homedir(), '.mipham', 'skills')
|
|
17
23
|
|
|
@@ -117,17 +123,13 @@ function isMarketplaceAllowed(url: string, config?: MiphamConfig['marketplace'])
|
|
|
117
123
|
* Clones the repo to a temp directory, copies the skill file(s),
|
|
118
124
|
* and cleans up.
|
|
119
125
|
*/
|
|
120
|
-
export function installSkill(
|
|
126
|
+
export async function installSkill(
|
|
121
127
|
skillName: string,
|
|
122
128
|
marketplaceConfig?: MiphamConfig['marketplace'],
|
|
123
|
-
): InstallResult {
|
|
129
|
+
): Promise<InstallResult> {
|
|
124
130
|
const entry = COMMUNITY_SKILLS.find((s) => s.name === skillName)
|
|
125
131
|
if (!entry) {
|
|
126
|
-
return
|
|
127
|
-
success: false,
|
|
128
|
-
name: skillName,
|
|
129
|
-
message: `Skill "${skillName}" not found in the registry. Use /browse-skills to see available skills.`,
|
|
130
|
-
}
|
|
132
|
+
return installFromMarketplace(skillName, marketplaceConfig)
|
|
131
133
|
}
|
|
132
134
|
|
|
133
135
|
// Check marketplace restrictions
|
|
@@ -165,7 +167,7 @@ export function installSkill(
|
|
|
165
167
|
try {
|
|
166
168
|
// Download the skill file from GitHub raw content
|
|
167
169
|
const rawUrl = githubRawUrl(entry.url, entry.file || `${skillName}.SKILL.md`)
|
|
168
|
-
const content = downloadFile(rawUrl)
|
|
170
|
+
const content = await downloadFile(rawUrl)
|
|
169
171
|
|
|
170
172
|
// Validate it's a proper skill file (has frontmatter)
|
|
171
173
|
if (!content.includes('---')) {
|
|
@@ -193,13 +195,74 @@ export function installSkill(
|
|
|
193
195
|
}
|
|
194
196
|
}
|
|
195
197
|
|
|
198
|
+
/**
|
|
199
|
+
* Install a skill by discovering it across the user's marketplace sources.
|
|
200
|
+
* Called when the name is not in the built-in community registry.
|
|
201
|
+
*/
|
|
202
|
+
async function installFromMarketplace(
|
|
203
|
+
skillName: string,
|
|
204
|
+
marketplaceConfig?: MiphamConfig['marketplace'],
|
|
205
|
+
): Promise<InstallResult> {
|
|
206
|
+
const sources = readMarketplaces((p) => (existsSync(p) ? readFileSync(p, 'utf-8') : null))
|
|
207
|
+
const found = await findSkillInMarketplaces(skillName, sources, globalThis.fetch, downloadFile)
|
|
208
|
+
|
|
209
|
+
if (!found) {
|
|
210
|
+
return {
|
|
211
|
+
success: false,
|
|
212
|
+
name: skillName,
|
|
213
|
+
message: `Skill "${skillName}" not found in the registry or any marketplace. Use /browse-skills or /browse-marketplace.`,
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
if (!isMarketplaceAllowed(found.rawUrl, marketplaceConfig)) {
|
|
217
|
+
return {
|
|
218
|
+
success: false,
|
|
219
|
+
name: skillName,
|
|
220
|
+
message: `Skill "${skillName}" is from a blocked or unapproved marketplace: ${found.rawUrl}`,
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
const destPath = join(SKILLS_DIR, `${found.name}.SKILL.md`)
|
|
225
|
+
if (existsSync(destPath)) {
|
|
226
|
+
return {
|
|
227
|
+
success: false,
|
|
228
|
+
name: found.name,
|
|
229
|
+
message: `Skill "${found.name}" is already installed. Remove it first to reinstall.`,
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
mkdirSync(SKILLS_DIR, { recursive: true })
|
|
234
|
+
try {
|
|
235
|
+
const content = await downloadFile(found.rawUrl)
|
|
236
|
+
if (!content.includes('---')) {
|
|
237
|
+
return {
|
|
238
|
+
success: false,
|
|
239
|
+
name: found.name,
|
|
240
|
+
message: `Downloaded file does not appear to be a valid skill (missing frontmatter).`,
|
|
241
|
+
}
|
|
242
|
+
}
|
|
243
|
+
writeFileSync(destPath, content, 'utf-8')
|
|
244
|
+
return {
|
|
245
|
+
success: true,
|
|
246
|
+
name: found.name,
|
|
247
|
+
message: `Skill "${found.name}" installed from ${found.source.owner}/${found.source.repo} to ${destPath}\nRun /reload-skills to activate it.`,
|
|
248
|
+
}
|
|
249
|
+
} catch (err: unknown) {
|
|
250
|
+
const msg = err instanceof Error ? err.message : String(err)
|
|
251
|
+
return {
|
|
252
|
+
success: false,
|
|
253
|
+
name: found.name,
|
|
254
|
+
message: `Failed to install "${found.name}": ${msg}`,
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
196
259
|
/**
|
|
197
260
|
* Install a skill from a direct URL (GitHub raw, gist, or any HTTP URL).
|
|
198
261
|
*/
|
|
199
|
-
export function installSkillFromUrl(
|
|
262
|
+
export async function installSkillFromUrl(
|
|
200
263
|
url: string,
|
|
201
264
|
marketplaceConfig?: MiphamConfig['marketplace'],
|
|
202
|
-
): InstallResult {
|
|
265
|
+
): Promise<InstallResult> {
|
|
203
266
|
// Check marketplace restrictions
|
|
204
267
|
if (!isMarketplaceAllowed(url, marketplaceConfig)) {
|
|
205
268
|
return {
|
|
@@ -222,7 +285,7 @@ export function installSkillFromUrl(
|
|
|
222
285
|
mkdirSync(destDir, { recursive: true })
|
|
223
286
|
|
|
224
287
|
try {
|
|
225
|
-
const content = downloadFile(url)
|
|
288
|
+
const content = await downloadFile(url)
|
|
226
289
|
|
|
227
290
|
if (!content.includes('---')) {
|
|
228
291
|
return {
|
|
@@ -288,52 +351,3 @@ function githubRawUrl(repoUrl: string, file: string): string {
|
|
|
288
351
|
const base = repoUrl.replace('https://github.com/', 'https://raw.githubusercontent.com/')
|
|
289
352
|
return `${base}/main/${file}`
|
|
290
353
|
}
|
|
291
|
-
|
|
292
|
-
/** Allowed domains for remote skill installation */
|
|
293
|
-
const ALLOWED_DOMAINS = [
|
|
294
|
-
'raw.githubusercontent.com',
|
|
295
|
-
'github.com',
|
|
296
|
-
'gist.githubusercontent.com',
|
|
297
|
-
'gitlab.com',
|
|
298
|
-
]
|
|
299
|
-
|
|
300
|
-
/**
|
|
301
|
-
* Download a file from a URL using curl with spawn (no shell injection).
|
|
302
|
-
* Only allows HTTPS URLs from known safe domains.
|
|
303
|
-
*/
|
|
304
|
-
function downloadFile(rawUrl: string): string {
|
|
305
|
-
let parsed: URL
|
|
306
|
-
try {
|
|
307
|
-
parsed = new URL(rawUrl)
|
|
308
|
-
} catch {
|
|
309
|
-
throw new Error(`Invalid URL: ${rawUrl}`)
|
|
310
|
-
}
|
|
311
|
-
|
|
312
|
-
// Protocol must be HTTPS
|
|
313
|
-
if (parsed.protocol !== 'https:') {
|
|
314
|
-
throw new Error(`Only HTTPS URLs are allowed (got: ${parsed.protocol})`)
|
|
315
|
-
}
|
|
316
|
-
|
|
317
|
-
// Domain must be in allowlist
|
|
318
|
-
if (!ALLOWED_DOMAINS.some((d) => parsed.hostname === d || parsed.hostname.endsWith('.' + d))) {
|
|
319
|
-
throw new Error(
|
|
320
|
-
`Domain not allowed: ${parsed.hostname}. Allowed: ${ALLOWED_DOMAINS.join(', ')}`,
|
|
321
|
-
)
|
|
322
|
-
}
|
|
323
|
-
|
|
324
|
-
// Use spawnSync with args array — no shell, no injection
|
|
325
|
-
const result = spawnSync('curl', ['-fsSL', rawUrl], {
|
|
326
|
-
encoding: 'utf-8',
|
|
327
|
-
timeout: 15_000,
|
|
328
|
-
stdio: ['pipe', 'pipe', 'pipe'],
|
|
329
|
-
})
|
|
330
|
-
|
|
331
|
-
if (result.error) {
|
|
332
|
-
throw new Error(`Failed to download: ${result.error.message}`)
|
|
333
|
-
}
|
|
334
|
-
if (result.status !== 0) {
|
|
335
|
-
throw new Error(`Download failed with status ${result.status}`)
|
|
336
|
-
}
|
|
337
|
-
|
|
338
|
-
return result.stdout
|
|
339
|
-
}
|
|
@@ -3,6 +3,8 @@ import { join } from 'node:path'
|
|
|
3
3
|
import { homedir } from 'node:os'
|
|
4
4
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
5
5
|
import { MemoryManager } from '../../core/memory/memory-manager'
|
|
6
|
+
import { renderWorkingState } from '../../core/working-memory'
|
|
7
|
+
import { getTasks } from '../exec/task'
|
|
6
8
|
|
|
7
9
|
const MEMORY_DIR = join(homedir(), '.mipham', 'memory')
|
|
8
10
|
|
|
@@ -58,7 +60,9 @@ export const memoryTool: ToolDefinition = {
|
|
|
58
60
|
if (!query) return { success: false, content: '', error: 'query is required' }
|
|
59
61
|
const mm = new MemoryManager(MEMORY_DIR)
|
|
60
62
|
mm.loadAll()
|
|
61
|
-
|
|
63
|
+
// 状态接地(治「前存后忘」):除了精确匹配 query,还带上「当前未完成任务」相关的记忆。
|
|
64
|
+
const grounding = renderWorkingState(getTasks())
|
|
65
|
+
const results = mm.recall(query, 10, grounding)
|
|
62
66
|
if (results.length === 0) return { success: true, content: '(no matching memories)' }
|
|
63
67
|
const lines = results.map((entry) => {
|
|
64
68
|
const snippet = entry.content.replace(/\n+/g, ' ').trim().slice(0, 140)
|
package/src/tools/exec/task.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import type { ToolDefinition } from '../../shared/index.ts'
|
|
2
|
+
import { hasSupportedEvidenceSince } from '../../core/working-memory'
|
|
2
3
|
|
|
3
4
|
export interface Task {
|
|
4
5
|
id: string
|
|
@@ -11,6 +12,8 @@ export interface Task {
|
|
|
11
12
|
blockedBy: string[]
|
|
12
13
|
owner?: string
|
|
13
14
|
createdAt: string
|
|
15
|
+
/** 完成证据:completed 时是否有 supported 决策接地(软门,unverified 只标记不阻断)。 */
|
|
16
|
+
completionEvidence?: 'supported' | 'unverified'
|
|
14
17
|
/** Background task output content (set when a background agent completes). */
|
|
15
18
|
output?: string
|
|
16
19
|
/** Background task error message (set when a background agent fails). */
|
|
@@ -218,6 +221,9 @@ export const taskTool: ToolDefinition = {
|
|
|
218
221
|
`Status: ${task.status}`,
|
|
219
222
|
`Description: ${task.description || '(none)'}`,
|
|
220
223
|
]
|
|
224
|
+
if (task.completionEvidence) {
|
|
225
|
+
lines.push(`Completion evidence: ${task.completionEvidence}`)
|
|
226
|
+
}
|
|
221
227
|
if (task.activeForm) lines.push(`Active form: ${task.activeForm}`)
|
|
222
228
|
if (task.owner) lines.push(`Owner: ${task.owner}`)
|
|
223
229
|
if (task.blocks.length) lines.push(`Blocks: ${task.blocks.join(', ')}`)
|
|
@@ -255,7 +261,16 @@ export const taskTool: ToolDefinition = {
|
|
|
255
261
|
if (params.subject !== undefined) task.subject = params.subject as string
|
|
256
262
|
if (params.description !== undefined) task.description = params.description as string
|
|
257
263
|
if (params.activeForm !== undefined) task.activeForm = params.activeForm as string
|
|
258
|
-
if (params.status !== undefined)
|
|
264
|
+
if (params.status !== undefined) {
|
|
265
|
+
task.status = params.status as Task['status']
|
|
266
|
+
// 完成证据门(软门):completed 时按「自任务创建以来是否有 supported 决策」标记。
|
|
267
|
+
// unverified 只标记不阻断(对齐 CRSI「最小干预」)。
|
|
268
|
+
if (params.status === 'completed') {
|
|
269
|
+
task.completionEvidence = hasSupportedEvidenceSince(new Date(task.createdAt).getTime())
|
|
270
|
+
? 'supported'
|
|
271
|
+
: 'unverified'
|
|
272
|
+
}
|
|
273
|
+
}
|
|
259
274
|
if (params.owner !== undefined) task.owner = params.owner as string
|
|
260
275
|
|
|
261
276
|
// Merge dependency arrays
|
|
@@ -281,10 +296,14 @@ export const taskTool: ToolDefinition = {
|
|
|
281
296
|
const blockedNote = isBlocked(task)
|
|
282
297
|
? ` (blocked — waiting on: ${getBlockingIds(task).join(', ')})`
|
|
283
298
|
: ''
|
|
299
|
+
const unverifiedNote =
|
|
300
|
+
task.status === 'completed' && task.completionEvidence === 'unverified'
|
|
301
|
+
? '\n⚠ completed without verified evidence (no supported tool decision since task start)'
|
|
302
|
+
: ''
|
|
284
303
|
|
|
285
304
|
return {
|
|
286
305
|
success: true,
|
|
287
|
-
content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}`,
|
|
306
|
+
content: `Task #${taskId} updated.\n${formatTask(task)}${blockedNote}${unverifiedNote}`,
|
|
288
307
|
}
|
|
289
308
|
}
|
|
290
309
|
|