coder-tools.js
5.57 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
/**
* DevLoop · coder-tools (3 个)
* contract_bridge verify contract.json ↔ units.json hash 一致
* sandbox_check 扫本 Unit 周围 mtime,确认没写出沙箱
* append_coder_log 结构化追加 coder.log (强制 retry 模板字段)
*
* 规格: docs/prd/newfunction/_spec/agents-coder.md · §7
*/
const fs = require('fs');
const path = require('path');
const pathResolver = require('../../../services/devloop/path-resolver');
const docStore = require('../../../services/devloop/doc-store');
const { ToolValidationError, ToolMissingDependencyError } = require('./_errors');
const contract_bridge = {
name: 'contract_bridge',
description: 'verify contract.json hash 与 units.json 中该 Unit 的 contract_hash 一致',
inputSchema: {
type: 'object',
required: ['unit_id'],
properties: { unit_id: { type: 'string' } },
},
async execute({ unit_id }, ctx) {
_req(ctx, ['pid']);
const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id);
const contractPath = path.join(unitDir, 'contract.json');
if (!fs.existsSync(contractPath)) {
return { ok: false, reason: 'contract.json missing' };
}
const contract = JSON.parse(fs.readFileSync(contractPath, 'utf8'));
const units = docStore.readUnits(ctx.pid);
if (!units) return { ok: false, reason: 'units.json missing' };
const unit = units.units.find(u => u.id === unit_id);
if (!unit) return { ok: false, reason: 'unit not in units.json' };
const recomputed = docStore.hashContract(contract);
if (contract.version !== recomputed) {
return {
ok: false,
reason: 'contract.version self-inconsistent',
claimed: contract.version, actual: recomputed,
};
}
if (recomputed !== unit.contract_hash) {
return {
ok: false,
reason: 'contract does not match SDD-declared hash',
expected: unit.contract_hash, actual: recomputed,
hint: 'contract.json 与 SDD 不一致,改 code 让契约对齐 SDD',
};
}
return { ok: true, hash: recomputed };
},
};
const sandbox_check = {
name: 'sandbox_check',
description: '扫本 Unit 沙箱外的文件 mtime 变化,确认 Coder 没写出去',
inputSchema: {
type: 'object',
required: ['unit_id', 'since'],
properties: {
unit_id: { type: 'string' },
since: { type: 'number', description: 'epoch ms; 之后被修改的文件才算' },
},
},
async execute({ unit_id, since }, ctx) {
_req(ctx, ['pid']);
const projectRoot = pathResolver.resolveProjectRoot(ctx.pid);
const unitDir = pathResolver.resolveUnitDir(ctx.pid, unit_id);
const allowedPrefixes = [
path.join(unitDir, 'code'),
path.join(unitDir, 'contract.json'),
path.join(unitDir, 'coder.log'),
path.join(projectRoot, 'agent-state'),
path.join(projectRoot, 'timeline.ndjson'),
path.join(projectRoot, 'bugs.ndjson'),
path.join(projectRoot, 'changelog.ndjson'),
path.join(projectRoot, '.git'),
];
const violations = [];
function walk(dir) {
if (!fs.existsSync(dir)) return;
for (const e of fs.readdirSync(dir, { withFileTypes: true })) {
const p = path.join(dir, e.name);
if (e.isDirectory()) walk(p);
else {
try {
const st = fs.statSync(p);
if (st.mtimeMs < since) continue;
const within = allowedPrefixes.some(a => p === a || p.startsWith(a + path.sep));
if (!within) violations.push(p);
} catch { /* ignore */ }
}
}
}
walk(projectRoot);
return { ok: violations.length === 0, violations };
},
};
const append_coder_log = {
name: 'append_coder_log',
description: '结构化追加 coder.log。retry 模式必含 last_failure + last_cause 字段。',
inputSchema: {
type: 'object',
required: ['unit_id', 'attempt', 'this_fix', 'rationale'],
properties: {
unit_id: { type: 'string' },
attempt: { type: 'integer', minimum: 1 },
ts: { type: 'string' },
last_failure: { type: 'string' },
last_cause: { type: 'string' },
this_fix: { type: 'string', minLength: 4 },
rationale: { type: 'string', minLength: 4 },
changed_files: { type: 'array', items: { type: 'string' } },
},
},
async execute(decision, ctx) {
_req(ctx, ['pid']);
if (decision.attempt > 1) {
if (!decision.last_failure || !decision.last_cause) {
throw new ToolValidationError(
'retry_decision',
'attempt > 1 requires last_failure + last_cause fields'
);
}
}
const p = path.join(pathResolver.resolveUnitDir(ctx.pid, decision.unit_id), 'coder.log');
fs.mkdirSync(path.dirname(p), { recursive: true });
const fullEntry = {
ts: decision.ts || new Date().toISOString(),
attempt: decision.attempt,
...(decision.last_failure ? { last_failure: decision.last_failure } : {}),
...(decision.last_cause ? { last_cause: decision.last_cause } : {}),
this_fix: decision.this_fix,
rationale: decision.rationale,
changed_files: decision.changed_files || [],
};
const block = '---\n' +
Object.entries(fullEntry).map(([k, v]) => {
const val = typeof v === 'object' ? JSON.stringify(v) : v;
return `${k}: ${val}`;
}).join('\n') + '\n---\n\n';
fs.appendFileSync(p, block);
return { ok: true, path: p, attempt: decision.attempt };
},
};
function _req(ctx, keys) {
for (const k of keys) {
if (!ctx || ctx[k] == null) throw new ToolMissingDependencyError(`ctx.${k}`);
}
}
module.exports = { contract_bridge, sandbox_check, append_coder_log };