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/store.js ADDED
@@ -0,0 +1,415 @@
1
+ // Core store: find the .decisions/ directory, read/write/list entries.
2
+ //
3
+ // Zero dependencies. Everything is plain files under version control, so if
4
+ // this tool disappears tomorrow your notes still work with grep.
5
+
6
+ import fs from 'node:fs';
7
+ import path from 'node:path';
8
+ import { parse, stringify } from './frontmatter.js';
9
+
10
+ export const DIR_NAME = '.decisions';
11
+ export const TYPES = ['rejected', 'decision', 'constraint'];
12
+
13
+ /**
14
+ * How long an entry stays authoritative before a human must re-confirm it.
15
+ *
16
+ * The default is EXPIRY, not permanence. A 2019 "do not use TypeScript" note
17
+ * made 6 of 6 model responses reject TypeScript for a brand-new 2026 service,
18
+ * and telling the agent "this is old, treat it as an assumption to confirm"
19
+ * did not fix it (0/3 -> 1/3 for one model, and worse for the other). Prompt
20
+ * engineering is not a safety mechanism.
21
+ *
22
+ * So expiry is structural instead: an expired entry is not shown to the agent
23
+ * at all. The agent cannot obey what it cannot see. Renewing is a human action.
24
+ */
25
+ export const DEFAULT_TTL_DAYS = { rejected: 90, decision: 180, constraint: 365 };
26
+
27
+ /** Sentinel for a deliberately permanent entry. Must be written explicitly. */
28
+ export const NEVER = 'never';
29
+
30
+ function addDays(date, days) {
31
+ const d = new Date(date);
32
+ d.setDate(d.getDate() + days);
33
+ return d.toISOString().slice(0, 10);
34
+ }
35
+
36
+ /** Effective expiry date, or null when the entry was explicitly marked permanent. */
37
+ export function expiresOn(entry) {
38
+ if (String(entry.expires_on) === NEVER) return null;
39
+ if (entry.expires_on) return String(entry.expires_on);
40
+
41
+ const base = entry.renewed_on || entry.created;
42
+ if (!base) return null;
43
+ const ttl = DEFAULT_TTL_DAYS[entry.type] ?? 180;
44
+ return addDays(base, ttl);
45
+ }
46
+
47
+ export function isExpired(entry, now = new Date()) {
48
+ const exp = expiresOn(entry);
49
+ if (!exp) return false;
50
+ const d = new Date(exp);
51
+ if (Number.isNaN(d.getTime())) return false;
52
+ return d < now;
53
+ }
54
+
55
+ export function daysUntilExpiry(entry, now = new Date()) {
56
+ const exp = expiresOn(entry);
57
+ if (!exp) return null;
58
+ const d = new Date(exp);
59
+ if (Number.isNaN(d.getTime())) return null;
60
+ return Math.ceil((d - now) / 86_400_000);
61
+ }
62
+
63
+ /**
64
+ * Walk up from `startDir` looking for an existing .decisions/ directory.
65
+ * Falls back to the nearest directory containing .git/, so `whynot add` from a
66
+ * subdirectory lands in the repo root. Returns null if neither is found.
67
+ */
68
+ export function findRoot(startDir = process.cwd()) {
69
+ let dir = path.resolve(startDir);
70
+ let gitFallback = null;
71
+
72
+ for (;;) {
73
+ const decisions = path.join(dir, DIR_NAME);
74
+ if (fs.existsSync(decisions) && fs.statSync(decisions).isDirectory()) {
75
+ return dir;
76
+ }
77
+ if (!gitFallback && fs.existsSync(path.join(dir, '.git'))) gitFallback = dir;
78
+
79
+ const parent = path.dirname(dir);
80
+ if (parent === dir) return gitFallback;
81
+ dir = parent;
82
+ }
83
+ }
84
+
85
+ /**
86
+ * Filename-safe slug.
87
+ *
88
+ * Windows forbids \ / : * ? " < > | and both Windows and macOS are
89
+ * case-insensitive, so we lowercase and strip everything outside
90
+ * [a-z0-9] plus CJK ranges. Keeps CJK so Chinese titles stay readable.
91
+ */
92
+ export function slugify(input, maxLen = 40) {
93
+ const slug = String(input)
94
+ .toLowerCase()
95
+ .replace(/[^a-z0-9぀-ヿ㐀-䶿一-鿿]+/g, '-')
96
+ .replace(/^-+|-+$/g, '')
97
+ .replace(/-{2,}/g, '-')
98
+ .slice(0, maxLen)
99
+ .replace(/-+$/, '');
100
+ return slug || 'entry';
101
+ }
102
+
103
+ function decisionsDir(root) {
104
+ return path.join(root, DIR_NAME);
105
+ }
106
+
107
+ function entryFiles(root) {
108
+ const dir = decisionsDir(root);
109
+ if (!fs.existsSync(dir)) return [];
110
+ return fs
111
+ .readdirSync(dir)
112
+ .filter((f) => f.endsWith('.md'))
113
+ .sort()
114
+ .map((f) => path.join(dir, f));
115
+ }
116
+
117
+ /** Read every entry. Malformed files are skipped, not fatal. */
118
+ export function readAll(root) {
119
+ const out = [];
120
+ for (const file of entryFiles(root)) {
121
+ try {
122
+ const raw = fs.readFileSync(file, 'utf8');
123
+ const { data, body } = parse(raw);
124
+ out.push({
125
+ ...data,
126
+ id: String(data.id || ''),
127
+ type: data.type || 'decision',
128
+ status: data.status || 'live',
129
+ tags: Array.isArray(data.tags) ? data.tags : data.tags ? [data.tags] : [],
130
+ evidence: Array.isArray(data.evidence) ? data.evidence : data.evidence ? [data.evidence] : [],
131
+ body: body.trim(),
132
+ file,
133
+ filename: path.basename(file),
134
+ });
135
+ } catch {
136
+ // A corrupt file must never break a session.
137
+ }
138
+ }
139
+ return out;
140
+ }
141
+
142
+ export function nextId(root) {
143
+ const existing = readAll(root)
144
+ .map((e) => parseInt(e.id, 10))
145
+ .filter((n) => Number.isFinite(n));
146
+ const next = existing.length ? Math.max(...existing) + 1 : 1;
147
+ return String(next).padStart(4, '0');
148
+ }
149
+
150
+ export function init(root) {
151
+ const dir = decisionsDir(root);
152
+ fs.mkdirSync(dir, { recursive: true });
153
+
154
+ const keep = path.join(dir, '.gitkeep');
155
+ if (!fs.existsSync(keep)) fs.writeFileSync(keep, '', 'utf8');
156
+
157
+ // Without this, Windows checkouts write CRLF and every diff shows the whole
158
+ // file as changed.
159
+ const attrs = path.join(root, '.gitattributes');
160
+ const rule = '*.md text eol=lf';
161
+ if (!fs.existsSync(attrs)) {
162
+ fs.writeFileSync(attrs, `# added by whynot\n${rule}\n`, 'utf8');
163
+ } else {
164
+ const cur = fs.readFileSync(attrs, 'utf8');
165
+ if (!/\.md\s+text\s+eol=lf/.test(cur)) {
166
+ fs.appendFileSync(attrs, `\n# added by whynot\n${rule}\n`, 'utf8');
167
+ }
168
+ }
169
+
170
+ return { root, dir };
171
+ }
172
+
173
+ export function create(root, entry) {
174
+ if (!entry || !entry.title) throw new Error('title is required');
175
+ const type = entry.type || 'rejected';
176
+ if (!TYPES.includes(type)) {
177
+ throw new Error(`type must be one of: ${TYPES.join(', ')}`);
178
+ }
179
+
180
+ const id = entry.id || nextId(root);
181
+ const filename = `${id}-${slugify(entry.title)}.md`;
182
+ const file = path.join(decisionsDir(root), filename);
183
+
184
+ const created = entry.created || new Date().toISOString().slice(0, 10);
185
+ const data = {
186
+ id,
187
+ title: entry.title,
188
+ type,
189
+ status: 'live',
190
+ created,
191
+ };
192
+
193
+ // Written explicitly rather than computed on read, so the expiry is visible
194
+ // in the file and a human editing it by hand sees what they are changing.
195
+ if (entry.expires_on === NEVER) {
196
+ data.expires_on = NEVER;
197
+ } else if (entry.expires_on) {
198
+ data.expires_on = entry.expires_on;
199
+ } else {
200
+ const ttl = Number.isFinite(entry.ttl_days) ? entry.ttl_days : DEFAULT_TTL_DAYS[type] ?? 180;
201
+ data.expires_on = addDays(created, ttl);
202
+ }
203
+
204
+ if (entry.revisit_when) data.revisit_when = entry.revisit_when;
205
+ if (entry.evidence?.length) data.evidence = entry.evidence;
206
+ if (entry.tags?.length) data.tags = entry.tags;
207
+
208
+ const body = entry.body?.trim() || '## 结论\n\n## 为什么\n';
209
+ fs.mkdirSync(decisionsDir(root), { recursive: true });
210
+ fs.writeFileSync(file, stringify(data, body), 'utf8');
211
+
212
+ return { id, file, filename };
213
+ }
214
+
215
+ export function retire(root, id) {
216
+ const match = readAll(root).find((e) => e.id === id);
217
+ if (!match) throw new Error(`no entry with id ${id}`);
218
+
219
+ const { data, body } = parse(fs.readFileSync(match.file, 'utf8'));
220
+ data.status = 'retired';
221
+ data.retired_on = new Date().toISOString().slice(0, 10);
222
+ fs.writeFileSync(match.file, stringify(data, body), 'utf8');
223
+
224
+ return { id, file: match.file };
225
+ }
226
+
227
+ const TYPE_RANK = { rejected: 0, constraint: 1, decision: 2 };
228
+
229
+ function haystackOf(entry) {
230
+ return `${entry.title} ${entry.tags.join(' ')} ${entry.body} ${entry.revisit_when || ''}`.toLowerCase();
231
+ }
232
+
233
+ /**
234
+ * Live entries relevant to a query.
235
+ *
236
+ * Matching is scored rather than ANDed. Requiring every term to appear looks
237
+ * tidy but fails on the common case: an entry titled "session 不存 Redis" will
238
+ * not match a query for "session 存储", so the agent sees nothing and
239
+ * re-proposes Redis. Recall matters more than precision here — missing a
240
+ * relevant rejection is the expensive failure, and an extra hit is cheap to
241
+ * ignore. No index and no embeddings, on purpose.
242
+ */
243
+ /** Filter and rank a set of entries. Pure — no expiry logic in here. */
244
+ function rank(entries, { query, tags, type, limit }) {
245
+ let results = entries;
246
+ if (type) results = results.filter((e) => e.type === type);
247
+
248
+ const wanted = (Array.isArray(tags) ? tags : [tags]).filter(Boolean).map((t) => t.toLowerCase());
249
+ if (wanted.length) {
250
+ results = results.filter((e) => {
251
+ const entryTags = e.tags.map((t) => String(t).toLowerCase());
252
+ return wanted.some((w) => entryTags.includes(w));
253
+ });
254
+ }
255
+
256
+ const terms = String(query).trim().toLowerCase().split(/\s+/).filter(Boolean);
257
+
258
+ if (terms.length) {
259
+ results = results
260
+ .map((entry) => ({
261
+ entry,
262
+ score: terms.reduce((n, t) => n + (haystackOf(entry).includes(t) ? 1 : 0), 0),
263
+ }))
264
+ .filter((r) => r.score > 0)
265
+ .sort(
266
+ (a, b) =>
267
+ b.score - a.score ||
268
+ (TYPE_RANK[a.entry.type] ?? 9) - (TYPE_RANK[b.entry.type] ?? 9) ||
269
+ a.entry.id.localeCompare(b.entry.id)
270
+ )
271
+ .map((r) => r.entry);
272
+ } else {
273
+ // Rejections first: they are the expensive ones to rediscover.
274
+ results = [...results].sort(
275
+ (a, b) => (TYPE_RANK[a.type] ?? 9) - (TYPE_RANK[b.type] ?? 9) || a.id.localeCompare(b.id)
276
+ );
277
+ }
278
+
279
+ return results.slice(0, limit);
280
+ }
281
+
282
+ export function check(root, { query = '', tags = [], type = null, limit = 20, now = new Date() } = {}) {
283
+ // Expired entries are excluded here, and therefore from every agent-facing
284
+ // read path. This is the safety mechanism: the agent cannot obey an entry it
285
+ // cannot see. Renewal is a human action, performed via the CLI.
286
+ const live = readAll(root).filter((e) => e.status === 'live' && !isExpired(e, now));
287
+ return rank(live, { query, tags, type, limit });
288
+ }
289
+
290
+ /**
291
+ * Expired entries matching a query — used only to tell the agent that
292
+ * something exists and needs the user's confirmation. The caller must not
293
+ * forward the content, only the fact of it.
294
+ */
295
+ export function expiredMatching(root, { query = '', tags = [], type = null, now = new Date() } = {}) {
296
+ const gone = readAll(root).filter((e) => e.status === 'live' && isExpired(e, now));
297
+ return rank(gone, { query, tags, type, limit: 50 });
298
+ }
299
+
300
+ /**
301
+ * One line per live entry, for the start-of-session "what is already settled?"
302
+ * call. Deliberately excludes bodies: dumping every full entry into context is
303
+ * exactly the bloat this tool exists to avoid.
304
+ */
305
+ export function listIndex(root, { limit = 200 } = {}) {
306
+ return check(root, { limit }).map((e) => ({
307
+ id: e.id,
308
+ type: e.type,
309
+ title: e.title,
310
+ tags: e.tags,
311
+ revisit_when: e.revisit_when || '',
312
+ }));
313
+ }
314
+
315
+ /**
316
+ * Entries a human must act on.
317
+ *
318
+ * Expired entries lead the list. Once an entry expires it stops reaching the
319
+ * agent, so if nobody reviews it the knowledge is simply lost. That is the
320
+ * intended trade: an entry that silently keeps applying long after its reason
321
+ * has gone is worse than one that quietly stops.
322
+ */
323
+ export function stale(root, { soonDays = 14, now = new Date() } = {}) {
324
+ const out = [];
325
+
326
+ for (const e of readAll(root)) {
327
+ if (e.status !== 'live') continue;
328
+
329
+ const reasons = [];
330
+ const left = daysUntilExpiry(e, now);
331
+
332
+ if (left !== null && left < 0) {
333
+ reasons.push(`EXPIRED ${-left}d ago — no longer shown to the agent`);
334
+ } else if (left !== null && left <= soonDays) {
335
+ reasons.push(`expires in ${left}d`);
336
+ }
337
+
338
+ // Only mention the revisit condition when the entry actually needs a
339
+ // decision. Listing every fresh entry that happens to carry one would fill
340
+ // the queue with things that need no action, and a queue nobody reads is
341
+ // the same as no queue.
342
+ if (e.revisit_when && reasons.length) reasons.push(`revisit_when: ${e.revisit_when}`);
343
+
344
+ if (reasons.length) {
345
+ out.push({ ...e, reasons, daysLeft: left, expired: left !== null && left < 0 });
346
+ }
347
+ }
348
+
349
+ // Expired first (they are already invisible), then soonest to expire.
350
+ out.sort(
351
+ (a, b) =>
352
+ Number(b.expired) - Number(a.expired) ||
353
+ (a.daysLeft ?? Number.MAX_SAFE_INTEGER) - (b.daysLeft ?? Number.MAX_SAFE_INTEGER)
354
+ );
355
+ return out;
356
+ }
357
+
358
+ /**
359
+ * Extend an entry's life. The human half of the expiry mechanism — without
360
+ * this, expiry would just be data loss.
361
+ */
362
+ export function renew(root, id, { days = null, now = new Date() } = {}) {
363
+ const match = readAll(root).find((e) => e.id === id);
364
+ if (!match) throw new Error(`no entry with id ${id}`);
365
+
366
+ const { data, body } = parse(fs.readFileSync(match.file, 'utf8'));
367
+ const today = now.toISOString().slice(0, 10);
368
+ const ttl = Number.isFinite(days) ? days : DEFAULT_TTL_DAYS[data.type] ?? 180;
369
+
370
+ data.renewed_on = today;
371
+ data.expires_on = addDays(today, ttl);
372
+ fs.writeFileSync(match.file, stringify(data, body), 'utf8');
373
+
374
+ return { id, expires_on: data.expires_on };
375
+ }
376
+
377
+ /** Compact one-line rendering, used by the CLI and the MCP tool. */
378
+ export function summarize(entry) {
379
+ const bits = [`[${entry.id}]`, `(${entry.type})`, entry.title];
380
+ if (entry.revisit_when) bits.push(`— revisit when: ${entry.revisit_when}`);
381
+ return bits.join(' ');
382
+ }
383
+
384
+ /**
385
+ * Full rendering for an agent to read.
386
+ *
387
+ * Expiry is stated plainly because it is the fact the agent most needs: it
388
+ * bounds how much weight the entry deserves. Entries that have already expired
389
+ * never reach here — check() filters them out.
390
+ */
391
+ export function render(entry, { now = new Date() } = {}) {
392
+ const left = daysUntilExpiry(entry, now);
393
+
394
+ const head = [`## [${entry.id}] ${entry.title}`, `type: ${entry.type} | status: ${entry.status}`];
395
+ if (entry.created) head.push(`created: ${entry.created}`);
396
+ if (entry.renewed_on) head.push(`renewed: ${entry.renewed_on}`);
397
+ head.push(
398
+ left === null
399
+ ? 'expires: never (explicitly marked permanent)'
400
+ : `expires: ${expiresOn(entry)} (in ${left} day${left === 1 ? '' : 's'})`
401
+ );
402
+ if (entry.revisit_when) head.push(`revisit_when: ${entry.revisit_when}`);
403
+ if (entry.evidence?.length) head.push(`evidence: ${entry.evidence.join(', ')}`);
404
+ if (entry.tags?.length) head.push(`tags: ${entry.tags.join(', ')}`);
405
+
406
+ if (entry.revisit_when) {
407
+ head.push(
408
+ '',
409
+ '> Apply this only if the revisit_when condition has NOT been met. If you cannot tell ' +
410
+ 'whether it has been met, ask the user.'
411
+ );
412
+ }
413
+
414
+ return `${head.join('\n')}\n\n${entry.body}`;
415
+ }