whynot-cli 0.0.0-stage → 0.1.1

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/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';