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/audit.js
ADDED
|
@@ -0,0 +1,222 @@
|
|
|
1
|
+
// Audit an existing repository for agent rules that are likely to be obeyed
|
|
2
|
+
// long after they stopped being true.
|
|
3
|
+
//
|
|
4
|
+
// This is a heuristic, not a verdict. It looks for the two properties that
|
|
5
|
+
// caused the failure in our experiments: absolute language with no stated
|
|
6
|
+
// condition, and age. It does not call a model, and it never changes files.
|
|
7
|
+
|
|
8
|
+
import fs from 'node:fs';
|
|
9
|
+
import path from 'node:path';
|
|
10
|
+
import { execFileSync } from 'node:child_process';
|
|
11
|
+
|
|
12
|
+
export const CONFIG_CANDIDATES = [
|
|
13
|
+
'AGENTS.md',
|
|
14
|
+
'AGENTS.override.md',
|
|
15
|
+
'CLAUDE.md',
|
|
16
|
+
'.claude/CLAUDE.md',
|
|
17
|
+
'GEMINI.md',
|
|
18
|
+
'.cursorrules',
|
|
19
|
+
'.windsurfrules',
|
|
20
|
+
'.github/copilot-instructions.md',
|
|
21
|
+
];
|
|
22
|
+
|
|
23
|
+
// Absolute phrasing — the kind of rule that becomes a permanent constraint.
|
|
24
|
+
const ABSOLUTE = [
|
|
25
|
+
/\bnever\b/i,
|
|
26
|
+
/\balways\b/i,
|
|
27
|
+
/\bmust not\b/i,
|
|
28
|
+
/\bdo not\b/i,
|
|
29
|
+
/\bdon'?t\b/i,
|
|
30
|
+
/\bavoid\b/i,
|
|
31
|
+
/不要/,
|
|
32
|
+
/不能/,
|
|
33
|
+
/必须/,
|
|
34
|
+
/禁止/,
|
|
35
|
+
/绝不/,
|
|
36
|
+
/永远/,
|
|
37
|
+
/一律/,
|
|
38
|
+
/不允许/,
|
|
39
|
+
];
|
|
40
|
+
|
|
41
|
+
// Conditional phrasing — the escape hatch that lets a rule expire on its own.
|
|
42
|
+
const CONDITION = [
|
|
43
|
+
/\bunless\b/i,
|
|
44
|
+
/\bexcept\b/i,
|
|
45
|
+
/\bif\b/i,
|
|
46
|
+
/\bwhen\b/i,
|
|
47
|
+
/\buntil\b/i,
|
|
48
|
+
/\bonly (?:if|when)\b/i,
|
|
49
|
+
/\brevisit\b/i,
|
|
50
|
+
/除非/,
|
|
51
|
+
/如果/,
|
|
52
|
+
/若/,
|
|
53
|
+
/当/,
|
|
54
|
+
/直到/,
|
|
55
|
+
/仅在/,
|
|
56
|
+
/只有/,
|
|
57
|
+
];
|
|
58
|
+
|
|
59
|
+
// Instruction words carry no topic. Without this, "never" looks like a theme
|
|
60
|
+
// shared by every rule in the repo.
|
|
61
|
+
const STOPWORDS = new Set([
|
|
62
|
+
'never', 'always', 'must', 'dont', 'avoid', 'only', 'also', 'just', 'even',
|
|
63
|
+
'this', 'that', 'these', 'those', 'with', 'from', 'into', 'your', 'their',
|
|
64
|
+
'there', 'here', 'have', 'has', 'been', 'being', 'when', 'unless', 'except',
|
|
65
|
+
'until', 'some', 'more', 'than', 'then', 'they', 'them', 'what', 'which',
|
|
66
|
+
'code', 'file', 'files', 'project', 'repo', 'repository', 'please', 'should',
|
|
67
|
+
'would', 'could', 'make', 'sure', 'used', 'using', 'need', 'want', 'keep',
|
|
68
|
+
'every', 'each', 'other', 'into', 'over', 'under', 'about', 'after', 'before',
|
|
69
|
+
]);
|
|
70
|
+
|
|
71
|
+
/** Topic-ish tokens. Trailing punctuation is stripped so "redis." == "redis". */
|
|
72
|
+
function topicTokens(text) {
|
|
73
|
+
return [
|
|
74
|
+
...new Set(
|
|
75
|
+
(text.toLowerCase().match(/[a-z][a-z0-9.+#-]{2,}/g) || [])
|
|
76
|
+
.map((t) => t.replace(/[.+#-]+$/, ''))
|
|
77
|
+
.filter((t) => t.length >= 4 && !STOPWORDS.has(t))
|
|
78
|
+
),
|
|
79
|
+
];
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
function findConfigFiles(root) {
|
|
83
|
+
const found = [];
|
|
84
|
+
|
|
85
|
+
for (const rel of CONFIG_CANDIDATES) {
|
|
86
|
+
const p = path.join(root, rel);
|
|
87
|
+
if (fs.existsSync(p) && fs.statSync(p).isFile()) found.push(p);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Directory-style configs.
|
|
91
|
+
for (const dir of ['.cursor/rules', '.github/instructions']) {
|
|
92
|
+
const d = path.join(root, dir);
|
|
93
|
+
if (!fs.existsSync(d) || !fs.statSync(d).isDirectory()) continue;
|
|
94
|
+
for (const f of fs.readdirSync(d)) {
|
|
95
|
+
const p = path.join(d, f);
|
|
96
|
+
if (fs.statSync(p).isFile() && /\.(md|mdc|txt)$/i.test(f)) found.push(p);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return [...new Set(found)];
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function lastCommitDaysAgo(root, file, now) {
|
|
104
|
+
try {
|
|
105
|
+
const out = execFileSync('git', ['log', '-1', '--format=%ct', '--', path.relative(root, file)], {
|
|
106
|
+
cwd: root,
|
|
107
|
+
encoding: 'utf8',
|
|
108
|
+
stdio: ['ignore', 'pipe', 'ignore'],
|
|
109
|
+
}).trim();
|
|
110
|
+
if (!out) return null;
|
|
111
|
+
return Math.floor((now - new Date(Number(out) * 1000)) / 86_400_000);
|
|
112
|
+
} catch {
|
|
113
|
+
return null; // not a git repo, or git missing — age unknown
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/** Lines that read like an instruction rather than prose or a heading. */
|
|
118
|
+
function candidateRules(text) {
|
|
119
|
+
return text
|
|
120
|
+
.split(/\r?\n/)
|
|
121
|
+
.map((line, i) => ({ line: line.trim(), number: i + 1 }))
|
|
122
|
+
.filter(({ line }) => line.length >= 6 && !line.startsWith('#') && !line.startsWith('<!--'))
|
|
123
|
+
.filter(({ line }) => ABSOLUTE.some((re) => re.test(line)));
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export function audit(root, { now = new Date(), staleDays = 365 } = {}) {
|
|
127
|
+
const files = findConfigFiles(root);
|
|
128
|
+
const findings = [];
|
|
129
|
+
const ages = new Map();
|
|
130
|
+
|
|
131
|
+
for (const file of files) {
|
|
132
|
+
const age = lastCommitDaysAgo(root, file, now);
|
|
133
|
+
ages.set(file, age);
|
|
134
|
+
|
|
135
|
+
let text;
|
|
136
|
+
try {
|
|
137
|
+
text = fs.readFileSync(file, 'utf8');
|
|
138
|
+
} catch {
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
for (const rule of candidateRules(text)) {
|
|
143
|
+
const hasCondition = CONDITION.some((re) => re.test(rule.line));
|
|
144
|
+
const isOld = age !== null && age >= staleDays;
|
|
145
|
+
if (hasCondition) continue;
|
|
146
|
+
|
|
147
|
+
findings.push({
|
|
148
|
+
file: path.relative(root, file),
|
|
149
|
+
line: rule.number,
|
|
150
|
+
text: rule.line.replace(/\s+/g, ' ').slice(0, 140),
|
|
151
|
+
ageDays: age,
|
|
152
|
+
reasons: [
|
|
153
|
+
'absolute rule with no stated condition',
|
|
154
|
+
...(isOld ? [`unchanged for ${age} days`] : []),
|
|
155
|
+
],
|
|
156
|
+
});
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Same topic mentioned in several places drifts apart. Cheap proxy: shared
|
|
161
|
+
// distinctive tokens between rules in different files.
|
|
162
|
+
const byToken = new Map();
|
|
163
|
+
for (const f of findings) {
|
|
164
|
+
for (const token of topicTokens(f.text)) {
|
|
165
|
+
if (!byToken.has(token)) byToken.set(token, []);
|
|
166
|
+
byToken.get(token).push(f);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
const duplicated = [...byToken.entries()]
|
|
170
|
+
.filter(([, hits]) => new Set(hits.map((h) => h.file)).size > 1)
|
|
171
|
+
.map(([token]) => token);
|
|
172
|
+
|
|
173
|
+
return {
|
|
174
|
+
files: files.map((f) => ({ path: path.relative(root, f), ageDays: ages.get(f) })),
|
|
175
|
+
findings,
|
|
176
|
+
duplicatedTokens: [...new Set(duplicated)].slice(0, 10),
|
|
177
|
+
scanned: files.length,
|
|
178
|
+
};
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
/** Human-readable report. Plain text so it can be pasted anywhere. */
|
|
182
|
+
export function renderAudit(report) {
|
|
183
|
+
const lines = [];
|
|
184
|
+
|
|
185
|
+
if (!report.scanned) {
|
|
186
|
+
lines.push('No agent config files found.');
|
|
187
|
+
lines.push('Looked for: AGENTS.md, CLAUDE.md, GEMINI.md, .cursor/rules/*, .github/copilot-instructions.md');
|
|
188
|
+
return lines.join('\n');
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
lines.push(`Scanned ${report.scanned} agent config file${report.scanned === 1 ? '' : 's'}:`);
|
|
192
|
+
for (const f of report.files) {
|
|
193
|
+
lines.push(` ${f.path}${f.ageDays === null ? '' : ` (last changed ${f.ageDays}d ago)`}`);
|
|
194
|
+
}
|
|
195
|
+
lines.push('');
|
|
196
|
+
|
|
197
|
+
if (!report.findings.length) {
|
|
198
|
+
lines.push('No unqualified absolute rules found. Nothing to review.');
|
|
199
|
+
return lines.join('\n');
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
lines.push(`${report.findings.length} rule${report.findings.length === 1 ? '' : 's'} look like permanent constraints:`);
|
|
203
|
+
lines.push('');
|
|
204
|
+
|
|
205
|
+
for (const f of report.findings.slice(0, 20)) {
|
|
206
|
+
lines.push(` ${f.file}:${f.line}`);
|
|
207
|
+
lines.push(` "${f.text}"`);
|
|
208
|
+
lines.push(` → ${f.reasons.join('; ')}`);
|
|
209
|
+
}
|
|
210
|
+
if (report.findings.length > 20) lines.push(` ... and ${report.findings.length - 20} more`);
|
|
211
|
+
|
|
212
|
+
if (report.duplicatedTokens.length) {
|
|
213
|
+
lines.push('');
|
|
214
|
+
lines.push(`Topics mentioned across more than one file (may have drifted): ${report.duplicatedTokens.join(', ')}`);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
lines.push('');
|
|
218
|
+
lines.push('These are heuristics, not errors. A rule with no condition is fine —');
|
|
219
|
+
lines.push('until nobody remembers why it exists. whynot turns those into entries');
|
|
220
|
+
lines.push('that expire unless someone renews them.');
|
|
221
|
+
return lines.join('\n');
|
|
222
|
+
}
|
package/src/cli.js
ADDED
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
// CLI: the human half of the loop. Agents get the MCP server; people get this.
|
|
2
|
+
|
|
3
|
+
import fs from 'node:fs';
|
|
4
|
+
import path from 'node:path';
|
|
5
|
+
import {
|
|
6
|
+
findRoot,
|
|
7
|
+
init,
|
|
8
|
+
create,
|
|
9
|
+
retire,
|
|
10
|
+
renew,
|
|
11
|
+
check,
|
|
12
|
+
stale,
|
|
13
|
+
readAll,
|
|
14
|
+
summarize,
|
|
15
|
+
render,
|
|
16
|
+
expiresOn,
|
|
17
|
+
daysUntilExpiry,
|
|
18
|
+
DIR_NAME,
|
|
19
|
+
TYPES,
|
|
20
|
+
} from './store.js';
|
|
21
|
+
import { audit, renderAudit } from './audit.js';
|
|
22
|
+
import { serve } from './mcp.js';
|
|
23
|
+
|
|
24
|
+
const USAGE = `whynot — record what you ruled out, and why
|
|
25
|
+
|
|
26
|
+
Usage:
|
|
27
|
+
whynot init Create ${DIR_NAME}/ and the .gitattributes rule
|
|
28
|
+
whynot add --title T --why W Record an entry
|
|
29
|
+
[--type rejected|decision|constraint]
|
|
30
|
+
[--revisit "condition"] When to reconsider
|
|
31
|
+
[--ttl N] Days until it expires (default: rejected 90,
|
|
32
|
+
decision 180, constraint 365)
|
|
33
|
+
[--expires never] Explicitly permanent
|
|
34
|
+
[--evidence pr:412] Repeatable
|
|
35
|
+
[--tags infra,session]
|
|
36
|
+
whynot list [--all] List live entries (--all includes retired)
|
|
37
|
+
whynot check "<query>" Show live entries matching a query
|
|
38
|
+
whynot stale Entries needing a human decision (expired first)
|
|
39
|
+
whynot renew <id> [--days N] Extend an entry's life
|
|
40
|
+
whynot retire <id> Mark an entry retired (never deletes)
|
|
41
|
+
whynot doctor Validate the store
|
|
42
|
+
whynot audit [--days N] Scan AGENTS.md / CLAUDE.md for rules that
|
|
43
|
+
will be obeyed forever (read-only)
|
|
44
|
+
whynot mcp Run the MCP server on stdio (used by agents)
|
|
45
|
+
|
|
46
|
+
An entry that expires stops being shown to your agent until you renew it.
|
|
47
|
+
That is deliberate: see VALIDATION.md for why.
|
|
48
|
+
|
|
49
|
+
Options:
|
|
50
|
+
--root <dir> Operate on this repo instead of searching upward from cwd
|
|
51
|
+
-h, --help Show this message
|
|
52
|
+
`;
|
|
53
|
+
|
|
54
|
+
function parseArgs(argv) {
|
|
55
|
+
const flags = {};
|
|
56
|
+
const positional = [];
|
|
57
|
+
|
|
58
|
+
for (let i = 0; i < argv.length; i++) {
|
|
59
|
+
const a = argv[i];
|
|
60
|
+
if (!a.startsWith('--')) {
|
|
61
|
+
positional.push(a);
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
const eq = a.indexOf('=');
|
|
65
|
+
if (eq !== -1) {
|
|
66
|
+
const key = a.slice(2, eq);
|
|
67
|
+
(flags[key] ??= []).push(a.slice(eq + 1));
|
|
68
|
+
} else {
|
|
69
|
+
const key = a.slice(2);
|
|
70
|
+
const next = argv[i + 1];
|
|
71
|
+
if (next !== undefined && !next.startsWith('--')) {
|
|
72
|
+
(flags[key] ??= []).push(next);
|
|
73
|
+
i++;
|
|
74
|
+
} else {
|
|
75
|
+
(flags[key] ??= []).push(true);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const one = (k) => (Array.isArray(flags[k]) ? flags[k][flags[k].length - 1] : undefined);
|
|
81
|
+
return { flags, positional, one };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function resolveRoot(one) {
|
|
85
|
+
const explicit = one('root');
|
|
86
|
+
if (explicit && explicit !== true) return path.resolve(explicit);
|
|
87
|
+
const found = findRoot(process.cwd());
|
|
88
|
+
if (!found) {
|
|
89
|
+
throw new Error(
|
|
90
|
+
`No ${DIR_NAME}/ found from ${process.cwd()} upward. Run \`whynot init\` first, or pass --root.`
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
return found;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const out = (s = '') => process.stdout.write(s + '\n');
|
|
97
|
+
const err = (s) => process.stderr.write(s + '\n');
|
|
98
|
+
|
|
99
|
+
export function run(argv = process.argv.slice(2)) {
|
|
100
|
+
const command = argv[0];
|
|
101
|
+
const { flags, positional, one } = parseArgs(argv.slice(1));
|
|
102
|
+
|
|
103
|
+
if (!command || command === '-h' || command === '--help' || one('help')) {
|
|
104
|
+
out(USAGE);
|
|
105
|
+
return 0;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
switch (command) {
|
|
109
|
+
case 'init': {
|
|
110
|
+
const root = one('root') && one('root') !== true ? path.resolve(one('root')) : process.cwd();
|
|
111
|
+
const { dir } = init(root);
|
|
112
|
+
out(`Initialized ${path.relative(process.cwd(), dir) || dir}`);
|
|
113
|
+
out('Added "*.md text eol=lf" to .gitattributes');
|
|
114
|
+
out('');
|
|
115
|
+
out('Next: add the whynot block to your AGENTS.md (see AGENTS.snippet.md),');
|
|
116
|
+
out('and register the MCP server with your agent.');
|
|
117
|
+
return 0;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
case 'add': {
|
|
121
|
+
const root = resolveRoot(one);
|
|
122
|
+
const title = one('title');
|
|
123
|
+
const why = one('why');
|
|
124
|
+
if (!title || title === true) throw new Error('--title is required');
|
|
125
|
+
if (!why || why === true) throw new Error('--why is required');
|
|
126
|
+
|
|
127
|
+
const type = one('type') || 'rejected';
|
|
128
|
+
const ttlRaw = one('ttl');
|
|
129
|
+
const ttl = ttlRaw && ttlRaw !== true ? parseInt(ttlRaw, 10) : undefined;
|
|
130
|
+
const expiresRaw = one('expires');
|
|
131
|
+
|
|
132
|
+
const created = create(root, {
|
|
133
|
+
title,
|
|
134
|
+
type,
|
|
135
|
+
revisit_when: one('revisit') === true ? undefined : one('revisit'),
|
|
136
|
+
ttl_days: Number.isFinite(ttl) ? ttl : undefined,
|
|
137
|
+
expires_on: expiresRaw && expiresRaw !== true ? expiresRaw : undefined,
|
|
138
|
+
evidence: (flags.evidence || []).filter((e) => e !== true),
|
|
139
|
+
tags: (one('tags') || '')
|
|
140
|
+
.toString()
|
|
141
|
+
.split(',')
|
|
142
|
+
.map((t) => t.trim())
|
|
143
|
+
.filter(Boolean),
|
|
144
|
+
body: `## 为什么\n\n${why}\n`,
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
const [saved] = readAll(root).filter((e) => e.id === created.id);
|
|
148
|
+
const left = daysUntilExpiry(saved);
|
|
149
|
+
out(`Recorded [${created.id}] ${created.filename}`);
|
|
150
|
+
out(
|
|
151
|
+
left === null
|
|
152
|
+
? ' Expires: never (explicitly permanent)'
|
|
153
|
+
: ` Expires: ${expiresOn(saved)} (in ${left} days) — renew with \`whynot renew ${created.id}\``
|
|
154
|
+
);
|
|
155
|
+
return 0;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
case 'list': {
|
|
159
|
+
const root = resolveRoot(one);
|
|
160
|
+
const all = Boolean(one('all'));
|
|
161
|
+
const entries = readAll(root).filter((e) => all || e.status === 'live');
|
|
162
|
+
if (!entries.length) {
|
|
163
|
+
out(`No entries. (\`whynot add\` to record one, \`whynot list --all\` to include retired.)`);
|
|
164
|
+
return 0;
|
|
165
|
+
}
|
|
166
|
+
for (const e of entries) {
|
|
167
|
+
const tag = e.status === 'retired' ? ' [retired]' : '';
|
|
168
|
+
out(summarize(e) + tag);
|
|
169
|
+
}
|
|
170
|
+
out('');
|
|
171
|
+
out(`${entries.length} entr${entries.length === 1 ? 'y' : 'ies'}`);
|
|
172
|
+
return 0;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
case 'check': {
|
|
176
|
+
const root = resolveRoot(one);
|
|
177
|
+
const query = positional.join(' ');
|
|
178
|
+
const found = check(root, { query, tags: one('tags') ? one('tags').split(',') : [] });
|
|
179
|
+
if (!found.length) {
|
|
180
|
+
out(query ? `Nothing recorded matching "${query}".` : 'No live entries.');
|
|
181
|
+
return 0;
|
|
182
|
+
}
|
|
183
|
+
out(found.map(render).join('\n\n---\n\n'));
|
|
184
|
+
return 0;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'stale': {
|
|
188
|
+
const root = resolveRoot(one);
|
|
189
|
+
const found = stale(root);
|
|
190
|
+
if (!found.length) {
|
|
191
|
+
out('Nothing needs review.');
|
|
192
|
+
return 0;
|
|
193
|
+
}
|
|
194
|
+
for (const e of found) {
|
|
195
|
+
out(`${summarize(e)}\n ${e.reasons.join('\n ')}`);
|
|
196
|
+
}
|
|
197
|
+
out('');
|
|
198
|
+
out(
|
|
199
|
+
`${found.length} to review.\n` +
|
|
200
|
+
' `whynot renew <id>` — it still holds, extend it\n' +
|
|
201
|
+
' `whynot retire <id>` — it no longer holds, archive it'
|
|
202
|
+
);
|
|
203
|
+
return 0;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
case 'renew': {
|
|
207
|
+
const root = resolveRoot(one);
|
|
208
|
+
const id = positional[0];
|
|
209
|
+
if (!id) throw new Error('usage: whynot renew <id> [--days N]');
|
|
210
|
+
const days = parseInt(one('days'), 10);
|
|
211
|
+
const done = renew(root, id, { days: Number.isFinite(days) ? days : null });
|
|
212
|
+
out(`Renewed [${done.id}] — now expires ${done.expires_on}.`);
|
|
213
|
+
return 0;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
case 'retire': {
|
|
217
|
+
const root = resolveRoot(one);
|
|
218
|
+
const id = positional[0];
|
|
219
|
+
if (!id) throw new Error('usage: whynot retire <id>');
|
|
220
|
+
const done = retire(root, id);
|
|
221
|
+
out(`Retired [${done.id}]. The file is kept, not deleted.`);
|
|
222
|
+
return 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
case 'audit': {
|
|
226
|
+
const explicit = one('root');
|
|
227
|
+
const root = explicit && explicit !== true ? path.resolve(explicit) : process.cwd();
|
|
228
|
+
const days = parseInt(one('days'), 10);
|
|
229
|
+
const report = audit(root, { staleDays: Number.isFinite(days) ? days : 365 });
|
|
230
|
+
out(renderAudit(report));
|
|
231
|
+
return 0;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
case 'mcp': {
|
|
235
|
+
// Blocks on stdin for the life of the process. This is the entry point
|
|
236
|
+
// the documented MCP config uses: `npx -y whynot mcp`.
|
|
237
|
+
serve();
|
|
238
|
+
return 0;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
case 'doctor': {
|
|
242
|
+
const root = resolveRoot(one);
|
|
243
|
+
const entries = readAll(root);
|
|
244
|
+
const problems = [];
|
|
245
|
+
|
|
246
|
+
const seen = new Map();
|
|
247
|
+
for (const e of entries) {
|
|
248
|
+
if (!e.id) problems.push(`${e.filename}: missing id`);
|
|
249
|
+
if (!e.title) problems.push(`${e.filename}: missing title`);
|
|
250
|
+
if (!TYPES.includes(e.type)) problems.push(`${e.filename}: unknown type "${e.type}"`);
|
|
251
|
+
if (e.id) {
|
|
252
|
+
if (seen.has(e.id)) problems.push(`duplicate id ${e.id}: ${seen.get(e.id)} and ${e.filename}`);
|
|
253
|
+
else seen.set(e.id, e.filename);
|
|
254
|
+
}
|
|
255
|
+
const expected = e.id && e.title ? `${e.id}-` : null;
|
|
256
|
+
if (expected && !e.filename.startsWith(expected)) {
|
|
257
|
+
problems.push(`${e.filename}: filename does not start with its id (${e.id})`);
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
// Retired entries with no retirement date are almost always a hand-edit.
|
|
262
|
+
for (const e of entries) {
|
|
263
|
+
if (e.status === 'retired' && !e.retired_on) {
|
|
264
|
+
problems.push(`${e.filename}: retired but no retired_on date`);
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
out(`${entries.length} entr${entries.length === 1 ? 'y' : 'ies'} in ${path.join(root, DIR_NAME)}`);
|
|
269
|
+
if (!problems.length) {
|
|
270
|
+
out('No problems found.');
|
|
271
|
+
return 0;
|
|
272
|
+
}
|
|
273
|
+
out('');
|
|
274
|
+
for (const p of problems) out(` ! ${p}`);
|
|
275
|
+
return 1;
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
default:
|
|
279
|
+
err(`Unknown command: ${command}`);
|
|
280
|
+
err('');
|
|
281
|
+
err(USAGE);
|
|
282
|
+
return 2;
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
export function main() {
|
|
287
|
+
try {
|
|
288
|
+
process.exitCode = run();
|
|
289
|
+
} catch (e) {
|
|
290
|
+
err(`error: ${e.message}`);
|
|
291
|
+
process.exitCode = 1;
|
|
292
|
+
}
|
|
293
|
+
}
|
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
// Minimal YAML-subset frontmatter parser.
|
|
2
|
+
//
|
|
3
|
+
// Deliberately NOT a general YAML parser. It handles exactly the shapes whynot
|
|
4
|
+
// emits, so there is no dependency to rot and no surprising parse results:
|
|
5
|
+
//
|
|
6
|
+
// key: scalar
|
|
7
|
+
// key: "quoted scalar"
|
|
8
|
+
// key: [a, b, c]
|
|
9
|
+
// key:
|
|
10
|
+
// - item
|
|
11
|
+
//
|
|
12
|
+
// Anything it does not understand is ignored rather than throwing, because a
|
|
13
|
+
// malformed decision file should never break an agent's session.
|
|
14
|
+
|
|
15
|
+
const DELIM = '---';
|
|
16
|
+
|
|
17
|
+
function unquote(s) {
|
|
18
|
+
if (s.length >= 2) {
|
|
19
|
+
const first = s[0];
|
|
20
|
+
const last = s[s.length - 1];
|
|
21
|
+
if ((first === '"' && last === '"') || (first === "'" && last === "'")) {
|
|
22
|
+
return s.slice(1, -1);
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
return s;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
/** Needs quoting if it could be misread as a list, comment, or empty. */
|
|
29
|
+
function needsQuote(s) {
|
|
30
|
+
return s === '' || /^[\s[\]{}#&*!|>'"%@`-]/.test(s) || /:\s/.test(s) || s !== s.trim();
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function quote(s) {
|
|
34
|
+
return needsQuote(s) ? JSON.stringify(s) : s;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parse(text) {
|
|
38
|
+
const lines = String(text).split(/\r?\n/);
|
|
39
|
+
if ((lines[0] || '').trim() !== DELIM) {
|
|
40
|
+
return { data: {}, body: String(text) };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
let end = -1;
|
|
44
|
+
for (let i = 1; i < lines.length; i++) {
|
|
45
|
+
if (lines[i].trim() === DELIM) {
|
|
46
|
+
end = i;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
if (end === -1) return { data: {}, body: String(text) };
|
|
51
|
+
|
|
52
|
+
const data = {};
|
|
53
|
+
let pendingListKey = null;
|
|
54
|
+
|
|
55
|
+
for (let i = 1; i < end; i++) {
|
|
56
|
+
const raw = lines[i];
|
|
57
|
+
if (!raw.trim() || raw.trim().startsWith('#')) continue;
|
|
58
|
+
|
|
59
|
+
const listItem = raw.match(/^\s+-\s+(.*)$/);
|
|
60
|
+
if (listItem && pendingListKey) {
|
|
61
|
+
data[pendingListKey].push(unquote(listItem[1].trim()));
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const kv = raw.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
66
|
+
if (!kv) continue;
|
|
67
|
+
|
|
68
|
+
const key = kv[1];
|
|
69
|
+
const value = kv[2].trim();
|
|
70
|
+
|
|
71
|
+
if (value === '') {
|
|
72
|
+
data[key] = [];
|
|
73
|
+
pendingListKey = key;
|
|
74
|
+
} else if (value.startsWith('[') && value.endsWith(']')) {
|
|
75
|
+
data[key] = value
|
|
76
|
+
.slice(1, -1)
|
|
77
|
+
.split(',')
|
|
78
|
+
.map((s) => unquote(s.trim()))
|
|
79
|
+
.filter(Boolean);
|
|
80
|
+
pendingListKey = null;
|
|
81
|
+
} else {
|
|
82
|
+
data[key] = unquote(value);
|
|
83
|
+
pendingListKey = null;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// A key declared with no value and no list items becomes '' rather than [],
|
|
88
|
+
// so consumers can treat "unset scalar" and "unset list" uniformly.
|
|
89
|
+
for (const k of Object.keys(data)) {
|
|
90
|
+
if (Array.isArray(data[k]) && data[k].length === 0) data[k] = '';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const body = lines
|
|
94
|
+
.slice(end + 1)
|
|
95
|
+
.join('\n')
|
|
96
|
+
.replace(/^\n+/, '');
|
|
97
|
+
|
|
98
|
+
return { data, body };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
export function stringify(data, body = '') {
|
|
102
|
+
const out = [DELIM];
|
|
103
|
+
|
|
104
|
+
for (const [key, value] of Object.entries(data)) {
|
|
105
|
+
if (value === undefined || value === null) continue;
|
|
106
|
+
|
|
107
|
+
if (Array.isArray(value)) {
|
|
108
|
+
if (value.length === 0) {
|
|
109
|
+
out.push(`${key}: []`);
|
|
110
|
+
} else {
|
|
111
|
+
out.push(`${key}:`);
|
|
112
|
+
for (const item of value) out.push(` - ${quote(String(item))}`);
|
|
113
|
+
}
|
|
114
|
+
} else if (typeof value === 'boolean' || typeof value === 'number') {
|
|
115
|
+
out.push(`${key}: ${value}`);
|
|
116
|
+
} else {
|
|
117
|
+
out.push(`${key}: ${quote(String(value))}`);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
out.push(DELIM);
|
|
122
|
+
if (body) {
|
|
123
|
+
out.push('');
|
|
124
|
+
out.push(body.replace(/\s+$/, ''));
|
|
125
|
+
}
|
|
126
|
+
out.push('');
|
|
127
|
+
return out.join('\n');
|
|
128
|
+
}
|
package/src/index.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
// Public API.
|
|
2
|
+
//
|
|
3
|
+
// `import { create, check } from 'whynot'` gives you the store. The MCP server
|
|
4
|
+
// and the CLI are separate entry points; they are not part of this surface.
|
|
5
|
+
//
|
|
6
|
+
// Everything here is synchronous and filesystem-backed. There is no client, no
|
|
7
|
+
// connection, and no state beyond the files in the repository.
|
|
8
|
+
|
|
9
|
+
export {
|
|
10
|
+
// Layout
|
|
11
|
+
findRoot,
|
|
12
|
+
DIR_NAME,
|
|
13
|
+
TYPES,
|
|
14
|
+
// Write
|
|
15
|
+
init,
|
|
16
|
+
create,
|
|
17
|
+
retire,
|
|
18
|
+
renew,
|
|
19
|
+
// Read
|
|
20
|
+
readAll,
|
|
21
|
+
check,
|
|
22
|
+
listIndex,
|
|
23
|
+
stale,
|
|
24
|
+
render,
|
|
25
|
+
summarize,
|
|
26
|
+
// Expiry
|
|
27
|
+
expiresOn,
|
|
28
|
+
isExpired,
|
|
29
|
+
daysUntilExpiry,
|
|
30
|
+
DEFAULT_TTL_DAYS,
|
|
31
|
+
NEVER,
|
|
32
|
+
} from './store.js';
|
|
33
|
+
|
|
34
|
+
export { audit, renderAudit } from './audit.js';
|