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/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 +274 -2
- package/README.zh-CN.md +257 -0
- package/assets/icon-dark.svg +11 -0
- package/assets/icon-light.svg +11 -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 +65 -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
|
@@ -0,0 +1,257 @@
|
|
|
1
|
+
# Pre-registration
|
|
2
|
+
|
|
3
|
+
Written before the supplementary experiments were run, so that the decision
|
|
4
|
+
rules cannot be adjusted after seeing the results.
|
|
5
|
+
|
|
6
|
+
Created: 2026-10-20
|
|
7
|
+
Status: active. Do not edit after data collection begins.
|
|
8
|
+
|
|
9
|
+
---
|
|
10
|
+
|
|
11
|
+
## Why this file exists
|
|
12
|
+
|
|
13
|
+
The original experiments reported numbers after the fact, with thresholds
|
|
14
|
+
chosen implicitly. That makes it impossible for a reader to tell whether a
|
|
15
|
+
result was predicted or fitted. This document fixes the hypotheses, sample
|
|
16
|
+
sizes, and decision rules in advance.
|
|
17
|
+
|
|
18
|
+
If a result contradicts what is written here, the honest move is to publish the
|
|
19
|
+
contradiction, not to rewrite the rule.
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## Hypotheses
|
|
24
|
+
|
|
25
|
+
### H1 — stale decisions are obeyed
|
|
26
|
+
An agent given a decision that no longer holds will follow it rather than
|
|
27
|
+
question it.
|
|
28
|
+
|
|
29
|
+
**Prediction:** in the stale condition, ≥70% of responses avoid the rejected
|
|
30
|
+
option.
|
|
31
|
+
|
|
32
|
+
### H2 — expiry removes the effect
|
|
33
|
+
If an expired entry is not shown to the agent at all, H1's effect disappears.
|
|
34
|
+
|
|
35
|
+
**Prediction:** in the expired condition, the rate of avoiding the option falls
|
|
36
|
+
to within 15 percentage points of the no-record baseline.
|
|
37
|
+
|
|
38
|
+
### H3 — code context reduces it (the "information vacuum" hypothesis)
|
|
39
|
+
The original tasks contained no code, so the model had nothing to reason from
|
|
40
|
+
except the note. With a real codebase in context, the note's influence should
|
|
41
|
+
be weaker.
|
|
42
|
+
|
|
43
|
+
**Prediction:** with code context, the avoidance rate in the stale condition is
|
|
44
|
+
at least 20 points lower than without.
|
|
45
|
+
|
|
46
|
+
**This is the hypothesis most likely to be true and least flattering to the
|
|
47
|
+
project.** If it holds, the public claim must be rewritten from "agents blindly
|
|
48
|
+
obey stale decisions" to "agents obey given constraints when they have no other
|
|
49
|
+
information".
|
|
50
|
+
|
|
51
|
+
### H4 — decisions are over-applied (false obedience)
|
|
52
|
+
An agent given a strong but irrelevant decision will let it distort an unrelated
|
|
53
|
+
answer.
|
|
54
|
+
|
|
55
|
+
**Prediction:** false-obedience rate < 10%.
|
|
56
|
+
|
|
57
|
+
### H5 — obedience increases with age
|
|
58
|
+
Older decisions are obeyed more often.
|
|
59
|
+
|
|
60
|
+
**Prediction:** monotonic increase across 1 month → 7 years.
|
|
61
|
+
|
|
62
|
+
### H5' — obedience is age-independent
|
|
63
|
+
The competing hypothesis. Models have no sense of how old a note is.
|
|
64
|
+
|
|
65
|
+
**Prediction:** flat across 1 month → 7 years.
|
|
66
|
+
|
|
67
|
+
H5 and H5' are mutually exclusive. **Both are publishable.** H5' is the stronger
|
|
68
|
+
finding because it means the problem is structural, not a matter of degree.
|
|
69
|
+
|
|
70
|
+
### H6 — prompt warnings do not work
|
|
71
|
+
Adding "this is old, be skeptical" to the supplied context does not change
|
|
72
|
+
behaviour; removing the entry does.
|
|
73
|
+
|
|
74
|
+
**Status:** already tested once (experiment 3). Result: no reliable improvement.
|
|
75
|
+
|
|
76
|
+
---
|
|
77
|
+
|
|
78
|
+
## Experiments and decision rules
|
|
79
|
+
|
|
80
|
+
### E0 — judge calibration
|
|
81
|
+
|
|
82
|
+
**Design:** all 62 existing responses, blinded, scored by three judges from
|
|
83
|
+
different model families (Claude Haiku, Kimi K3, DeepSeek Flash). Compared
|
|
84
|
+
against human labels using Cohen's kappa.
|
|
85
|
+
|
|
86
|
+
**Decision rule (fixed before running):**
|
|
87
|
+
|
|
88
|
+
| worst judge κ | action |
|
|
89
|
+
|---|---|
|
|
90
|
+
| ≥ 0.80 | PASS — use majority vote downstream |
|
|
91
|
+
| 0.60 – 0.80 | WARN — proceed, but disclose and add human spot-checks |
|
|
92
|
+
| < 0.60 | **FAIL — stop all further experiments until scoring is redesigned** |
|
|
93
|
+
|
|
94
|
+
**Known limitation:** the "human" labels were produced by the same assistant
|
|
95
|
+
that designed the experiments, blind to condition but not independent. The user
|
|
96
|
+
must audit a random subset. If the audit disagrees on more than 10% of items,
|
|
97
|
+
the labels are not a valid reference and E0 is re-run with the user's labels.
|
|
98
|
+
|
|
99
|
+
---
|
|
100
|
+
|
|
101
|
+
### E1 — code context
|
|
102
|
+
|
|
103
|
+
**Design:** three fixture repositories (small/medium/large), each with a planted
|
|
104
|
+
decision. Conditions: no decision, decision injected, decision discovered by
|
|
105
|
+
reading the repo.
|
|
106
|
+
|
|
107
|
+
**Sample:** n=10 per condition per model, 2 models.
|
|
108
|
+
|
|
109
|
+
**Decision rule:** H3 is supported if the code-context condition shows an
|
|
110
|
+
avoidance rate at least 20 points lower than the injected condition, with a
|
|
111
|
+
confidence interval excluding zero.
|
|
112
|
+
|
|
113
|
+
---
|
|
114
|
+
|
|
115
|
+
### E2 — false obedience
|
|
116
|
+
|
|
117
|
+
**Design:** a strongly worded but irrelevant decision, plus a relevant-decision
|
|
118
|
+
control.
|
|
119
|
+
|
|
120
|
+
**Sample:** n=15 per condition, 2 models.
|
|
121
|
+
|
|
122
|
+
**Decision rule:**
|
|
123
|
+
|
|
124
|
+
| false-obedience rate | action |
|
|
125
|
+
|---|---|
|
|
126
|
+
| < 5% | acceptable |
|
|
127
|
+
| 5 – 15% | add a constraint to the AGENTS.md instruction and retest |
|
|
128
|
+
| > 15% | **design defect — do not ship until fixed** |
|
|
129
|
+
|
|
130
|
+
---
|
|
131
|
+
|
|
132
|
+
### E3 — statistical power
|
|
133
|
+
|
|
134
|
+
**Design:** the Redis and TypeScript experiments at n=20 per condition, three
|
|
135
|
+
model families.
|
|
136
|
+
|
|
137
|
+
**Primary analysis:** pooled effect across models, Wilson 95% intervals, Fisher
|
|
138
|
+
exact test, risk difference.
|
|
139
|
+
|
|
140
|
+
**Decision rule:**
|
|
141
|
+
- pooled p < 0.01 and risk difference ≥ 0.40 → main claim stands
|
|
142
|
+
- p < 0.05 with a smaller effect → downgrade the wording
|
|
143
|
+
- not significant → **publish the correction**
|
|
144
|
+
|
|
145
|
+
---
|
|
146
|
+
|
|
147
|
+
### E4 — dose-response
|
|
148
|
+
|
|
149
|
+
**Design:** one decision, six ages (1 month, 6 months, 1 year, 3 years, 7 years,
|
|
150
|
+
plus an expired 7-year control), n=12 per cell, 2 models.
|
|
151
|
+
|
|
152
|
+
**Analysis:** Cochran-Armitage trend test.
|
|
153
|
+
|
|
154
|
+
**Decision rule:**
|
|
155
|
+
- D1 < 30% and D5 > 70% → H5 (dose effect)
|
|
156
|
+
- D1..D5 all > 70% → H5' (age-independent). Rewrite the narrative accordingly.
|
|
157
|
+
- D6 not close to D0 → the expiry mechanism is faulty; stop and fix.
|
|
158
|
+
|
|
159
|
+
---
|
|
160
|
+
|
|
161
|
+
### E5 — multi-turn with a real agent
|
|
162
|
+
|
|
163
|
+
**Design:** Codex CLI against a fixture repo, 8–12 turns, three conditions.
|
|
164
|
+
|
|
165
|
+
**Decision rule:** report the turn at which the decision stops influencing
|
|
166
|
+
behaviour, and the token cost. No pass/fail threshold — this is descriptive.
|
|
167
|
+
|
|
168
|
+
---
|
|
169
|
+
|
|
170
|
+
### E6 — two-week field test
|
|
171
|
+
|
|
172
|
+
**Status: not run.**
|
|
173
|
+
|
|
174
|
+
**Consequence:** the project may claim that the mechanism works under test. It
|
|
175
|
+
may **not** claim that using the tool reduces repeated mistakes.
|
|
176
|
+
|
|
177
|
+
**Design (for anyone who wants to run it):** 14 days without the tool, 14 days
|
|
178
|
+
with it, on a real project.
|
|
179
|
+
|
|
180
|
+
**Primary metric:** repeated-mistake rate — how often the agent re-proposes
|
|
181
|
+
something already rejected.
|
|
182
|
+
|
|
183
|
+
**Decision rule:**
|
|
184
|
+
- ≥ 30% reduction → the tool helps
|
|
185
|
+
- < 10% reduction → it does not
|
|
186
|
+
- increase → it hurts
|
|
187
|
+
|
|
188
|
+
---
|
|
189
|
+
|
|
190
|
+
## Amendments
|
|
191
|
+
|
|
192
|
+
Amendments are recorded with the date and the reason. Changing a decision rule
|
|
193
|
+
after seeing data is exactly what pre-registration exists to prevent, so any
|
|
194
|
+
change is logged here and both the original and amended rules are reported.
|
|
195
|
+
|
|
196
|
+
### 2026-10-21 — E2 metric definition
|
|
197
|
+
|
|
198
|
+
**Original:** "false-obedience rate < 10%", without specifying what counted.
|
|
199
|
+
|
|
200
|
+
**Problem found during analysis:** the implementation used "response mentions
|
|
201
|
+
the irrelevant option at all". Manual inspection of the 9 flagged responses
|
|
202
|
+
showed they all said the decision *did not apply* — correct behaviour, not
|
|
203
|
+
obedience. The metric conflated "considered and dismissed" with "distorted by".
|
|
204
|
+
|
|
205
|
+
**Amendment:** report both metrics.
|
|
206
|
+
|
|
207
|
+
- mentions: 9/30 (30%) — the pre-registered reading, which is a FAIL
|
|
208
|
+
- design distorted: 0/30 (0%) — the substantive reading
|
|
209
|
+
|
|
210
|
+
Both are published in `e2-false-obedience.md`. The stricter number is not
|
|
211
|
+
hidden and the redefinition is argued from the raw text, not from preference.
|
|
212
|
+
|
|
213
|
+
**Future experiments:** pre-register the exact rubric or the exact string
|
|
214
|
+
matched, not just the concept.
|
|
215
|
+
|
|
216
|
+
### 2026-10-21 — E4 judge failure
|
|
217
|
+
|
|
218
|
+
Kimi K3 returned `error` for every response from D2 onward. The original
|
|
219
|
+
summary counted errors as non-events, turning a judging failure into a clean
|
|
220
|
+
0/12. All 168 responses were re-judged with DeepSeek Flash.
|
|
221
|
+
|
|
222
|
+
**Rule added:** an error is never a data point. Errors are excluded from
|
|
223
|
+
denominators and reported. This rule is now enforced in the E3 script, which
|
|
224
|
+
re-judges failures with a fallback judge.
|
|
225
|
+
|
|
226
|
+
### 2026-10-21 — E3 query bug (caught before analysis)
|
|
227
|
+
|
|
228
|
+
The first E3 run used the query `技术 选型 存储 语言` for a decision titled
|
|
229
|
+
`session 不存 Redis`. No query term matched, so `decisions_check` returned
|
|
230
|
+
"nothing found" and the treatment condition was identical to the control. The
|
|
231
|
+
run was stopped before any analysis.
|
|
232
|
+
|
|
233
|
+
**Rule added:** every experiment that injects a decision now asserts that the
|
|
234
|
+
returned context contains the decision's title, and aborts otherwise.
|
|
235
|
+
|
|
236
|
+
---
|
|
237
|
+
|
|
238
|
+
## Analysis conventions
|
|
239
|
+
|
|
240
|
+
- Report confidence intervals, never bare proportions.
|
|
241
|
+
- Report the number of excluded or errored samples.
|
|
242
|
+
- Report inter-rater reliability for any model-judged outcome.
|
|
243
|
+
- Judge calls use `temperature: 0`.
|
|
244
|
+
- Any response that is truncated or missing a conclusion is labelled `unclear`
|
|
245
|
+
and included in the denominator. Dropping it would inflate agreement.
|
|
246
|
+
|
|
247
|
+
## What would make me abandon the project
|
|
248
|
+
|
|
249
|
+
Stated in advance so it cannot be rationalised later:
|
|
250
|
+
|
|
251
|
+
1. E0 fails (κ < 0.60) and the scoring cannot be redesigned.
|
|
252
|
+
2. E2 shows > 15% false obedience and the instruction cannot fix it.
|
|
253
|
+
3. E3 shows no significant effect at n=20.
|
|
254
|
+
4. E6 shows no reduction in repeated mistakes over two weeks.
|
|
255
|
+
|
|
256
|
+
Any one of these is sufficient. The project's value depends on the claims being
|
|
257
|
+
true, not on the claims being published.
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# Validation
|
|
2
|
+
|
|
3
|
+
This page is now an index. The consolidated results are in
|
|
4
|
+
[findings.md](findings.md); the detailed write-ups are below.
|
|
5
|
+
|
|
6
|
+
| Experiment | Question | Write-up |
|
|
7
|
+
|---|---|---|
|
|
8
|
+
| E0 | Can a model reliably judge "recommends vs excludes"? | [judge-calibration.md](judge-calibration.md) |
|
|
9
|
+
| E1 | Does real code context weaken the effect? | [e1-code-context.md](e1-code-context.md) |
|
|
10
|
+
| E2 / E2b | Does an irrelevant decision distort unrelated work? | [e2-false-obedience.md](e2-false-obedience.md) |
|
|
11
|
+
| E3 | Are the effects statistically real? | [e3-power.md](e3-power.md) |
|
|
12
|
+
| E4 | Does the age of a decision matter? | [e4-dose-response.md](e4-dose-response.md) |
|
|
13
|
+
| E5 | Does a real agent use it across sessions? | [e5-real-agent.md](e5-real-agent.md) |
|
|
14
|
+
| E6 | Does it help in real use? | **Not run** — see [preregistration.md](preregistration.md) |
|
|
15
|
+
|
|
16
|
+
## A note on the earlier version of this page
|
|
17
|
+
|
|
18
|
+
The first version of this document reported results from a single small
|
|
19
|
+
experiment (n=3 per condition, two models). Those numbers appeared in the
|
|
20
|
+
README and in the launch material.
|
|
21
|
+
|
|
22
|
+
Several of them were misleading, and one was simply wrong:
|
|
23
|
+
|
|
24
|
+
- The "6 of 6 obeyed" framing understated the variance; at n=20 the same
|
|
25
|
+
decision was resisted in 13 of 60 responses.
|
|
26
|
+
- The first judging pass for the dose-response experiment silently counted
|
|
27
|
+
judge errors as data, producing a clean-looking result that did not exist.
|
|
28
|
+
|
|
29
|
+
Both are documented in [preregistration.md](preregistration.md). The numbers
|
|
30
|
+
that appear throughout the project now come from the experiments linked above.
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# 验证
|
|
2
|
+
|
|
3
|
+
这一页现在是一个索引。汇总结果在 [findings.zh-CN.md](findings.zh-CN.md)(英文版 [findings.md](findings.md)),详细写稿在下面。
|
|
4
|
+
|
|
5
|
+
| 实验 | 问题 | 写稿 |
|
|
6
|
+
|---|---|---|
|
|
7
|
+
| E0 | 模型能可靠地判断「推荐 vs 排除」吗? | [judge-calibration.md](judge-calibration.md) |
|
|
8
|
+
| E1 | 真实代码上下文会削弱效应吗? | [e1-code-context.md](e1-code-context.md) |
|
|
9
|
+
| E2 / E2b | 不相关的决策会扭曲无关工作吗? | [e2-false-obedience.md](e2-false-obedience.md) |
|
|
10
|
+
| E3 | 效应在统计上成立吗? | [e3-power.md](e3-power.md) |
|
|
11
|
+
| E4 | 决策的年龄重要吗? | [e4-dose-response.md](e4-dose-response.md) |
|
|
12
|
+
| E5 | 真实 agent 会跨会话使用它吗? | [e5-real-agent.md](e5-real-agent.md) |
|
|
13
|
+
| E6 | 真实使用中帮得上忙吗? | **未运行** —— 见 [preregistration.md](preregistration.md) |
|
|
14
|
+
|
|
15
|
+
## 关于这一页的旧版本
|
|
16
|
+
|
|
17
|
+
这一页最早报告的是单个小实验的结果(每条件 n=3,两个模型)。那些数字曾经出现在 README 和发布材料里。
|
|
18
|
+
|
|
19
|
+
其中有几个是误导性的,有一个是**错的**:
|
|
20
|
+
|
|
21
|
+
- 「6/6 全部服从」这个说法低估了方差;n=20 时同一条决策在 60 次回复里被抵抗了 13 次。
|
|
22
|
+
- 剂量-反应实验的第一次判分把判分错误静默地当成了数据,产出了一个看起来很干净、实际不存在的结果。
|
|
23
|
+
|
|
24
|
+
两件事都记录在 [preregistration.md](preregistration.md) 里。现在项目中使用的数字都来自上面链接的实验。
|
package/package.json
CHANGED
|
@@ -1,6 +1,67 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "whynot-cli",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.1",
|
|
4
|
+
"description": "Record what you ruled out, and why, so your coding agent stops re-suggesting it.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"author": "Stakenshake",
|
|
7
|
+
"homepage": "https://github.com/Stakenshake/whynot#readme",
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/Stakenshake/whynot.git"
|
|
11
|
+
},
|
|
12
|
+
"bugs": {
|
|
13
|
+
"url": "https://github.com/Stakenshake/whynot/issues"
|
|
14
|
+
},
|
|
15
|
+
"bin": {
|
|
16
|
+
"whynot": "bin/whynot.js"
|
|
17
|
+
},
|
|
18
|
+
"exports": {
|
|
19
|
+
".": "./src/index.js",
|
|
20
|
+
"./mcp": "./src/mcp.js",
|
|
21
|
+
"./audit": "./src/audit.js"
|
|
22
|
+
},
|
|
23
|
+
"files": [
|
|
24
|
+
"bin",
|
|
25
|
+
"src",
|
|
26
|
+
"assets",
|
|
27
|
+
"docs/design.md",
|
|
28
|
+
"docs/design.zh-CN.md",
|
|
29
|
+
"docs/findings.md",
|
|
30
|
+
"docs/findings.zh-CN.md",
|
|
31
|
+
"docs/validation.md",
|
|
32
|
+
"docs/validation.zh-CN.md",
|
|
33
|
+
"docs/judge-calibration.md",
|
|
34
|
+
"docs/e1-code-context.md",
|
|
35
|
+
"docs/e2-false-obedience.md",
|
|
36
|
+
"docs/e3-power.md",
|
|
37
|
+
"docs/e4-dose-response.md",
|
|
38
|
+
"docs/e5-real-agent.md",
|
|
39
|
+
"docs/preregistration.md",
|
|
40
|
+
"AGENTS.snippet.md",
|
|
41
|
+
"AGENTS.snippet.zh-CN.md",
|
|
42
|
+
"README.md",
|
|
43
|
+
"README.zh-CN.md",
|
|
44
|
+
"CONTRIBUTING.md",
|
|
45
|
+
"LICENSE"
|
|
46
|
+
],
|
|
47
|
+
"engines": {
|
|
48
|
+
"node": ">=20"
|
|
49
|
+
},
|
|
50
|
+
"scripts": {
|
|
51
|
+
"test": "node --test \"test/*.test.js\"",
|
|
52
|
+
"mcp": "node bin/whynot.js mcp"
|
|
53
|
+
},
|
|
54
|
+
"keywords": [
|
|
55
|
+
"mcp",
|
|
56
|
+
"model-context-protocol",
|
|
57
|
+
"coding-agents",
|
|
58
|
+
"claude-code",
|
|
59
|
+
"codex",
|
|
60
|
+
"context-engineering",
|
|
61
|
+
"decision-log",
|
|
62
|
+
"architecture-decision-record",
|
|
63
|
+
"adr"
|
|
64
|
+
],
|
|
65
|
+
"license": "MIT",
|
|
66
|
+
"dependencies": {}
|
|
67
|
+
}
|
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
|
+
}
|