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/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
|
+
}
|