whynot-cli 0.0.0-stage → 0.1.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/AGENTS.snippet.md +69 -0
- package/AGENTS.snippet.zh-CN.md +60 -0
- package/CONTRIBUTING.md +75 -0
- package/LICENSE +21 -0
- package/README.md +246 -2
- package/README.zh-CN.md +204 -0
- package/bin/whynot.js +4 -0
- package/docs/design.md +139 -0
- package/docs/design.zh-CN.md +102 -0
- package/docs/e1-code-context.md +169 -0
- package/docs/e2-false-obedience.md +167 -0
- package/docs/e3-power.md +156 -0
- package/docs/e4-dose-response.md +171 -0
- package/docs/e5-real-agent.md +151 -0
- package/docs/findings.md +108 -0
- package/docs/findings.zh-CN.md +90 -0
- package/docs/judge-calibration.md +170 -0
- package/docs/preregistration.md +257 -0
- package/docs/validation.md +30 -0
- package/docs/validation.zh-CN.md +24 -0
- package/package.json +64 -4
- package/src/audit.js +222 -0
- package/src/cli.js +293 -0
- package/src/frontmatter.js +128 -0
- package/src/index.js +34 -0
- package/src/mcp.js +310 -0
- package/src/store.js +415 -0
package/src/mcp.js
ADDED
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
// Minimal MCP server over stdio (newline-delimited JSON-RPC 2.0).
|
|
2
|
+
//
|
|
3
|
+
// Hand-rolled on purpose: it keeps the package dependency-free so `node` can
|
|
4
|
+
// run it with no install step, which is what makes the validation experiments
|
|
5
|
+
// frictionless. Swap in @modelcontextprotocol/sdk once the shape is settled.
|
|
6
|
+
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import {
|
|
9
|
+
findRoot,
|
|
10
|
+
check,
|
|
11
|
+
listIndex,
|
|
12
|
+
expiredMatching,
|
|
13
|
+
create,
|
|
14
|
+
stale,
|
|
15
|
+
render,
|
|
16
|
+
readAll,
|
|
17
|
+
daysUntilExpiry,
|
|
18
|
+
TYPES,
|
|
19
|
+
DEFAULT_TTL_DAYS,
|
|
20
|
+
} from './store.js';
|
|
21
|
+
|
|
22
|
+
export const SERVER_INFO = { name: 'whynot', version: '0.1.0' };
|
|
23
|
+
|
|
24
|
+
// Versions we know how to speak. We echo the client's choice when we can.
|
|
25
|
+
const SUPPORTED_PROTOCOLS = ['2025-06-18', '2024-11-05'];
|
|
26
|
+
const DEFAULT_PROTOCOL = '2024-11-05';
|
|
27
|
+
|
|
28
|
+
const TOOLS = [
|
|
29
|
+
{
|
|
30
|
+
name: 'decisions_check',
|
|
31
|
+
description:
|
|
32
|
+
'Look up decisions, constraints, and rejected approaches already recorded for this repository. ' +
|
|
33
|
+
'Call this BEFORE proposing a technical approach, so you do not re-suggest something the team already ruled out.',
|
|
34
|
+
inputSchema: {
|
|
35
|
+
type: 'object',
|
|
36
|
+
properties: {
|
|
37
|
+
query: {
|
|
38
|
+
type: 'string',
|
|
39
|
+
description: 'Keywords to match against title, tags, and body. Omit to list everything live.',
|
|
40
|
+
},
|
|
41
|
+
tags: {
|
|
42
|
+
type: 'array',
|
|
43
|
+
items: { type: 'string' },
|
|
44
|
+
description: 'Only return entries carrying at least one of these tags.',
|
|
45
|
+
},
|
|
46
|
+
type: {
|
|
47
|
+
type: 'string',
|
|
48
|
+
enum: TYPES,
|
|
49
|
+
description: 'Restrict to one entry type.',
|
|
50
|
+
},
|
|
51
|
+
},
|
|
52
|
+
},
|
|
53
|
+
},
|
|
54
|
+
{
|
|
55
|
+
name: 'decisions_record',
|
|
56
|
+
description:
|
|
57
|
+
'Record a decision, constraint, or — most valuable — an approach that was tried and REJECTED, with the reason. ' +
|
|
58
|
+
'Use this when the user rejects a suggestion, or when you discover a path that does not work.',
|
|
59
|
+
inputSchema: {
|
|
60
|
+
type: 'object',
|
|
61
|
+
properties: {
|
|
62
|
+
title: { type: 'string', description: 'Short statement of the decision or rejection.' },
|
|
63
|
+
why: { type: 'string', description: 'The reason. For rejections, what specifically failed.' },
|
|
64
|
+
type: { type: 'string', enum: TYPES, default: 'rejected' },
|
|
65
|
+
revisit_when: {
|
|
66
|
+
type: 'string',
|
|
67
|
+
description:
|
|
68
|
+
'Condition under which this should be reconsidered. Strongly preferred over leaving it blank.',
|
|
69
|
+
},
|
|
70
|
+
ttl_days: {
|
|
71
|
+
type: 'number',
|
|
72
|
+
description:
|
|
73
|
+
`Days until this entry expires and stops being shown. Defaults: rejected ${DEFAULT_TTL_DAYS.rejected}, ` +
|
|
74
|
+
`decision ${DEFAULT_TTL_DAYS.decision}, constraint ${DEFAULT_TTL_DAYS.constraint}.`,
|
|
75
|
+
},
|
|
76
|
+
evidence: {
|
|
77
|
+
type: 'array',
|
|
78
|
+
items: { type: 'string' },
|
|
79
|
+
description: 'Pointers that let a human verify this later, e.g. "commit:a3f9c2", "pr:412", a URL.',
|
|
80
|
+
},
|
|
81
|
+
tags: { type: 'array', items: { type: 'string' } },
|
|
82
|
+
},
|
|
83
|
+
required: ['title', 'why'],
|
|
84
|
+
},
|
|
85
|
+
},
|
|
86
|
+
{
|
|
87
|
+
name: 'decisions_stale',
|
|
88
|
+
description:
|
|
89
|
+
'List entries that need a human decision: those that have expired (and are therefore no longer being applied) ' +
|
|
90
|
+
'and those expiring soon. Use this when planning, so expired knowledge gets re-confirmed instead of silently lost.',
|
|
91
|
+
inputSchema: { type: 'object', properties: {} },
|
|
92
|
+
},
|
|
93
|
+
];
|
|
94
|
+
|
|
95
|
+
function resolveRoot(args) {
|
|
96
|
+
const explicit = args?.root || process.env.WHYNOT_ROOT;
|
|
97
|
+
if (explicit) return path.resolve(explicit);
|
|
98
|
+
const found = findRoot(process.cwd());
|
|
99
|
+
if (!found) {
|
|
100
|
+
throw new Error(
|
|
101
|
+
`No ${'.decisions'} directory found from ${process.cwd()} upward. Run \`whynot init\` in your repo first.`
|
|
102
|
+
);
|
|
103
|
+
}
|
|
104
|
+
return found;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function text(s) {
|
|
108
|
+
return { content: [{ type: 'text', text: s }] };
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
export function callTool(name, args = {}) {
|
|
112
|
+
const root = resolveRoot(args);
|
|
113
|
+
|
|
114
|
+
switch (name) {
|
|
115
|
+
case 'decisions_check': {
|
|
116
|
+
const query = args.query || '';
|
|
117
|
+
|
|
118
|
+
// Entries that have expired are deliberately never shown. Telling the
|
|
119
|
+
// agent "this is old, be appropriately skeptical" was tested and does not
|
|
120
|
+
// work; it cannot obey what it cannot see. It is told only that something
|
|
121
|
+
// exists, so it can ask the user — the content stays hidden.
|
|
122
|
+
const expiredNote = () => {
|
|
123
|
+
const gone = expiredMatching(root, { query, tags: args.tags || [], type: args.type || null });
|
|
124
|
+
if (!gone.length) return '';
|
|
125
|
+
return (
|
|
126
|
+
`\n\nNote: ${gone.length} older entr${gone.length === 1 ? 'y' : 'ies'} on this topic ` +
|
|
127
|
+
`(${gone.map((e) => `[${e.id}]`).join(', ')}) ${gone.length === 1 ? 'has' : 'have'} expired and ` +
|
|
128
|
+
`${gone.length === 1 ? 'is' : 'are'} not shown. They may or may not still apply. ` +
|
|
129
|
+
`Tell the user and let them decide — do not guess.`
|
|
130
|
+
);
|
|
131
|
+
};
|
|
132
|
+
|
|
133
|
+
// No query = "what is already settled here?" Return a compact index, not
|
|
134
|
+
// full bodies. Dumping every entry would reintroduce exactly the context
|
|
135
|
+
// bloat this tool exists to remove; the agent can follow up on the few
|
|
136
|
+
// ids that look relevant.
|
|
137
|
+
if (!query && !args.tags?.length && !args.type) {
|
|
138
|
+
const rows = listIndex(root);
|
|
139
|
+
if (!rows.length) return text('No decisions recorded yet.');
|
|
140
|
+
return text(
|
|
141
|
+
`${rows.length} settled decision${rows.length === 1 ? '' : 's'} in this repository ` +
|
|
142
|
+
`(titles only — call again with a \`query\` to read the full entry):\n\n` +
|
|
143
|
+
rows
|
|
144
|
+
.map(
|
|
145
|
+
(r) =>
|
|
146
|
+
`[${r.id}] (${r.type}) ${r.title}` +
|
|
147
|
+
(r.tags.length ? ` #${r.tags.join(' #')}` : '') +
|
|
148
|
+
(r.revisit_when ? ` — revisit when: ${r.revisit_when}` : '')
|
|
149
|
+
)
|
|
150
|
+
.join('\n')
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
const found = check(root, {
|
|
155
|
+
query,
|
|
156
|
+
tags: args.tags || [],
|
|
157
|
+
type: args.type || null,
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
if (!found.length) {
|
|
161
|
+
return text(
|
|
162
|
+
`No current decision matches "${query}". Nothing is settled on this topic right now.` +
|
|
163
|
+
expiredNote()
|
|
164
|
+
);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const header = `${found.length} live entr${found.length === 1 ? 'y' : 'ies'} relevant — treat these as already-settled:\n`;
|
|
168
|
+
return text(header + '\n\n---\n\n' + found.map(render).join('\n\n---\n\n') + expiredNote());
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
case 'decisions_record': {
|
|
172
|
+
// The schema declares these required; enforce it here too, because a
|
|
173
|
+
// rejection recorded without a reason is worse than no record at all —
|
|
174
|
+
// it will be trusted for months without saying anything useful.
|
|
175
|
+
if (!args.title || !String(args.title).trim()) {
|
|
176
|
+
throw new Error('title is required');
|
|
177
|
+
}
|
|
178
|
+
if (!args.why || !String(args.why).trim()) {
|
|
179
|
+
throw new Error('why is required — say what specifically failed, not just "it did not work"');
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const created = create(root, {
|
|
183
|
+
title: args.title,
|
|
184
|
+
type: args.type || 'rejected',
|
|
185
|
+
revisit_when: args.revisit_when,
|
|
186
|
+
ttl_days: args.ttl_days,
|
|
187
|
+
evidence: args.evidence,
|
|
188
|
+
tags: args.tags,
|
|
189
|
+
body: `## 为什么\n\n${args.why}\n`,
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
const [saved] = readAll(root).filter((e) => e.id === created.id);
|
|
193
|
+
const left = daysUntilExpiry(saved);
|
|
194
|
+
return text(
|
|
195
|
+
`Recorded as ${created.id} (${created.filename}). ` +
|
|
196
|
+
(left === null
|
|
197
|
+
? 'Marked permanent — it will not expire.'
|
|
198
|
+
: `It expires in ${left} days unless a human renews it. ` +
|
|
199
|
+
'Tell the user, so a stale entry does not silently keep applying later.')
|
|
200
|
+
);
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
case 'decisions_stale': {
|
|
204
|
+
const found = stale(root);
|
|
205
|
+
if (!found.length) return text('Nothing needs review.');
|
|
206
|
+
|
|
207
|
+
const expired = found.filter((e) => e.expired);
|
|
208
|
+
const soon = found.filter((e) => !e.expired);
|
|
209
|
+
|
|
210
|
+
let msg = '';
|
|
211
|
+
if (expired.length) {
|
|
212
|
+
msg +=
|
|
213
|
+
`${expired.length} entr${expired.length === 1 ? 'y has' : 'ies have'} EXPIRED and ` +
|
|
214
|
+
`${expired.length === 1 ? 'is' : 'are'} no longer being applied. Ask the user whether each still ` +
|
|
215
|
+
`holds, then have them run \`whynot renew <id>\` or \`whynot retire <id>\`:\n` +
|
|
216
|
+
expired.map((e) => `- [${e.id}] ${e.title}`).join('\n');
|
|
217
|
+
}
|
|
218
|
+
if (soon.length) {
|
|
219
|
+
msg +=
|
|
220
|
+
(msg ? '\n\n' : '') +
|
|
221
|
+
`${soon.length} will expire soon:\n` +
|
|
222
|
+
soon.map((e) => `- [${e.id}] ${e.title} — ${e.reasons.join('; ')}`).join('\n');
|
|
223
|
+
}
|
|
224
|
+
return text(msg);
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
default:
|
|
228
|
+
throw new Error(`unknown tool: ${name}`);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
export function handle(msg) {
|
|
233
|
+
const { id, method, params } = msg || {};
|
|
234
|
+
|
|
235
|
+
// Notifications have no id and expect no response.
|
|
236
|
+
if (id === undefined || id === null) return null;
|
|
237
|
+
|
|
238
|
+
switch (method) {
|
|
239
|
+
case 'initialize': {
|
|
240
|
+
const requested = params?.protocolVersion;
|
|
241
|
+
return {
|
|
242
|
+
jsonrpc: '2.0',
|
|
243
|
+
id,
|
|
244
|
+
result: {
|
|
245
|
+
protocolVersion: SUPPORTED_PROTOCOLS.includes(requested) ? requested : DEFAULT_PROTOCOL,
|
|
246
|
+
capabilities: { tools: {} },
|
|
247
|
+
serverInfo: SERVER_INFO,
|
|
248
|
+
},
|
|
249
|
+
};
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
case 'ping':
|
|
253
|
+
return { jsonrpc: '2.0', id, result: {} };
|
|
254
|
+
|
|
255
|
+
case 'tools/list':
|
|
256
|
+
return { jsonrpc: '2.0', id, result: { tools: TOOLS } };
|
|
257
|
+
|
|
258
|
+
case 'tools/call': {
|
|
259
|
+
try {
|
|
260
|
+
return { jsonrpc: '2.0', id, result: callTool(params?.name, params?.arguments) };
|
|
261
|
+
} catch (err) {
|
|
262
|
+
// Tool errors are reported in-band so the agent can recover.
|
|
263
|
+
return {
|
|
264
|
+
jsonrpc: '2.0',
|
|
265
|
+
id,
|
|
266
|
+
result: { content: [{ type: 'text', text: `Error: ${err.message}` }], isError: true },
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
default:
|
|
272
|
+
return {
|
|
273
|
+
jsonrpc: '2.0',
|
|
274
|
+
id,
|
|
275
|
+
error: { code: -32601, message: `Method not found: ${method}` },
|
|
276
|
+
};
|
|
277
|
+
}
|
|
278
|
+
}
|
|
279
|
+
|
|
280
|
+
export function serve(io = process) {
|
|
281
|
+
let buffer = '';
|
|
282
|
+
|
|
283
|
+
io.stdin.setEncoding('utf8');
|
|
284
|
+
io.stdin.on('data', (chunk) => {
|
|
285
|
+
buffer += chunk;
|
|
286
|
+
let idx;
|
|
287
|
+
while ((idx = buffer.indexOf('\n')) !== -1) {
|
|
288
|
+
const line = buffer.slice(0, idx).trim();
|
|
289
|
+
buffer = buffer.slice(idx + 1);
|
|
290
|
+
if (!line) continue;
|
|
291
|
+
|
|
292
|
+
let msg;
|
|
293
|
+
try {
|
|
294
|
+
msg = JSON.parse(line);
|
|
295
|
+
} catch {
|
|
296
|
+
continue; // ignore unparseable lines rather than killing the server
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
const response = handle(msg);
|
|
300
|
+
if (response) io.stdout.write(JSON.stringify(response) + '\n');
|
|
301
|
+
}
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
io.stdin.on('end', () => process.exit(0));
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Allow `node src/mcp.js` directly.
|
|
308
|
+
if (import.meta.url === `file://${process.argv[1]?.replace(/\\/g, '/')}`) {
|
|
309
|
+
serve();
|
|
310
|
+
}
|