vigiles 2.0.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/.claude/settings.json +46 -0
- package/.claude/settings.local.json +8 -0
- package/.claude-plugin/hooks/post-edit.sh +34 -0
- package/.claude-plugin/hooks/pre-edit.sh +40 -0
- package/.claude-plugin/hooks/session-start.sh +38 -0
- package/.claude-plugin/marketplace.json +14 -0
- package/.claude-plugin/plugin.json +47 -0
- package/.github/workflows/ci.yml +81 -0
- package/.prettierignore +1 -0
- package/.vigiles/generated.d.ts +205 -0
- package/CLAUDE.md +95 -0
- package/CLAUDE.md.spec.ts +142 -0
- package/CONTRIBUTING.md +121 -0
- package/LICENSE +21 -0
- package/README.md +377 -0
- package/action.yml +25 -0
- package/dist/action.d.ts +7 -0
- package/dist/action.d.ts.map +1 -0
- package/dist/action.js +180 -0
- package/dist/action.js.map +1 -0
- package/dist/cli.d.ts +12 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +1267 -0
- package/dist/cli.js.map +1 -0
- package/dist/cli.test.d.ts +2 -0
- package/dist/cli.test.d.ts.map +1 -0
- package/dist/cli.test.js +650 -0
- package/dist/cli.test.js.map +1 -0
- package/dist/compile.d.ts +101 -0
- package/dist/compile.d.ts.map +1 -0
- package/dist/compile.js +503 -0
- package/dist/compile.js.map +1 -0
- package/dist/evolve.d.ts +132 -0
- package/dist/evolve.d.ts.map +1 -0
- package/dist/evolve.js +599 -0
- package/dist/evolve.js.map +1 -0
- package/dist/freshness.d.ts +67 -0
- package/dist/freshness.d.ts.map +1 -0
- package/dist/freshness.js +244 -0
- package/dist/freshness.js.map +1 -0
- package/dist/freshness.test.d.ts +2 -0
- package/dist/freshness.test.d.ts.map +1 -0
- package/dist/freshness.test.js +356 -0
- package/dist/freshness.test.js.map +1 -0
- package/dist/generate-types.d.ts +34 -0
- package/dist/generate-types.d.ts.map +1 -0
- package/dist/generate-types.js +381 -0
- package/dist/generate-types.js.map +1 -0
- package/dist/inline.d.ts +58 -0
- package/dist/inline.d.ts.map +1 -0
- package/dist/inline.js +142 -0
- package/dist/inline.js.map +1 -0
- package/dist/inline.test.d.ts +5 -0
- package/dist/inline.test.d.ts.map +1 -0
- package/dist/inline.test.js +152 -0
- package/dist/inline.test.js.map +1 -0
- package/dist/linters.d.ts +38 -0
- package/dist/linters.d.ts.map +1 -0
- package/dist/linters.js +588 -0
- package/dist/linters.js.map +1 -0
- package/dist/proofs.d.ts +272 -0
- package/dist/proofs.d.ts.map +1 -0
- package/dist/proofs.js +622 -0
- package/dist/proofs.js.map +1 -0
- package/dist/proofs.test.d.ts +9 -0
- package/dist/proofs.test.d.ts.map +1 -0
- package/dist/proofs.test.js +952 -0
- package/dist/proofs.test.js.map +1 -0
- package/dist/spec.d.ts +258 -0
- package/dist/spec.d.ts.map +1 -0
- package/dist/spec.js +113 -0
- package/dist/spec.js.map +1 -0
- package/dist/spec.test.d.ts +2 -0
- package/dist/spec.test.d.ts.map +1 -0
- package/dist/spec.test.js +1222 -0
- package/dist/spec.test.js.map +1 -0
- package/dist/types.d.ts +101 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +3 -0
- package/dist/types.js.map +1 -0
- package/dist/validate.d.ts +10 -0
- package/dist/validate.d.ts.map +1 -0
- package/dist/validate.js +286 -0
- package/dist/validate.js.map +1 -0
- package/dist/validate.test.d.ts +2 -0
- package/dist/validate.test.d.ts.map +1 -0
- package/dist/validate.test.js +531 -0
- package/dist/validate.test.js.map +1 -0
- package/docs/agent-setup.md +85 -0
- package/docs/agent-workflows.md +103 -0
- package/docs/comparison.md +71 -0
- package/docs/freshness.md +124 -0
- package/docs/inline-mode.md +119 -0
- package/docs/linter-support.md +166 -0
- package/docs/spec-format.md +194 -0
- package/eslint.config.mjs +79 -0
- package/examples/CLAUDE.md +54 -0
- package/examples/CLAUDE.md.spec.ts +65 -0
- package/examples/SKILL.md +50 -0
- package/examples/SKILL.md.spec.ts +57 -0
- package/fixtures/example-project/CLAUDE.md +11 -0
- package/fixtures/example-project/package.json +9 -0
- package/fixtures/example-project/src/index.ts +3 -0
- package/fixtures/example-project/src/utils.test.ts +2 -0
- package/fixtures/example-project/src/utils.ts +3 -0
- package/logo.png +0 -0
- package/package.json +42 -0
- package/research/adoption-strategy.md +111 -0
- package/research/agent-integration.md +145 -0
- package/research/ai-code-quality.md +197 -0
- package/research/code-search-for-agents.md +313 -0
- package/research/competitive-landscape.md +163 -0
- package/research/doc-freshness.md +516 -0
- package/research/executable-specs.md +368 -0
- package/research/feature-ideas.md +464 -0
- package/research/formal-proofs-for-agents.md +338 -0
- package/research/fp-for-agent-harness.md +150 -0
- package/research/fp-for-deterministic-ai.md +131 -0
- package/research/self-evolving-specs.md +298 -0
- package/schemas/claude-md-strict.yml +18 -0
- package/schemas/claude-md.yml +6 -0
- package/schemas/skill-strict.yml +12 -0
- package/schemas/skill.yml +5 -0
- package/skills/audit-feedback-loop/SKILL.md +76 -0
- package/skills/edit-spec/SKILL.md +131 -0
- package/skills/enforce-rules-format/SKILL.md +71 -0
- package/skills/generate-logo/SKILL.md +102 -0
- package/skills/generate-rule/SKILL.md +90 -0
- package/skills/linter-docs/clippy.md +241 -0
- package/skills/linter-docs/eslint.md +384 -0
- package/skills/linter-docs/pylint.md +288 -0
- package/skills/linter-docs/rubocop.md +277 -0
- package/skills/linter-docs/ruff.md +187 -0
- package/skills/linter-docs/stylelint.md +247 -0
- package/skills/migrate-to-spec/SKILL.md +124 -0
- package/skills/pr-to-lint-rule/SKILL.md +97 -0
- package/skills/strengthen/SKILL.md +168 -0
- package/src/action.ts +214 -0
- package/src/cli.test.ts +914 -0
- package/src/cli.ts +1631 -0
- package/src/compile.ts +691 -0
- package/src/evolve.ts +781 -0
- package/src/freshness.test.ts +449 -0
- package/src/freshness.ts +299 -0
- package/src/generate-types.ts +448 -0
- package/src/inline.test.ts +206 -0
- package/src/inline.ts +164 -0
- package/src/linters.ts +739 -0
- package/src/proofs.test.ts +1314 -0
- package/src/proofs.ts +849 -0
- package/src/spec.test.ts +1471 -0
- package/src/spec.ts +427 -0
- package/src/types.ts +117 -0
- package/src/validate.test.ts +701 -0
- package/src/validate.ts +381 -0
- package/tsconfig.json +23 -0
|
@@ -0,0 +1,516 @@
|
|
|
1
|
+
# Doc Freshness: Input Fingerprinting for Stale Spec Detection
|
|
2
|
+
|
|
3
|
+
## The Gap
|
|
4
|
+
|
|
5
|
+
vigiles hashes the **compiled output** (the `.md` file). This catches manual edits to the markdown. It does NOT catch when the **inputs** that produced that markdown have changed.
|
|
6
|
+
|
|
7
|
+
After `vigiles compile` runs successfully:
|
|
8
|
+
|
|
9
|
+
| What changes | Detected today? | Consequence |
|
|
10
|
+
| ------------------------------------ | ----------------------- | --------------------------------------------- |
|
|
11
|
+
| Someone edits CLAUDE.md by hand | Yes (hash mismatch) | `vigiles audit` catches it |
|
|
12
|
+
| Linter rule gets disabled in config | No | Spec claims `enforce()`, rule is actually off |
|
|
13
|
+
| Referenced file deleted | No (until next compile) | Markdown contains stale `file()` path |
|
|
14
|
+
| npm script removed from package.json | No (until next compile) | Markdown references dead command |
|
|
15
|
+
| Spec file edited, nobody recompiles | No | Markdown and spec diverge |
|
|
16
|
+
| ESLint plugin uninstalled | No (until next compile) | Markdown references non-existent rules |
|
|
17
|
+
| `.vigiles/generated.d.ts` stale | Yes (`--check` mode) | Types don't match reality |
|
|
18
|
+
|
|
19
|
+
The hash answers: "Was the output tampered with?" It doesn't answer: "Is the output still valid given what's true about the project right now?"
|
|
20
|
+
|
|
21
|
+
## Prior Art
|
|
22
|
+
|
|
23
|
+
### Make / CMake — mtime comparison
|
|
24
|
+
|
|
25
|
+
Track file modification times. If source is newer than target, rebuild.
|
|
26
|
+
|
|
27
|
+
- Pro: simple, zero overhead
|
|
28
|
+
- Con: mtime is unreliable (git clone resets it, clock skew in CI, `touch` defeats it)
|
|
29
|
+
- Con: doesn't detect content changes on same-second edits
|
|
30
|
+
- **Verdict:** Not suitable. vigiles needs content-based detection, not time-based.
|
|
31
|
+
|
|
32
|
+
### Bazel / Buck — input fingerprinting
|
|
33
|
+
|
|
34
|
+
Hash **all inputs** to a build action into a single key. Cache the output by that key. If any input changes, the key changes, the cache misses, and the action re-runs.
|
|
35
|
+
|
|
36
|
+
- Pro: precise — catches any input variation
|
|
37
|
+
- Pro: deterministic — same inputs always produce same output
|
|
38
|
+
- Pro: cacheable — skip redundant work
|
|
39
|
+
- Con: requires strict input isolation (must enumerate every file that affects the output)
|
|
40
|
+
- **Verdict:** The right model. vigiles can enumerate its inputs: spec file, linter configs, package.json, referenced files.
|
|
41
|
+
|
|
42
|
+
### Nix — derivation input hashing
|
|
43
|
+
|
|
44
|
+
Every build step declares its inputs. The derivation hash changes if any input hash changes. The output path is derived from the input hash, so stale outputs are impossible — they live at a different path.
|
|
45
|
+
|
|
46
|
+
- Pro: hermetic — output is always fresh by construction
|
|
47
|
+
- Con: overkill for vigiles (we're not building packages, just compiling markdown)
|
|
48
|
+
- **Verdict:** The principle is right (hash inputs, not outputs). The mechanism is too heavy.
|
|
49
|
+
|
|
50
|
+
### Terraform — drift detection
|
|
51
|
+
|
|
52
|
+
`terraform plan` compares desired state (config) against actual state (cloud resources). Any delta is flagged as drift.
|
|
53
|
+
|
|
54
|
+
- Pro: detects both directions of drift (config changed, resource changed)
|
|
55
|
+
- Pro: shows a diff, not just "stale"
|
|
56
|
+
- **Verdict:** Good analogy. vigiles should compare "what the spec claims" against "what the project actually has."
|
|
57
|
+
|
|
58
|
+
### Git tree hashes
|
|
59
|
+
|
|
60
|
+
Git hashes directory trees content-addressably. Changing one file changes the tree hash all the way up. `git diff --stat` shows exactly what changed.
|
|
61
|
+
|
|
62
|
+
- Pro: always available, zero setup
|
|
63
|
+
- Pro: can pin a specific commit as "known good"
|
|
64
|
+
- Con: requires git repo
|
|
65
|
+
- Con: only tracks committed state (uncommitted changes invisible)
|
|
66
|
+
- **Verdict:** Useful as one signal. Can record "compiled at git tree hash X" and check if the relevant files changed since.
|
|
67
|
+
|
|
68
|
+
### TypeScript `tsBuildInfo` — incremental compilation
|
|
69
|
+
|
|
70
|
+
Stores per-file hashes and dependency graph. On next compile, skips files whose hash (and all transitive dependency hashes) haven't changed.
|
|
71
|
+
|
|
72
|
+
- Pro: fast incremental builds
|
|
73
|
+
- Con: complex dependency graph maintenance
|
|
74
|
+
- **Verdict:** vigiles has a simpler dependency model (spec → {linter configs, files, package.json}). Full DAG is unnecessary.
|
|
75
|
+
|
|
76
|
+
## Design
|
|
77
|
+
|
|
78
|
+
### Input manifest
|
|
79
|
+
|
|
80
|
+
At compile time, record every input that affected the output. Store alongside the output hash.
|
|
81
|
+
|
|
82
|
+
```
|
|
83
|
+
<!-- vigiles:sha256:a1b2c3d4e5f6g7h8 compiled from CLAUDE.md.spec.ts -->
|
|
84
|
+
<!-- vigiles:inputs:sha256:f9e8d7c6b5a49382 -->
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
The input hash is a SHA-256 of the **sorted, concatenated hashes** of all input files:
|
|
88
|
+
|
|
89
|
+
```
|
|
90
|
+
inputs = sort([
|
|
91
|
+
sha256(readFile("CLAUDE.md.spec.ts")), # the spec source
|
|
92
|
+
sha256(readFile("eslint.config.mjs")), # linter config
|
|
93
|
+
sha256(readFile("package.json")), # scripts + deps
|
|
94
|
+
sha256(readFile("tsconfig.json")), # if referenced
|
|
95
|
+
sha256(readFile("src/compile.ts")), # each file() reference
|
|
96
|
+
sha256(readFile("src/linters.ts")), # each file() reference
|
|
97
|
+
... # all keyFiles entries
|
|
98
|
+
])
|
|
99
|
+
inputHash = sha256(inputs.join("\n"))
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
### What counts as an input
|
|
103
|
+
|
|
104
|
+
| Input category | Files | Why |
|
|
105
|
+
| ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------ |
|
|
106
|
+
| Spec source | `CLAUDE.md.spec.ts` | Any change to the spec should force recompile |
|
|
107
|
+
| Linter configs | `eslint.config.*`, `.eslintrc.*`, `pyproject.toml` `[tool.ruff]`, `Cargo.toml` `[lints.clippy]`, `.pylintrc`, `.rubocop.yml`, `.stylelintrc.*` | Disabling a rule makes `enforce()` claims stale |
|
|
108
|
+
| Package manifest | `package.json` | Scripts (`cmd()` references) and deps (linter plugins) |
|
|
109
|
+
| Referenced files | Every `file()` call in the spec | Deletion or rename makes the reference stale |
|
|
110
|
+
| Generated types | `.vigiles/generated.d.ts` | If types are stale, rule references may be invalid |
|
|
111
|
+
|
|
112
|
+
### What does NOT count as an input
|
|
113
|
+
|
|
114
|
+
- Source code files that aren't in `keyFiles` — vigiles doesn't lint code, it lints specs
|
|
115
|
+
- `node_modules/` — too large, too volatile; linter config is the proxy
|
|
116
|
+
- `.git/` — internal state, not a build input
|
|
117
|
+
- Other specs — each spec is compiled independently
|
|
118
|
+
|
|
119
|
+
### Audit behavior
|
|
120
|
+
|
|
121
|
+
`vigiles audit` gains a new check:
|
|
122
|
+
|
|
123
|
+
1. Read the compiled `.md` file
|
|
124
|
+
2. Extract the output hash (existing) and input hash (new)
|
|
125
|
+
3. Recompute the input hash from current file state
|
|
126
|
+
4. If input hash mismatches: **"Inputs changed since last compile — run `vigiles compile`"**
|
|
127
|
+
|
|
128
|
+
This is separate from the output hash check:
|
|
129
|
+
|
|
130
|
+
| Output hash | Input hash | Meaning |
|
|
131
|
+
| ----------- | ---------- | -------------------------------------------------- |
|
|
132
|
+
| Valid | Valid | Everything fresh |
|
|
133
|
+
| Invalid | Valid | Someone hand-edited the markdown |
|
|
134
|
+
| Valid | Invalid | Inputs changed, output is stale — recompile needed |
|
|
135
|
+
| Invalid | Invalid | Both changed — recompile needed |
|
|
136
|
+
|
|
137
|
+
### Discovery: which linter configs exist
|
|
138
|
+
|
|
139
|
+
vigiles already detects linters in `src/linters.ts` and `src/generate-types.ts`. The input manifest reuses this:
|
|
140
|
+
|
|
141
|
+
```typescript
|
|
142
|
+
function discoverInputFiles(spec: ClaudeSpec, basePath: string): string[] {
|
|
143
|
+
const inputs: string[] = [];
|
|
144
|
+
|
|
145
|
+
// 1. Spec source
|
|
146
|
+
inputs.push(spec._sourceFile);
|
|
147
|
+
|
|
148
|
+
// 2. Linter configs — check existence of known config files
|
|
149
|
+
const linterConfigs = [
|
|
150
|
+
"eslint.config.mjs",
|
|
151
|
+
"eslint.config.js",
|
|
152
|
+
"eslint.config.ts",
|
|
153
|
+
".eslintrc.json",
|
|
154
|
+
".eslintrc.js",
|
|
155
|
+
".eslintrc.yml",
|
|
156
|
+
"pyproject.toml",
|
|
157
|
+
"ruff.toml",
|
|
158
|
+
"Cargo.toml",
|
|
159
|
+
".pylintrc",
|
|
160
|
+
".rubocop.yml",
|
|
161
|
+
".stylelintrc.json",
|
|
162
|
+
".stylelintrc.js",
|
|
163
|
+
".stylelintrc.yml",
|
|
164
|
+
];
|
|
165
|
+
for (const cfg of linterConfigs) {
|
|
166
|
+
if (existsSync(resolve(basePath, cfg))) inputs.push(cfg);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
// 3. Package manifest
|
|
170
|
+
inputs.push("package.json");
|
|
171
|
+
|
|
172
|
+
// 4. Referenced files from keyFiles
|
|
173
|
+
for (const filePath of Object.keys(spec.keyFiles ?? {})) {
|
|
174
|
+
inputs.push(filePath);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
// 5. Generated types
|
|
178
|
+
if (existsSync(resolve(basePath, ".vigiles/generated.d.ts"))) {
|
|
179
|
+
inputs.push(".vigiles/generated.d.ts");
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
return inputs.sort();
|
|
183
|
+
}
|
|
184
|
+
```
|
|
185
|
+
|
|
186
|
+
### Hash computation
|
|
187
|
+
|
|
188
|
+
```typescript
|
|
189
|
+
function computeInputHash(inputs: string[], basePath: string): string {
|
|
190
|
+
const fileHashes = inputs.map((f) => {
|
|
191
|
+
const fullPath = resolve(basePath, f);
|
|
192
|
+
if (!existsSync(fullPath)) return `MISSING:${f}`;
|
|
193
|
+
const content = readFileSync(fullPath, "utf-8");
|
|
194
|
+
return createHash("sha256").update(content).digest("hex");
|
|
195
|
+
});
|
|
196
|
+
return createHash("sha256")
|
|
197
|
+
.update(fileHashes.join("\n"))
|
|
198
|
+
.digest("hex")
|
|
199
|
+
.slice(0, 16);
|
|
200
|
+
}
|
|
201
|
+
```
|
|
202
|
+
|
|
203
|
+
Missing files hash to `MISSING:<path>` so that file deletion changes the input hash (correctly signaling staleness).
|
|
204
|
+
|
|
205
|
+
### Storage format
|
|
206
|
+
|
|
207
|
+
Two options:
|
|
208
|
+
|
|
209
|
+
**Option A: Second HTML comment** (minimal change)
|
|
210
|
+
|
|
211
|
+
```html
|
|
212
|
+
<!-- vigiles:sha256:a1b2c3d4e5f6g7h8 compiled from CLAUDE.md.spec.ts -->
|
|
213
|
+
<!-- vigiles:inputs:f9e8d7c6b5a4 -->
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
Pro: backward-compatible (old vigiles ignores the new comment). Con: two comments at the top.
|
|
217
|
+
|
|
218
|
+
**Option B: Extend existing comment**
|
|
219
|
+
|
|
220
|
+
```html
|
|
221
|
+
<!-- vigiles:sha256:a1b2c3d4e5f6g7h8 inputs:f9e8d7c6b5a4 compiled from CLAUDE.md.spec.ts -->
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
Pro: single comment. Con: breaks existing regex; requires migration.
|
|
225
|
+
|
|
226
|
+
**Recommendation:** Option A. Backward-compatible, no migration needed.
|
|
227
|
+
|
|
228
|
+
### Git-based enhancement (optional)
|
|
229
|
+
|
|
230
|
+
In addition to content hashing, record the git tree hash of tracked input files at compile time:
|
|
231
|
+
|
|
232
|
+
```html
|
|
233
|
+
<!-- vigiles:git:abc1234 -->
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
On audit, compare:
|
|
237
|
+
|
|
238
|
+
```bash
|
|
239
|
+
git diff --name-only abc1234 -- eslint.config.mjs package.json src/compile.ts ...
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
If any listed file changed in git since that commit, flag as stale.
|
|
243
|
+
|
|
244
|
+
Pros:
|
|
245
|
+
|
|
246
|
+
- Catches committed changes (CI scenario)
|
|
247
|
+
- Can show exactly which files changed
|
|
248
|
+
- Very fast (git does the diffing)
|
|
249
|
+
|
|
250
|
+
Cons:
|
|
251
|
+
|
|
252
|
+
- Doesn't catch uncommitted changes
|
|
253
|
+
- Requires git repo
|
|
254
|
+
- Commit hash may not exist on shallow clones
|
|
255
|
+
|
|
256
|
+
**Recommendation:** Content hash is the primary mechanism. Git hash is an optional fast-path optimization for CI.
|
|
257
|
+
|
|
258
|
+
## Edge Cases
|
|
259
|
+
|
|
260
|
+
### File deleted after compile
|
|
261
|
+
|
|
262
|
+
`MISSING:src/utils.ts` in the input hash computation ensures the hash changes when a referenced file is deleted. Audit correctly flags staleness.
|
|
263
|
+
|
|
264
|
+
### Linter config changes but rules stay the same
|
|
265
|
+
|
|
266
|
+
Example: reformatting `eslint.config.mjs` (whitespace change, no semantic change). The input hash changes, audit says "recompile needed", but `vigiles compile` produces identical output. This is a false positive.
|
|
267
|
+
|
|
268
|
+
**Mitigation:** Could hash the "effective config" (linter rule set) instead of the raw config file. But this requires running `calculateConfigForFile` on every audit, which is slow. The false positive is cheap (just re-run compile), so raw file hashing is acceptable.
|
|
269
|
+
|
|
270
|
+
### Monorepo with shared configs
|
|
271
|
+
|
|
272
|
+
A root `.eslintrc.json` is inherited by all packages. Changing the root config should invalidate all specs that use ESLint. The input discovery already handles this — it checks for config files at `basePath`, which is the spec's directory.
|
|
273
|
+
|
|
274
|
+
**Gap:** If the root config is at `../../.eslintrc.json` via ESLint's config cascade, we won't discover it. Fix: resolve the actual ESLint config file location using ESLint's API, not just checking known filenames.
|
|
275
|
+
|
|
276
|
+
### Large `package.json`
|
|
277
|
+
|
|
278
|
+
In monorepos, `package.json` can be large. Hashing the full file means any dependency change (even unrelated) triggers a stale signal.
|
|
279
|
+
|
|
280
|
+
**Mitigation:** Hash only the `scripts` and `devDependencies` sections (the parts vigiles cares about). This is a minor optimization; the false positive cost is low.
|
|
281
|
+
|
|
282
|
+
## Implementation Plan
|
|
283
|
+
|
|
284
|
+
### The simpler alternative: `compile --check`
|
|
285
|
+
|
|
286
|
+
Before building input fingerprinting, consider: if compilation is cheap (2-5 seconds), the simplest freshness check is to just recompile in memory and diff:
|
|
287
|
+
|
|
288
|
+
```bash
|
|
289
|
+
vigiles compile --check # recompile in memory, compare to existing output
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
No manifest, no input tracking, no false positives from whitespace reformatting. If the output would differ, it's stale. If identical, it's fresh. This is exactly what `generate-types --check` already does.
|
|
293
|
+
|
|
294
|
+
Input fingerprinting only wins when compilation is expensive enough that you want to **avoid** running it. For vigiles today, it isn't.
|
|
295
|
+
|
|
296
|
+
### Recommended: `freshness` validation rule
|
|
297
|
+
|
|
298
|
+
Add `freshness` to `RulesConfig` alongside `require-spec` and `require-skill-spec`. Global rule in `.vigilesrc.json`, optional per-spec override in the spec itself.
|
|
299
|
+
|
|
300
|
+
```json
|
|
301
|
+
{
|
|
302
|
+
"rules": {
|
|
303
|
+
"require-spec": "warn",
|
|
304
|
+
"freshness": "error"
|
|
305
|
+
},
|
|
306
|
+
"freshnessMode": "strict"
|
|
307
|
+
}
|
|
308
|
+
```
|
|
309
|
+
|
|
310
|
+
Severity controls what happens when staleness is detected (`"error"` = CI fails, `"warn"` = prints warning, `false` = skip). Mode controls **how** staleness is detected:
|
|
311
|
+
|
|
312
|
+
| Mode | What `vigiles audit` does | Cost | False positives | False negatives |
|
|
313
|
+
| -------------------- | ------------------------------------------------------------------------------------------ | ------------------------ | -------------------------------------------------- | ------------------------------- |
|
|
314
|
+
| `"strict"` (default) | Recompiles in memory, diffs output. Fails if compiled markdown would change. | 2-5s (runs full compile) | Zero — it checks the actual output | Zero |
|
|
315
|
+
| `"input-hash"` | Checks input fingerprint only. Fails if any tracked input file changed since last compile. | <100ms (hash comparison) | Possible — whitespace changes in config trigger it | Possible — transitive deps |
|
|
316
|
+
| `"output-hash"` | Current behavior. Only checks if the `.md` was hand-edited. | <1ms (single hash) | Zero — but misses input drift entirely | Many — misses all input changes |
|
|
317
|
+
|
|
318
|
+
**Strict mode is correct by default.** Zero false positives AND zero false negatives. The cost is re-running compilation, which takes the same time as `vigiles compile`.
|
|
319
|
+
|
|
320
|
+
**Input-hash mode is the fast-path optimization.** For projects where compilation is slow (many specs, large linter configs, slow ESLint plugin loading). Accepts occasional false positives in exchange for faster CI.
|
|
321
|
+
|
|
322
|
+
**Output-hash mode is the minimal fallback.** "Don't hand-edit the markdown" enforcement only.
|
|
323
|
+
|
|
324
|
+
### Per-spec override
|
|
325
|
+
|
|
326
|
+
A spec can override the global freshness mode:
|
|
327
|
+
|
|
328
|
+
```typescript
|
|
329
|
+
export default claude({
|
|
330
|
+
freshness: "input-hash", // override global "strict" for this slow spec
|
|
331
|
+
rules: { ... },
|
|
332
|
+
});
|
|
333
|
+
```
|
|
334
|
+
|
|
335
|
+
### Lock files as inputs
|
|
336
|
+
|
|
337
|
+
For input-hash mode, the lock file is a better signal than `package.json` for dependency changes. vigiles auto-detects lock files by language:
|
|
338
|
+
|
|
339
|
+
| Lock file | Language | What it catches |
|
|
340
|
+
| ------------------- | -------- | -------------------------------------------- |
|
|
341
|
+
| `package-lock.json` | Node.js | ESLint/Stylelint plugin version changes |
|
|
342
|
+
| `yarn.lock` | Node.js | Same as above (Yarn) |
|
|
343
|
+
| `pnpm-lock.yaml` | Node.js | Same as above (pnpm) |
|
|
344
|
+
| `bun.lockb` | Node.js | Same as above (Bun) |
|
|
345
|
+
| `Gemfile.lock` | Ruby | RuboCop gem version changes |
|
|
346
|
+
| `poetry.lock` | Python | Pylint plugin version changes |
|
|
347
|
+
| `uv.lock` | Python | Same as above (uv) |
|
|
348
|
+
| `Cargo.lock` | Rust | Clippy version changes (via rustc version) |
|
|
349
|
+
| `requirements.txt` | Python | Fallback if no lock file (pip freeze output) |
|
|
350
|
+
|
|
351
|
+
Detection is simple: check `existsSync` for each. First match wins (projects rarely have competing lock files for the same language). The lock file goes into the input hash alongside linter configs and `package.json`.
|
|
352
|
+
|
|
353
|
+
Why the lock file and not just `package.json`? Because `package.json` can stay identical while the resolved dependency tree changes (version ranges). A Stylelint plugin upgrade from 15.0.0 to 16.0.0 might add/remove rules — `package.json` says `"^15.0.0"` in both cases, but the lock file changes.
|
|
354
|
+
|
|
355
|
+
For strict mode this doesn't matter (it recompiles from scratch). For input-hash mode it prevents a class of false negatives where dependencies change but `package.json` doesn't.
|
|
356
|
+
|
|
357
|
+
If the auto-detection is wrong (e.g., monorepo with lock file at a non-standard location), it can be configured explicitly:
|
|
358
|
+
|
|
359
|
+
```json
|
|
360
|
+
{
|
|
361
|
+
"freshnessInputs": ["../../yarn.lock"]
|
|
362
|
+
}
|
|
363
|
+
```
|
|
364
|
+
|
|
365
|
+
### Type changes
|
|
366
|
+
|
|
367
|
+
```typescript
|
|
368
|
+
// src/types.ts
|
|
369
|
+
|
|
370
|
+
export type FreshnessMode = "strict" | "input-hash" | "output-hash";
|
|
371
|
+
|
|
372
|
+
export interface RulesConfig {
|
|
373
|
+
"require-spec"?: RuleSeverity;
|
|
374
|
+
"require-skill-spec"?: RuleSeverity;
|
|
375
|
+
freshness?: RuleSeverity; // NEW
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
export interface VigilesConfig {
|
|
379
|
+
ruleMarkers: MarkerType[];
|
|
380
|
+
rules: Required<RulesConfig>;
|
|
381
|
+
files: string[];
|
|
382
|
+
freshnessMode?: FreshnessMode; // NEW — default "strict"
|
|
383
|
+
freshnessInputs?: string[]; // NEW — extra files to include in input hash
|
|
384
|
+
}
|
|
385
|
+
```
|
|
386
|
+
|
|
387
|
+
### Default behavior change
|
|
388
|
+
|
|
389
|
+
Today: `vigiles audit` only checks output hashes (hand-edit detection).
|
|
390
|
+
After: `vigiles audit` also recompiles in memory and diffs (freshness: "error", strict mode by default).
|
|
391
|
+
|
|
392
|
+
This is a **breaking change** for projects where the compiled markdown has drifted from the spec. But that's the point — those projects have stale instructions. The `"warn"` severity softens the migration, and `false` opts out entirely.
|
|
393
|
+
|
|
394
|
+
### Phase 1: `compile --check` (strict mode)
|
|
395
|
+
|
|
396
|
+
1. `compile.ts` — add `--check` / `dryRun` flag that compiles in memory, compares to existing file
|
|
397
|
+
2. `cli.ts` (`audit`) — when `freshness` rule is enabled and mode is `"strict"`, run compile in check mode
|
|
398
|
+
3. Error message: `"CLAUDE.md is stale — run vigiles compile"`
|
|
399
|
+
|
|
400
|
+
### Phase 2: Input fingerprinting (input-hash mode)
|
|
401
|
+
|
|
402
|
+
1. `compile.ts` — compute input hash after compilation, embed as second HTML comment
|
|
403
|
+
2. Auto-detect lock files + linter configs as inputs
|
|
404
|
+
3. `cli.ts` (`audit`) — when mode is `"input-hash"`, extract and verify input hash
|
|
405
|
+
4. Error message: `"Inputs changed since last compile (eslint.config.mjs, yarn.lock) — run vigiles compile"`
|
|
406
|
+
5. Show which files changed (diff input list against current state)
|
|
407
|
+
|
|
408
|
+
### Phase 3: Granular reporting
|
|
409
|
+
|
|
410
|
+
1. Store the individual file paths + hashes in a sidecar file (`.vigiles/CLAUDE.md.inputs.json`)
|
|
411
|
+
2. On audit, report exactly which inputs changed
|
|
412
|
+
3. Optionally show the delta: "eslint.config.mjs: rule `no-console` was disabled"
|
|
413
|
+
|
|
414
|
+
### Phase 4: Git integration (optional)
|
|
415
|
+
|
|
416
|
+
1. Record git commit hash at compile time
|
|
417
|
+
2. On audit, use `git diff` for fast change detection before falling back to content hashing
|
|
418
|
+
3. Support shallow clones by falling back to content hash when commit is missing
|
|
419
|
+
|
|
420
|
+
## TOC Manifests: Recursive Directory Fingerprinting
|
|
421
|
+
|
|
422
|
+
A complementary approach: require a `TOC.md` (or `INDEX.md`) in each documented directory that lists all files with descriptions. This turns "directory contents" into a tracked, verifiable artifact.
|
|
423
|
+
|
|
424
|
+
### How it works
|
|
425
|
+
|
|
426
|
+
```markdown
|
|
427
|
+
<!-- docs/TOC.md -->
|
|
428
|
+
|
|
429
|
+
# docs
|
|
430
|
+
|
|
431
|
+
- `linter-support.md` — Linter cross-referencing engine (6 linters + generate-types)
|
|
432
|
+
- `spec-format.md` — Spec format reference (target, sections, rules)
|
|
433
|
+
- `agent-workflows.md` — Agent workflows (Claude Code, Codex, multi-agent, Cursor)
|
|
434
|
+
- `agent-setup.md` — Non-interactive agent setup guide
|
|
435
|
+
- `inline-mode.md` — Inline mode for gradual adoption
|
|
436
|
+
- `eslint.md` — ESLint reference (shared by strengthen + pr-to-lint-rule)
|
|
437
|
+
```
|
|
438
|
+
|
|
439
|
+
### Recursive nesting
|
|
440
|
+
|
|
441
|
+
If a subdirectory has its own `TOC.md`, the parent TOC references the directory, not its individual files:
|
|
442
|
+
|
|
443
|
+
```markdown
|
|
444
|
+
<!-- skills/TOC.md -->
|
|
445
|
+
|
|
446
|
+
# skills
|
|
447
|
+
|
|
448
|
+
- `strengthen/` — Upgrade guidance() → enforce() using linter reference docs
|
|
449
|
+
- `pr-to-lint-rule/` — Convert PR review comments into automated lint rules
|
|
450
|
+
- `edit-spec/` — Edit a spec file with guided workflow
|
|
451
|
+
- `linter-docs/` — Per-linter reference docs (ESLint, RuboCop, Pylint)
|
|
452
|
+
- See `linter-docs/TOC.md` for contents
|
|
453
|
+
```
|
|
454
|
+
|
|
455
|
+
### What this enables
|
|
456
|
+
|
|
457
|
+
1. **Staleness detection** — `vigiles audit` can diff the TOC against `fs.readdirSync`. File added without TOC entry? File deleted but still in TOC? Both are errors.
|
|
458
|
+
2. **Agent discoverability** — Agents reading CLAUDE.md can follow TOC.md to find relevant docs without scanning the filesystem. It's a curated index, not `ls`.
|
|
459
|
+
3. **Compilation** — vigiles can compile TOC.md from a TOC.md.spec.ts, getting the same hash-based freshness guarantees as CLAUDE.md.
|
|
460
|
+
4. **Nested verification** — audit walks the TOC tree recursively. Each level is self-contained.
|
|
461
|
+
|
|
462
|
+
### Prior art
|
|
463
|
+
|
|
464
|
+
| Tool | Mechanism | Notes |
|
|
465
|
+
| ------------------------- | --------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
466
|
+
| Rust `mod.rs` / `lib.rs` | Module declarations | Every `.rs` file must be declared in its parent module. Undeclared files are dead code. Same principle — directory manifest enforced by compiler. |
|
|
467
|
+
| Python `__init__.py` | Package marker | Historically required for Python packages. Declares what's importable. Not a full manifest but marks directory as "this is intentional." |
|
|
468
|
+
| Cargo `[lib]` / `[[bin]]` | Explicit targets | Cargo.toml must list all crate entry points. Adding a file without updating Cargo.toml = nothing happens. |
|
|
469
|
+
| Go module / package | Implicit (convention) | Go uses directory = package, file = source. No manifest — but the convention IS the manifest. |
|
|
470
|
+
| mdBook `SUMMARY.md` | Book structure | mdBook requires `SUMMARY.md` listing all chapters in order. Unlisted files are excluded from the build. Closest prior art to TOC.md. |
|
|
471
|
+
| Docusaurus `sidebars.js` | Sidebar structure | Lists all docs in navigation order. `autogenerated` mode scans filesystem but explicit mode is a manifest. |
|
|
472
|
+
| Sphinx `toctree` | Document tree | RST directive listing sub-documents. Missing entries = build warning. Closest to nested TOC approach. |
|
|
473
|
+
| CODEOWNERS | File ownership | GitHub's `CODEOWNERS` lists file patterns → owners. Not a manifest but a structured file-level declaration. |
|
|
474
|
+
|
|
475
|
+
**mdBook's `SUMMARY.md` is the closest match.** It's a markdown file that lists all chapters. Unlisted files are excluded. It's the source of truth for book structure.
|
|
476
|
+
|
|
477
|
+
### Integration with input fingerprinting
|
|
478
|
+
|
|
479
|
+
The TOC becomes an input to the fingerprint:
|
|
480
|
+
|
|
481
|
+
```
|
|
482
|
+
inputs = sort([
|
|
483
|
+
sha256(readFile("CLAUDE.md.spec.ts")),
|
|
484
|
+
sha256(readFile("docs/TOC.md")), # NEW: directory manifest
|
|
485
|
+
sha256(readFile("skills/TOC.md")), # NEW: nested manifest
|
|
486
|
+
sha256(readFile("eslint.config.mjs")),
|
|
487
|
+
...
|
|
488
|
+
])
|
|
489
|
+
```
|
|
490
|
+
|
|
491
|
+
If someone adds a file to `docs/` without updating `docs/TOC.md`:
|
|
492
|
+
|
|
493
|
+
1. The TOC doesn't change → input hash unchanged → no recompile triggered
|
|
494
|
+
2. But `vigiles audit` independently checks TOC completeness → flags the unlisted file
|
|
495
|
+
3. Two independent signals: "TOC is incomplete" + "inputs haven't changed since last compile"
|
|
496
|
+
|
|
497
|
+
### vigiles-specific design
|
|
498
|
+
|
|
499
|
+
For vigiles, the TOC.md could be:
|
|
500
|
+
|
|
501
|
+
1. **Hand-written** — just a markdown file that humans/agents maintain. Audit verifies it matches the directory.
|
|
502
|
+
2. **Compiled from a spec** — `docs/TOC.md.spec.ts` that uses `file()` references. Then the compiler verifies every listed file exists, and audit verifies no unlisted files snuck in.
|
|
503
|
+
3. **Auto-generated** — `vigiles generate-toc docs/` scans the directory and emits a TOC. Then it's a build artifact like CLAUDE.md, with the same hash-based freshness.
|
|
504
|
+
|
|
505
|
+
Option 3 (auto-generated) is cleanest: the filesystem is the source of truth, the TOC is a build artifact, and audit verifies freshness. No manual maintenance.
|
|
506
|
+
|
|
507
|
+
But option 2 (compiled from spec) is more valuable: descriptions can't be auto-generated (they require understanding), so the spec is the right place for human-written descriptions. The compiler just verifies nothing was missed.
|
|
508
|
+
|
|
509
|
+
## Open Questions
|
|
510
|
+
|
|
511
|
+
1. **Should `vigiles compile` auto-verify inputs before compiling?** (i.e., if inputs haven't changed, skip compilation entirely — Bazel-style memoization)
|
|
512
|
+
2. **Should the input manifest include the vigiles version?** (A vigiles upgrade might change compilation output even with identical inputs)
|
|
513
|
+
3. **Should `vigiles audit --fix` automatically recompile when inputs are stale?** (Convenient but hides drift)
|
|
514
|
+
4. **How to handle inline mode?** Inline rules have no spec file — the "spec source" input doesn't exist. Input hash would cover just the markdown file + linter configs.
|
|
515
|
+
5. **Should TOC.md be a new compilation target?** (`vigiles init --target=TOC.md` scaffolds a TOC spec for a directory.) Or should it be a separate `vigiles toc` command?
|
|
516
|
+
6. **Recursive TOC depth:** Should nested TOCs be mandatory, or should a root TOC be allowed to list all files flat? Mandatory nesting scales better but adds friction for small projects.
|