sillyspec 3.28.4 → 3.28.5
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/AGENTS.md +1 -0
- package/package.json +1 -1
- package/results-review-tier.md +18 -0
- package/src/agent-session-log.js +4 -3
- package/src/archive-delta.js +2 -2
- package/src/change-list.js +11 -1
- package/src/commit-suggest.js +52 -1
- package/src/config-cat.js +6 -1
- package/src/decision-distill.js +45 -5
- package/src/decisions-io.js +96 -0
- package/src/design-facts.js +3 -0
- package/src/dispatch/probe.js +31 -8
- package/src/docs-check.js +68 -3
- package/src/index.js +426 -17
- package/src/mcp-server.js +144 -0
- package/src/module-impact.js +8 -1
- package/src/modules.js +46 -34
- package/src/progress/change-registry.js +18 -0
- package/src/progress.js +13 -1
- package/src/quicklog.js +60 -6
- package/src/review-dispatch.js +377 -0
- package/src/run/archive-distill.js +48 -0
- package/src/run/command.js +19 -5
- package/src/run/complete-handlers.js +63 -3
- package/src/run/complete.js +139 -2
- package/src/run/gate-snapshot.js +88 -0
- package/src/run/gates.js +115 -55
- package/src/run/prompt.js +54 -9
- package/src/run/quick-audit.js +38 -13
- package/src/run/shared.js +109 -6
- package/src/run/stage.js +17 -2
- package/src/run/verify-quality-scan.js +336 -0
- package/src/scope-audit.js +735 -0
- package/src/sillyhub-mcp/client.js +58 -3
- package/src/stage-review-checklist.js +44 -0
- package/src/stage-review.js +122 -4
- package/src/stages/archive.js +57 -164
- package/src/stages/brainstorm.js +5 -9
- package/src/stages/execute.js +13 -10
- package/src/stages/plan.js +3 -10
- package/src/stages/verify.js +14 -33
- package/src/sync.js +51 -10
- package/src/task-review.js +108 -16
- package/src/taskcard.js +46 -3
- package/src/validate-artifacts.js +163 -0
- package/src/verify-postcheck.js +90 -10
- package/src/verify-probes.js +134 -12
- package/src/worktree-apply.js +119 -14
- package/templates/agents-instruction.md +1 -0
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* mcp-server.js(P2-g = MCP Phase 2,noai-ir-roadmap §5):SillySpec 最小 stdio MCP server。
|
|
3
|
+
*
|
|
4
|
+
* 动机:agent 此前靠 shell 调 CLI + 解析人类可读文本,flag 幻觉与文本解析漂移是真实痛点
|
|
5
|
+
* (铁律里专门有一条「不确定命令时停下问用户」)。MCP tools 的 schema 化参数把 flag 面锁死;
|
|
6
|
+
* machine-interface 的 JSON envelope(gate/derive/progress)与 next 的 JSON 输出是现成地基。
|
|
7
|
+
*
|
|
8
|
+
* 形态:`sillyspec mcp` 启动(hosts 配 command=sillyspec args=[mcp])。stdio 上跑 JSON-RPC 2.0
|
|
9
|
+
* 行协议(每行一个请求/响应)。**只读 tools**(Phase 2 边界:--done 等状态推进不暴露——
|
|
10
|
+
* Phase 3 另计;工具全部经子进程 `--json` 执行,stdout 纪律天然隔离,不 import 重模块):
|
|
11
|
+
* - sillyspec_next:状态探测与下一步建议(detectNextStep)
|
|
12
|
+
* - sillyspec_gate:阶段完成门控聚合(machine-interface runGate envelope)
|
|
13
|
+
* - sillyspec_derive:单项事实核验(runDerive envelope)
|
|
14
|
+
* - sillyspec_progress:全局进度总览(progress show --json envelope)
|
|
15
|
+
*/
|
|
16
|
+
import { createInterface } from 'node:readline'
|
|
17
|
+
import { spawn } from 'node:child_process'
|
|
18
|
+
import { join, dirname } from 'node:path'
|
|
19
|
+
import { fileURLToPath } from 'node:url'
|
|
20
|
+
|
|
21
|
+
const __dirname = dirname(fileURLToPath(import.meta.url))
|
|
22
|
+
const CLI_ENTRY = join(__dirname, 'index.js')
|
|
23
|
+
const PROTOCOL_VERSION = '2025-06-18'
|
|
24
|
+
const SERVER_INFO = { name: 'sillyspec', version: '3.28.4' }
|
|
25
|
+
|
|
26
|
+
const TOOLS = [
|
|
27
|
+
{
|
|
28
|
+
name: 'sillyspec_next',
|
|
29
|
+
description: '项目状态探测:状态 + 下一步命令 + 依据 + 活跃变更列表(只读;无活跃进度时从产物反推)',
|
|
30
|
+
inputSchema: { type: 'object', properties: { spec_dir: { type: 'string', description: '规范目录(缺省 <cwd>/.sillyspec)' } } },
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: 'sillyspec_gate',
|
|
34
|
+
description: '阶段完成门控聚合:变更的 <stage> 阶段此刻能否标记完成(envelope:ok/errors/warnings/checks;exit 0/1/2)',
|
|
35
|
+
inputSchema: {
|
|
36
|
+
type: 'object',
|
|
37
|
+
properties: {
|
|
38
|
+
stage: { type: 'string', enum: ['brainstorm', 'plan', 'execute', 'verify', 'archive'], description: '目标阶段' },
|
|
39
|
+
change: { type: 'string', description: '变更名(必填)' },
|
|
40
|
+
spec_dir: { type: 'string' },
|
|
41
|
+
},
|
|
42
|
+
required: ['stage', 'change'],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
{
|
|
46
|
+
name: 'sillyspec_derive',
|
|
47
|
+
description: '单项事实核验(facet:execute-evidence / verify-test / task-reviews / artifacts)',
|
|
48
|
+
inputSchema: {
|
|
49
|
+
type: 'object',
|
|
50
|
+
properties: {
|
|
51
|
+
facet: { type: 'string', enum: ['execute-evidence', 'verify-test', 'task-reviews', 'artifacts'] },
|
|
52
|
+
change: { type: 'string' },
|
|
53
|
+
spec_dir: { type: 'string' },
|
|
54
|
+
},
|
|
55
|
+
required: ['facet', 'change'],
|
|
56
|
+
},
|
|
57
|
+
},
|
|
58
|
+
{
|
|
59
|
+
name: 'sillyspec_progress',
|
|
60
|
+
description: '全局进度总览(活跃变更 × 阶段 × 步骤态,envelope JSON;只读)',
|
|
61
|
+
inputSchema: { type: 'object', properties: { spec_dir: { type: 'string' } } },
|
|
62
|
+
},
|
|
63
|
+
]
|
|
64
|
+
|
|
65
|
+
/** tools/call 路由:子进程 --json(stdout 纪律隔离;CLI 的 stderr 透传进 text 供诊断) */
|
|
66
|
+
async function callTool(name, args) {
|
|
67
|
+
const argv = ['--meta'] // 占位防误配:--meta 是 run 的 flag,工具不走 run
|
|
68
|
+
argv.length = 0
|
|
69
|
+
const specDir = args && typeof args.spec_dir === 'string' && args.spec_dir ? ['--spec-dir', args.spec_dir] : []
|
|
70
|
+
switch (name) {
|
|
71
|
+
case 'sillyspec_next':
|
|
72
|
+
return await runCli(['next', '--json', ...specDir])
|
|
73
|
+
case 'sillyspec_gate':
|
|
74
|
+
return await runCli(['gate', String(args.stage), '--change', String(args.change), '--json', ...specDir])
|
|
75
|
+
case 'sillyspec_derive':
|
|
76
|
+
return await runCli(['derive', String(args.facet), '--change', String(args.change), '--json', ...specDir])
|
|
77
|
+
case 'sillyspec_progress':
|
|
78
|
+
return await runCli(['progress', 'show', '--json', ...specDir])
|
|
79
|
+
default:
|
|
80
|
+
return { text: `未知工具: ${name}`, isError: true }
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function runCli(cliArgs) {
|
|
85
|
+
return new Promise((resolve) => {
|
|
86
|
+
const p = spawn(process.execPath, [CLI_ENTRY, ...cliArgs], {
|
|
87
|
+
cwd: process.cwd(),
|
|
88
|
+
env: process.env,
|
|
89
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
90
|
+
})
|
|
91
|
+
let out = ''
|
|
92
|
+
let err = ''
|
|
93
|
+
p.stdout.on('data', (d) => { out += String(d) })
|
|
94
|
+
p.stderr.on('data', (d) => { err += String(d) })
|
|
95
|
+
p.on('close', (code) => {
|
|
96
|
+
// envelope 原样透传(text = stdout JSON 全文;machine 侧自解);stderr 仅失败时附诊断
|
|
97
|
+
const text = (out.trim() || err.trim().slice(-2000) || `(exit ${code},无输出)`)
|
|
98
|
+
resolve({ text, isError: code !== null && code !== 0 && code !== 1, exitCode: code })
|
|
99
|
+
})
|
|
100
|
+
p.on('error', (e) => resolve({ text: `CLI 子进程失败: ${e.message}`, isError: true }))
|
|
101
|
+
})
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
/** 单请求分发。返回 result 对象(或抛 {code, message} 形态错误由上层包 error)。 */
|
|
105
|
+
async function handleMessage(msg) {
|
|
106
|
+
const { id, method, params } = msg
|
|
107
|
+
switch (method) {
|
|
108
|
+
case 'initialize':
|
|
109
|
+
return { protocolVersion: (params && params.protocolVersion) || PROTOCOL_VERSION, capabilities: { tools: {} }, serverInfo: SERVER_INFO }
|
|
110
|
+
case 'ping':
|
|
111
|
+
return {}
|
|
112
|
+
case 'tools/list':
|
|
113
|
+
return { tools: TOOLS }
|
|
114
|
+
case 'tools/call': {
|
|
115
|
+
const name = params && params.name
|
|
116
|
+
const args = (params && params.arguments) || {}
|
|
117
|
+
const r = await callTool(name, args)
|
|
118
|
+
return { content: [{ type: 'text', text: r.text }], ...(r.isError ? { isError: true } : {}) }
|
|
119
|
+
}
|
|
120
|
+
default:
|
|
121
|
+
throw { code: -32601, message: `Method not found: ${method}` }
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/** stdio 行协议主循环(readline 逐行 JSON;通知无 id 不回包)。导出供测试与 `sillyspec mcp` 接线。 */
|
|
126
|
+
export function startMcpServer({ input = process.stdin, output = process.stdout } = {}) {
|
|
127
|
+
const rl = createInterface({ input })
|
|
128
|
+
rl.on('line', async (line) => {
|
|
129
|
+
const trimmed = line.trim()
|
|
130
|
+
if (!trimmed) return
|
|
131
|
+
let msg
|
|
132
|
+
try { msg = JSON.parse(trimmed) } catch { return /* 非 JSON 行静默忽略(协议健壮性) */ }
|
|
133
|
+
if (msg === null || typeof msg !== 'object' || !msg.method) return
|
|
134
|
+
if (msg.id === undefined || msg.id === null) return // notification(如 notifications/initialized)
|
|
135
|
+
try {
|
|
136
|
+
const result = await handleMessage(msg)
|
|
137
|
+
output.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, result }) + '\n')
|
|
138
|
+
} catch (e) {
|
|
139
|
+
const err = e && typeof e === 'object' && e.code !== undefined ? e : { code: -32603, message: (e && e.message) || String(e) }
|
|
140
|
+
output.write(JSON.stringify({ jsonrpc: '2.0', id: msg.id, error: err }) + '\n')
|
|
141
|
+
}
|
|
142
|
+
})
|
|
143
|
+
return new Promise((resolve) => rl.on('close', () => resolve()))
|
|
144
|
+
}
|
package/src/module-impact.js
CHANGED
|
@@ -152,7 +152,14 @@ export function generateModuleImpactSkeleton({ cwd, changeName, specDir = null,
|
|
|
152
152
|
|
|
153
153
|
let sourceFileList
|
|
154
154
|
if (sourceFiles) {
|
|
155
|
-
|
|
155
|
+
// NEW: 前缀剥离(坑 module-impact-new-prefix-mismatch,2026-09-10 驾驭小结③,用户实锤):
|
|
156
|
+
// design 清单待建文件带 NEW: 标记,pathMatches 比对侧早已剥(坑
|
|
157
|
+
// platform-sync-progress-rollback-and-db-corruption 坑3「仅在比对处剥」),本归类是同
|
|
158
|
+
// 语义比对(目标文件 × module-map paths)却漏剥——前缀不剥则 classifyFile 前缀匹配必
|
|
159
|
+
// 失配,NEW 文件全部落「未匹配文件」逼手动回填。剥后与裸路径去重(NEW: a.js 与 a.js 同文件)。
|
|
160
|
+
sourceFileList = [...new Set(sourceFiles
|
|
161
|
+
.map(f => String(f).replace(/^NEW:\s*/, '').split('\\').join('/').trim())
|
|
162
|
+
.filter(f => f && !f.startsWith('.sillyspec/')))]
|
|
156
163
|
} else {
|
|
157
164
|
// includeWorkingTree 同 verify module 子集(坑 module-subset-zero-hit-uncommitted):
|
|
158
165
|
// worktree 未提交改动也计入模块影响面(module-impact 按实际 diff 归属模块)
|
package/src/modules.js
CHANGED
|
@@ -87,18 +87,16 @@ export async function rebuildModuleMap(cwd, { force = false } = {}) {
|
|
|
87
87
|
}
|
|
88
88
|
}
|
|
89
89
|
|
|
90
|
-
// 解析现有 _module-map.yaml
|
|
91
|
-
|
|
92
|
-
//
|
|
90
|
+
// 解析现有 _module-map.yaml(复用 parseModuleMapSimple canonical 解析)。P2-c
|
|
91
|
+
//(noai-ir-roadmap §5 字段分离):existing 条目**全字段**整体保留——merge 语义下
|
|
92
|
+
// rebuild 只做「补缺 + 增新卡」,--force 不再可能清空 tags/main_symbols/depends_on/
|
|
93
|
+
// used_by/status/needs_review 等人工维护字段(旧版只保 3 个列表字段,实例 map 文件头
|
|
94
|
+
// 常年挂着「勿跑 rebuild --force 会清空手动维护」的警告——该警告随本变更退役)。
|
|
93
95
|
let existingModules = {};
|
|
94
96
|
if (existingMap) {
|
|
95
97
|
const parsed = parseModuleMapSimple(existingMap);
|
|
96
98
|
for (const [id, fields] of Object.entries(parsed || {})) {
|
|
97
|
-
existingModules[id] = {
|
|
98
|
-
core_files: Array.isArray(fields.core_files) ? fields.core_files : [],
|
|
99
|
-
test_files: Array.isArray(fields.test_files) ? fields.test_files : [],
|
|
100
|
-
entrypoints: Array.isArray(fields.entrypoints) ? fields.entrypoints : [],
|
|
101
|
-
};
|
|
99
|
+
existingModules[id] = { ...(fields || {}) };
|
|
102
100
|
}
|
|
103
101
|
}
|
|
104
102
|
|
|
@@ -132,53 +130,67 @@ export async function rebuildModuleMap(cwd, { force = false } = {}) {
|
|
|
132
130
|
...cards.map(c => c.moduleId)
|
|
133
131
|
]);
|
|
134
132
|
|
|
133
|
+
// ── P2-c merge 语义发射:existing 全字段优先(人工维护不丢)→ 卡片补缺 → 骨架默认垫底 ──
|
|
134
|
+
const stripQuotes = (v) => String(v).replace(/^"|"$/g, '')
|
|
135
135
|
for (const moduleId of allModuleIds) {
|
|
136
136
|
const card = cards.find(c => c.moduleId === moduleId);
|
|
137
137
|
const cardContent = card?.content || '';
|
|
138
138
|
|
|
139
139
|
// 从模块卡片提取 context index 字段
|
|
140
140
|
const role = extractSection(cardContent, '定位') || '';
|
|
141
|
-
const contract = extractSection(cardContent, '契约摘要') || '';
|
|
142
|
-
const logic = extractSection(cardContent, '关键逻辑') || '';
|
|
143
|
-
const notes = extractSection(cardContent, '注意事项') || '';
|
|
144
141
|
|
|
145
|
-
yaml += ` ${moduleId}:\n`;
|
|
146
|
-
yaml += ` status: active\n`;
|
|
147
|
-
if (card) yaml += ` doc: modules/${card.filename}\n`;
|
|
148
|
-
else yaml += ` doc: modules/${moduleId}.md\n`;
|
|
149
|
-
yaml += ` needs_review: false\n`;
|
|
150
|
-
yaml += ` review_reasons: []\n`;
|
|
151
|
-
// context index 字段(v2)
|
|
152
|
-
if (role) yaml += ` role: "${escapeYamlString(role)}"\n`;
|
|
153
|
-
// core_files / test_files / entrypoints 从已有 _module-map.yaml 全量保留(列表项级保真)
|
|
154
142
|
const existing = existingModules[moduleId] || {};
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
143
|
+
const merged = {
|
|
144
|
+
// 骨架默认(垫底)
|
|
145
|
+
status: 'active',
|
|
146
|
+
doc: `modules/${(card && card.filename) || moduleId + '.md'}`,
|
|
147
|
+
needs_review: false,
|
|
148
|
+
review_reasons: [],
|
|
149
|
+
risk_level: 'low',
|
|
150
|
+
verify_commands: [],
|
|
151
|
+
related_docs: [],
|
|
152
|
+
// existing 全字段覆盖(人工维护优先)
|
|
153
|
+
...existing,
|
|
154
|
+
}
|
|
155
|
+
// 卡片补缺:role 仅当 existing 没有时从卡片取;doc 在卡片在场时以卡片文件名为准
|
|
156
|
+
//(existing.doc 可能指向已改名的旧文件);卡片不在场则保留 existing.doc
|
|
157
|
+
if (!merged.role && role) merged.role = role
|
|
158
|
+
if (card) merged.doc = `modules/${card.filename}`
|
|
159
|
+
|
|
160
|
+
yaml += ` ${moduleId}:\n`;
|
|
161
|
+
for (const k of ['status', 'doc', 'needs_review']) {
|
|
162
|
+
if (merged[k] !== undefined) yaml += ` ${k}: ${typeof merged[k] === 'boolean' ? merged[k] : merged[k]}\n`;
|
|
163
|
+
}
|
|
164
|
+
if (merged.role) yaml += ` role: "${escapeYamlString(stripQuotes(merged.role))}"\n`;
|
|
165
|
+
// 列表字段全量保真(existing 用什么字段名就保什么——paths/core_files 双形态兼容读者)
|
|
166
|
+
for (const k of ['paths', 'core_files', 'test_files', 'entrypoints', 'main_symbols', 'tags', 'aliases', 'depends_on', 'used_by', 'review_reasons', 'related_docs', 'verify_commands']) {
|
|
167
|
+
const items = merged[k];
|
|
168
|
+
if (items === undefined) continue;
|
|
169
|
+
if (Array.isArray(items) && items.length === 0) { yaml += ` ${k}: []\n`; continue }
|
|
170
|
+
if (!Array.isArray(items) || items.length === 0) continue;
|
|
171
|
+
yaml += ` ${k}:\n`;
|
|
172
|
+
for (const p of items) yaml += ` - ${p}\n`;
|
|
160
173
|
}
|
|
161
|
-
if (
|
|
162
|
-
yaml += ` risk_level: low\n`;
|
|
163
|
-
if (contract || logic) yaml += ` related_docs: []\n`;
|
|
174
|
+
if (merged.risk_level !== undefined) yaml += ` risk_level: ${merged.risk_level}\n`;
|
|
164
175
|
yaml += `\n`;
|
|
165
176
|
}
|
|
166
177
|
|
|
167
|
-
//
|
|
178
|
+
// 写入保护(multi-agent-platform 坑 modules-rebuild-destructive):默认 dry-run 只预览不写。
|
|
179
|
+
// P2-c 后 --force 为 merge 语义(手动字段全量保留),但重写仍会刷新 generated_at/source_commit
|
|
180
|
+
// 与排版——dry-run 预览默认保留,确认写入用 --force。
|
|
168
181
|
if (!force && existingMap) {
|
|
169
|
-
console.log('⚠️ rebuild
|
|
170
|
-
console.log('
|
|
182
|
+
console.log('⚠️ rebuild 默认不写入(预览保护)。--force 为 merge 语义:existing 全字段保留(tags/main_symbols/depends_on/used_by/status/needs_review 等手动维护字段不丢),只补缺 + 增新卡');
|
|
183
|
+
console.log(' 本次为预览,未写入磁盘。确认写入请运行:sillyspec modules rebuild --force');
|
|
171
184
|
return { dryRun: true, path: mapPath }
|
|
172
185
|
}
|
|
173
186
|
|
|
174
187
|
writeFileSync(mapPath, yaml, 'utf8');
|
|
175
|
-
console.log(`✅ _module-map.yaml
|
|
188
|
+
console.log(`✅ _module-map.yaml 已重建(merge 语义):${mapPath}`);
|
|
176
189
|
console.log(` 模块数量:${allModuleIds.size}`);
|
|
177
190
|
for (const id of allModuleIds) {
|
|
178
191
|
console.log(` - ${id}`);
|
|
179
192
|
}
|
|
180
|
-
console.log(`\n
|
|
181
|
-
console.log(`ℹ️ schema 已升级到 v2,支持 role/core_files/test_files/entrypoints/depends_on/risk_level/verify_commands 等字段。`);
|
|
193
|
+
console.log(`\nℹ️ 手动维护字段(tags/entrypoints/main_symbols/depends_on/used_by/status/needs_review 等)已全量保留;rebuild 只补缺与增新卡。`);
|
|
182
194
|
return { dryRun: false, path: mapPath, modules: allModuleIds.size }
|
|
183
195
|
}
|
|
184
196
|
|
|
@@ -62,6 +62,24 @@ export class ChangeRegistry {
|
|
|
62
62
|
} catch { return null; }
|
|
63
63
|
}
|
|
64
64
|
|
|
65
|
+
/**
|
|
66
|
+
* 读某变更某阶段的 started_at(2026-09-10 用户反馈②:evidence mtime 窗口锚点从
|
|
67
|
+
* execute completed_at 放宽到 started_at——证据合法产自 execute 或 verify 两窗口,
|
|
68
|
+
* 锚「完成时刻」会把 execute 期间产的证据判旧,逼出「先提交则 diff 空、不提交则
|
|
69
|
+
* mtime 旧」的时序两难)。只读不抛:无行/列空/读失败返回 null(调用方退
|
|
70
|
+
* getStageCompletedAt → R-05 fallback,与 getStageCompletedAt 同族容错)。
|
|
71
|
+
*/
|
|
72
|
+
getStageStartedAt(cwd, changeName, stage) {
|
|
73
|
+
try {
|
|
74
|
+
const db = this.pm._ensureDB(cwd);
|
|
75
|
+
const row = db.getDb().prepare(
|
|
76
|
+
`SELECT s.started_at FROM stages s JOIN changes c ON s.change_id = c.id
|
|
77
|
+
WHERE c.name = ? AND s.stage = ? ORDER BY s.started_at DESC LIMIT 1`
|
|
78
|
+
).get(changeName, stage);
|
|
79
|
+
return row && row.started_at ? row.started_at : null;
|
|
80
|
+
} catch { return null; }
|
|
81
|
+
}
|
|
82
|
+
|
|
65
83
|
getChangeStage(cwd, changeName) {
|
|
66
84
|
const db = this.pm._ensureDB(cwd);
|
|
67
85
|
const sqlDb = db.getDb();
|
package/src/progress.js
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
* worktree-guard hook 直读 sillyspec.db,不再有 gate-status.json 缓存双源(task-10 废除)。
|
|
11
11
|
*/
|
|
12
12
|
|
|
13
|
-
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync } from 'fs';
|
|
13
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync, renameSync, unlinkSync, appendFileSync, copyFileSync, readdirSync, statSync, realpathSync } from 'fs';
|
|
14
14
|
import { join, basename, dirname, resolve, sep } from 'path';
|
|
15
15
|
import { tmpdir } from 'os';
|
|
16
16
|
import { writeAtomicSync } from './fs-atomic.js';
|
|
@@ -159,6 +159,14 @@ export function resolvePlatformSpecDir(cwd, explicitSpecDir = null) {
|
|
|
159
159
|
` 修复:sillyspec platform pointer --cleanup(删除 ${pointerPath}),或删除该 pointer 文件后回到项目目录重跑。`
|
|
160
160
|
);
|
|
161
161
|
}
|
|
162
|
+
// junction 规范化(2026-09-10 驾驭小结第五批①,用户实锤「design-init/endpoints 落盘路径
|
|
163
|
+
// 偶发锚到 daemon junction 副本」):daemon 写入的 pointer specRoot 可能是通向真实 spec 根的
|
|
164
|
+
// junction/symlink 路径——经 junction 读写与真实路径同文件(无害)但路径呈现与比对漂移、
|
|
165
|
+
// junction 随 daemon 生命周期可能蒸发。realpath 坍缩为真实路径(失败回退原值,保守不干预)。
|
|
166
|
+
try {
|
|
167
|
+
const canonical = realpathSync(ptr.specRoot)
|
|
168
|
+
if (canonical && canonical !== ptr.specRoot) return canonical
|
|
169
|
+
} catch { /* realpath 失败(权限/竞态删除)→ 原值(上方已过存在性检查) */ }
|
|
162
170
|
return ptr.specRoot;
|
|
163
171
|
}
|
|
164
172
|
|
|
@@ -900,6 +908,10 @@ export class ProgressManager {
|
|
|
900
908
|
// verifyStartAt 基准只读(2026-09-08-ir-verify-facts):execute 行 completed_at;无行 null 走 R-05 fallback
|
|
901
909
|
getStageCompletedAt(cwd, changeName, stage) { return this._changeRegistry.getStageCompletedAt(cwd, changeName, stage); }
|
|
902
910
|
|
|
911
|
+
// evidence mtime 窗口锚点(2026-09-10 用户反馈②):execute 行 started_at——证据合法产自 execute
|
|
912
|
+
// 或 verify 两窗口,锚「完成时刻」会把 execute 期间产的证据判旧(时序两难);无行 null 走 completed_at → R-05
|
|
913
|
+
getStageStartedAt(cwd, changeName, stage) { return this._changeRegistry.getStageStartedAt(cwd, changeName, stage); }
|
|
914
|
+
|
|
903
915
|
registerChange(cwd, changeName) { return this._changeRegistry.registerChange(cwd, changeName); }
|
|
904
916
|
|
|
905
917
|
updateChangeIsolation(cwd, changeName, isolation) { return this._changeRegistry.updateChangeIsolation(cwd, changeName, isolation); }
|
package/src/quicklog.js
CHANGED
|
@@ -155,6 +155,39 @@ function extractRawBlock(content, qlId) {
|
|
|
155
155
|
|
|
156
156
|
// 落盘条目块 → 推送 payload(design §5.3:以落盘终态为准,不从入参拼)。
|
|
157
157
|
// 宽松标签解析对齐平台:全半角冒号、多条状态行取最后、文件行单行/多行 bullet。
|
|
158
|
+
/**
|
|
159
|
+
* QUICKLOG 结构化 sidecar(P1-4,noai-ir-roadmap §4):completeQuicklogEntry 组装 payload 的
|
|
160
|
+
* 同时把终态结构落 .runtime/quicklog-sidecar/<qlId>.json——一次解析持久化,daemon/查询/重推
|
|
161
|
+
* 直读结构化数据不必再解析 md(md 只给人看)。fail-soft:sidecar 是加速层不是正确性依赖,
|
|
162
|
+
* 落盘失败零阻断(读取侧自动回退 md 解析路径)。
|
|
163
|
+
*/
|
|
164
|
+
const QUICKLOG_SIDECAR_SCHEMA_VERSION = 1
|
|
165
|
+
|
|
166
|
+
export function quicklogSidecarPath(specBase, qlId) {
|
|
167
|
+
return join(specBase, '.runtime', 'quicklog-sidecar', `${qlId}.json`)
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
export function writeQuicklogSidecar(specBase, payload) {
|
|
171
|
+
if (!payload || !payload.ql_id) return false
|
|
172
|
+
try {
|
|
173
|
+
const p = quicklogSidecarPath(specBase, payload.ql_id)
|
|
174
|
+
mkdirSync(dirname(p), { recursive: true })
|
|
175
|
+
writeAtomic(p, JSON.stringify({ schemaVersion: QUICKLOG_SIDECAR_SCHEMA_VERSION, ...payload }, null, 2) + String.fromCharCode(10))
|
|
176
|
+
return true
|
|
177
|
+
} catch {
|
|
178
|
+
return false
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
export function readQuicklogSidecar(specBase, qlId) {
|
|
183
|
+
try {
|
|
184
|
+
const rec = JSON.parse(readFileSync(quicklogSidecarPath(specBase, qlId), 'utf8'))
|
|
185
|
+
return rec && rec.schemaVersion === QUICKLOG_SIDECAR_SCHEMA_VERSION ? rec : null
|
|
186
|
+
} catch {
|
|
187
|
+
return null
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
|
|
158
191
|
function buildPushPayloadFromRaw(rawBlock, { ql_id, author_raw, status, linked_changes, fallback_title }) {
|
|
159
192
|
const payload = {
|
|
160
193
|
ql_id,
|
|
@@ -568,7 +601,7 @@ function splitSingleLineFields(body) {
|
|
|
568
601
|
return scanFields(body, findBoundaryLabel) ?? scanFields(body, (b, l, f) => b.indexOf(l, f))
|
|
569
602
|
}
|
|
570
603
|
|
|
571
|
-
function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = [], auditNotes = []) {
|
|
604
|
+
function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes = [], auditNotes = [], softFiles = []) {
|
|
572
605
|
const lines = content.split('\n')
|
|
573
606
|
const startIdx = lines.findIndex(l => l.startsWith(`## ${qlId} |`))
|
|
574
607
|
if (startIdx === -1) return null
|
|
@@ -591,9 +624,19 @@ function flipEntryInContent(content, qlId, result, changedFiles = [], fileNotes
|
|
|
591
624
|
// 都空则不动(保持「(见实际改动)」)。bullet 用「数组元素内嵌 \n」写——lines.join('\n') 展平为
|
|
592
625
|
// 多行,数组长度不变 → 与下方状态/结果 splice 的索引完全解耦(changedFiles 单行同理原地替换)。
|
|
593
626
|
if (lines[i].startsWith('文件:')) {
|
|
627
|
+
// 软归属(2026-09-10 用户反馈):softFiles = 审计判定的「窗口内未声明同模块测试文件」——
|
|
628
|
+
// 补入文件行 bullet 带括注标记,消每回手工核对;fileNotes 已显式括注的不重复加。
|
|
629
|
+
// fileNotes 空但有 softFiles 时同样升级 bullet(单行格式放不下括注标记)。
|
|
630
|
+
const notedPaths = new Set(fileNotes.map((n) => n.path))
|
|
631
|
+
const softBullets = []
|
|
632
|
+
for (const f of softFiles) {
|
|
633
|
+
if (!notedPaths.has(f)) softBullets.push(`- ${f}(软归属·同模块测试,未声明)`)
|
|
634
|
+
}
|
|
594
635
|
if (fileNotes.length > 0) {
|
|
595
|
-
const bullets = fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`)
|
|
636
|
+
const bullets = [...fileNotes.map((n) => `- ${n.path}${n.note ? `(${n.note})` : ''}`), ...softBullets]
|
|
596
637
|
lines[i] = `文件:\n${bullets.join('\n')}`
|
|
638
|
+
} else if (softBullets.length > 0) {
|
|
639
|
+
lines[i] = `文件:\n${[...changedFiles.map((f) => `- ${f}`), ...softBullets].join('\n')}`
|
|
597
640
|
} else if (changedFiles.length > 0) {
|
|
598
641
|
lines[i] = `文件:${changedFiles.join(', ')}`
|
|
599
642
|
}
|
|
@@ -775,7 +818,7 @@ export async function appendQuicklogEntryWithId(specBase, gitUser, qlId, { descr
|
|
|
775
818
|
/**
|
|
776
819
|
* 翻某 qlId 条目为「已完成」+ 追加结果 + 勾选关联 tasks.md。持锁。
|
|
777
820
|
*/
|
|
778
|
-
export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [], auditNotes = [] } = {}) {
|
|
821
|
+
export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultText = '', linkedChanges = [], changedFiles = [], auditNotes = [], softFiles = [] } = {}) {
|
|
779
822
|
const quicklogDir = join(specBase, 'quicklog')
|
|
780
823
|
// 与 allocateQuicklogEntry 同源消毒:锁文件路径含 user(防穿越写,两入口必须一致否则锁不上同一文件)
|
|
781
824
|
const user = sanitizeQuicklogUser(gitUser) || 'unknown'
|
|
@@ -788,6 +831,11 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
788
831
|
// 读后即清(per-process,防残留跨调用)。flipEntryInContent 优先用 fileNotes,空则回退 realFiles。
|
|
789
832
|
const fileNotes = parseFileNotes(_pendingFileNotes)
|
|
790
833
|
_pendingFileNotes = ''
|
|
834
|
+
// 软归属文件(complete-handlers 传入,已过 isQuickMetadata;反斜杠归一与 fileNotes 同口径):
|
|
835
|
+
// 窗口内未声明同模块测试文件,flipEntryInContent 补入文件行 bullet 带「软归属」括注。
|
|
836
|
+
const soft = Array.isArray(softFiles)
|
|
837
|
+
? softFiles.filter(Boolean).map((f) => String(f).replace(/\\/g, '/'))
|
|
838
|
+
: []
|
|
791
839
|
|
|
792
840
|
await withFileLock(lockPath, async () => {
|
|
793
841
|
// 条目可能在主文件或轮转归档中
|
|
@@ -798,7 +846,7 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
798
846
|
let content = ''
|
|
799
847
|
try { content = readFileSync(filePath, 'utf8') } catch { continue }
|
|
800
848
|
const updated = flipEntryInContent(content, qlId, result, realFiles, fileNotes,
|
|
801
|
-
Array.isArray(auditNotes) ? auditNotes.filter((n) => typeof n === 'string' && n.trim() !== '') : [])
|
|
849
|
+
Array.isArray(auditNotes) ? auditNotes.filter((n) => typeof n === 'string' && n.trim() !== '') : [], soft)
|
|
802
850
|
if (updated !== null) {
|
|
803
851
|
await writeAtomic(filePath, updated) // 命中处原子落盘(只改含目标条目的那一个文件)
|
|
804
852
|
updatedContent = updated
|
|
@@ -812,12 +860,15 @@ export async function completeQuicklogEntry(specBase, gitUser, qlId, { resultTex
|
|
|
812
860
|
// (design §5.3:翻完成时标题行被 extractTitleFromResult 刷新,推送须与落盘一致,
|
|
813
861
|
// 不能用入参拼——标题/结果块/文件行都会在 flipEntryInContent 中重写)。
|
|
814
862
|
const rawBlock = extractRawBlock(updatedContent, qlId)
|
|
815
|
-
|
|
863
|
+
const finalPayload = buildPushPayloadFromRaw(rawBlock, {
|
|
816
864
|
ql_id: qlId,
|
|
817
865
|
author_raw: user,
|
|
818
866
|
status: 'completed',
|
|
819
867
|
linked_changes: linked,
|
|
820
|
-
})
|
|
868
|
+
})
|
|
869
|
+
// P1-4:终态结构 sidecar 持久化(同一解析器产出,md/sidecar 恒一致;fail-soft)
|
|
870
|
+
writeQuicklogSidecar(specBase, finalPayload)
|
|
871
|
+
await pushQuicklogEntryToPlatform(specBase, finalPayload).catch(() => {})
|
|
821
872
|
})
|
|
822
873
|
}
|
|
823
874
|
|
|
@@ -895,6 +946,9 @@ export async function cancelQuickSession({ specBase, gitUser, qlId, sessionId =
|
|
|
895
946
|
// 会产出 \r\r\n 字节级污染(git diff 全文件红);原子写防 agent/dashboard 轮询读半截
|
|
896
947
|
const eol = content.includes('\r\n') ? '\r\n' : '\n'
|
|
897
948
|
await writeAtomic(p, lines.join(eol))
|
|
949
|
+
// P1-4:sidecar 同步翻「已取消」(无 sidecar 的存量条目跳过——md 仍是真相源)
|
|
950
|
+
const _sc = readQuicklogSidecar(specBase, qlId)
|
|
951
|
+
if (_sc) writeQuicklogSidecar(specBase, { ..._sc, status: '已取消' })
|
|
898
952
|
}
|
|
899
953
|
break
|
|
900
954
|
}
|