@zosmaai/pi-llm-wiki 0.10.7 → 0.11.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/CHANGELOG.md +4 -0
- package/README.de.md +35 -4
- package/README.es.md +260 -170
- package/README.fr.md +35 -4
- package/README.hi.md +35 -4
- package/README.ja.md +35 -4
- package/README.ko.md +35 -4
- package/README.md +38 -3
- package/README.pt.md +35 -4
- package/README.ru.md +35 -4
- package/README.zh.md +260 -170
- package/assets/demo.gif +0 -0
- package/dist/extensions/llm-wiki/lib/bootstrap.js +71 -0
- package/dist/extensions/llm-wiki/lib/embeddings.js +401 -0
- package/dist/extensions/llm-wiki/lib/guardrails.js +232 -0
- package/dist/extensions/llm-wiki/lib/indexing.js +78 -0
- package/dist/extensions/llm-wiki/lib/ingest-worker.js +310 -0
- package/dist/extensions/llm-wiki/lib/inject.js +65 -0
- package/dist/extensions/llm-wiki/lib/knowledge-document.js +442 -0
- package/dist/extensions/llm-wiki/lib/knowledge-links.js +206 -0
- package/dist/extensions/llm-wiki/lib/legacy-repair.js +443 -0
- package/dist/extensions/llm-wiki/lib/metadata.js +499 -0
- package/dist/extensions/llm-wiki/lib/model-command.js +86 -0
- package/dist/extensions/llm-wiki/lib/observation.js +283 -0
- package/dist/extensions/llm-wiki/lib/recall.js +875 -0
- package/dist/extensions/llm-wiki/lib/retro.js +158 -0
- package/dist/extensions/llm-wiki/lib/runtime.js +191 -0
- package/dist/extensions/llm-wiki/lib/source-extractors.js +426 -0
- package/dist/extensions/llm-wiki/lib/source-packet.js +229 -0
- package/dist/extensions/llm-wiki/lib/subagent.js +41 -0
- package/dist/extensions/llm-wiki/lib/task-config.js +172 -0
- package/dist/extensions/llm-wiki/lib/tools.js +1192 -0
- package/dist/extensions/llm-wiki/lib/trajectories-command.js +51 -0
- package/dist/extensions/llm-wiki/lib/trajectory.js +467 -0
- package/dist/extensions/llm-wiki/lib/utils.js +347 -0
- package/dist/extensions/llm-wiki/lib/vault-format.js +247 -0
- package/dist/extensions/llm-wiki/lib/visible-status.js +31 -0
- package/dist/extensions/llm-wiki/lib/wiki-service.js +128 -0
- package/dist/mcp/exec.js +121 -0
- package/dist/mcp/index.js +229 -0
- package/dist/mcp/operations.js +130 -0
- package/dist/package.json +1 -0
- package/docs/superpowers/plans/2026-08-02-okf-foundation.md +1579 -0
- package/docs/superpowers/plans/2026-08-03-okf-foundation-remediation.md +3005 -0
- package/docs/superpowers/plans/2026-08-06-okf-foundation-release-remediation.md +1174 -0
- package/docs/superpowers/specs/2026-08-02-okf-foundation-design.md +578 -0
- package/docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md +538 -0
- package/extensions/llm-wiki/index.ts +22 -36
- package/extensions/llm-wiki/lib/bootstrap.ts +84 -0
- package/extensions/llm-wiki/lib/embeddings.ts +9 -3
- package/extensions/llm-wiki/lib/guardrails.ts +174 -29
- package/extensions/llm-wiki/lib/indexing.ts +2 -1
- package/extensions/llm-wiki/lib/ingest-worker.ts +170 -29
- package/extensions/llm-wiki/lib/knowledge-document.ts +661 -0
- package/extensions/llm-wiki/lib/knowledge-links.ts +282 -0
- package/extensions/llm-wiki/lib/legacy-repair.ts +572 -0
- package/extensions/llm-wiki/lib/metadata.ts +531 -116
- package/extensions/llm-wiki/lib/observation.ts +37 -43
- package/extensions/llm-wiki/lib/recall.ts +61 -33
- package/extensions/llm-wiki/lib/retro.ts +65 -41
- package/extensions/llm-wiki/lib/source-extractors.ts +12 -17
- package/extensions/llm-wiki/lib/source-packet.ts +44 -31
- package/extensions/llm-wiki/lib/tools.ts +406 -348
- package/extensions/llm-wiki/lib/trajectory.ts +15 -1
- package/extensions/llm-wiki/lib/utils.ts +121 -130
- package/extensions/llm-wiki/lib/vault-format.ts +363 -0
- package/extensions/llm-wiki/lib/wiki-service.ts +183 -0
- package/mcp/exec.ts +122 -0
- package/mcp/index.ts +60 -250
- package/mcp/operations.ts +176 -0
- package/package.json +8 -2
- package/scripts/migrate-llm-wiki.js +801 -0
- package/skills/llm-wiki/SKILL.md +8 -6
|
@@ -0,0 +1,3005 @@
|
|
|
1
|
+
# OKF Foundation Remediation Implementation Plan
|
|
2
|
+
|
|
3
|
+
> **For agentic workers:** REQUIRED SUB-SKILL: Use /skill:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [x]`) syntax for tracking.
|
|
4
|
+
|
|
5
|
+
**Goal:** Repair the reviewed OKF Foundation implementation so strict document handling, vault-mode enforcement, authoritative writes, projections, Pi, and the exact five MCP operations conform to the Foundation specification without adding later-phase features.
|
|
6
|
+
|
|
7
|
+
**Architecture:** Keep the existing shared `KnowledgeDocument`, vault-format, projection, and service modules, but make them the only trusted boundaries. Parsing becomes strict before conversion; vault validation becomes a shared precondition inside authoritative writers; existing pages are patched rather than reconstructed; Pi and MCP remain thin adapters over the same services. Acceptance tests exercise real extension/MCP seams instead of constructing equivalent state directly.
|
|
8
|
+
|
|
9
|
+
**Tech Stack:** TypeScript ES2022, Node.js filesystem/path/child-process APIs, `yaml` 2.9, `mdast-util-from-markdown` 2.0, Vitest 3, Biome, pnpm.
|
|
10
|
+
|
|
11
|
+
**Roadmap:** `docs/superpowers/specs/2026-08-02-okf-v0.2-interoperability-design.md` (sequencing only; non-normative)
|
|
12
|
+
|
|
13
|
+
**Phase:** Phase 1: Foundation remediation
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## Normative Inputs and Scope
|
|
18
|
+
|
|
19
|
+
The only normative behavior source is `docs/superpowers/specs/2026-08-02-okf-foundation-design.md`. The original plan at `docs/superpowers/plans/2026-08-02-okf-foundation.md` is implementation history, not authority. Review findings are defects to verify with failing tests before changing production code.
|
|
20
|
+
|
|
21
|
+
This remediation fixes Foundation behavior only. It does not add import, export, migration, transaction journals, trust/freshness scoring, graph UI, git snapshots, expanded extraction adapters, or Attested Computation execution. Per-file atomic projection replacement remains the Foundation ceiling; cross-file transactions remain out of scope.
|
|
22
|
+
|
|
23
|
+
Baseline before remediation:
|
|
24
|
+
|
|
25
|
+
```text
|
|
26
|
+
pnpm test: 35 files, 459 tests pass
|
|
27
|
+
pnpm typecheck: pass
|
|
28
|
+
pnpm lint: pass
|
|
29
|
+
pnpm test:coverage: pass without thresholds
|
|
30
|
+
coverage: 69.02% statements; extensions/llm-wiki/index.ts 0%
|
|
31
|
+
```
|
|
32
|
+
|
|
33
|
+
## Reviewed Defect Disposition
|
|
34
|
+
|
|
35
|
+
| Reviewed finding | Classification | Planned fix |
|
|
36
|
+
|---|---|---|
|
|
37
|
+
| Malformed YAML, nested duplicates, non-string `type`, null coercion, inexact fence | Implementation defect | Task 1 |
|
|
38
|
+
| Unknown `__proto__` field loss and creation-time `sources` drop | Implementation defect | Task 1 |
|
|
39
|
+
| NFC-equivalent physical paths evade collision detection | Implementation defect | Task 2 |
|
|
40
|
+
| Symlink traversal and swallowed scan errors | Implementation defect | Task 2 |
|
|
41
|
+
| Malformed percent encoding throws; root links without `.md` accepted | Implementation defect | Task 2 |
|
|
42
|
+
| Silent bootstrap omits `okf-0.2`, event, and projections | Implementation defect | Task 3 |
|
|
43
|
+
| Missing/malformed config and malformed/versionless OKF root fail open | Implementation defect | Task 3 |
|
|
44
|
+
| Bootstrap writes before validating existing state | Implementation defect | Task 3 |
|
|
45
|
+
| OKF generated-path guard is not containment-safe or fail-closed | Implementation defect | Task 3 |
|
|
46
|
+
| Capture, ingest, trajectory, event, observation, retro, lint, ensure-page, rebuild, and embeddings do not share one write precondition | Implementation defect | Tasks 3–5 |
|
|
47
|
+
| Existing source page is reconstructed and loses metadata | Implementation defect | Task 4 |
|
|
48
|
+
| Retro slug permits path traversal | Implementation defect | Task 4 |
|
|
49
|
+
| Background ingestion does not revalidate before commit | Implementation defect | Task 5 |
|
|
50
|
+
| Embeddings run after failed metadata rebuild | Implementation defect | Task 5 |
|
|
51
|
+
| Lint uses obsolete parser/scanner; old helpers remain | Implementation defect | Task 6 |
|
|
52
|
+
| Pi recall omits format diagnostics and may expose malformed stale pages | Implementation defect | Task 6 |
|
|
53
|
+
| Event diagnostics are discarded, timestamps sort lexically, details override reserved fields | Implementation defect | Task 6 |
|
|
54
|
+
| MCP writes are not discoverable and production exec is a no-op | Implementation defect | Task 7 |
|
|
55
|
+
| Task 10 acceptance test bypasses production seams | Test defect | Task 8 |
|
|
56
|
+
| Temporary-file assertion checks the wrong filename pattern | Test defect | Task 8 |
|
|
57
|
+
| Coverage plan assumed thresholds that did not exist | Original-plan defect | Task 8 |
|
|
58
|
+
| Original plan did not specify a real MCP command runner | Original-plan defect | Task 7 |
|
|
59
|
+
|
|
60
|
+
## File Responsibility Map
|
|
61
|
+
|
|
62
|
+
### New files
|
|
63
|
+
|
|
64
|
+
- `extensions/llm-wiki/lib/bootstrap.ts` — one validated bootstrap service used by silent and explicit Pi paths.
|
|
65
|
+
- `mcp/exec.ts` — Node-backed implementation of the narrow `ExecApi` used by MCP capture.
|
|
66
|
+
- `test/bootstrap.test.ts` — shared bootstrap behavior plus actual extension `session_start` seam.
|
|
67
|
+
- `test/mutation-guards.test.ts` — fail-closed regression matrix for authoritative writers.
|
|
68
|
+
- `test/indexing-fail-closed.test.ts` — background rebuild/embedding failure checks.
|
|
69
|
+
- `test/ingest-concurrency.test.ts` — configuration race between synthesis and commit.
|
|
70
|
+
- `test/mcp-exec.test.ts` — subprocess success, failure, cancellation, timeout, and real capture behavior.
|
|
71
|
+
|
|
72
|
+
### Existing production files to modify
|
|
73
|
+
|
|
74
|
+
- `extensions/llm-wiki/lib/knowledge-document.ts` — strict YAML validation and semantic conversion.
|
|
75
|
+
- `extensions/llm-wiki/lib/vault-format.ts` — strict config/root inspection, physical identity tracking, safe scanning, shared write assertion.
|
|
76
|
+
- `extensions/llm-wiki/lib/knowledge-links.ts` — non-throwing URI decoding and consistent `.md` target rules.
|
|
77
|
+
- `extensions/llm-wiki/lib/metadata.ts` — event integrity, chronological sorting, diagnostic propagation.
|
|
78
|
+
- `extensions/llm-wiki/lib/source-packet.ts` — shared write assertion before packet creation.
|
|
79
|
+
- `extensions/llm-wiki/lib/ingest-worker.ts` — immediate pre-commit validation and legacy-preserving patch.
|
|
80
|
+
- `extensions/llm-wiki/lib/observation.ts` — shared write assertion in the service.
|
|
81
|
+
- `extensions/llm-wiki/lib/retro.ts` — shared write assertion and safe slug/path handling.
|
|
82
|
+
- `extensions/llm-wiki/lib/trajectory.ts` — shared write assertion before trajectory packet creation.
|
|
83
|
+
- `extensions/llm-wiki/lib/embeddings.ts` — shared write assertion before embedding-store replacement.
|
|
84
|
+
- `extensions/llm-wiki/lib/indexing.ts` — skip embeddings after a blocked rebuild.
|
|
85
|
+
- `extensions/llm-wiki/lib/recall.ts` — format diagnostics and malformed-page exclusion.
|
|
86
|
+
- `extensions/llm-wiki/lib/tools.ts` — shared bootstrap, strict mutation checks, shared lint/link services, safe event input.
|
|
87
|
+
- `extensions/llm-wiki/lib/guardrails.ts` — fail closed for invalid vault state and protect only contained OKF generated paths.
|
|
88
|
+
- `extensions/llm-wiki/lib/utils.ts` — remove obsolete YAML/page/link helpers; retain path, JSON, slug, and exec utilities.
|
|
89
|
+
- `extensions/llm-wiki/index.ts` — use shared bootstrap during `session_start`.
|
|
90
|
+
- `mcp/operations.ts` — rebuild after writes and propagate diagnostics.
|
|
91
|
+
- `mcp/index.ts` — use the real MCP exec adapter.
|
|
92
|
+
- `vitest.config.ts` — enforce global and trusted-boundary coverage thresholds.
|
|
93
|
+
|
|
94
|
+
### Existing tests to extend
|
|
95
|
+
|
|
96
|
+
- `test/knowledge-document.test.ts`
|
|
97
|
+
- `test/vault-format.test.ts`
|
|
98
|
+
- `test/knowledge-links.test.ts`
|
|
99
|
+
- `test/okf-projections.test.ts`
|
|
100
|
+
- `test/ingest-worker.test.ts`
|
|
101
|
+
- `test/indexing.test.ts`
|
|
102
|
+
- `test/guardrails.test.ts`
|
|
103
|
+
- `test/background-tools.test.ts`
|
|
104
|
+
- `test/ingest-tool.test.ts`
|
|
105
|
+
- `test/trajectory.test.ts`
|
|
106
|
+
- `test/recall.test.ts`
|
|
107
|
+
- `test/mcp-parity.test.ts`
|
|
108
|
+
- `test/okf-integration.test.ts`
|
|
109
|
+
- `test/package-structure.test.ts`
|
|
110
|
+
|
|
111
|
+
---
|
|
112
|
+
|
|
113
|
+
### Task 1: Make the Knowledge Document Boundary Strict and Semantic
|
|
114
|
+
|
|
115
|
+
**Files:**
|
|
116
|
+
- Modify: `extensions/llm-wiki/lib/knowledge-document.ts:150-633`
|
|
117
|
+
- Modify: `test/knowledge-document.test.ts`
|
|
118
|
+
|
|
119
|
+
- [x] **Step 1: Add adversarial parser and semantic round-trip tests**
|
|
120
|
+
|
|
121
|
+
Append these cases to `test/knowledge-document.test.ts`:
|
|
122
|
+
|
|
123
|
+
```ts
|
|
124
|
+
it.each([
|
|
125
|
+
["frontmatter_parse_error", "---\ntype: concept\nx: [1,\n---\n"],
|
|
126
|
+
["frontmatter_duplicate_key", "---\ntype: concept\nx:\n a: 1\n a: 2\n---\n"],
|
|
127
|
+
["frontmatter_missing", "---oops\ntype: concept\n---\n"],
|
|
128
|
+
["concept_missing_type", "---\ntype: 42\n---\n"],
|
|
129
|
+
["concept_missing_type", "---\ntype: []\n---\n"],
|
|
130
|
+
])("rejects adversarial input with %s", (code, input) => {
|
|
131
|
+
const result = parseKnowledgeDocument(input, "concepts/adversarial.md");
|
|
132
|
+
expect(result.ok).toBe(false);
|
|
133
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(code);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("preserves null and prototype-named unknown fields semantically", () => {
|
|
137
|
+
const input = [
|
|
138
|
+
"---",
|
|
139
|
+
"type: concept",
|
|
140
|
+
"nullable: null",
|
|
141
|
+
"__proto__:",
|
|
142
|
+
" enabled: true",
|
|
143
|
+
"constructor:",
|
|
144
|
+
" nested: value",
|
|
145
|
+
"---",
|
|
146
|
+
"",
|
|
147
|
+
"Body.",
|
|
148
|
+
"",
|
|
149
|
+
].join("\n");
|
|
150
|
+
const first = parsed(input);
|
|
151
|
+
expect(first.extensions.nullable).toBeNull();
|
|
152
|
+
expect(Object.hasOwn(first.extensions, "__proto__")).toBe(true);
|
|
153
|
+
expect(first.extensions.__proto__).toEqual({ enabled: true });
|
|
154
|
+
const second = parsed(serializeKnowledgeDocument(first));
|
|
155
|
+
expect(second.extensions).toEqual(first.extensions);
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("preserves explicit null sources as an unknown shape", () => {
|
|
159
|
+
const doc = parsed("---\ntype: concept\nsources: null\n---\n");
|
|
160
|
+
expect(doc.sources).toEqual({ kind: "unknown-shape", value: null });
|
|
161
|
+
expect(parsed(serializeKnowledgeDocument(doc)).sources).toEqual(doc.sources);
|
|
162
|
+
});
|
|
163
|
+
|
|
164
|
+
it("rejects sources passed through creation fields instead of the canonical argument", () => {
|
|
165
|
+
expect(() =>
|
|
166
|
+
createKnowledgeDocument(
|
|
167
|
+
"concepts/bad.md",
|
|
168
|
+
{ type: "concept", sources: [] } as never,
|
|
169
|
+
"Body.",
|
|
170
|
+
),
|
|
171
|
+
).toThrow("Pass canonical sources as the fourth argument");
|
|
172
|
+
});
|
|
173
|
+
```
|
|
174
|
+
|
|
175
|
+
- [x] **Step 2: Run the focused test and verify the reviewed failures**
|
|
176
|
+
|
|
177
|
+
Run:
|
|
178
|
+
|
|
179
|
+
```bash
|
|
180
|
+
pnpm vitest run test/knowledge-document.test.ts
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
Expected: FAIL because malformed YAML and nested duplicates parse successfully, numeric `type` is accepted, null is stringified, `__proto__` is lost, and creation-time `sources` is silently ignored.
|
|
184
|
+
|
|
185
|
+
- [x] **Step 3: Enforce exact fences and YAML document errors before conversion**
|
|
186
|
+
|
|
187
|
+
In `knowledge-document.ts`, replace the opening-fence check and parser options/error handling with:
|
|
188
|
+
|
|
189
|
+
```ts
|
|
190
|
+
if (!normalized.startsWith("---\n")) {
|
|
191
|
+
return {
|
|
192
|
+
ok: false,
|
|
193
|
+
diagnostics: [
|
|
194
|
+
diag("error", "frontmatter_missing", path, "Missing frontmatter opening delimiter"),
|
|
195
|
+
],
|
|
196
|
+
};
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
let docs: Document[];
|
|
200
|
+
try {
|
|
201
|
+
docs = parseAllDocuments(yamlText, {
|
|
202
|
+
schema: "core",
|
|
203
|
+
merge: false,
|
|
204
|
+
uniqueKeys: true,
|
|
205
|
+
});
|
|
206
|
+
} catch (error: unknown) {
|
|
207
|
+
const parsed = error as {
|
|
208
|
+
code?: string;
|
|
209
|
+
message: string;
|
|
210
|
+
linePos?: Array<{ line: number; col: number }>;
|
|
211
|
+
};
|
|
212
|
+
const position = parsed.linePos?.[0];
|
|
213
|
+
return {
|
|
214
|
+
ok: false,
|
|
215
|
+
diagnostics: [
|
|
216
|
+
diag(
|
|
217
|
+
"error",
|
|
218
|
+
parsed.code === "DUPLICATE_KEY"
|
|
219
|
+
? "frontmatter_duplicate_key"
|
|
220
|
+
: "frontmatter_parse_error",
|
|
221
|
+
path,
|
|
222
|
+
`YAML parse error: ${parsed.message}`,
|
|
223
|
+
position?.line,
|
|
224
|
+
position?.col,
|
|
225
|
+
),
|
|
226
|
+
],
|
|
227
|
+
};
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
if (docs.length !== 1) {
|
|
231
|
+
return {
|
|
232
|
+
ok: false,
|
|
233
|
+
diagnostics: [
|
|
234
|
+
diag(
|
|
235
|
+
"error",
|
|
236
|
+
"frontmatter_multiple_documents",
|
|
237
|
+
path,
|
|
238
|
+
"Multiple YAML documents in frontmatter",
|
|
239
|
+
),
|
|
240
|
+
],
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
const yamlError = docs.flatMap((document) => document.errors)[0] as
|
|
245
|
+
| {
|
|
246
|
+
code?: string;
|
|
247
|
+
message: string;
|
|
248
|
+
linePos?: Array<{ line: number; col: number }>;
|
|
249
|
+
}
|
|
250
|
+
| undefined;
|
|
251
|
+
if (yamlError) {
|
|
252
|
+
const position = yamlError.linePos?.[0];
|
|
253
|
+
return {
|
|
254
|
+
ok: false,
|
|
255
|
+
diagnostics: [
|
|
256
|
+
diag(
|
|
257
|
+
"error",
|
|
258
|
+
yamlError.code === "DUPLICATE_KEY"
|
|
259
|
+
? "frontmatter_duplicate_key"
|
|
260
|
+
: "frontmatter_parse_error",
|
|
261
|
+
path,
|
|
262
|
+
`YAML parse error: ${yamlError.message}`,
|
|
263
|
+
position?.line,
|
|
264
|
+
position?.col,
|
|
265
|
+
),
|
|
266
|
+
],
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
```
|
|
270
|
+
|
|
271
|
+
Delete the manual root-only `seenKeys` loop. Keep alias, custom-tag, byte-limit, depth-limit, and multiple-document checks; existing tests prove those restrictions and must remain green.
|
|
272
|
+
|
|
273
|
+
- [x] **Step 4: Preserve all JSON-compatible YAML scalar and mapping values**
|
|
274
|
+
|
|
275
|
+
Replace `classifySources` and `toKnowledgeValue` with:
|
|
276
|
+
|
|
277
|
+
```ts
|
|
278
|
+
function classifySources(raw: KnowledgeValue | undefined): KnowledgeSources {
|
|
279
|
+
if (raw === undefined) return { kind: "absent" };
|
|
280
|
+
if (typeof raw === "string") return { kind: "legacy-scalar", value: raw };
|
|
281
|
+
if (Array.isArray(raw)) {
|
|
282
|
+
if (raw.every((value) => typeof value === "string")) {
|
|
283
|
+
return { kind: "legacy-list", value: raw };
|
|
284
|
+
}
|
|
285
|
+
if (raw.every((value) => value !== null && typeof value === "object" && !Array.isArray(value))) {
|
|
286
|
+
return { kind: "canonical", value: raw as Array<Record<string, KnowledgeValue>> };
|
|
287
|
+
}
|
|
288
|
+
}
|
|
289
|
+
return { kind: "unknown-shape", value: raw };
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
function toKnowledgeValue(node: unknown): KnowledgeValue {
|
|
293
|
+
if (node === null) return null;
|
|
294
|
+
if (isScalar(node)) {
|
|
295
|
+
const value = node.value;
|
|
296
|
+
if (
|
|
297
|
+
value === null ||
|
|
298
|
+
typeof value === "boolean" ||
|
|
299
|
+
typeof value === "number" ||
|
|
300
|
+
typeof value === "string"
|
|
301
|
+
) {
|
|
302
|
+
return value;
|
|
303
|
+
}
|
|
304
|
+
return String(value);
|
|
305
|
+
}
|
|
306
|
+
if (isSeq(node)) return node.items.map(toKnowledgeValue);
|
|
307
|
+
if (isMap(node)) {
|
|
308
|
+
return Object.fromEntries(
|
|
309
|
+
node.items.map((item) => [
|
|
310
|
+
String(isScalar(item.key) ? item.key.value : item.key),
|
|
311
|
+
toKnowledgeValue(item.value),
|
|
312
|
+
]),
|
|
313
|
+
) as Record<string, KnowledgeValue>;
|
|
314
|
+
}
|
|
315
|
+
return null;
|
|
316
|
+
}
|
|
317
|
+
```
|
|
318
|
+
|
|
319
|
+
Require `type` to be a trimmed non-empty string:
|
|
320
|
+
|
|
321
|
+
```ts
|
|
322
|
+
const rawType = mapping.type;
|
|
323
|
+
if (typeof rawType !== "string" || !rawType.trim()) {
|
|
324
|
+
return {
|
|
325
|
+
ok: false,
|
|
326
|
+
diagnostics: [diag("error", "concept_missing_type", path, "Missing or empty string type field")],
|
|
327
|
+
};
|
|
328
|
+
}
|
|
329
|
+
const frontmatter: KnowledgeFrontmatter = { type: rawType };
|
|
330
|
+
```
|
|
331
|
+
|
|
332
|
+
- [x] **Step 5: Prevent creation-time provenance bypasses**
|
|
333
|
+
|
|
334
|
+
Add and use this creation type and runtime guard:
|
|
335
|
+
|
|
336
|
+
```ts
|
|
337
|
+
export type KnowledgeCreationFields = {
|
|
338
|
+
type: string;
|
|
339
|
+
sources?: never;
|
|
340
|
+
} & Record<string, KnowledgeValue | undefined>;
|
|
341
|
+
|
|
342
|
+
export function createKnowledgeDocument(
|
|
343
|
+
path: string,
|
|
344
|
+
fields: KnowledgeCreationFields,
|
|
345
|
+
body: string,
|
|
346
|
+
sources?: Array<Record<string, KnowledgeValue>>,
|
|
347
|
+
): KnowledgeDocument {
|
|
348
|
+
if (Object.hasOwn(fields, "sources")) {
|
|
349
|
+
throw new Error("Pass canonical sources as the fourth argument");
|
|
350
|
+
}
|
|
351
|
+
// Keep the existing field split, body normalization, and return value.
|
|
352
|
+
}
|
|
353
|
+
```
|
|
354
|
+
|
|
355
|
+
The implementation body must retain the current canonical source union and standard/extension split; only the signature and guard change.
|
|
356
|
+
|
|
357
|
+
- [x] **Step 6: Run parser, type, and producer tests**
|
|
358
|
+
|
|
359
|
+
Run:
|
|
360
|
+
|
|
361
|
+
```bash
|
|
362
|
+
pnpm vitest run test/knowledge-document.test.ts test/source-capture.test.ts test/ingest-worker.test.ts test/observation.test.ts test/retro.test.ts
|
|
363
|
+
pnpm typecheck
|
|
364
|
+
```
|
|
365
|
+
|
|
366
|
+
Expected: all pass. No YAML-library exception reaches a caller, and all producer call sites satisfy `KnowledgeCreationFields`.
|
|
367
|
+
|
|
368
|
+
- [x] **Step 7: Commit the strict document boundary**
|
|
369
|
+
|
|
370
|
+
```bash
|
|
371
|
+
git add extensions/llm-wiki/lib/knowledge-document.ts test/knowledge-document.test.ts
|
|
372
|
+
git commit -m "fix: enforce strict OKF document parsing"
|
|
373
|
+
```
|
|
374
|
+
|
|
375
|
+
---
|
|
376
|
+
|
|
377
|
+
### Task 2: Make Discovery and Link Resolution Deterministic and Contained
|
|
378
|
+
|
|
379
|
+
**Files:**
|
|
380
|
+
- Modify: `extensions/llm-wiki/lib/vault-format.ts:165-289`
|
|
381
|
+
- Modify: `extensions/llm-wiki/lib/knowledge-links.ts:109-227`
|
|
382
|
+
- Modify: `test/vault-format.test.ts`
|
|
383
|
+
- Modify: `test/knowledge-links.test.ts`
|
|
384
|
+
|
|
385
|
+
- [x] **Step 1: Add physical-collision, symlink, scan-error, and URI tests**
|
|
386
|
+
|
|
387
|
+
Add to `test/vault-format.test.ts`:
|
|
388
|
+
|
|
389
|
+
```ts
|
|
390
|
+
it("blocks physically distinct NFC-equivalent paths", () => {
|
|
391
|
+
const paths = vault({ knowledge_format: "okf-0.2" });
|
|
392
|
+
mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
|
|
393
|
+
writeFileSync(join(paths.wiki, "concepts", "café.md"), "---\ntype: concept\n---\n");
|
|
394
|
+
writeFileSync(join(paths.wiki, "concepts", "café.md"), "---\ntype: concept\n---\n");
|
|
395
|
+
const result = discoverKnowledgeDocuments(paths);
|
|
396
|
+
expect(result.blocking).toBe(true);
|
|
397
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
398
|
+
"concept_identity_collision",
|
|
399
|
+
);
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
it("does not follow directory symlinks outside the wiki", () => {
|
|
403
|
+
const paths = vault({ knowledge_format: "legacy" });
|
|
404
|
+
const external = join(paths.root, "external");
|
|
405
|
+
mkdirSync(external, { recursive: true });
|
|
406
|
+
writeFileSync(join(external, "outside.md"), "---\ntype: concept\n---\n");
|
|
407
|
+
symlinkSync(external, join(paths.wiki, "linked"), "dir");
|
|
408
|
+
expect(discoverKnowledgeDocuments(paths).documents).toEqual([]);
|
|
409
|
+
});
|
|
410
|
+
|
|
411
|
+
it("blocks publication when a knowledge directory cannot be scanned", () => {
|
|
412
|
+
const paths = vault({ knowledge_format: "legacy" });
|
|
413
|
+
const unreadable = join(paths.wiki, "concepts");
|
|
414
|
+
mkdirSync(unreadable, { recursive: true });
|
|
415
|
+
chmodSync(unreadable, 0o000);
|
|
416
|
+
try {
|
|
417
|
+
const result = discoverKnowledgeDocuments(paths);
|
|
418
|
+
if (process.getuid?.() !== 0) {
|
|
419
|
+
expect(result.blocking).toBe(true);
|
|
420
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
421
|
+
"frontmatter_parse_error",
|
|
422
|
+
);
|
|
423
|
+
}
|
|
424
|
+
} finally {
|
|
425
|
+
chmodSync(unreadable, 0o700);
|
|
426
|
+
}
|
|
427
|
+
});
|
|
428
|
+
```
|
|
429
|
+
|
|
430
|
+
Add `chmodSync` and `symlinkSync` to the test's `node:fs` imports.
|
|
431
|
+
|
|
432
|
+
Add to `test/knowledge-links.test.ts`:
|
|
433
|
+
|
|
434
|
+
```ts
|
|
435
|
+
it("turns malformed percent encoding into a diagnostic instead of throwing", () => {
|
|
436
|
+
expect(() =>
|
|
437
|
+
buildResolvedBacklinks("concepts/source", "[bad](bad%ZZ.md)", known),
|
|
438
|
+
).not.toThrow();
|
|
439
|
+
const result = buildResolvedBacklinks(
|
|
440
|
+
"concepts/source",
|
|
441
|
+
"[bad](bad%ZZ.md)",
|
|
442
|
+
known,
|
|
443
|
+
);
|
|
444
|
+
expect(result.targets).toEqual([]);
|
|
445
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toEqual([
|
|
446
|
+
"link_unresolved",
|
|
447
|
+
]);
|
|
448
|
+
});
|
|
449
|
+
|
|
450
|
+
it("requires md suffix for root-relative Markdown links", () => {
|
|
451
|
+
const result = buildResolvedBacklinks(
|
|
452
|
+
"concepts/source",
|
|
453
|
+
"[missing suffix](/shared/root)",
|
|
454
|
+
known,
|
|
455
|
+
);
|
|
456
|
+
expect(result.targets).toEqual([]);
|
|
457
|
+
});
|
|
458
|
+
```
|
|
459
|
+
|
|
460
|
+
- [x] **Step 2: Run focused tests and verify failure**
|
|
461
|
+
|
|
462
|
+
```bash
|
|
463
|
+
pnpm vitest run test/vault-format.test.ts test/knowledge-links.test.ts
|
|
464
|
+
```
|
|
465
|
+
|
|
466
|
+
Expected: NFC-equivalent paths are not reported as collisions, symlinked content is scanned, and malformed URI encoding throws `URIError`.
|
|
467
|
+
|
|
468
|
+
- [x] **Step 3: Return scan diagnostics instead of swallowing directory errors**
|
|
469
|
+
|
|
470
|
+
Replace `collectMarkdownFiles` with a result-bearing recursive scanner:
|
|
471
|
+
|
|
472
|
+
```ts
|
|
473
|
+
interface MarkdownScan {
|
|
474
|
+
files: string[];
|
|
475
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
function collectMarkdownFiles(dir: string, wikiRoot: string): MarkdownScan {
|
|
479
|
+
const files: string[] = [];
|
|
480
|
+
const diagnostics: KnowledgeDiagnostic[] = [];
|
|
481
|
+
let entries: string[];
|
|
482
|
+
try {
|
|
483
|
+
entries = readdirSync(dir).sort(compareCodePoint);
|
|
484
|
+
} catch (error: unknown) {
|
|
485
|
+
diagnostics.push(
|
|
486
|
+
diag(
|
|
487
|
+
"error",
|
|
488
|
+
"frontmatter_parse_error",
|
|
489
|
+
relative(wikiRoot, dir).replace(/\\/g, "/") || ".",
|
|
490
|
+
`Failed to scan knowledge directory: ${(error as Error).message}`,
|
|
491
|
+
),
|
|
492
|
+
);
|
|
493
|
+
return { files, diagnostics };
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
for (const entry of entries) {
|
|
497
|
+
const fullPath = join(dir, entry);
|
|
498
|
+
try {
|
|
499
|
+
const stat = lstatSync(fullPath);
|
|
500
|
+
if (stat.isSymbolicLink()) continue;
|
|
501
|
+
if (stat.isDirectory()) {
|
|
502
|
+
const child = collectMarkdownFiles(fullPath, wikiRoot);
|
|
503
|
+
files.push(...child.files);
|
|
504
|
+
diagnostics.push(...child.diagnostics);
|
|
505
|
+
} else if (stat.isFile() && entry.toLowerCase().endsWith(".md") && !isReservedName(entry)) {
|
|
506
|
+
files.push(fullPath);
|
|
507
|
+
}
|
|
508
|
+
} catch (error: unknown) {
|
|
509
|
+
diagnostics.push(
|
|
510
|
+
diag(
|
|
511
|
+
"error",
|
|
512
|
+
"frontmatter_parse_error",
|
|
513
|
+
relative(wikiRoot, fullPath).replace(/\\/g, "/"),
|
|
514
|
+
`Failed to inspect knowledge path: ${(error as Error).message}`,
|
|
515
|
+
),
|
|
516
|
+
);
|
|
517
|
+
}
|
|
518
|
+
}
|
|
519
|
+
return { files, diagnostics };
|
|
520
|
+
}
|
|
521
|
+
```
|
|
522
|
+
|
|
523
|
+
Change imports from `statSync`/`normalize` to `join`, `lstatSync`, and `relative`. In `discoverKnowledgeDocuments`, initialize from the scan and make scan errors blocking:
|
|
524
|
+
|
|
525
|
+
```ts
|
|
526
|
+
const scan = collectMarkdownFiles(paths.wiki, paths.wiki);
|
|
527
|
+
diagnostics.push(...scan.diagnostics);
|
|
528
|
+
if (scan.diagnostics.length > 0) blocking = true;
|
|
529
|
+
|
|
530
|
+
for (const file of scan.files) {
|
|
531
|
+
const physicalPath = relative(paths.wiki, file).replace(/\\/g, "/");
|
|
532
|
+
const normalizedPath = physicalPath.normalize("NFC");
|
|
533
|
+
const id = normalizedPath.replace(/\.md$/, "");
|
|
534
|
+
const collisionKey = id.toLowerCase();
|
|
535
|
+
const existing = seenIds.get(collisionKey);
|
|
536
|
+
if (existing && existing.physicalPath !== physicalPath) {
|
|
537
|
+
diagnostics.push(
|
|
538
|
+
diag(
|
|
539
|
+
"error",
|
|
540
|
+
"concept_identity_collision",
|
|
541
|
+
physicalPath,
|
|
542
|
+
`Identity collision between ${existing.physicalPath} and ${physicalPath}`,
|
|
543
|
+
),
|
|
544
|
+
);
|
|
545
|
+
blocking = true;
|
|
546
|
+
continue;
|
|
547
|
+
}
|
|
548
|
+
seenIds.set(collisionKey, { id, physicalPath });
|
|
549
|
+
// Keep the existing reserved-name, parsing, and document construction logic.
|
|
550
|
+
}
|
|
551
|
+
```
|
|
552
|
+
|
|
553
|
+
Declare `seenIds` as:
|
|
554
|
+
|
|
555
|
+
```ts
|
|
556
|
+
const seenIds = new Map<string, { id: string; physicalPath: string }>();
|
|
557
|
+
```
|
|
558
|
+
|
|
559
|
+
- [x] **Step 4: Make malformed URI segments non-throwing and require `.md` consistently**
|
|
560
|
+
|
|
561
|
+
Extend `resolveMarkdownTarget`'s return union with `{ kind: "invalid" }`, then replace decoding and root-relative suffix handling:
|
|
562
|
+
|
|
563
|
+
```ts
|
|
564
|
+
let decoded: string[];
|
|
565
|
+
try {
|
|
566
|
+
decoded = clean.split("/").map((segment) => decodeURIComponent(segment));
|
|
567
|
+
} catch {
|
|
568
|
+
return { kind: "invalid" };
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
// After resolving root-relative stack:
|
|
572
|
+
const resolved = stack.join("/");
|
|
573
|
+
if (!resolved.endsWith(".md")) return { kind: "empty" };
|
|
574
|
+
return { kind: "concept", id: resolved.slice(0, -3) };
|
|
575
|
+
```
|
|
576
|
+
|
|
577
|
+
Handle the new result in `buildResolvedBacklinks`:
|
|
578
|
+
|
|
579
|
+
```ts
|
|
580
|
+
if (resolved.kind === "invalid") {
|
|
581
|
+
diagnostics.push(
|
|
582
|
+
diag(
|
|
583
|
+
"warning",
|
|
584
|
+
"link_unresolved",
|
|
585
|
+
`${sourceId}.md`,
|
|
586
|
+
`Malformed percent-encoded link: ${link.target}`,
|
|
587
|
+
),
|
|
588
|
+
);
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
```
|
|
592
|
+
|
|
593
|
+
- [x] **Step 5: Run discovery, link, projection, and type checks**
|
|
594
|
+
|
|
595
|
+
```bash
|
|
596
|
+
pnpm vitest run test/vault-format.test.ts test/knowledge-links.test.ts test/okf-projections.test.ts
|
|
597
|
+
pnpm typecheck
|
|
598
|
+
```
|
|
599
|
+
|
|
600
|
+
Expected: all pass. Symlinks are excluded, physical NFC/case collisions block, unreadable scans cannot publish partial metadata, and malformed links produce diagnostics without throwing.
|
|
601
|
+
|
|
602
|
+
- [x] **Step 6: Commit discovery and link containment**
|
|
603
|
+
|
|
604
|
+
```bash
|
|
605
|
+
git add extensions/llm-wiki/lib/vault-format.ts extensions/llm-wiki/lib/knowledge-links.ts test/vault-format.test.ts test/knowledge-links.test.ts
|
|
606
|
+
git commit -m "fix: contain OKF discovery and link resolution"
|
|
607
|
+
```
|
|
608
|
+
|
|
609
|
+
---
|
|
610
|
+
|
|
611
|
+
### Task 3: Centralize Strict Vault Inspection and Bootstrap
|
|
612
|
+
|
|
613
|
+
**Files:**
|
|
614
|
+
- Create: `extensions/llm-wiki/lib/bootstrap.ts`
|
|
615
|
+
- Create: `test/bootstrap.test.ts`
|
|
616
|
+
- Modify: `extensions/llm-wiki/lib/vault-format.ts:52-217`
|
|
617
|
+
- Modify: `extensions/llm-wiki/lib/tools.ts:96-244`
|
|
618
|
+
- Modify: `extensions/llm-wiki/index.ts:1-180`
|
|
619
|
+
- Modify: `extensions/llm-wiki/lib/guardrails.ts:185-258`
|
|
620
|
+
- Modify: `test/vault-format.test.ts`
|
|
621
|
+
- Modify: `test/guardrails.test.ts`
|
|
622
|
+
|
|
623
|
+
- [x] **Step 1: Add strict config/root inspection tests**
|
|
624
|
+
|
|
625
|
+
Add to `test/vault-format.test.ts`:
|
|
626
|
+
|
|
627
|
+
```ts
|
|
628
|
+
it.each([
|
|
629
|
+
["missing", undefined],
|
|
630
|
+
["malformed", "{not-json"],
|
|
631
|
+
["array", "[]"],
|
|
632
|
+
])("fails closed for %s config", (_label, configText) => {
|
|
633
|
+
const paths = vault({ knowledge_format: "legacy" });
|
|
634
|
+
const configPath = join(paths.dotWiki, "config.json");
|
|
635
|
+
if (configText === undefined) rmSync(configPath);
|
|
636
|
+
else writeFileSync(configPath, configText);
|
|
637
|
+
const state = inspectVaultFormat(paths);
|
|
638
|
+
expect(state.blocking).toBe(true);
|
|
639
|
+
expect(state.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
640
|
+
"config_invalid_knowledge_format",
|
|
641
|
+
);
|
|
642
|
+
expect(inspectWritableVault(paths).ok).toBe(false);
|
|
643
|
+
});
|
|
644
|
+
|
|
645
|
+
it.each([
|
|
646
|
+
["frontmatter-less", "# user index\n"],
|
|
647
|
+
["malformed", "---\nokf_version: [\n---\n"],
|
|
648
|
+
["versionless", "---\ntitle: Root\n---\n"],
|
|
649
|
+
])("blocks an existing %s OKF root index", (_label, content) => {
|
|
650
|
+
const paths = vault({ knowledge_format: "okf-0.2" });
|
|
651
|
+
writeFileSync(join(paths.wiki, "index.md"), content);
|
|
652
|
+
const state = inspectVaultFormat(paths);
|
|
653
|
+
expect(state.blocking).toBe(true);
|
|
654
|
+
expect(state.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
655
|
+
"okf_version_mismatch",
|
|
656
|
+
);
|
|
657
|
+
});
|
|
658
|
+
|
|
659
|
+
it("turns an unreadable OKF root into a blocking diagnostic", () => {
|
|
660
|
+
const paths = vault({ knowledge_format: "okf-0.2" });
|
|
661
|
+
mkdirSync(join(paths.wiki, "index.md"));
|
|
662
|
+
expect(() => inspectVaultFormat(paths)).not.toThrow();
|
|
663
|
+
const state = inspectVaultFormat(paths);
|
|
664
|
+
expect(state.blocking).toBe(true);
|
|
665
|
+
expect(state.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
666
|
+
"okf_version_mismatch",
|
|
667
|
+
);
|
|
668
|
+
});
|
|
669
|
+
```
|
|
670
|
+
|
|
671
|
+
- [x] **Step 2: Add bootstrap service and actual extension-seam tests**
|
|
672
|
+
|
|
673
|
+
Create `test/bootstrap.test.ts` with a minimal extension harness:
|
|
674
|
+
|
|
675
|
+
```ts
|
|
676
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
677
|
+
import { join } from "node:path";
|
|
678
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
679
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
680
|
+
import extension from "../extensions/llm-wiki/index.js";
|
|
681
|
+
import { bootstrapVault } from "../extensions/llm-wiki/lib/bootstrap.js";
|
|
682
|
+
import {
|
|
683
|
+
getVaultPaths,
|
|
684
|
+
resolveVaultPaths,
|
|
685
|
+
} from "../extensions/llm-wiki/lib/utils.js";
|
|
686
|
+
|
|
687
|
+
const roots: string[] = [];
|
|
688
|
+
const originalCwd = process.cwd();
|
|
689
|
+
const originalWikiHome = process.env.WIKI_HOME;
|
|
690
|
+
afterEach(() => {
|
|
691
|
+
process.chdir(originalCwd);
|
|
692
|
+
// biome-ignore lint/performance/noDelete: restore an originally absent variable
|
|
693
|
+
if (originalWikiHome === undefined) delete process.env.WIKI_HOME;
|
|
694
|
+
else process.env.WIKI_HOME = originalWikiHome;
|
|
695
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
696
|
+
});
|
|
697
|
+
|
|
698
|
+
function root() {
|
|
699
|
+
const value = join(import.meta.dirname, "..", "tmp", `bootstrap-${Date.now()}-${Math.random()}`);
|
|
700
|
+
roots.push(value);
|
|
701
|
+
return value;
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function extensionHarness() {
|
|
705
|
+
const handlers = new Map<string, Array<(...args: any[]) => unknown>>();
|
|
706
|
+
const tools = new Map<string, { execute: (...args: any[]) => Promise<any> }>();
|
|
707
|
+
const pi = {
|
|
708
|
+
on: (name: string, handler: (...args: any[]) => unknown) => {
|
|
709
|
+
const current = handlers.get(name) ?? [];
|
|
710
|
+
current.push(handler);
|
|
711
|
+
handlers.set(name, current);
|
|
712
|
+
},
|
|
713
|
+
registerTool: (tool: { name: string; execute: (...args: any[]) => Promise<any> }) => {
|
|
714
|
+
tools.set(tool.name, tool);
|
|
715
|
+
},
|
|
716
|
+
registerCommand: () => {},
|
|
717
|
+
sendMessage: () => {},
|
|
718
|
+
} as unknown as ExtensionAPI;
|
|
719
|
+
extension(pi);
|
|
720
|
+
return { handlers, tools };
|
|
721
|
+
}
|
|
722
|
+
|
|
723
|
+
it("silent session bootstrap persists OKF mode and creates current projections", async () => {
|
|
724
|
+
const cwd = root();
|
|
725
|
+
mkdirSync(cwd, { recursive: true });
|
|
726
|
+
process.chdir(cwd);
|
|
727
|
+
process.env.WIKI_HOME = cwd;
|
|
728
|
+
expect(resolveVaultPaths(cwd).root).toBe(cwd);
|
|
729
|
+
const { handlers } = extensionHarness();
|
|
730
|
+
const sessionStart = handlers.get("session_start")?.at(-1);
|
|
731
|
+
expect(sessionStart).toBeDefined();
|
|
732
|
+
await sessionStart?.({}, {
|
|
733
|
+
hasUI: true,
|
|
734
|
+
ui: { setStatus: () => {} },
|
|
735
|
+
model: { id: "test" },
|
|
736
|
+
});
|
|
737
|
+
const paths = getVaultPaths(cwd);
|
|
738
|
+
expect(resolveVaultPaths(cwd).root).toBe(cwd);
|
|
739
|
+
const config = JSON.parse(readFileSync(join(paths.dotWiki, "config.json"), "utf8"));
|
|
740
|
+
expect(config.knowledge_format).toBe("okf-0.2");
|
|
741
|
+
expect(readFileSync(join(paths.wiki, "index.md"), "utf8")).toContain('okf_version: "0.2"');
|
|
742
|
+
expect(readFileSync(join(paths.wiki, "log.md"), "utf8")).toContain("bootstrap");
|
|
743
|
+
});
|
|
744
|
+
|
|
745
|
+
it("blocks an existing invalid vault during real session startup", async () => {
|
|
746
|
+
const cwd = root();
|
|
747
|
+
const paths = getVaultPaths(cwd);
|
|
748
|
+
mkdirSync(paths.dotWiki, { recursive: true });
|
|
749
|
+
writeFileSync(
|
|
750
|
+
join(paths.dotWiki, "config.json"),
|
|
751
|
+
JSON.stringify({ knowledge_format: "future" }),
|
|
752
|
+
);
|
|
753
|
+
process.chdir(cwd);
|
|
754
|
+
process.env.WIKI_HOME = cwd;
|
|
755
|
+
expect(resolveVaultPaths(cwd).root).toBe(cwd);
|
|
756
|
+
const { handlers } = extensionHarness();
|
|
757
|
+
const statuses: string[] = [];
|
|
758
|
+
const sessionStart = handlers.get("session_start")?.at(-1);
|
|
759
|
+
await sessionStart?.({}, {
|
|
760
|
+
hasUI: true,
|
|
761
|
+
ui: { setStatus: (_key: string, value: string) => statuses.push(value) },
|
|
762
|
+
model: { id: "test" },
|
|
763
|
+
});
|
|
764
|
+
expect(statuses.some((status) => status.includes("setup blocked"))).toBe(true);
|
|
765
|
+
expect(existsSync(join(paths.meta, "events.jsonl"))).toBe(false);
|
|
766
|
+
});
|
|
767
|
+
|
|
768
|
+
it("explicit bootstrap preserves an old vault's missing mode field", () => {
|
|
769
|
+
const cwd = root();
|
|
770
|
+
const paths = getVaultPaths(cwd);
|
|
771
|
+
mkdirSync(paths.dotWiki, { recursive: true });
|
|
772
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ name: "Old" }));
|
|
773
|
+
const result = bootstrapVault(paths, { topic: "Updated", mode: "personal" });
|
|
774
|
+
expect(result.ok).toBe(true);
|
|
775
|
+
if (!result.ok) return;
|
|
776
|
+
expect(result.projection.ok).toBe(true);
|
|
777
|
+
const config = JSON.parse(readFileSync(join(paths.dotWiki, "config.json"), "utf8"));
|
|
778
|
+
expect(Object.hasOwn(config, "knowledge_format")).toBe(false);
|
|
779
|
+
expect(existsSync(join(paths.wiki, "index.md"))).toBe(false);
|
|
780
|
+
expect(existsSync(join(paths.wiki, "log.md"))).toBe(false);
|
|
781
|
+
});
|
|
782
|
+
|
|
783
|
+
it("does not mutate an existing malformed vault during bootstrap", () => {
|
|
784
|
+
const cwd = root();
|
|
785
|
+
const paths = getVaultPaths(cwd);
|
|
786
|
+
mkdirSync(paths.dotWiki, { recursive: true });
|
|
787
|
+
const configPath = join(paths.dotWiki, "config.json");
|
|
788
|
+
writeFileSync(configPath, "{broken");
|
|
789
|
+
const before = readFileSync(configPath, "utf8");
|
|
790
|
+
const result = bootstrapVault(paths, { topic: "Do not write", mode: "personal" });
|
|
791
|
+
expect(result.ok).toBe(false);
|
|
792
|
+
expect(readFileSync(configPath, "utf8")).toBe(before);
|
|
793
|
+
expect(existsSync(join(paths.dotWiki, "WIKI_SCHEMA.md"))).toBe(false);
|
|
794
|
+
expect(existsSync(paths.meta)).toBe(false);
|
|
795
|
+
});
|
|
796
|
+
```
|
|
797
|
+
|
|
798
|
+
- [x] **Step 3: Run mode/bootstrap tests and verify failure**
|
|
799
|
+
|
|
800
|
+
```bash
|
|
801
|
+
pnpm vitest run test/vault-format.test.ts test/bootstrap.test.ts
|
|
802
|
+
```
|
|
803
|
+
|
|
804
|
+
Expected: strict config/root cases fail, silent bootstrap leaves legacy config, and `bootstrap.ts` does not exist.
|
|
805
|
+
|
|
806
|
+
- [x] **Step 4: Make config and root-index inspection fail closed**
|
|
807
|
+
|
|
808
|
+
Export a strict config reader from `vault-format.ts`:
|
|
809
|
+
|
|
810
|
+
```ts
|
|
811
|
+
export type VaultConfigRead =
|
|
812
|
+
| { ok: true; config: Record<string, unknown> }
|
|
813
|
+
| { ok: false; diagnostic: KnowledgeDiagnostic };
|
|
814
|
+
|
|
815
|
+
export function readVaultConfig(paths: VaultPaths): VaultConfigRead {
|
|
816
|
+
const path = join(paths.dotWiki, "config.json");
|
|
817
|
+
try {
|
|
818
|
+
const parsed: unknown = JSON.parse(readFileSync(path, "utf8"));
|
|
819
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
|
|
820
|
+
throw new Error("config.json must contain an object");
|
|
821
|
+
}
|
|
822
|
+
return { ok: true, config: parsed as Record<string, unknown> };
|
|
823
|
+
} catch (error: unknown) {
|
|
824
|
+
return {
|
|
825
|
+
ok: false,
|
|
826
|
+
diagnostic: diag(
|
|
827
|
+
"error",
|
|
828
|
+
"config_invalid_knowledge_format",
|
|
829
|
+
"config.json",
|
|
830
|
+
`Cannot read valid wiki config: ${(error as Error).message}`,
|
|
831
|
+
),
|
|
832
|
+
};
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
```
|
|
836
|
+
|
|
837
|
+
`inspectVaultFormat` must return `{ knowledgeFormat: "legacy", blocking: true }` with that diagnostic when `readVaultConfig` fails. A successfully parsed config lacking `knowledge_format` remains valid legacy behavior.
|
|
838
|
+
|
|
839
|
+
In OKF mode, distinguish a missing root from every other failure without an `existsSync`/read race:
|
|
840
|
+
|
|
841
|
+
```ts
|
|
842
|
+
const rootIndexPath = join(paths.wiki, "index.md");
|
|
843
|
+
let rootContent: string | undefined;
|
|
844
|
+
try {
|
|
845
|
+
rootContent = readFileSync(rootIndexPath, "utf8");
|
|
846
|
+
} catch (error: unknown) {
|
|
847
|
+
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
|
|
848
|
+
diagnostics.push(
|
|
849
|
+
diag(
|
|
850
|
+
"error",
|
|
851
|
+
"okf_version_mismatch",
|
|
852
|
+
"wiki/index.md",
|
|
853
|
+
`Cannot read OKF root index: ${(error as Error).message}`,
|
|
854
|
+
),
|
|
855
|
+
);
|
|
856
|
+
blocking = true;
|
|
857
|
+
}
|
|
858
|
+
}
|
|
859
|
+
if (rootContent !== undefined) {
|
|
860
|
+
const frontmatter = parseMarkdownFrontmatter(rootContent, "index.md");
|
|
861
|
+
if (!frontmatter.ok || frontmatter.mapping.okf_version !== "0.2") {
|
|
862
|
+
diagnostics.push(
|
|
863
|
+
diag(
|
|
864
|
+
"error",
|
|
865
|
+
"okf_version_mismatch",
|
|
866
|
+
"wiki/index.md",
|
|
867
|
+
frontmatter.ok
|
|
868
|
+
? 'OKF root index must declare okf_version "0.2"'
|
|
869
|
+
: `Malformed OKF root index: ${frontmatter.diagnostics[0].message}`,
|
|
870
|
+
),
|
|
871
|
+
);
|
|
872
|
+
blocking = true;
|
|
873
|
+
}
|
|
874
|
+
}
|
|
875
|
+
```
|
|
876
|
+
|
|
877
|
+
Keep legacy root files user-owned: only a successfully parsed explicit unsupported `okf_version` blocks; malformed or versionless legacy root files remain untouched.
|
|
878
|
+
|
|
879
|
+
- [x] **Step 5: Create one bootstrap service**
|
|
880
|
+
|
|
881
|
+
Create `extensions/llm-wiki/lib/bootstrap.ts`:
|
|
882
|
+
|
|
883
|
+
```ts
|
|
884
|
+
import { existsSync, writeFileSync } from "node:fs";
|
|
885
|
+
import { join } from "node:path";
|
|
886
|
+
import type { KnowledgeDiagnostic } from "./knowledge-document.js";
|
|
887
|
+
import { appendEvent, type ProjectionResult, rebuildMetadata } from "./metadata.js";
|
|
888
|
+
import {
|
|
889
|
+
type VaultPaths,
|
|
890
|
+
ensureVaultStructure,
|
|
891
|
+
fmtDate,
|
|
892
|
+
writeJson,
|
|
893
|
+
} from "./utils.js";
|
|
894
|
+
import { inspectWritableVault, readVaultConfig } from "./vault-format.js";
|
|
895
|
+
|
|
896
|
+
export const WIKI_SCHEMA = [
|
|
897
|
+
"# LLM Wiki Schema",
|
|
898
|
+
"",
|
|
899
|
+
"## Ownership Rules",
|
|
900
|
+
"",
|
|
901
|
+
"| Path | Owner | Rule |",
|
|
902
|
+
"|------|-------|------|",
|
|
903
|
+
"| raw/** | extension | immutable after capture |",
|
|
904
|
+
"| wiki/** | model + user | editable knowledge pages |",
|
|
905
|
+
"| meta/* | extension | auto-generated |",
|
|
906
|
+
"| . | human + explicit request | operating rules |",
|
|
907
|
+
"",
|
|
908
|
+
"## Source Packet Format",
|
|
909
|
+
"",
|
|
910
|
+
"```",
|
|
911
|
+
"raw/sources/SRC-YYYY-MM-DD-NNN/",
|
|
912
|
+
" manifest.json",
|
|
913
|
+
" original/",
|
|
914
|
+
" extracted.md",
|
|
915
|
+
" attachments/",
|
|
916
|
+
"```",
|
|
917
|
+
"",
|
|
918
|
+
"## Page Types",
|
|
919
|
+
"",
|
|
920
|
+
"- **source** — what this specific source says",
|
|
921
|
+
"- **entity** — people, orgs, tools, products",
|
|
922
|
+
"- **concept** — ideas, patterns, frameworks",
|
|
923
|
+
"- **synthesis** — cross-source theses and tensions",
|
|
924
|
+
"- **analysis** — durable filed answers from queries",
|
|
925
|
+
"- **requirement** — atomic requirements with status, priority, and traceability",
|
|
926
|
+
"",
|
|
927
|
+
"## Linking Style",
|
|
928
|
+
"",
|
|
929
|
+
"- New internal links: [label](/folder/page.md)",
|
|
930
|
+
"- Legacy readable links: [[folder/page]]",
|
|
931
|
+
"- Source citation: [source](/sources/SRC-YYYY-MM-DD-NNN.md)",
|
|
932
|
+
"",
|
|
933
|
+
].join("\n");
|
|
934
|
+
|
|
935
|
+
export interface BootstrapInput {
|
|
936
|
+
topic: string;
|
|
937
|
+
mode: string;
|
|
938
|
+
}
|
|
939
|
+
|
|
940
|
+
export type BootstrapResult =
|
|
941
|
+
| { ok: true; created: boolean; projection: ProjectionResult }
|
|
942
|
+
| { ok: false; created: false; diagnostics: KnowledgeDiagnostic[] };
|
|
943
|
+
|
|
944
|
+
export function bootstrapVault(paths: VaultPaths, input: BootstrapInput): BootstrapResult {
|
|
945
|
+
const configPath = join(paths.dotWiki, "config.json");
|
|
946
|
+
const created = !existsSync(configPath);
|
|
947
|
+
let existing: Record<string, unknown> = {};
|
|
948
|
+
|
|
949
|
+
if (!created) {
|
|
950
|
+
const writable = inspectWritableVault(paths);
|
|
951
|
+
if (!writable.ok) return { ok: false, created: false, diagnostics: writable.diagnostics };
|
|
952
|
+
const config = readVaultConfig(paths);
|
|
953
|
+
if (!config.ok) return { ok: false, created: false, diagnostics: [config.diagnostic] };
|
|
954
|
+
existing = config.config;
|
|
955
|
+
}
|
|
956
|
+
|
|
957
|
+
const config: Record<string, unknown> = {
|
|
958
|
+
...existing,
|
|
959
|
+
name: input.topic,
|
|
960
|
+
mode: input.mode,
|
|
961
|
+
topic: input.topic,
|
|
962
|
+
created: existing.created ?? fmtDate(),
|
|
963
|
+
version: existing.version ?? "1.0",
|
|
964
|
+
...(created ? { knowledge_format: "okf-0.2" } : {}),
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
ensureVaultStructure(paths);
|
|
968
|
+
writeJson(configPath, config);
|
|
969
|
+
writeFileSync(join(paths.dotWiki, "WIKI_SCHEMA.md"), WIKI_SCHEMA, "utf8");
|
|
970
|
+
appendEvent(paths, { kind: "bootstrap", topic: input.topic, mode: input.mode });
|
|
971
|
+
return { ok: true, created, projection: rebuildMetadata(paths) };
|
|
972
|
+
}
|
|
973
|
+
```
|
|
974
|
+
|
|
975
|
+
- [x] **Step 6: Route silent and explicit Pi bootstrap through the service**
|
|
976
|
+
|
|
977
|
+
In `tools.ts`, replace manual bootstrap config/schema/event/rebuild code with:
|
|
978
|
+
|
|
979
|
+
```ts
|
|
980
|
+
const result = bootstrapVault(paths, { topic: params.topic, mode });
|
|
981
|
+
if (!result.ok) {
|
|
982
|
+
return {
|
|
983
|
+
content: [{ type: "text", text: `Wiki vault error: ${result.diagnostics[0].message}` }],
|
|
984
|
+
details: { error: result.diagnostics[0].code, diagnostics: result.diagnostics },
|
|
985
|
+
isError: true,
|
|
986
|
+
};
|
|
987
|
+
}
|
|
988
|
+
const projection = result.projection;
|
|
989
|
+
```
|
|
990
|
+
|
|
991
|
+
Keep existing interface-specific success rendering. Remove now-unused `ensureVaultStructure`, schema construction, and direct config/event writes from this handler.
|
|
992
|
+
|
|
993
|
+
In `index.ts`, replace silent bootstrap writes with:
|
|
994
|
+
|
|
995
|
+
```ts
|
|
996
|
+
const result = bootstrapVault(paths, { topic: "pending", mode: "personal" });
|
|
997
|
+
if (!result.ok || !result.projection.ok) {
|
|
998
|
+
ctx.ui.setStatus(
|
|
999
|
+
"llm-wiki",
|
|
1000
|
+
`🧠 Wiki setup blocked: ${
|
|
1001
|
+
result.ok ? result.projection.diagnostics[0].message : result.diagnostics[0].message
|
|
1002
|
+
}`,
|
|
1003
|
+
);
|
|
1004
|
+
return;
|
|
1005
|
+
}
|
|
1006
|
+
needsTopicInference = true;
|
|
1007
|
+
ctx.ui.setStatus("llm-wiki", "🧠 Wiki created (inferring topic from first prompt…)");
|
|
1008
|
+
return;
|
|
1009
|
+
```
|
|
1010
|
+
|
|
1011
|
+
After the no-config branch in `session_start`, validate the existing vault before status, notices, or recall setup:
|
|
1012
|
+
|
|
1013
|
+
```ts
|
|
1014
|
+
const writable = inspectWritableVault(paths);
|
|
1015
|
+
if (!writable.ok) {
|
|
1016
|
+
ctx.ui.setStatus(
|
|
1017
|
+
"llm-wiki",
|
|
1018
|
+
`🧠 Wiki setup blocked: ${writable.diagnostics[0].message}`,
|
|
1019
|
+
);
|
|
1020
|
+
return;
|
|
1021
|
+
}
|
|
1022
|
+
```
|
|
1023
|
+
|
|
1024
|
+
Import `bootstrapVault` and `inspectWritableVault`; delete unused bootstrap filesystem/config imports.
|
|
1025
|
+
|
|
1026
|
+
- [x] **Step 7: Make generated-path guardrails contained and invalid-state aware**
|
|
1027
|
+
|
|
1028
|
+
Replace `isGeneratedOkfPath` with a containment-safe implementation:
|
|
1029
|
+
|
|
1030
|
+
```ts
|
|
1031
|
+
export function isGeneratedOkfPath(path: string, paths: VaultPaths): boolean {
|
|
1032
|
+
const state = inspectVaultFormat(paths);
|
|
1033
|
+
if (state.blocking || state.knowledgeFormat !== "okf-0.2") return false;
|
|
1034
|
+
const rel = relative(paths.wiki, resolve(path));
|
|
1035
|
+
if (!rel || rel === ".." || rel.startsWith(`..${sep}`) || isAbsolute(rel)) return false;
|
|
1036
|
+
const parts = rel.split(/[\\/]/);
|
|
1037
|
+
const name = parts.at(-1)?.toLowerCase();
|
|
1038
|
+
return name === "index.md" || (parts.length === 1 && name === "log.md");
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
In `guardrails.ts`, centralize per-path decisions in a pure helper and call it from both write and edit handlers:
|
|
1043
|
+
|
|
1044
|
+
```ts
|
|
1045
|
+
export function mutationBlockReason(path: string, paths: VaultPaths): string | undefined {
|
|
1046
|
+
const protectedPath = isProtectedPath(path, paths);
|
|
1047
|
+
if (protectedPath.protected) return protectedPath.reason;
|
|
1048
|
+
|
|
1049
|
+
const relativeToVault = relative(resolve(paths.dotWiki), resolve(path));
|
|
1050
|
+
const insideVault =
|
|
1051
|
+
relativeToVault === "" ||
|
|
1052
|
+
(!isAbsolute(relativeToVault) &&
|
|
1053
|
+
relativeToVault !== ".." &&
|
|
1054
|
+
!relativeToVault.startsWith(`..${sep}`));
|
|
1055
|
+
if (insideVault) {
|
|
1056
|
+
const state = inspectVaultFormat(paths);
|
|
1057
|
+
if (state.blocking) {
|
|
1058
|
+
return `Wiki vault configuration is invalid: ${state.diagnostics[0].message}`;
|
|
1059
|
+
}
|
|
1060
|
+
}
|
|
1061
|
+
|
|
1062
|
+
if (isGeneratedOkfPath(path, paths)) {
|
|
1063
|
+
return "Generated OKF indexes and log are read-only. Use wiki_rebuild_meta or the page-producing tool that owns the source mutation.";
|
|
1064
|
+
}
|
|
1065
|
+
return undefined;
|
|
1066
|
+
}
|
|
1067
|
+
```
|
|
1068
|
+
|
|
1069
|
+
For a write target or every recovered edit target, return `{ block: true, reason }` when `mutationBlockReason` returns a string. Import `VaultPaths`, `inspectVaultFormat`, `isAbsolute`, and `relative`. This leaves outside files alone and preserves existing raw/meta reasons.
|
|
1070
|
+
|
|
1071
|
+
- [x] **Step 8: Add guardrail regressions**
|
|
1072
|
+
|
|
1073
|
+
Extend `test/guardrails.test.ts` using the pure helper, while existing handler tests continue proving integration:
|
|
1074
|
+
|
|
1075
|
+
```ts
|
|
1076
|
+
import { rmSync, writeFileSync } from "node:fs";
|
|
1077
|
+
import { mutationBlockReason } from "../extensions/llm-wiki/lib/guardrails.js";
|
|
1078
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
1079
|
+
|
|
1080
|
+
it("blocks contained wiki writes when config is malformed", () => {
|
|
1081
|
+
const root = join(import.meta.dirname, "..", "tmp", `guardrail-${Date.now()}`);
|
|
1082
|
+
const paths = getVaultPaths(root);
|
|
1083
|
+
ensureVaultStructure(paths);
|
|
1084
|
+
writeFileSync(join(paths.dotWiki, "config.json"), "{broken");
|
|
1085
|
+
try {
|
|
1086
|
+
expect(mutationBlockReason(join(paths.wiki, "concepts", "x.md"), paths)).toContain(
|
|
1087
|
+
"configuration is invalid",
|
|
1088
|
+
);
|
|
1089
|
+
} finally {
|
|
1090
|
+
rmSync(root, { recursive: true, force: true });
|
|
1091
|
+
}
|
|
1092
|
+
});
|
|
1093
|
+
|
|
1094
|
+
it("allows an outside index file and blocks only contained OKF indexes", () => {
|
|
1095
|
+
const root = join(import.meta.dirname, "..", "tmp", `guardrail-${Date.now()}`);
|
|
1096
|
+
const paths = getVaultPaths(root);
|
|
1097
|
+
ensureVaultStructure(paths);
|
|
1098
|
+
writeFileSync(
|
|
1099
|
+
join(paths.dotWiki, "config.json"),
|
|
1100
|
+
JSON.stringify({ knowledge_format: "okf-0.2" }),
|
|
1101
|
+
);
|
|
1102
|
+
try {
|
|
1103
|
+
expect(mutationBlockReason(join(root, "outside", "index.md"), paths)).toBeUndefined();
|
|
1104
|
+
expect(mutationBlockReason(join(paths.wiki, "nested", "INDEX.md"), paths)).toContain(
|
|
1105
|
+
"Generated OKF",
|
|
1106
|
+
);
|
|
1107
|
+
} finally {
|
|
1108
|
+
rmSync(root, { recursive: true, force: true });
|
|
1109
|
+
}
|
|
1110
|
+
});
|
|
1111
|
+
```
|
|
1112
|
+
|
|
1113
|
+
Merge these imports into the existing import blocks.
|
|
1114
|
+
|
|
1115
|
+
- [x] **Step 9: Run bootstrap, mode, guardrail, projection, and type checks**
|
|
1116
|
+
|
|
1117
|
+
```bash
|
|
1118
|
+
pnpm vitest run test/bootstrap.test.ts test/vault-format.test.ts test/guardrails.test.ts test/okf-projections.test.ts
|
|
1119
|
+
pnpm typecheck
|
|
1120
|
+
```
|
|
1121
|
+
|
|
1122
|
+
Expected: all pass. New silent vaults are immediately valid OKF bundles; old mode-less configs remain legacy; malformed existing state causes no bootstrap or guardrail write.
|
|
1123
|
+
|
|
1124
|
+
- [x] **Step 10: Commit strict bootstrap and mode enforcement**
|
|
1125
|
+
|
|
1126
|
+
```bash
|
|
1127
|
+
git add extensions/llm-wiki/lib/bootstrap.ts extensions/llm-wiki/lib/vault-format.ts extensions/llm-wiki/lib/tools.ts extensions/llm-wiki/lib/guardrails.ts extensions/llm-wiki/index.ts test/bootstrap.test.ts test/vault-format.test.ts test/guardrails.test.ts
|
|
1128
|
+
git commit -m "fix: bootstrap and validate OKF vaults fail closed"
|
|
1129
|
+
```
|
|
1130
|
+
|
|
1131
|
+
---
|
|
1132
|
+
|
|
1133
|
+
### Task 4: Guard Every Authoritative Writer and Preserve Existing Documents
|
|
1134
|
+
|
|
1135
|
+
**Files:**
|
|
1136
|
+
- Create: `test/mutation-guards.test.ts`
|
|
1137
|
+
- Modify: `extensions/llm-wiki/lib/vault-format.ts`
|
|
1138
|
+
- Modify: `extensions/llm-wiki/lib/source-packet.ts`
|
|
1139
|
+
- Modify: `extensions/llm-wiki/lib/ingest-worker.ts`
|
|
1140
|
+
- Modify: `extensions/llm-wiki/lib/observation.ts`
|
|
1141
|
+
- Modify: `extensions/llm-wiki/lib/retro.ts`
|
|
1142
|
+
- Modify: `extensions/llm-wiki/lib/trajectory.ts`
|
|
1143
|
+
- Modify: `extensions/llm-wiki/lib/metadata.ts`
|
|
1144
|
+
- Modify: `extensions/llm-wiki/lib/embeddings.ts`
|
|
1145
|
+
- Modify: `extensions/llm-wiki/lib/tools.ts`
|
|
1146
|
+
- Modify: `mcp/operations.ts`
|
|
1147
|
+
- Modify: `test/ingest-worker.test.ts`
|
|
1148
|
+
- Modify: `test/retro.test.ts`
|
|
1149
|
+
- Modify: `test/trajectory.test.ts`
|
|
1150
|
+
- Modify: `test/background-tools.test.ts`
|
|
1151
|
+
- Modify: `test/source-capture.test.ts`
|
|
1152
|
+
- Modify: `test/e2e-binary-detection.test.ts`
|
|
1153
|
+
- Modify: `test/e2e-docx.test.ts`
|
|
1154
|
+
- Modify: `test/e2e-html-normalization.test.ts`
|
|
1155
|
+
- Modify: `test/embeddings.test.ts`
|
|
1156
|
+
|
|
1157
|
+
- [x] **Step 1: Add a fail-closed mutation matrix**
|
|
1158
|
+
|
|
1159
|
+
Create `test/mutation-guards.test.ts`:
|
|
1160
|
+
|
|
1161
|
+
```ts
|
|
1162
|
+
import { existsSync, readdirSync, rmSync, writeFileSync } from "node:fs";
|
|
1163
|
+
import { join } from "node:path";
|
|
1164
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
1165
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
1166
|
+
import {
|
|
1167
|
+
embedPages,
|
|
1168
|
+
launchEmbedPages,
|
|
1169
|
+
reindexEmbeddings,
|
|
1170
|
+
writeEmbeddingStore,
|
|
1171
|
+
} from "../extensions/llm-wiki/lib/embeddings.js";
|
|
1172
|
+
import { commitSynthesis } from "../extensions/llm-wiki/lib/ingest-worker.js";
|
|
1173
|
+
import { appendEvent } from "../extensions/llm-wiki/lib/metadata.js";
|
|
1174
|
+
import { Runtime } from "../extensions/llm-wiki/lib/runtime.js";
|
|
1175
|
+
import {
|
|
1176
|
+
registerWikiObserve,
|
|
1177
|
+
saveObservation,
|
|
1178
|
+
} from "../extensions/llm-wiki/lib/observation.js";
|
|
1179
|
+
import { registerWikiRetro, saveInsight } from "../extensions/llm-wiki/lib/retro.js";
|
|
1180
|
+
import { captureText } from "../extensions/llm-wiki/lib/source-packet.js";
|
|
1181
|
+
import {
|
|
1182
|
+
captureTrajectory,
|
|
1183
|
+
registerWikiCaptureTrajectory,
|
|
1184
|
+
} from "../extensions/llm-wiki/lib/trajectory.js";
|
|
1185
|
+
import {
|
|
1186
|
+
registerWikiCaptureSource,
|
|
1187
|
+
registerWikiEnsurePage,
|
|
1188
|
+
registerWikiIngest,
|
|
1189
|
+
registerWikiLint,
|
|
1190
|
+
registerWikiLogEvent,
|
|
1191
|
+
registerWikiRebuildMeta,
|
|
1192
|
+
registerWikiReindexEmbeddings,
|
|
1193
|
+
} from "../extensions/llm-wiki/lib/tools.js";
|
|
1194
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
1195
|
+
import { VaultWriteError } from "../extensions/llm-wiki/lib/vault-format.js";
|
|
1196
|
+
|
|
1197
|
+
const roots: string[] = [];
|
|
1198
|
+
afterEach(() => {
|
|
1199
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
1200
|
+
});
|
|
1201
|
+
|
|
1202
|
+
function invalidVault(config = "{broken") {
|
|
1203
|
+
const root = join(import.meta.dirname, "..", "tmp", `mutation-${Date.now()}-${Math.random()}`);
|
|
1204
|
+
roots.push(root);
|
|
1205
|
+
const paths = getVaultPaths(root);
|
|
1206
|
+
ensureVaultStructure(paths);
|
|
1207
|
+
writeFileSync(join(paths.dotWiki, "config.json"), config);
|
|
1208
|
+
return paths;
|
|
1209
|
+
}
|
|
1210
|
+
|
|
1211
|
+
function tree(path: string): string[] {
|
|
1212
|
+
if (!existsSync(path)) return [];
|
|
1213
|
+
return readdirSync(path, { recursive: true }).map(String).sort();
|
|
1214
|
+
}
|
|
1215
|
+
|
|
1216
|
+
it("blocks every shared authoritative writer before changing the vault", async () => {
|
|
1217
|
+
const operations: Array<[string, (paths: ReturnType<typeof invalidVault>) => unknown]> = [
|
|
1218
|
+
["capture", (paths) => captureText(paths, "body", "title")],
|
|
1219
|
+
["observe", (paths) => saveObservation(paths, { title: "x", content: "y", relevance: "low" })],
|
|
1220
|
+
["retro", (paths) => saveInsight(paths, "safe-slug", "title", "body")],
|
|
1221
|
+
["trajectory", (paths) => captureTrajectory(paths, { steps: [{ role: "user", text: "x" }] })],
|
|
1222
|
+
["event", (paths) => appendEvent(paths, { kind: "manual" })],
|
|
1223
|
+
];
|
|
1224
|
+
|
|
1225
|
+
for (const [name, operation] of operations) {
|
|
1226
|
+
const paths = invalidVault();
|
|
1227
|
+
const before = tree(paths.dotWiki);
|
|
1228
|
+
expect(() => operation(paths), name).toThrow(VaultWriteError);
|
|
1229
|
+
expect(tree(paths.dotWiki), name).toEqual(before);
|
|
1230
|
+
}
|
|
1231
|
+
|
|
1232
|
+
const ingestPaths = invalidVault();
|
|
1233
|
+
const ingestBefore = tree(ingestPaths.dotWiki);
|
|
1234
|
+
const ingest = commitSynthesis(
|
|
1235
|
+
ingestPaths,
|
|
1236
|
+
"SRC-1",
|
|
1237
|
+
{ id: "SRC-1", title: "Source" },
|
|
1238
|
+
{ summary: "s", key_takeaways: [], entities: [], concepts: [] },
|
|
1239
|
+
);
|
|
1240
|
+
expect(ingest.ok).toBe(false);
|
|
1241
|
+
expect(tree(ingestPaths.dotWiki)).toEqual(ingestBefore);
|
|
1242
|
+
|
|
1243
|
+
const paths = invalidVault();
|
|
1244
|
+
const before = tree(paths.dotWiki);
|
|
1245
|
+
const embed = vi.fn(async (texts: string[]) => texts.map(() => [1, 0, 0]));
|
|
1246
|
+
const embedder = { model: "test", embed };
|
|
1247
|
+
await expect(embedPages(paths, ["concepts/x"], embedder)).rejects.toBeInstanceOf(
|
|
1248
|
+
VaultWriteError,
|
|
1249
|
+
);
|
|
1250
|
+
await expect(reindexEmbeddings(paths, embedder)).rejects.toBeInstanceOf(VaultWriteError);
|
|
1251
|
+
expect(() =>
|
|
1252
|
+
writeEmbeddingStore(paths, { version: "1.0", entries: {} }),
|
|
1253
|
+
).toThrow(VaultWriteError);
|
|
1254
|
+
expect(embed).not.toHaveBeenCalled();
|
|
1255
|
+
expect(tree(paths.dotWiki)).toEqual(before);
|
|
1256
|
+
});
|
|
1257
|
+
|
|
1258
|
+
it("rechecks vault mode after embedding and immediately before sidecar write", async () => {
|
|
1259
|
+
const paths = invalidVault(JSON.stringify({ knowledge_format: "legacy" }));
|
|
1260
|
+
writeFileSync(
|
|
1261
|
+
join(paths.wiki, "concepts", "x.md"),
|
|
1262
|
+
"---\ntype: concept\ntitle: X\n---\n\nBody\n",
|
|
1263
|
+
);
|
|
1264
|
+
let release!: () => void;
|
|
1265
|
+
let markStarted!: () => void;
|
|
1266
|
+
const gate = new Promise<void>((resolve) => {
|
|
1267
|
+
release = resolve;
|
|
1268
|
+
});
|
|
1269
|
+
const started = new Promise<void>((resolve) => {
|
|
1270
|
+
markStarted = resolve;
|
|
1271
|
+
});
|
|
1272
|
+
const pending = embedPages(paths, ["concepts/x"], {
|
|
1273
|
+
model: "test",
|
|
1274
|
+
embed: async (texts) => {
|
|
1275
|
+
markStarted();
|
|
1276
|
+
await gate;
|
|
1277
|
+
return texts.map(() => [1, 0, 0]);
|
|
1278
|
+
},
|
|
1279
|
+
});
|
|
1280
|
+
await started;
|
|
1281
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ knowledge_format: "future" }));
|
|
1282
|
+
release();
|
|
1283
|
+
await expect(pending).rejects.toBeInstanceOf(VaultWriteError);
|
|
1284
|
+
expect(existsSync(join(paths.meta, "embeddings.json"))).toBe(false);
|
|
1285
|
+
});
|
|
1286
|
+
|
|
1287
|
+
it.each(["invalid-mode", "unsupported-root-version"])(
|
|
1288
|
+
"rechecks the embedding guard inside a launched background task: %s",
|
|
1289
|
+
async (state) => {
|
|
1290
|
+
const paths = invalidVault(
|
|
1291
|
+
JSON.stringify({ knowledge_format: state === "invalid-mode" ? "future" : "okf-0.2" }),
|
|
1292
|
+
);
|
|
1293
|
+
if (state === "unsupported-root-version") {
|
|
1294
|
+
writeFileSync(join(paths.wiki, "index.md"), '---\nokf_version: "0.3"\n---\n');
|
|
1295
|
+
}
|
|
1296
|
+
const runtime = new Runtime();
|
|
1297
|
+
runtime.ensureConfig = () => {};
|
|
1298
|
+
runtime.config = { embeddingProvider: "openai", embeddingApiKey: "test" };
|
|
1299
|
+
const fetchSpy = vi
|
|
1300
|
+
.spyOn(globalThis, "fetch")
|
|
1301
|
+
.mockRejectedValue(new Error("network should not run"));
|
|
1302
|
+
expect(launchEmbedPages(runtime, { hasUI: false }, paths, ["concepts/x"], "test")).toBe(
|
|
1303
|
+
true,
|
|
1304
|
+
);
|
|
1305
|
+
await runtime.awaitAll();
|
|
1306
|
+
expect(fetchSpy).not.toHaveBeenCalled();
|
|
1307
|
+
expect(existsSync(join(paths.meta, "embeddings.json"))).toBe(false);
|
|
1308
|
+
fetchSpy.mockRestore();
|
|
1309
|
+
},
|
|
1310
|
+
);
|
|
1311
|
+
|
|
1312
|
+
it("blocks every mutating Pi tool adapter before dispatch or write", async () => {
|
|
1313
|
+
const cases: Array<[
|
|
1314
|
+
string,
|
|
1315
|
+
(pi: ExtensionAPI) => void,
|
|
1316
|
+
Record<string, unknown>,
|
|
1317
|
+
]> = [
|
|
1318
|
+
["capture", (pi) => registerWikiCaptureSource(pi), { text: "body", title: "title" }],
|
|
1319
|
+
["ingest", (pi) => registerWikiIngest(pi), { background: false }],
|
|
1320
|
+
["ensure", (pi) => registerWikiEnsurePage(pi), { type: "concept", title: "Title" }],
|
|
1321
|
+
["lint", (pi) => registerWikiLint(pi), { auto_fix: false }],
|
|
1322
|
+
["rebuild", (pi) => registerWikiRebuildMeta(pi), {}],
|
|
1323
|
+
["embeddings", (pi) => registerWikiReindexEmbeddings(pi), { force: false }],
|
|
1324
|
+
["event", (pi) => registerWikiLogEvent(pi), { kind: "manual" }],
|
|
1325
|
+
["observe", (pi) => registerWikiObserve(pi), { title: "x", content: "y", relevance: "low" }],
|
|
1326
|
+
["retro", (pi) => registerWikiRetro(pi), { slug: "safe", title: "x", body: "y" }],
|
|
1327
|
+
[
|
|
1328
|
+
"trajectory",
|
|
1329
|
+
(pi) => registerWikiCaptureTrajectory(pi),
|
|
1330
|
+
{ steps: [{ role: "user", text: "x" }] },
|
|
1331
|
+
],
|
|
1332
|
+
];
|
|
1333
|
+
|
|
1334
|
+
for (const [name, register, params] of cases) {
|
|
1335
|
+
const paths = invalidVault();
|
|
1336
|
+
let tool: { execute: (...args: any[]) => Promise<any> } | undefined;
|
|
1337
|
+
register({
|
|
1338
|
+
registerTool: (definition: unknown) => {
|
|
1339
|
+
tool = definition as { execute: (...args: any[]) => Promise<any> };
|
|
1340
|
+
},
|
|
1341
|
+
} as unknown as ExtensionAPI);
|
|
1342
|
+
if (!tool) throw new Error(`Tool not registered: ${name}`);
|
|
1343
|
+
const before = tree(paths.dotWiki);
|
|
1344
|
+
const result = await tool.execute(
|
|
1345
|
+
"test",
|
|
1346
|
+
params,
|
|
1347
|
+
undefined,
|
|
1348
|
+
undefined,
|
|
1349
|
+
{ cwd: paths.root, hasUI: false, ui: { notify: () => {} } },
|
|
1350
|
+
);
|
|
1351
|
+
expect(result.isError, name).toBe(true);
|
|
1352
|
+
expect(tree(paths.dotWiki), name).toEqual(before);
|
|
1353
|
+
}
|
|
1354
|
+
});
|
|
1355
|
+
```
|
|
1356
|
+
|
|
1357
|
+
For existing direct-writer fixtures in `test/source-capture.test.ts`, `test/e2e-binary-detection.test.ts`, `test/e2e-docx.test.ts`, `test/e2e-html-normalization.test.ts`, `test/embeddings.test.ts`, and `test/ingest-worker.test.ts`, add this immediately after `ensureVaultStructure(paths)`:
|
|
1358
|
+
|
|
1359
|
+
```ts
|
|
1360
|
+
writeFileSync(
|
|
1361
|
+
join(paths.dotWiki, "config.json"),
|
|
1362
|
+
JSON.stringify({ name: "Test legacy vault" }),
|
|
1363
|
+
);
|
|
1364
|
+
```
|
|
1365
|
+
|
|
1366
|
+
A config file with a missing `knowledge_format` intentionally exercises valid legacy fallback; a missing or malformed config is reserved for fail-closed tests.
|
|
1367
|
+
|
|
1368
|
+
- [x] **Step 2: Add legacy-preserving ingestion tests**
|
|
1369
|
+
|
|
1370
|
+
Append to `test/ingest-worker.test.ts`:
|
|
1371
|
+
|
|
1372
|
+
```ts
|
|
1373
|
+
it.each([
|
|
1374
|
+
["scalar", "sources: sources/SRC-legacy"],
|
|
1375
|
+
["list", "sources: [sources/SRC-a, sources/SRC-b]"],
|
|
1376
|
+
])("patches an existing %s-source page without migration or field loss", (_label, sources) => {
|
|
1377
|
+
const paths = getVaultPaths(wikiDir);
|
|
1378
|
+
const page = join(paths.wiki, "sources", "SRC-001.md");
|
|
1379
|
+
mkdirSync(join(paths.wiki, "sources"), { recursive: true });
|
|
1380
|
+
writeFileSync(
|
|
1381
|
+
page,
|
|
1382
|
+
[
|
|
1383
|
+
"---",
|
|
1384
|
+
"type: source",
|
|
1385
|
+
"title: Original title",
|
|
1386
|
+
sources,
|
|
1387
|
+
"producer_data:",
|
|
1388
|
+
" nested:",
|
|
1389
|
+
" keep: true",
|
|
1390
|
+
"status: skeleton",
|
|
1391
|
+
"---",
|
|
1392
|
+
"",
|
|
1393
|
+
"Old body.",
|
|
1394
|
+
"",
|
|
1395
|
+
].join("\n"),
|
|
1396
|
+
);
|
|
1397
|
+
|
|
1398
|
+
const before = parseKnowledgeDocument(readFileSync(page, "utf8"), "sources/SRC-001.md");
|
|
1399
|
+
expect(before.ok).toBe(true);
|
|
1400
|
+
const result = commitSynthesis(paths, "SRC-001", MANIFEST, DATA, "2026-06-06");
|
|
1401
|
+
expect(result.ok).toBe(true);
|
|
1402
|
+
const after = parseKnowledgeDocument(readFileSync(page, "utf8"), "sources/SRC-001.md");
|
|
1403
|
+
expect(after.ok).toBe(true);
|
|
1404
|
+
if (!before.ok || !after.ok) return;
|
|
1405
|
+
expect(after.document.sources).toEqual(before.document.sources);
|
|
1406
|
+
expect(after.document.extensions.producer_data).toEqual(
|
|
1407
|
+
before.document.extensions.producer_data,
|
|
1408
|
+
);
|
|
1409
|
+
expect(after.document.frontmatter.title).toBe("Original title");
|
|
1410
|
+
expect(after.document.frontmatter.status).toBe("ingested");
|
|
1411
|
+
expect(after.document.frontmatter.updated).toBe("2026-06-06");
|
|
1412
|
+
});
|
|
1413
|
+
```
|
|
1414
|
+
|
|
1415
|
+
Import `parseKnowledgeDocument` in the test.
|
|
1416
|
+
|
|
1417
|
+
- [x] **Step 3: Add retro path-containment tests**
|
|
1418
|
+
|
|
1419
|
+
Append to `test/retro.test.ts`:
|
|
1420
|
+
|
|
1421
|
+
```ts
|
|
1422
|
+
it.each(["../../../outside", "../../meta/pwn", "with/slash", ".", "Index", "LOG"])(
|
|
1423
|
+
"rejects unsafe slug %s without writing",
|
|
1424
|
+
(slug) => {
|
|
1425
|
+
const paths = getVaultPaths(wikiDir);
|
|
1426
|
+
expect(() => saveInsight(paths, slug, "Title", "Body")).toThrow("Invalid insight slug");
|
|
1427
|
+
expect(existsSync(join(paths.root, "outside.md"))).toBe(false);
|
|
1428
|
+
expect(existsSync(join(paths.meta, "pwn.md"))).toBe(false);
|
|
1429
|
+
},
|
|
1430
|
+
);
|
|
1431
|
+
|
|
1432
|
+
it("maps unsafe retro slugs to structured Pi and MCP errors", async () => {
|
|
1433
|
+
const paths = getVaultPaths(wikiDir);
|
|
1434
|
+
let tool: { execute: (...args: any[]) => Promise<any> } | undefined;
|
|
1435
|
+
registerWikiRetro({
|
|
1436
|
+
registerTool: (definition: unknown) => {
|
|
1437
|
+
tool = definition as { execute: (...args: any[]) => Promise<any> };
|
|
1438
|
+
},
|
|
1439
|
+
} as unknown as ExtensionAPI);
|
|
1440
|
+
if (!tool) throw new Error("wiki_retro was not registered");
|
|
1441
|
+
|
|
1442
|
+
const piResult = await tool.execute(
|
|
1443
|
+
"test",
|
|
1444
|
+
{ slug: "../../escape", title: "Title", body: "Body" },
|
|
1445
|
+
undefined,
|
|
1446
|
+
undefined,
|
|
1447
|
+
{ cwd: paths.root, hasUI: false },
|
|
1448
|
+
);
|
|
1449
|
+
expect(piResult.isError).toBe(true);
|
|
1450
|
+
expect(piResult.details.error).toBe("invalid_insight_slug");
|
|
1451
|
+
|
|
1452
|
+
const mcpResult = await retroOperation(paths, "../../escape", "Title", "Body");
|
|
1453
|
+
expect(mcpResult).toEqual({
|
|
1454
|
+
ok: false,
|
|
1455
|
+
diagnostics: [{ code: "invalid_insight_slug", message: "Invalid insight slug: ../../escape" }],
|
|
1456
|
+
});
|
|
1457
|
+
expect(existsSync(join(paths.root, "escape.md"))).toBe(false);
|
|
1458
|
+
expect(existsSync(join(paths.meta, "events.jsonl"))).toBe(false);
|
|
1459
|
+
});
|
|
1460
|
+
```
|
|
1461
|
+
|
|
1462
|
+
Import `ExtensionAPI`, `registerWikiRetro`, and `retroOperation` in `test/retro.test.ts`.
|
|
1463
|
+
|
|
1464
|
+
- [x] **Step 4: Run mutation tests and verify failure**
|
|
1465
|
+
|
|
1466
|
+
```bash
|
|
1467
|
+
pnpm vitest run test/mutation-guards.test.ts test/ingest-worker.test.ts test/retro.test.ts test/trajectory.test.ts
|
|
1468
|
+
```
|
|
1469
|
+
|
|
1470
|
+
Expected: shared writers proceed in malformed vaults, ingestion loses fields, unsafe retro slugs escape the source directory, and Pi/MCP retro adapters reject by throwing across their boundary rather than returning structured errors.
|
|
1471
|
+
|
|
1472
|
+
- [x] **Step 5: Add one shared write assertion**
|
|
1473
|
+
|
|
1474
|
+
In `vault-format.ts`, add:
|
|
1475
|
+
|
|
1476
|
+
```ts
|
|
1477
|
+
export class VaultWriteError extends Error {
|
|
1478
|
+
constructor(readonly diagnostics: KnowledgeDiagnostic[]) {
|
|
1479
|
+
super(diagnostics[0]?.message ?? "Wiki vault is not writable");
|
|
1480
|
+
this.name = "VaultWriteError";
|
|
1481
|
+
}
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
export function assertWritableVault(paths: VaultPaths): KnowledgeFormat {
|
|
1485
|
+
const result = inspectWritableVault(paths);
|
|
1486
|
+
if (!result.ok) throw new VaultWriteError(result.diagnostics);
|
|
1487
|
+
return result.format;
|
|
1488
|
+
}
|
|
1489
|
+
```
|
|
1490
|
+
|
|
1491
|
+
Call `assertWritableVault(paths)` immediately before the first authoritative or metadata write inside:
|
|
1492
|
+
|
|
1493
|
+
- `captureSource` and `captureSourceSync` in `source-packet.ts`
|
|
1494
|
+
- `commitSynthesis` in `ingest-worker.ts`, catching `VaultWriteError` and returning its diagnostics through the existing `CommitSynthesisOutcome` union
|
|
1495
|
+
- `saveObservation` in `observation.ts`
|
|
1496
|
+
- `saveInsight` in `retro.ts`
|
|
1497
|
+
- `captureTrajectory` in `trajectory.ts`
|
|
1498
|
+
- `appendEvent` in `metadata.ts`
|
|
1499
|
+
- `embedPages` and `reindexEmbeddings` in `embeddings.ts`, before any network call
|
|
1500
|
+
- `writeEmbeddingStore` in `embeddings.ts`, the unavoidable exported sidecar write boundary
|
|
1501
|
+
- background lint work in `tools.ts`
|
|
1502
|
+
|
|
1503
|
+
Tool adapters still use `inspectWritableVault` to return stable tool errors. The service assertion is defense in depth and closes direct-call and asynchronous-check gaps.
|
|
1504
|
+
|
|
1505
|
+
- [x] **Step 6: Patch existing source documents instead of rebuilding them**
|
|
1506
|
+
|
|
1507
|
+
Replace the existing-source and source-write portion of `commitSynthesis` with:
|
|
1508
|
+
|
|
1509
|
+
```ts
|
|
1510
|
+
try {
|
|
1511
|
+
assertWritableVault(paths);
|
|
1512
|
+
} catch (error: unknown) {
|
|
1513
|
+
if (error instanceof VaultWriteError) {
|
|
1514
|
+
return { ok: false, sourceId, diagnostics: error.diagnostics };
|
|
1515
|
+
}
|
|
1516
|
+
throw error;
|
|
1517
|
+
}
|
|
1518
|
+
let sourceDocument: KnowledgeDocument | undefined;
|
|
1519
|
+
if (existsSync(result.sourcePage)) {
|
|
1520
|
+
const parsed = parseKnowledgeDocument(
|
|
1521
|
+
readFileSync(result.sourcePage, "utf8"),
|
|
1522
|
+
`sources/${sourceId}.md`,
|
|
1523
|
+
);
|
|
1524
|
+
if (!parsed.ok) return { ok: false, sourceId, diagnostics: parsed.diagnostics };
|
|
1525
|
+
sourceDocument = patchKnowledgeDocument(parsed.document, {
|
|
1526
|
+
fields: { status: "ingested", updated: date },
|
|
1527
|
+
body: buildIngestedSourcePageBody(manifest, data, date),
|
|
1528
|
+
});
|
|
1529
|
+
} else {
|
|
1530
|
+
sourceDocument = createKnowledgeDocument(
|
|
1531
|
+
`sources/${sourceId}.md`,
|
|
1532
|
+
{
|
|
1533
|
+
type: "source",
|
|
1534
|
+
title: String(manifest.title || sourceId),
|
|
1535
|
+
format: String(manifest.format || "unknown"),
|
|
1536
|
+
source_id: sourceId,
|
|
1537
|
+
raw_path: `raw/sources/${sourceId}/extracted.md`,
|
|
1538
|
+
captured: String(manifest.captured || date),
|
|
1539
|
+
status: "ingested",
|
|
1540
|
+
updated: date,
|
|
1541
|
+
},
|
|
1542
|
+
buildIngestedSourcePageBody(manifest, data, date),
|
|
1543
|
+
);
|
|
1544
|
+
}
|
|
1545
|
+
mkdirSync(join(paths.wiki, "sources"), { recursive: true });
|
|
1546
|
+
writeKnowledgeDocumentFile(result.sourcePage, sourceDocument);
|
|
1547
|
+
```
|
|
1548
|
+
|
|
1549
|
+
Import `KnowledgeDocument` and `assertWritableVault`. Do not patch title, format, source identifiers, `sources`, or extensions on an existing page.
|
|
1550
|
+
|
|
1551
|
+
- [x] **Step 7: Validate retro slugs and resolved containment inside the shared service**
|
|
1552
|
+
|
|
1553
|
+
In `retro.ts`, add:
|
|
1554
|
+
|
|
1555
|
+
```ts
|
|
1556
|
+
function insightPath(paths: VaultPaths, slug: string): string {
|
|
1557
|
+
if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug) || slug === "index" || slug === "log") {
|
|
1558
|
+
throw new Error(`Invalid insight slug: ${slug}`);
|
|
1559
|
+
}
|
|
1560
|
+
const directory = resolve(paths.wiki, "sources");
|
|
1561
|
+
const target = resolve(directory, `${slug}.md`);
|
|
1562
|
+
if (dirname(target) !== directory) throw new Error(`Invalid insight slug: ${slug}`);
|
|
1563
|
+
return target;
|
|
1564
|
+
}
|
|
1565
|
+
```
|
|
1566
|
+
|
|
1567
|
+
Use `insightPath` after `assertWritableVault(paths)`. Import `dirname` and `resolve`. Wrap the Pi call in `retro.ts`:
|
|
1568
|
+
|
|
1569
|
+
```ts
|
|
1570
|
+
let result: RetroResult;
|
|
1571
|
+
try {
|
|
1572
|
+
result = saveInsight(paths, params.slug, params.title, params.body, params.category, {
|
|
1573
|
+
rebuild: !runtime,
|
|
1574
|
+
});
|
|
1575
|
+
} catch (error: unknown) {
|
|
1576
|
+
if ((error as Error).message.startsWith("Invalid insight slug:")) {
|
|
1577
|
+
return {
|
|
1578
|
+
content: [{ type: "text", text: (error as Error).message }],
|
|
1579
|
+
details: { error: "invalid_insight_slug" },
|
|
1580
|
+
isError: true,
|
|
1581
|
+
};
|
|
1582
|
+
}
|
|
1583
|
+
throw error;
|
|
1584
|
+
}
|
|
1585
|
+
```
|
|
1586
|
+
|
|
1587
|
+
In `mcp/operations.ts`, wrap `saveInsight` with the same predicate and return:
|
|
1588
|
+
|
|
1589
|
+
```ts
|
|
1590
|
+
return {
|
|
1591
|
+
ok: false,
|
|
1592
|
+
diagnostics: [{ code: "invalid_insight_slug", message: (error as Error).message }],
|
|
1593
|
+
};
|
|
1594
|
+
```
|
|
1595
|
+
|
|
1596
|
+
Do not catch unrelated exceptions.
|
|
1597
|
+
|
|
1598
|
+
- [x] **Step 8: Replace weak tool preconditions**
|
|
1599
|
+
|
|
1600
|
+
In `tools.ts`, replace `requireVault` with `inspectWritableVault` for `wiki_capture_source` and `wiki_ingest`. In `trajectory.ts`, replace config-existence checks with `inspectWritableVault`. Keep read-only trajectory recall behavior available; apply write checks to capture/distillation mutations only.
|
|
1601
|
+
|
|
1602
|
+
Inside deferred/background work for lint and embedding reindex, call `assertWritableVault(paths)` again immediately before work. Keep assertions at the start of `embedPages`/`reindexEmbeddings` and inside `writeEmbeddingStore`, so `launchEmbedPages`, direct callers, pruning, and future callers cannot bypass mode/version validation. `wiki_rebuild_meta` relies on `rebuildMetadata`'s strict inspection and must report `ProjectionResult` diagnostics.
|
|
1603
|
+
|
|
1604
|
+
- [x] **Step 9: Run all authoritative mutation tests**
|
|
1605
|
+
|
|
1606
|
+
```bash
|
|
1607
|
+
pnpm vitest run test/mutation-guards.test.ts test/source-capture.test.ts test/ingest-worker.test.ts test/observation.test.ts test/retro.test.ts test/trajectory.test.ts test/background-tools.test.ts test/ingest-tool.test.ts
|
|
1608
|
+
pnpm typecheck
|
|
1609
|
+
```
|
|
1610
|
+
|
|
1611
|
+
Expected: all pass. Every writer rejects malformed mode/version before writes; existing source metadata survives semantically; retro cannot escape its directory.
|
|
1612
|
+
|
|
1613
|
+
- [x] **Step 10: Commit guarded, preserving writers**
|
|
1614
|
+
|
|
1615
|
+
```bash
|
|
1616
|
+
git add extensions/llm-wiki/lib/vault-format.ts extensions/llm-wiki/lib/source-packet.ts extensions/llm-wiki/lib/ingest-worker.ts extensions/llm-wiki/lib/observation.ts extensions/llm-wiki/lib/retro.ts extensions/llm-wiki/lib/trajectory.ts extensions/llm-wiki/lib/metadata.ts extensions/llm-wiki/lib/embeddings.ts extensions/llm-wiki/lib/tools.ts mcp/operations.ts test/mutation-guards.test.ts test/ingest-worker.test.ts test/retro.test.ts test/trajectory.test.ts test/background-tools.test.ts test/ingest-tool.test.ts test/source-capture.test.ts test/e2e-binary-detection.test.ts test/e2e-docx.test.ts test/e2e-html-normalization.test.ts test/embeddings.test.ts
|
|
1617
|
+
git commit -m "fix: guard and preserve OKF authoritative writes"
|
|
1618
|
+
```
|
|
1619
|
+
|
|
1620
|
+
---
|
|
1621
|
+
|
|
1622
|
+
### Task 5: Fail Closed Across Background Ingestion and Indexing
|
|
1623
|
+
|
|
1624
|
+
**Files:**
|
|
1625
|
+
- Create: `test/indexing-fail-closed.test.ts`
|
|
1626
|
+
- Create: `test/ingest-concurrency.test.ts`
|
|
1627
|
+
- Modify: `extensions/llm-wiki/lib/indexing.ts:55-78`
|
|
1628
|
+
- Modify: `extensions/llm-wiki/lib/ingest-worker.ts:340-410`
|
|
1629
|
+
- Modify: `test/indexing.test.ts`
|
|
1630
|
+
- Modify: `test/ingest-tool.test.ts`
|
|
1631
|
+
|
|
1632
|
+
- [x] **Step 1: Add an embedding-after-failed-rebuild regression**
|
|
1633
|
+
|
|
1634
|
+
Create `test/indexing-fail-closed.test.ts`:
|
|
1635
|
+
|
|
1636
|
+
```ts
|
|
1637
|
+
import { mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
1638
|
+
import { join } from "node:path";
|
|
1639
|
+
import { afterEach, describe, expect, it, vi } from "vitest";
|
|
1640
|
+
|
|
1641
|
+
const mocks = vi.hoisted(() => ({
|
|
1642
|
+
reindexEmbeddings: vi.fn(async () => ({ embedded: 0, skipped: 0, pruned: 0 })),
|
|
1643
|
+
resolveEmbedder: vi.fn(() => ({
|
|
1644
|
+
model: "test",
|
|
1645
|
+
embed: async (texts: string[]) => texts.map(() => [1, 0, 0]),
|
|
1646
|
+
})),
|
|
1647
|
+
}));
|
|
1648
|
+
vi.mock("../extensions/llm-wiki/lib/embeddings.js", () => mocks);
|
|
1649
|
+
|
|
1650
|
+
import {
|
|
1651
|
+
__resetIndexingState,
|
|
1652
|
+
scheduleReindex,
|
|
1653
|
+
} from "../extensions/llm-wiki/lib/indexing.js";
|
|
1654
|
+
import { Runtime } from "../extensions/llm-wiki/lib/runtime.js";
|
|
1655
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
1656
|
+
|
|
1657
|
+
const roots: string[] = [];
|
|
1658
|
+
afterEach(() => {
|
|
1659
|
+
__resetIndexingState();
|
|
1660
|
+
mocks.reindexEmbeddings.mockClear();
|
|
1661
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
1662
|
+
});
|
|
1663
|
+
|
|
1664
|
+
it("does not refresh embeddings after a blocking projection failure", async () => {
|
|
1665
|
+
const root = join(import.meta.dirname, "..", "tmp", `index-fail-${Date.now()}`);
|
|
1666
|
+
roots.push(root);
|
|
1667
|
+
const paths = getVaultPaths(root);
|
|
1668
|
+
ensureVaultStructure(paths);
|
|
1669
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ knowledge_format: "legacy" }));
|
|
1670
|
+
mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
|
|
1671
|
+
writeFileSync(join(paths.wiki, "concepts", "bad.md"), "not frontmatter\n");
|
|
1672
|
+
const runtime = new Runtime();
|
|
1673
|
+
runtime.ensureConfig = () => {};
|
|
1674
|
+
runtime.config = { embeddingProvider: "openai" };
|
|
1675
|
+
await scheduleReindex(runtime, { hasUI: false }, paths);
|
|
1676
|
+
await runtime.awaitAll();
|
|
1677
|
+
expect(mocks.reindexEmbeddings).not.toHaveBeenCalled();
|
|
1678
|
+
});
|
|
1679
|
+
```
|
|
1680
|
+
|
|
1681
|
+
- [x] **Step 2: Add background-ingestion configuration race regression evidence**
|
|
1682
|
+
|
|
1683
|
+
Create `test/ingest-concurrency.test.ts` with a module-level delayed subagent:
|
|
1684
|
+
|
|
1685
|
+
```ts
|
|
1686
|
+
import { existsSync, rmSync, writeFileSync } from "node:fs";
|
|
1687
|
+
import { join } from "node:path";
|
|
1688
|
+
import { afterEach, expect, it, vi } from "vitest";
|
|
1689
|
+
|
|
1690
|
+
const control = vi.hoisted(() => {
|
|
1691
|
+
let releaseGate!: () => void;
|
|
1692
|
+
const gate = new Promise<void>((resolve) => {
|
|
1693
|
+
releaseGate = resolve;
|
|
1694
|
+
});
|
|
1695
|
+
return { gate, release: () => releaseGate() };
|
|
1696
|
+
});
|
|
1697
|
+
|
|
1698
|
+
vi.mock("../extensions/llm-wiki/lib/subagent.js", () => ({
|
|
1699
|
+
runSubAgent: vi.fn(async (args: { tools: Array<{ execute: (...args: any[]) => Promise<unknown> }> }) => {
|
|
1700
|
+
await control.gate;
|
|
1701
|
+
await args.tools[0].execute("commit", {
|
|
1702
|
+
summary: "Summary",
|
|
1703
|
+
key_takeaways: [],
|
|
1704
|
+
entities: [],
|
|
1705
|
+
concepts: [],
|
|
1706
|
+
});
|
|
1707
|
+
}),
|
|
1708
|
+
}));
|
|
1709
|
+
|
|
1710
|
+
import { runIngestSynthesis } from "../extensions/llm-wiki/lib/ingest-worker.js";
|
|
1711
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
1712
|
+
|
|
1713
|
+
const root = join(import.meta.dirname, "..", "tmp", `ingest-race-${Date.now()}`);
|
|
1714
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
1715
|
+
|
|
1716
|
+
it("rechecks vault mode after synthesis and before background commit", async () => {
|
|
1717
|
+
const paths = getVaultPaths(root);
|
|
1718
|
+
ensureVaultStructure(paths);
|
|
1719
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ knowledge_format: "legacy" }));
|
|
1720
|
+
const pending = runIngestSynthesis({
|
|
1721
|
+
model: { provider: "test", id: "model" } as never,
|
|
1722
|
+
apiKey: "key",
|
|
1723
|
+
paths,
|
|
1724
|
+
sourceId: "SRC-001",
|
|
1725
|
+
manifest: { id: "SRC-001", title: "Some Paper" },
|
|
1726
|
+
extracted: "content",
|
|
1727
|
+
});
|
|
1728
|
+
|
|
1729
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ knowledge_format: "invalid" }));
|
|
1730
|
+
control.release();
|
|
1731
|
+
const result = await pending;
|
|
1732
|
+
expect(result).toBeUndefined();
|
|
1733
|
+
expect(existsSync(join(paths.wiki, "sources", "SRC-001.md"))).toBe(false);
|
|
1734
|
+
expect(existsSync(join(paths.meta, "events.jsonl"))).toBe(false);
|
|
1735
|
+
});
|
|
1736
|
+
```
|
|
1737
|
+
|
|
1738
|
+
- [x] **Step 3: Run the new tests and isolate the remaining failure**
|
|
1739
|
+
|
|
1740
|
+
```bash
|
|
1741
|
+
pnpm vitest run test/indexing-fail-closed.test.ts test/ingest-concurrency.test.ts test/ingest-tool.test.ts
|
|
1742
|
+
```
|
|
1743
|
+
|
|
1744
|
+
Expected: `test/indexing-fail-closed.test.ts` fails because the embedding mock is called despite a blocked rebuild. `test/ingest-concurrency.test.ts` passes as regression evidence because Task 4 already placed the immediate `commitSynthesis` assertion at the authoritative write seam. Do not weaken or remove that passing race test.
|
|
1745
|
+
|
|
1746
|
+
- [x] **Step 4: Gate indexing on `ProjectionResult.ok`**
|
|
1747
|
+
|
|
1748
|
+
Change the drain loop in `indexing.ts`:
|
|
1749
|
+
|
|
1750
|
+
```ts
|
|
1751
|
+
while (dirty.has(root)) {
|
|
1752
|
+
dirty.delete(root);
|
|
1753
|
+
const projection = rebuildMetadataLight(paths);
|
|
1754
|
+
if (!projection.ok) continue;
|
|
1755
|
+
|
|
1756
|
+
runtime.ensureConfig(root);
|
|
1757
|
+
const embedder = resolveEmbedder(runtime.config);
|
|
1758
|
+
if (embedder) await reindexEmbeddings(paths, embedder);
|
|
1759
|
+
}
|
|
1760
|
+
```
|
|
1761
|
+
|
|
1762
|
+
`commitSynthesis` already catches `VaultWriteError` from its immediate Task 4 assertion and returns a failed `CommitSynthesisOutcome`; the subagent tool therefore leaves `committed` unset after a configuration race.
|
|
1763
|
+
|
|
1764
|
+
- [x] **Step 5: Run indexing, ingestion, and background tests**
|
|
1765
|
+
|
|
1766
|
+
```bash
|
|
1767
|
+
pnpm vitest run test/indexing-fail-closed.test.ts test/ingest-concurrency.test.ts test/indexing.test.ts test/ingest-tool.test.ts test/background-tools.test.ts
|
|
1768
|
+
pnpm typecheck
|
|
1769
|
+
```
|
|
1770
|
+
|
|
1771
|
+
Expected: all pass. A blocked projection never starts embeddings, and config changes during synthesis prevent every commit write.
|
|
1772
|
+
|
|
1773
|
+
- [x] **Step 6: Commit background fail-closed behavior**
|
|
1774
|
+
|
|
1775
|
+
```bash
|
|
1776
|
+
git add extensions/llm-wiki/lib/indexing.ts extensions/llm-wiki/lib/ingest-worker.ts test/indexing-fail-closed.test.ts test/ingest-concurrency.test.ts test/indexing.test.ts test/ingest-tool.test.ts
|
|
1777
|
+
git commit -m "fix: stop background writes after OKF validation failure"
|
|
1778
|
+
```
|
|
1779
|
+
|
|
1780
|
+
---
|
|
1781
|
+
|
|
1782
|
+
### Task 6: Unify Lint, Recall, and Event Projections on Shared Services
|
|
1783
|
+
|
|
1784
|
+
**Files:**
|
|
1785
|
+
- Modify: `extensions/llm-wiki/lib/tools.ts:840-1010,1230-1267`
|
|
1786
|
+
- Modify: `extensions/llm-wiki/lib/recall.ts:299-307,383-552,936-1045`
|
|
1787
|
+
- Modify: `extensions/llm-wiki/lib/metadata.ts:90-120,340-360,560-650`
|
|
1788
|
+
- Modify: `extensions/llm-wiki/lib/knowledge-links.ts`
|
|
1789
|
+
- Modify: `extensions/llm-wiki/lib/utils.ts:274-369`
|
|
1790
|
+
- Create: `test/lint-okf.test.ts`
|
|
1791
|
+
- Modify: `test/recall.test.ts`
|
|
1792
|
+
- Modify: `test/okf-projections.test.ts`
|
|
1793
|
+
- Modify: `test/package-structure.test.ts`
|
|
1794
|
+
|
|
1795
|
+
- [x] **Step 1: Add malformed-page recall and diagnostic tests**
|
|
1796
|
+
|
|
1797
|
+
In `test/recall.test.ts`, make the main `beforeEach` vault explicit legacy immediately after `ensureVaultStructure(getVaultPaths(wikiDir))`:
|
|
1798
|
+
|
|
1799
|
+
```ts
|
|
1800
|
+
writeFileSync(
|
|
1801
|
+
join(getVaultPaths(wikiDir).dotWiki, "config.json"),
|
|
1802
|
+
JSON.stringify({ name: "Recall test" }),
|
|
1803
|
+
);
|
|
1804
|
+
```
|
|
1805
|
+
|
|
1806
|
+
Add `registerWikiRecall` and `rebuildMetadata` imports, then append:
|
|
1807
|
+
|
|
1808
|
+
```ts
|
|
1809
|
+
it("skips a now-malformed page retained in the known-good registry", () => {
|
|
1810
|
+
const id = createRegistryPage("good", "concept", "Good", "needle body");
|
|
1811
|
+
const paths = getVaultPaths(wikiDir);
|
|
1812
|
+
expect(rebuildMetadata(paths).ok).toBe(true);
|
|
1813
|
+
writeFileSync(join(paths.wiki, `${id}.md`), "broken\n");
|
|
1814
|
+
expect(searchWiki(paths, "needle", 5)).toEqual([]);
|
|
1815
|
+
});
|
|
1816
|
+
|
|
1817
|
+
it("surfaces version diagnostics in Pi recall including no-match responses", async () => {
|
|
1818
|
+
const paths = getVaultPaths(wikiDir);
|
|
1819
|
+
writeFileSync(
|
|
1820
|
+
join(paths.dotWiki, "config.json"),
|
|
1821
|
+
JSON.stringify({ knowledge_format: "okf-0.2" }),
|
|
1822
|
+
);
|
|
1823
|
+
expect(rebuildMetadata(paths).ok).toBe(true);
|
|
1824
|
+
writeFileSync(join(paths.wiki, "index.md"), '---\nokf_version: "0.3"\n---\n');
|
|
1825
|
+
|
|
1826
|
+
let captured:
|
|
1827
|
+
| { execute: (...args: any[]) => Promise<{ content: Array<{ text: string }>; details: any }> }
|
|
1828
|
+
| undefined;
|
|
1829
|
+
const pi = {
|
|
1830
|
+
registerTool: (tool: typeof captured) => {
|
|
1831
|
+
captured = tool;
|
|
1832
|
+
},
|
|
1833
|
+
} as unknown as ExtensionAPI;
|
|
1834
|
+
registerWikiRecall(pi);
|
|
1835
|
+
if (!captured) throw new Error("wiki_recall was not registered");
|
|
1836
|
+
const response = await captured.execute(
|
|
1837
|
+
"id",
|
|
1838
|
+
{ query: "absent" },
|
|
1839
|
+
undefined,
|
|
1840
|
+
undefined,
|
|
1841
|
+
{ cwd: paths.root, hasUI: false },
|
|
1842
|
+
);
|
|
1843
|
+
expect(
|
|
1844
|
+
response.details.diagnostics.map((diagnostic: { code: string }) => diagnostic.code),
|
|
1845
|
+
).toContain("okf_version_mismatch");
|
|
1846
|
+
expect(response.content[0].text).toContain("okf_version_mismatch");
|
|
1847
|
+
});
|
|
1848
|
+
```
|
|
1849
|
+
|
|
1850
|
+
Import `ExtensionAPI` from `@mariozechner/pi-coding-agent`. Keep the existing separate no-vault case unchanged; it uses its own directory without `config.json` and calls the read-only `searchWiki` API.
|
|
1851
|
+
|
|
1852
|
+
- [x] **Step 2: Add event integrity, chronology, and diagnostic propagation tests**
|
|
1853
|
+
|
|
1854
|
+
Append to `test/okf-projections.test.ts`:
|
|
1855
|
+
|
|
1856
|
+
```ts
|
|
1857
|
+
it("sorts valid offset timestamps by instant and returns malformed-event diagnostics", () => {
|
|
1858
|
+
const result = buildOkfLog(
|
|
1859
|
+
[
|
|
1860
|
+
'{"timestamp":"2026-08-02T13:30:00Z","kind":"earlier"}',
|
|
1861
|
+
'{"timestamp":"2026-08-02T09:00:00-05:00","kind":"later"}',
|
|
1862
|
+
"not-json",
|
|
1863
|
+
].join("\n"),
|
|
1864
|
+
);
|
|
1865
|
+
expect(result.markdown.indexOf("later")).toBeLessThan(result.markdown.indexOf("earlier"));
|
|
1866
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain("event_invalid_json");
|
|
1867
|
+
});
|
|
1868
|
+
|
|
1869
|
+
it.each([
|
|
1870
|
+
["legacy", false],
|
|
1871
|
+
["okf-0.2", true],
|
|
1872
|
+
] as const)(
|
|
1873
|
+
"includes non-blocking event diagnostics in %s projection results",
|
|
1874
|
+
(knowledgeFormat, publishesOkfLog) => {
|
|
1875
|
+
const paths = createVault({ knowledge_format: knowledgeFormat });
|
|
1876
|
+
writeFileSync(join(paths.meta, "events.jsonl"), "not-json\n");
|
|
1877
|
+
const result = rebuildMetadata(paths);
|
|
1878
|
+
expect(result.ok).toBe(true);
|
|
1879
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain(
|
|
1880
|
+
"event_invalid_json",
|
|
1881
|
+
);
|
|
1882
|
+
expect(existsSync(join(paths.wiki, "log.md"))).toBe(publishesOkfLog);
|
|
1883
|
+
},
|
|
1884
|
+
);
|
|
1885
|
+
|
|
1886
|
+
it("does not allow event details to override trusted timestamp or kind", () => {
|
|
1887
|
+
const paths = createVault({ knowledge_format: "legacy" });
|
|
1888
|
+
appendEvent(paths, { kind: "trusted", timestamp: "forged", extra: 1 } as never);
|
|
1889
|
+
const event = JSON.parse(readFileSync(join(paths.meta, "events.jsonl"), "utf8"));
|
|
1890
|
+
expect(event.kind).toBe("trusted");
|
|
1891
|
+
expect(event.timestamp).not.toBe("forged");
|
|
1892
|
+
expect(Number.isNaN(Date.parse(event.timestamp))).toBe(false);
|
|
1893
|
+
});
|
|
1894
|
+
|
|
1895
|
+
it("rejects empty or reserved manual-event input at the Pi tool boundary", async () => {
|
|
1896
|
+
const paths = createVault({ knowledge_format: "legacy" });
|
|
1897
|
+
let tool: { execute: (...args: any[]) => Promise<any> } | undefined;
|
|
1898
|
+
registerWikiLogEvent({
|
|
1899
|
+
registerTool: (definition: unknown) => {
|
|
1900
|
+
tool = definition as { execute: (...args: any[]) => Promise<any> };
|
|
1901
|
+
},
|
|
1902
|
+
} as unknown as ExtensionAPI);
|
|
1903
|
+
if (!tool) throw new Error("wiki_log_event was not registered");
|
|
1904
|
+
for (const params of [
|
|
1905
|
+
{ kind: " " },
|
|
1906
|
+
{ kind: "safe", details: { timestamp: "forged" } },
|
|
1907
|
+
{ kind: "safe", details: { kind: "forged" } },
|
|
1908
|
+
]) {
|
|
1909
|
+
const result = await tool.execute(
|
|
1910
|
+
"test",
|
|
1911
|
+
params,
|
|
1912
|
+
undefined,
|
|
1913
|
+
undefined,
|
|
1914
|
+
{ cwd: paths.root, hasUI: false },
|
|
1915
|
+
);
|
|
1916
|
+
expect(result.isError).toBe(true);
|
|
1917
|
+
}
|
|
1918
|
+
expect(existsSync(join(paths.meta, "events.jsonl"))).toBe(false);
|
|
1919
|
+
});
|
|
1920
|
+
```
|
|
1921
|
+
|
|
1922
|
+
Import `ExtensionAPI`, `registerWikiLogEvent`, and `appendEvent`.
|
|
1923
|
+
|
|
1924
|
+
Create `test/lint-okf.test.ts`:
|
|
1925
|
+
|
|
1926
|
+
```ts
|
|
1927
|
+
import { existsSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
1928
|
+
import { join } from "node:path";
|
|
1929
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
1930
|
+
import { afterEach, expect, it } from "vitest";
|
|
1931
|
+
import { registerWikiLint } from "../extensions/llm-wiki/lib/tools.js";
|
|
1932
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
1933
|
+
|
|
1934
|
+
const root = join(import.meta.dirname, "..", "tmp", `lint-okf-${Date.now()}`);
|
|
1935
|
+
afterEach(() => rmSync(root, { recursive: true, force: true }));
|
|
1936
|
+
|
|
1937
|
+
it("reports and auto-fixes one target referenced by Markdown and a legacy wikilink", async () => {
|
|
1938
|
+
const paths = getVaultPaths(root);
|
|
1939
|
+
ensureVaultStructure(paths);
|
|
1940
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ name: "Lint test" }));
|
|
1941
|
+
writeFileSync(
|
|
1942
|
+
join(paths.wiki, "concepts", "markdown-source.md"),
|
|
1943
|
+
"---\ntype: concept\ntitle: Markdown source\n---\n\n[missing](/concepts/missing.md)\n",
|
|
1944
|
+
);
|
|
1945
|
+
writeFileSync(
|
|
1946
|
+
join(paths.wiki, "concepts", "wikilink-source.md"),
|
|
1947
|
+
"---\ntype: concept\ntitle: Wikilink source\n---\n\n[[concepts/missing]]\n",
|
|
1948
|
+
);
|
|
1949
|
+
|
|
1950
|
+
let tool: { execute: (...args: any[]) => Promise<any> } | undefined;
|
|
1951
|
+
registerWikiLint({
|
|
1952
|
+
registerTool: (definition: unknown) => {
|
|
1953
|
+
tool = definition as { execute: (...args: any[]) => Promise<any> };
|
|
1954
|
+
},
|
|
1955
|
+
} as unknown as ExtensionAPI);
|
|
1956
|
+
if (!tool) throw new Error("wiki_lint was not registered");
|
|
1957
|
+
const result = await tool.execute(
|
|
1958
|
+
"test",
|
|
1959
|
+
{ auto_fix: true },
|
|
1960
|
+
undefined,
|
|
1961
|
+
undefined,
|
|
1962
|
+
{ cwd: root, hasUI: false },
|
|
1963
|
+
);
|
|
1964
|
+
|
|
1965
|
+
expect(result.isError).not.toBe(true);
|
|
1966
|
+
expect(result.content[0].text).toContain("Missing: 2");
|
|
1967
|
+
expect(existsSync(join(paths.wiki, "concepts", "missing.md"))).toBe(true);
|
|
1968
|
+
const gaps = JSON.parse(readFileSync(join(paths.discoveries, "gaps.json"), "utf8"));
|
|
1969
|
+
expect(gaps.gaps).toEqual([
|
|
1970
|
+
{
|
|
1971
|
+
topic: "concepts/missing",
|
|
1972
|
+
mentionedBy: ["concepts/markdown-source", "concepts/wikilink-source"],
|
|
1973
|
+
},
|
|
1974
|
+
]);
|
|
1975
|
+
});
|
|
1976
|
+
```
|
|
1977
|
+
|
|
1978
|
+
- [x] **Step 3: Run reader/projection/lint tests and verify failure**
|
|
1979
|
+
|
|
1980
|
+
```bash
|
|
1981
|
+
pnpm vitest run test/recall.test.ts test/okf-projections.test.ts test/lint-okf.test.ts
|
|
1982
|
+
```
|
|
1983
|
+
|
|
1984
|
+
Expected: stale malformed page remains a recall result, Pi recall omits diagnostics, offset timestamps are misordered, rebuild drops event diagnostics, a supplied timestamp overrides the trusted one, and old lint ignores the Markdown edge so it reports only one missing edge and does not create the stub.
|
|
1985
|
+
|
|
1986
|
+
- [x] **Step 4: Skip malformed current files while retaining raw fallback entries**
|
|
1987
|
+
|
|
1988
|
+
In `searchWiki`, change the registry loop prelude:
|
|
1989
|
+
|
|
1990
|
+
```ts
|
|
1991
|
+
const pagePath = join(paths.wiki, `${id}.md`);
|
|
1992
|
+
const pageExists = existsSync(pagePath);
|
|
1993
|
+
const parsed = parsePage(pagePath, id);
|
|
1994
|
+
if (pageExists && !parsed) continue;
|
|
1995
|
+
const frontmatter = parsed?.frontmatter ?? {};
|
|
1996
|
+
const body = parsed?.body ?? "";
|
|
1997
|
+
```
|
|
1998
|
+
|
|
1999
|
+
This excludes corrupted concept pages but retains registry-only raw source/trajectory fallback entries whose page never existed.
|
|
2000
|
+
|
|
2001
|
+
In `registerWikiRecall`, compute:
|
|
2002
|
+
|
|
2003
|
+
```ts
|
|
2004
|
+
const vaultDiagnostics = inspectVaultFormat(paths).diagnostics;
|
|
2005
|
+
const diagnosticText = vaultDiagnostics.length
|
|
2006
|
+
? `\n\nDiagnostics: ${vaultDiagnostics.map((diagnostic) => diagnostic.code).join(", ")}`
|
|
2007
|
+
: "";
|
|
2008
|
+
```
|
|
2009
|
+
|
|
2010
|
+
Append `diagnosticText` to no-match, links-first, and preview text. Include `diagnostics: vaultDiagnostics` in every result's `details`.
|
|
2011
|
+
|
|
2012
|
+
- [x] **Step 5: Build lint from shared discovery and link resolution**
|
|
2013
|
+
|
|
2014
|
+
First extend the shared resolver result in `knowledge-links.ts` so callers never parse diagnostic prose:
|
|
2015
|
+
|
|
2016
|
+
```ts
|
|
2017
|
+
export interface UnresolvedKnowledgeLink {
|
|
2018
|
+
target: string;
|
|
2019
|
+
syntax: "markdown" | "wikilink";
|
|
2020
|
+
}
|
|
2021
|
+
|
|
2022
|
+
export interface ResolvedBacklinks {
|
|
2023
|
+
targets: string[];
|
|
2024
|
+
unresolved: UnresolvedKnowledgeLink[];
|
|
2025
|
+
diagnostics: KnowledgeDiagnostic[];
|
|
2026
|
+
}
|
|
2027
|
+
```
|
|
2028
|
+
|
|
2029
|
+
Initialize `const unresolved: UnresolvedKnowledgeLink[] = []`; whenever either Markdown or wikilink resolution emits `link_unresolved`, also push its normalized ID with the corresponding syntax. Return `{ targets: sorted, unresolved, diagnostics }`. Path escapes, malformed percent encodings, external links, and empty links must not enter `unresolved`.
|
|
2030
|
+
|
|
2031
|
+
Then replace `runWikiLint` with this shared-service implementation:
|
|
2032
|
+
|
|
2033
|
+
```ts
|
|
2034
|
+
function runWikiLint(paths: VaultPaths, autoFix: boolean): string {
|
|
2035
|
+
assertWritableVault(paths);
|
|
2036
|
+
const projection = rebuildMetadata(paths);
|
|
2037
|
+
if (!projection.ok) {
|
|
2038
|
+
return [
|
|
2039
|
+
"# Wiki Lint Report",
|
|
2040
|
+
"",
|
|
2041
|
+
"Projection-blocking diagnostics:",
|
|
2042
|
+
...projection.diagnostics.map(
|
|
2043
|
+
(diagnostic) => `- ${diagnostic.code}: ${diagnostic.path}: ${diagnostic.message}`,
|
|
2044
|
+
),
|
|
2045
|
+
].join("\n");
|
|
2046
|
+
}
|
|
2047
|
+
|
|
2048
|
+
const discovery = discoverKnowledgeDocuments(paths);
|
|
2049
|
+
const pages = discovery.documents;
|
|
2050
|
+
const knownIds = new Set(pages.map((page) => page.id));
|
|
2051
|
+
const inbound = Object.fromEntries(pages.map((page) => [page.id, 0]));
|
|
2052
|
+
const gapSources = new Map<string, Set<string>>();
|
|
2053
|
+
const findings: string[] = [];
|
|
2054
|
+
let missingPages = 0;
|
|
2055
|
+
let contradictions = 0;
|
|
2056
|
+
|
|
2057
|
+
for (const page of pages) {
|
|
2058
|
+
const resolved = buildResolvedBacklinks(page.id, page.body, knownIds);
|
|
2059
|
+
for (const target of resolved.targets) inbound[target]++;
|
|
2060
|
+
for (const unresolved of resolved.unresolved) {
|
|
2061
|
+
const sources = gapSources.get(unresolved.target) ?? new Set<string>();
|
|
2062
|
+
sources.add(page.id);
|
|
2063
|
+
gapSources.set(unresolved.target, sources);
|
|
2064
|
+
missingPages++;
|
|
2065
|
+
findings.push(`Missing page: ${unresolved.target} (in ${page.id})`);
|
|
2066
|
+
}
|
|
2067
|
+
}
|
|
2068
|
+
|
|
2069
|
+
let orphans = 0;
|
|
2070
|
+
for (const page of pages) {
|
|
2071
|
+
if (inbound[page.id] === 0) {
|
|
2072
|
+
orphans++;
|
|
2073
|
+
findings.push(`Orphan: ${page.id} has no inbound links`);
|
|
2074
|
+
}
|
|
2075
|
+
if (page.body.includes("⚠️ **Contradiction")) {
|
|
2076
|
+
contradictions++;
|
|
2077
|
+
findings.push(`Contradiction flagged in ${page.id}`);
|
|
2078
|
+
}
|
|
2079
|
+
}
|
|
2080
|
+
|
|
2081
|
+
const gaps = [...gapSources.entries()]
|
|
2082
|
+
.map(([topic, sources]) => ({ topic, mentionedBy: [...sources].sort(compareCodePoint) }))
|
|
2083
|
+
.sort((left, right) => compareCodePoint(left.topic, right.topic));
|
|
2084
|
+
let fixesApplied = 0;
|
|
2085
|
+
if (autoFix) {
|
|
2086
|
+
for (const gap of gaps) {
|
|
2087
|
+
if (gap.mentionedBy.length < 2) continue;
|
|
2088
|
+
const parts = gap.topic.split("/");
|
|
2089
|
+
const name = parts.length === 1 ? parts[0] : parts.length === 2 && parts[0] === "concepts" ? parts[1] : "";
|
|
2090
|
+
if (!name || slugify(name) !== name) continue;
|
|
2091
|
+
const pagePath = join(paths.wiki, "concepts", `${name}.md`);
|
|
2092
|
+
mkdirSync(join(paths.wiki, "concepts"), { recursive: true });
|
|
2093
|
+
const document = createKnowledgeDocument(
|
|
2094
|
+
`concepts/${name}.md`,
|
|
2095
|
+
{
|
|
2096
|
+
type: "concept",
|
|
2097
|
+
title: name.replace(/-/g, " "),
|
|
2098
|
+
created: fmtDate(),
|
|
2099
|
+
updated: fmtDate(),
|
|
2100
|
+
status: "stub",
|
|
2101
|
+
},
|
|
2102
|
+
`_Stub auto-created by lint. Expand with content from: ${gap.mentionedBy
|
|
2103
|
+
.map((source) => `[${source}](/${source}.md)`)
|
|
2104
|
+
.join(", ")}_`,
|
|
2105
|
+
);
|
|
2106
|
+
try {
|
|
2107
|
+
writeFileSync(pagePath, serializeKnowledgeDocument(document), {
|
|
2108
|
+
encoding: "utf8",
|
|
2109
|
+
flag: "wx",
|
|
2110
|
+
});
|
|
2111
|
+
fixesApplied++;
|
|
2112
|
+
} catch (error: unknown) {
|
|
2113
|
+
if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
|
|
2114
|
+
}
|
|
2115
|
+
}
|
|
2116
|
+
}
|
|
2117
|
+
|
|
2118
|
+
writeJson(join(paths.discoveries, "gaps.json"), {
|
|
2119
|
+
gaps,
|
|
2120
|
+
generated: new Date().toISOString(),
|
|
2121
|
+
});
|
|
2122
|
+
const reportLines = [
|
|
2123
|
+
"# Wiki Lint Report",
|
|
2124
|
+
`Generated: ${fmtDate()}`,
|
|
2125
|
+
"",
|
|
2126
|
+
"## Summary",
|
|
2127
|
+
`- Total pages: ${pages.length}`,
|
|
2128
|
+
`- Orphans: ${orphans}`,
|
|
2129
|
+
`- Missing pages: ${missingPages}`,
|
|
2130
|
+
`- Contradictions: ${contradictions}`,
|
|
2131
|
+
autoFix ? `- Fixes applied: ${fixesApplied}` : "",
|
|
2132
|
+
"",
|
|
2133
|
+
"## Findings",
|
|
2134
|
+
findings.length ? findings.map((finding) => `- ${finding}`).join("\n") : "✅ No issues found!",
|
|
2135
|
+
"",
|
|
2136
|
+
].filter(Boolean);
|
|
2137
|
+
const reportPath = join(paths.outputs, `lint-${fmtDate()}.md`);
|
|
2138
|
+
mkdirSync(paths.outputs, { recursive: true });
|
|
2139
|
+
writeFileSync(reportPath, `${reportLines.join("\n")}\n`, "utf8");
|
|
2140
|
+
appendEvent(paths, {
|
|
2141
|
+
kind: "lint",
|
|
2142
|
+
orphans,
|
|
2143
|
+
missing_pages: missingPages,
|
|
2144
|
+
contradictions,
|
|
2145
|
+
auto_fix: autoFix,
|
|
2146
|
+
});
|
|
2147
|
+
rebuildMetadataLight(paths);
|
|
2148
|
+
|
|
2149
|
+
return [
|
|
2150
|
+
"🧹 **LLM Wiki lint complete**",
|
|
2151
|
+
"",
|
|
2152
|
+
`- Pages: ${pages.length}`,
|
|
2153
|
+
`- Orphans: ${orphans}`,
|
|
2154
|
+
`- Missing: ${missingPages}`,
|
|
2155
|
+
`- Contradictions: ${contradictions}`,
|
|
2156
|
+
autoFix ? `- Auto-fixes: ${fixesApplied}` : "",
|
|
2157
|
+
"",
|
|
2158
|
+
`📄 Report: \`${reportPath}\``,
|
|
2159
|
+
gaps.length ? `💡 ${gaps.length} knowledge gap(s) tracked` : "",
|
|
2160
|
+
]
|
|
2161
|
+
.filter(Boolean)
|
|
2162
|
+
.join("\n");
|
|
2163
|
+
}
|
|
2164
|
+
```
|
|
2165
|
+
|
|
2166
|
+
Import `assertWritableVault`, `compareCodePoint`, `discoverKnowledgeDocuments`, and `buildResolvedBacklinks`. Generated indexes/logs remain excluded by discovery; malformed percent links remain diagnostics but are not auto-fixed. The lint regression must prove both syntaxes contribute to the same structured gap and auto-fix threshold.
|
|
2167
|
+
|
|
2168
|
+
- [x] **Step 6: Remove obsolete parser/scanner exports**
|
|
2169
|
+
|
|
2170
|
+
Delete these functions from `utils.ts`:
|
|
2171
|
+
|
|
2172
|
+
```text
|
|
2173
|
+
parseFrontmatterValue
|
|
2174
|
+
parseFrontmatter
|
|
2175
|
+
findWikiPages
|
|
2176
|
+
extractWikilinks
|
|
2177
|
+
```
|
|
2178
|
+
|
|
2179
|
+
Remove their imports from `tools.ts` and any remaining callers. Add this source-structure assertion to `test/package-structure.test.ts`:
|
|
2180
|
+
|
|
2181
|
+
```ts
|
|
2182
|
+
it("has no obsolete YAML or wikilink scanners", () => {
|
|
2183
|
+
const utils = readFile(join(rootDir, "extensions/llm-wiki/lib/utils.ts"));
|
|
2184
|
+
expect(utils).not.toContain("parseFrontmatter(");
|
|
2185
|
+
expect(utils).not.toContain("findWikiPages(");
|
|
2186
|
+
expect(utils).not.toContain("extractWikilinks(");
|
|
2187
|
+
});
|
|
2188
|
+
```
|
|
2189
|
+
|
|
2190
|
+
- [x] **Step 7: Protect event fields, sort by epoch, and propagate diagnostics**
|
|
2191
|
+
|
|
2192
|
+
Change `appendEvent`:
|
|
2193
|
+
|
|
2194
|
+
```ts
|
|
2195
|
+
export function appendEvent(paths: VaultPaths, event: Omit<WikiEvent, "timestamp">): void {
|
|
2196
|
+
assertWritableVault(paths);
|
|
2197
|
+
const { timestamp: _ignored, kind: rawKind, ...details } = event as WikiEvent;
|
|
2198
|
+
const kind = typeof rawKind === "string" ? rawKind.trim() : "";
|
|
2199
|
+
if (!kind) throw new Error("Event kind must be a non-empty string");
|
|
2200
|
+
mkdirSync(paths.meta, { recursive: true });
|
|
2201
|
+
const line = JSON.stringify({ ...details, timestamp: new Date().toISOString(), kind });
|
|
2202
|
+
writeFileSync(join(paths.meta, "events.jsonl"), `${line}\n`, {
|
|
2203
|
+
flag: "a",
|
|
2204
|
+
encoding: "utf8",
|
|
2205
|
+
});
|
|
2206
|
+
}
|
|
2207
|
+
```
|
|
2208
|
+
|
|
2209
|
+
In `buildOkfLog`, add `epoch: number` to the internal event type, set `epoch: ts.getTime()` in `events.push`, and sort:
|
|
2210
|
+
|
|
2211
|
+
```ts
|
|
2212
|
+
dayEvents.sort((left, right) => right.epoch - left.epoch || right.seq - left.seq);
|
|
2213
|
+
```
|
|
2214
|
+
|
|
2215
|
+
In `rebuildMetadata`, always parse events once so malformed-line diagnostics survive in both modes, but publish the root OKF log only in OKF mode:
|
|
2216
|
+
|
|
2217
|
+
```ts
|
|
2218
|
+
const eventLogResult = buildOkfLog(readText(join(paths.meta, "events.jsonl")));
|
|
2219
|
+
allDiagnostics.push(...eventLogResult.diagnostics);
|
|
2220
|
+
const okfLog =
|
|
2221
|
+
vaultState.knowledgeFormat === "okf-0.2" ? eventLogResult.markdown : null;
|
|
2222
|
+
```
|
|
2223
|
+
|
|
2224
|
+
In `wiki_log_event`, reject empty `kind` and own `kind`/`timestamp` keys in `details` before calling `appendEvent`.
|
|
2225
|
+
|
|
2226
|
+
- [x] **Step 8: Run shared-reader and event tests**
|
|
2227
|
+
|
|
2228
|
+
```bash
|
|
2229
|
+
pnpm vitest run test/recall.test.ts test/okf-projections.test.ts test/lint-okf.test.ts test/package-structure.test.ts test/wiki-structure.test.ts
|
|
2230
|
+
pnpm typecheck
|
|
2231
|
+
pnpm lint
|
|
2232
|
+
```
|
|
2233
|
+
|
|
2234
|
+
Expected: all pass. Lint, recall, backlinks, projections, and event rendering use shared semantic data and stable diagnostics.
|
|
2235
|
+
|
|
2236
|
+
- [x] **Step 9: Commit shared reader and event fixes**
|
|
2237
|
+
|
|
2238
|
+
```bash
|
|
2239
|
+
git add extensions/llm-wiki/lib/tools.ts extensions/llm-wiki/lib/recall.ts extensions/llm-wiki/lib/metadata.ts extensions/llm-wiki/lib/knowledge-links.ts extensions/llm-wiki/lib/utils.ts test/lint-okf.test.ts test/recall.test.ts test/okf-projections.test.ts test/package-structure.test.ts
|
|
2240
|
+
git commit -m "fix: unify OKF readers lint and event diagnostics"
|
|
2241
|
+
```
|
|
2242
|
+
|
|
2243
|
+
---
|
|
2244
|
+
|
|
2245
|
+
### Task 7: Make MCP Capture Real and Writes Immediately Discoverable
|
|
2246
|
+
|
|
2247
|
+
**Files:**
|
|
2248
|
+
- Create: `mcp/exec.ts`
|
|
2249
|
+
- Create: `test/mcp-exec.test.ts`
|
|
2250
|
+
- Modify: `mcp/operations.ts`
|
|
2251
|
+
- Modify: `mcp/index.ts:1-280`
|
|
2252
|
+
- Modify: `test/mcp-parity.test.ts`
|
|
2253
|
+
|
|
2254
|
+
- [x] **Step 1: Add subprocess and real-capture tests**
|
|
2255
|
+
|
|
2256
|
+
Create `test/mcp-exec.test.ts`:
|
|
2257
|
+
|
|
2258
|
+
```ts
|
|
2259
|
+
import { createServer } from "node:http";
|
|
2260
|
+
import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2261
|
+
import { join } from "node:path";
|
|
2262
|
+
import { afterEach, describe, expect, it } from "vitest";
|
|
2263
|
+
import { ensureVaultStructure, getVaultPaths } from "../extensions/llm-wiki/lib/utils.js";
|
|
2264
|
+
import { captureSourceOperation } from "../mcp/operations.js";
|
|
2265
|
+
import { createExecApi } from "../mcp/exec.js";
|
|
2266
|
+
|
|
2267
|
+
const roots: string[] = [];
|
|
2268
|
+
afterEach(() => {
|
|
2269
|
+
for (const root of roots.splice(0)) rmSync(root, { recursive: true, force: true });
|
|
2270
|
+
});
|
|
2271
|
+
|
|
2272
|
+
it("returns stdout, stderr, exit code, timeout, and abort state", async () => {
|
|
2273
|
+
const api = createExecApi();
|
|
2274
|
+
const success = await api.exec(process.execPath, ["-e", "console.log('ok')"]);
|
|
2275
|
+
expect(success).toMatchObject({ stdout: "ok\n", code: 0, killed: false });
|
|
2276
|
+
const failure = await api.exec(process.execPath, ["-e", "process.stderr.write('bad');process.exit(7)"]);
|
|
2277
|
+
expect(failure).toMatchObject({ stderr: "bad", code: 7, killed: false });
|
|
2278
|
+
const timedOut = await api.exec(process.execPath, ["-e", "setTimeout(()=>{}, 1000)"], {
|
|
2279
|
+
timeout: 10,
|
|
2280
|
+
});
|
|
2281
|
+
expect(timedOut.killed).toBe(true);
|
|
2282
|
+
|
|
2283
|
+
const controller = new AbortController();
|
|
2284
|
+
const aborted = api.exec(process.execPath, ["-e", "setTimeout(()=>{}, 1000)"], {
|
|
2285
|
+
signal: controller.signal,
|
|
2286
|
+
});
|
|
2287
|
+
controller.abort();
|
|
2288
|
+
await expect(aborted).resolves.toMatchObject({ killed: true });
|
|
2289
|
+
});
|
|
2290
|
+
|
|
2291
|
+
it("captures local files with a preserved original and current registry", async () => {
|
|
2292
|
+
const root = join(import.meta.dirname, "..", "tmp", `mcp-file-${Date.now()}`);
|
|
2293
|
+
roots.push(root);
|
|
2294
|
+
const paths = getVaultPaths(root);
|
|
2295
|
+
ensureVaultStructure(paths);
|
|
2296
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ knowledge_format: "legacy" }));
|
|
2297
|
+
const input = join(root, "input.txt");
|
|
2298
|
+
writeFileSync(input, "MCP file body");
|
|
2299
|
+
const result = await captureSourceOperation(paths, { filePath: input }, createExecApi());
|
|
2300
|
+
expect(result.ok).toBe(true);
|
|
2301
|
+
if (!result.ok) return;
|
|
2302
|
+
expect(
|
|
2303
|
+
existsSync(join(paths.rawSources, result.sourceId, "original", "input.txt")),
|
|
2304
|
+
).toBe(true);
|
|
2305
|
+
const registry = JSON.parse(readFileSync(join(paths.meta, "registry.json"), "utf8"));
|
|
2306
|
+
expect(registry.pages[`sources/${result.sourceId}`]).toBeDefined();
|
|
2307
|
+
});
|
|
2308
|
+
|
|
2309
|
+
it("captures a local HTTP page through the production MCP runner", async () => {
|
|
2310
|
+
const server = createServer((_request, response) => {
|
|
2311
|
+
response.end("<html><title>Local</title><body><h1>Captured</h1><p>HTTP body</p></body></html>");
|
|
2312
|
+
});
|
|
2313
|
+
await new Promise<void>((resolve) => server.listen(0, "127.0.0.1", resolve));
|
|
2314
|
+
try {
|
|
2315
|
+
const address = server.address();
|
|
2316
|
+
if (!address || typeof address === "string") throw new Error("expected TCP address");
|
|
2317
|
+
const root = join(import.meta.dirname, "..", "tmp", `mcp-url-${Date.now()}`);
|
|
2318
|
+
roots.push(root);
|
|
2319
|
+
const paths = getVaultPaths(root);
|
|
2320
|
+
ensureVaultStructure(paths);
|
|
2321
|
+
writeFileSync(join(paths.dotWiki, "config.json"), JSON.stringify({ knowledge_format: "legacy" }));
|
|
2322
|
+
const result = await captureSourceOperation(
|
|
2323
|
+
paths,
|
|
2324
|
+
{ url: `http://127.0.0.1:${address.port}/source` },
|
|
2325
|
+
createExecApi(),
|
|
2326
|
+
);
|
|
2327
|
+
expect(result.ok).toBe(true);
|
|
2328
|
+
if (!result.ok) return;
|
|
2329
|
+
expect(readFileSync(join(paths.rawSources, result.sourceId, "extracted.md"), "utf8")).toContain(
|
|
2330
|
+
"HTTP body",
|
|
2331
|
+
);
|
|
2332
|
+
} finally {
|
|
2333
|
+
await new Promise<void>((resolve, reject) =>
|
|
2334
|
+
server.close((error) => (error ? reject(error) : resolve())),
|
|
2335
|
+
);
|
|
2336
|
+
}
|
|
2337
|
+
});
|
|
2338
|
+
```
|
|
2339
|
+
|
|
2340
|
+
Remove unused `mkdirSync` from the final imports if Biome reports it.
|
|
2341
|
+
|
|
2342
|
+
- [x] **Step 2: Extend MCP parity tests to require post-write discoverability and failure propagation**
|
|
2343
|
+
|
|
2344
|
+
Add to `test/mcp-parity.test.ts`:
|
|
2345
|
+
|
|
2346
|
+
```ts
|
|
2347
|
+
it("makes MCP retro immediately searchable", async () => {
|
|
2348
|
+
const result = await retroOperation(paths, "mcp-visible", "Visible Insight", "searchable needle");
|
|
2349
|
+
expect(result.ok).toBe(true);
|
|
2350
|
+
expect(searchRegistry(paths, "Visible Insight").matches.map((match) => match.id)).toContain(
|
|
2351
|
+
"sources/mcp-visible",
|
|
2352
|
+
);
|
|
2353
|
+
});
|
|
2354
|
+
|
|
2355
|
+
it("makes MCP text capture immediately recallable", async () => {
|
|
2356
|
+
const result = await captureSourceOperation(
|
|
2357
|
+
paths,
|
|
2358
|
+
{ text: "capture needle", title: "Visible Capture" },
|
|
2359
|
+
createExecApi(),
|
|
2360
|
+
);
|
|
2361
|
+
expect(result.ok).toBe(true);
|
|
2362
|
+
expect(searchRegistry(paths, "Visible Capture").matches).toHaveLength(1);
|
|
2363
|
+
});
|
|
2364
|
+
|
|
2365
|
+
it("returns blocking projection diagnostics after a successful authoritative write", async () => {
|
|
2366
|
+
writeFileSync(join(paths.wiki, "concepts", "bad.md"), "malformed\n");
|
|
2367
|
+
const result = await retroOperation(paths, "written-but-blocked", "Written", "Body");
|
|
2368
|
+
expect(result.ok).toBe(false);
|
|
2369
|
+
if (result.ok) return;
|
|
2370
|
+
expect(result.diagnostics.map((diagnostic) => diagnostic.code)).toContain("frontmatter_missing");
|
|
2371
|
+
});
|
|
2372
|
+
```
|
|
2373
|
+
|
|
2374
|
+
Import `createExecApi`.
|
|
2375
|
+
|
|
2376
|
+
- [x] **Step 3: Run MCP tests and verify failure**
|
|
2377
|
+
|
|
2378
|
+
```bash
|
|
2379
|
+
pnpm vitest run test/mcp-exec.test.ts test/mcp-parity.test.ts
|
|
2380
|
+
```
|
|
2381
|
+
|
|
2382
|
+
Expected: `mcp/exec.ts` is missing, production operations do not rebuild, and current MCP capture uses a no-op executor.
|
|
2383
|
+
|
|
2384
|
+
- [x] **Step 4: Implement the narrow Node command runner**
|
|
2385
|
+
|
|
2386
|
+
Create `mcp/exec.ts`:
|
|
2387
|
+
|
|
2388
|
+
```ts
|
|
2389
|
+
import { execFile } from "node:child_process";
|
|
2390
|
+
import type { ExecApi } from "../extensions/llm-wiki/lib/utils.js";
|
|
2391
|
+
|
|
2392
|
+
export function createExecApi(): ExecApi {
|
|
2393
|
+
return {
|
|
2394
|
+
exec(command, args, options = {}) {
|
|
2395
|
+
return new Promise((resolve) => {
|
|
2396
|
+
let killed = false;
|
|
2397
|
+
const child = execFile(
|
|
2398
|
+
command,
|
|
2399
|
+
args,
|
|
2400
|
+
{
|
|
2401
|
+
cwd: options.cwd,
|
|
2402
|
+
encoding: "utf8",
|
|
2403
|
+
maxBuffer: 16 * 1024 * 1024,
|
|
2404
|
+
},
|
|
2405
|
+
(error, stdout, stderr) => {
|
|
2406
|
+
cleanup();
|
|
2407
|
+
resolve({
|
|
2408
|
+
stdout: String(stdout),
|
|
2409
|
+
stderr: String(stderr),
|
|
2410
|
+
code: typeof error?.code === "number" ? error.code : error ? 1 : 0,
|
|
2411
|
+
killed,
|
|
2412
|
+
});
|
|
2413
|
+
},
|
|
2414
|
+
);
|
|
2415
|
+
|
|
2416
|
+
const stop = () => {
|
|
2417
|
+
killed = true;
|
|
2418
|
+
child.kill("SIGTERM");
|
|
2419
|
+
};
|
|
2420
|
+
const timer = options.timeout ? setTimeout(stop, options.timeout) : undefined;
|
|
2421
|
+
const abort = () => stop();
|
|
2422
|
+
options.signal?.addEventListener("abort", abort, { once: true });
|
|
2423
|
+
|
|
2424
|
+
function cleanup() {
|
|
2425
|
+
if (timer) clearTimeout(timer);
|
|
2426
|
+
options.signal?.removeEventListener("abort", abort);
|
|
2427
|
+
}
|
|
2428
|
+
|
|
2429
|
+
if (options.signal?.aborted) stop();
|
|
2430
|
+
});
|
|
2431
|
+
},
|
|
2432
|
+
};
|
|
2433
|
+
}
|
|
2434
|
+
```
|
|
2435
|
+
|
|
2436
|
+
The runner never invokes a shell, so command arguments remain separate. It returns Pi-compatible result objects for nonzero exits, timeout, abort, and spawn errors.
|
|
2437
|
+
|
|
2438
|
+
- [x] **Step 5: Rebuild after successful MCP writes**
|
|
2439
|
+
|
|
2440
|
+
In `mcp/operations.ts`, after `saveInsight` and every capture variant, call `rebuildMetadata(paths)`. Return blocking diagnostics if projection publication fails:
|
|
2441
|
+
|
|
2442
|
+
```ts
|
|
2443
|
+
function projectionOutcome(
|
|
2444
|
+
projection: ProjectionResult,
|
|
2445
|
+
): { ok: true } | { ok: false; diagnostics: Array<{ code: string; message: string }> } {
|
|
2446
|
+
return projection.ok
|
|
2447
|
+
? { ok: true }
|
|
2448
|
+
: {
|
|
2449
|
+
ok: false,
|
|
2450
|
+
diagnostics: projection.diagnostics.map(({ code, message }) => ({ code, message })),
|
|
2451
|
+
};
|
|
2452
|
+
}
|
|
2453
|
+
```
|
|
2454
|
+
|
|
2455
|
+
For retro, call `saveInsight(..., { rebuild: false })`, then `projectionOutcome(rebuildMetadata(paths))`. For capture, retain the `CaptureResult`, rebuild, and return its `sourceId` only after checking the projection. Catch `VaultWriteError` and invalid-slug errors and render them as operation diagnostics.
|
|
2456
|
+
|
|
2457
|
+
- [x] **Step 6: Use the real runner in MCP transport**
|
|
2458
|
+
|
|
2459
|
+
In `mcp/index.ts`, import and create the adapter once:
|
|
2460
|
+
|
|
2461
|
+
```ts
|
|
2462
|
+
import { createExecApi } from "./exec.js";
|
|
2463
|
+
const execApi = createExecApi();
|
|
2464
|
+
```
|
|
2465
|
+
|
|
2466
|
+
Replace the hard-coded successful no-op object with `execApi`. Keep exactly the existing five registered MCP names and no read/import/export/migration operation.
|
|
2467
|
+
|
|
2468
|
+
- [x] **Step 7: Run MCP, type, and lint checks**
|
|
2469
|
+
|
|
2470
|
+
```bash
|
|
2471
|
+
pnpm vitest run test/mcp-exec.test.ts test/mcp-parity.test.ts test/source-capture.test.ts
|
|
2472
|
+
pnpm typecheck
|
|
2473
|
+
pnpm lint
|
|
2474
|
+
```
|
|
2475
|
+
|
|
2476
|
+
Expected: all pass. File and local-URL capture perform real subprocess work; successful MCP writes are immediately visible through shared registry/recall services.
|
|
2477
|
+
|
|
2478
|
+
- [x] **Step 8: Commit MCP production parity**
|
|
2479
|
+
|
|
2480
|
+
```bash
|
|
2481
|
+
git add mcp/exec.ts mcp/operations.ts mcp/index.ts test/mcp-exec.test.ts test/mcp-parity.test.ts
|
|
2482
|
+
git commit -m "fix: make MCP writes real and discoverable"
|
|
2483
|
+
```
|
|
2484
|
+
|
|
2485
|
+
---
|
|
2486
|
+
|
|
2487
|
+
### Task 8: Replace Helper-Level Acceptance with Real Foundation Gates
|
|
2488
|
+
|
|
2489
|
+
**Files:**
|
|
2490
|
+
- Modify: `test/okf-integration.test.ts`
|
|
2491
|
+
- Modify: `test/okf-projections.test.ts`
|
|
2492
|
+
- Modify: `test/package-structure.test.ts`
|
|
2493
|
+
- Modify: `vitest.config.ts`
|
|
2494
|
+
|
|
2495
|
+
- [x] **Step 1: Replace the weak Foundation acceptance test with a real seam flow**
|
|
2496
|
+
|
|
2497
|
+
Import the real extension, `ExtensionAPI`, and `relative`, then add these complete helpers above the integration `describe`:
|
|
2498
|
+
|
|
2499
|
+
```ts
|
|
2500
|
+
import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
|
|
2501
|
+
import extension from "../extensions/llm-wiki/index.js";
|
|
2502
|
+
|
|
2503
|
+
type RegisteredTool = {
|
|
2504
|
+
execute: (...args: any[]) => Promise<any>;
|
|
2505
|
+
};
|
|
2506
|
+
type ExtensionHandler = (...args: any[]) => unknown;
|
|
2507
|
+
|
|
2508
|
+
function registerFullExtensionHarness(root: string) {
|
|
2509
|
+
const handlers = new Map<string, ExtensionHandler[]>();
|
|
2510
|
+
const tools = new Map<string, RegisteredTool>();
|
|
2511
|
+
const messages: unknown[] = [];
|
|
2512
|
+
const pi = {
|
|
2513
|
+
on: (name: string, handler: ExtensionHandler) => {
|
|
2514
|
+
handlers.set(name, [...(handlers.get(name) ?? []), handler]);
|
|
2515
|
+
},
|
|
2516
|
+
registerTool: (tool: RegisteredTool & { name: string }) => tools.set(tool.name, tool),
|
|
2517
|
+
registerCommand: () => {},
|
|
2518
|
+
sendMessage: (message: unknown) => messages.push(message),
|
|
2519
|
+
} as unknown as ExtensionAPI;
|
|
2520
|
+
|
|
2521
|
+
const registerCwd = process.cwd();
|
|
2522
|
+
const registerHome = process.env.WIKI_HOME;
|
|
2523
|
+
process.chdir(root);
|
|
2524
|
+
process.env.WIKI_HOME = root;
|
|
2525
|
+
try {
|
|
2526
|
+
extension(pi);
|
|
2527
|
+
} finally {
|
|
2528
|
+
process.chdir(registerCwd);
|
|
2529
|
+
// biome-ignore lint/performance/noDelete: restore an actually absent environment variable
|
|
2530
|
+
if (registerHome === undefined) delete process.env.WIKI_HOME;
|
|
2531
|
+
else process.env.WIKI_HOME = registerHome;
|
|
2532
|
+
}
|
|
2533
|
+
|
|
2534
|
+
async function atRoot<T>(work: () => Promise<T>): Promise<T> {
|
|
2535
|
+
const priorCwd = process.cwd();
|
|
2536
|
+
const priorHome = process.env.WIKI_HOME;
|
|
2537
|
+
process.chdir(root);
|
|
2538
|
+
process.env.WIKI_HOME = root;
|
|
2539
|
+
try {
|
|
2540
|
+
return await work();
|
|
2541
|
+
} finally {
|
|
2542
|
+
process.chdir(priorCwd);
|
|
2543
|
+
// biome-ignore lint/performance/noDelete: restore an actually absent environment variable
|
|
2544
|
+
if (priorHome === undefined) delete process.env.WIKI_HOME;
|
|
2545
|
+
else process.env.WIKI_HOME = priorHome;
|
|
2546
|
+
}
|
|
2547
|
+
}
|
|
2548
|
+
|
|
2549
|
+
return {
|
|
2550
|
+
messages,
|
|
2551
|
+
emit: (name: string, event: unknown = {}, ctx: unknown = {}) =>
|
|
2552
|
+
atRoot(async () => {
|
|
2553
|
+
const results: unknown[] = [];
|
|
2554
|
+
for (const handler of handlers.get(name) ?? []) results.push(await handler(event, ctx));
|
|
2555
|
+
return results;
|
|
2556
|
+
}),
|
|
2557
|
+
execute: (name: string, params: Record<string, unknown>) =>
|
|
2558
|
+
atRoot(async () => {
|
|
2559
|
+
const tool = tools.get(name);
|
|
2560
|
+
if (!tool) throw new Error(`Tool not registered: ${name}`);
|
|
2561
|
+
return tool.execute("test", params, undefined, undefined, {
|
|
2562
|
+
cwd: root,
|
|
2563
|
+
hasUI: false,
|
|
2564
|
+
ui: { setStatus: () => {}, notify: () => {} },
|
|
2565
|
+
model: { provider: "test", id: "model" },
|
|
2566
|
+
modelRegistry: {
|
|
2567
|
+
find: () => undefined,
|
|
2568
|
+
getApiKeyAndHeaders: async () => ({ ok: false }),
|
|
2569
|
+
},
|
|
2570
|
+
});
|
|
2571
|
+
}),
|
|
2572
|
+
};
|
|
2573
|
+
}
|
|
2574
|
+
|
|
2575
|
+
function collectConceptFiles(wiki: string, directory = wiki): string[] {
|
|
2576
|
+
const files: string[] = [];
|
|
2577
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
2578
|
+
const fullPath = join(directory, entry.name);
|
|
2579
|
+
if (entry.isDirectory()) files.push(...collectConceptFiles(wiki, fullPath));
|
|
2580
|
+
if (!entry.isFile() || !entry.name.toLowerCase().endsWith(".md")) continue;
|
|
2581
|
+
const name = entry.name.toLowerCase();
|
|
2582
|
+
if (name === "index.md" || name === "log.md") continue;
|
|
2583
|
+
files.push(relative(wiki, fullPath).replace(/\\/g, "/"));
|
|
2584
|
+
}
|
|
2585
|
+
return files.sort();
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
function collectProjectionFiles(paths: ReturnType<typeof getVaultPaths>): string[] {
|
|
2589
|
+
const files = [
|
|
2590
|
+
"meta/registry.json",
|
|
2591
|
+
"meta/backlinks.json",
|
|
2592
|
+
"meta/index.md",
|
|
2593
|
+
"meta/log.md",
|
|
2594
|
+
"wiki/log.md",
|
|
2595
|
+
];
|
|
2596
|
+
function indexes(directory: string): void {
|
|
2597
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
2598
|
+
const fullPath = join(directory, entry.name);
|
|
2599
|
+
if (entry.isDirectory()) indexes(fullPath);
|
|
2600
|
+
else if (entry.isFile() && entry.name.toLowerCase() === "index.md") {
|
|
2601
|
+
files.push(relative(paths.dotWiki, fullPath).replace(/\\/g, "/"));
|
|
2602
|
+
}
|
|
2603
|
+
}
|
|
2604
|
+
}
|
|
2605
|
+
indexes(paths.wiki);
|
|
2606
|
+
return [...new Set(files)].sort();
|
|
2607
|
+
}
|
|
2608
|
+
|
|
2609
|
+
function collectDeterministicProjectionFiles(
|
|
2610
|
+
paths: ReturnType<typeof getVaultPaths>,
|
|
2611
|
+
): string[] {
|
|
2612
|
+
return collectProjectionFiles(paths).filter(
|
|
2613
|
+
(file) => file !== "meta/registry.json" && file !== "meta/index.md",
|
|
2614
|
+
);
|
|
2615
|
+
}
|
|
2616
|
+
|
|
2617
|
+
function projectionSnapshot(
|
|
2618
|
+
paths: ReturnType<typeof getVaultPaths>,
|
|
2619
|
+
files: string[],
|
|
2620
|
+
): Record<string, string> {
|
|
2621
|
+
return Object.fromEntries(
|
|
2622
|
+
files.map((file) => [file, readFileSync(join(paths.dotWiki, file), "utf8")]),
|
|
2623
|
+
);
|
|
2624
|
+
}
|
|
2625
|
+
```
|
|
2626
|
+
|
|
2627
|
+
Merge `relative` into the existing `node:path` import. Rewrite `foundation acceptance: end-to-end OKF lifecycle` to perform this exact flow:
|
|
2628
|
+
|
|
2629
|
+
```ts
|
|
2630
|
+
it("foundation acceptance: production seams preserve a conformant OKF vault", async () => {
|
|
2631
|
+
const root = join(import.meta.dirname, "..", "tmp", `okf-acceptance-${Date.now()}`);
|
|
2632
|
+
vaultRoots.push(root);
|
|
2633
|
+
mkdirSync(root, { recursive: true });
|
|
2634
|
+
const harness = registerFullExtensionHarness(root);
|
|
2635
|
+
|
|
2636
|
+
await harness.emit("session_start", {}, {
|
|
2637
|
+
cwd: root,
|
|
2638
|
+
hasUI: true,
|
|
2639
|
+
ui: { setStatus: () => {}, notify: () => {} },
|
|
2640
|
+
model: { id: "test" },
|
|
2641
|
+
});
|
|
2642
|
+
const paths = getVaultPaths(root);
|
|
2643
|
+
const config = readJson<Record<string, unknown>>(join(paths.dotWiki, "config.json"), {});
|
|
2644
|
+
expect(config.knowledge_format).toBe("okf-0.2");
|
|
2645
|
+
const agentStartResults = await harness.emit(
|
|
2646
|
+
"before_agent_start",
|
|
2647
|
+
{ prompt: "Build the Foundation feature", systemPrompt: "base" },
|
|
2648
|
+
{ cwd: root, hasUI: false, model: { id: "test" } },
|
|
2649
|
+
);
|
|
2650
|
+
expect(JSON.stringify(agentStartResults)).toContain("Wiki Setup Required");
|
|
2651
|
+
|
|
2652
|
+
await harness.execute("wiki_capture_source", {
|
|
2653
|
+
text: "Foundation source content.",
|
|
2654
|
+
title: "Foundation Source",
|
|
2655
|
+
});
|
|
2656
|
+
await harness.emit("session_shutdown");
|
|
2657
|
+
const sourceId = readdirSync(paths.rawSources).find((name) => name.startsWith("SRC-"));
|
|
2658
|
+
expect(sourceId).toBeDefined();
|
|
2659
|
+
if (!sourceId) return;
|
|
2660
|
+
const manifest = readJson<Record<string, unknown>>(
|
|
2661
|
+
join(paths.rawSources, sourceId, "manifest.json"),
|
|
2662
|
+
{},
|
|
2663
|
+
);
|
|
2664
|
+
const ingest = commitSynthesis(
|
|
2665
|
+
paths,
|
|
2666
|
+
sourceId,
|
|
2667
|
+
manifest,
|
|
2668
|
+
{
|
|
2669
|
+
summary: "Foundation summary.",
|
|
2670
|
+
key_takeaways: ["Foundation takeaway"],
|
|
2671
|
+
entities: [{ title: "Foundation Entity", description: "Entity description" }],
|
|
2672
|
+
concepts: [{ title: "Foundation Concept", definition: "Concept definition" }],
|
|
2673
|
+
},
|
|
2674
|
+
"2026-08-03",
|
|
2675
|
+
);
|
|
2676
|
+
expect(ingest.ok).toBe(true);
|
|
2677
|
+
|
|
2678
|
+
await harness.execute("wiki_observe", {
|
|
2679
|
+
title: "Foundation observation",
|
|
2680
|
+
content: "Observation body.",
|
|
2681
|
+
relevance: "medium",
|
|
2682
|
+
});
|
|
2683
|
+
await harness.execute("wiki_retro", {
|
|
2684
|
+
slug: "foundation-insight",
|
|
2685
|
+
title: "Foundation Insight",
|
|
2686
|
+
body: "Insight body.",
|
|
2687
|
+
});
|
|
2688
|
+
await harness.execute("wiki_ensure_page", {
|
|
2689
|
+
type: "requirement",
|
|
2690
|
+
title: "Foundation Requirement",
|
|
2691
|
+
content: "Requirement body.",
|
|
2692
|
+
});
|
|
2693
|
+
await harness.emit("session_shutdown");
|
|
2694
|
+
|
|
2695
|
+
const manualPage = join(paths.wiki, "concepts", "legacy-link.md");
|
|
2696
|
+
const eventPath = join(paths.meta, "events.jsonl");
|
|
2697
|
+
const eventsBeforeManual = readFileSync(eventPath, "utf8");
|
|
2698
|
+
writeFileSync(
|
|
2699
|
+
manualPage,
|
|
2700
|
+
"---\ntype: concept\ntitle: Legacy Link\n---\n\n[[sources/foundation-insight]]\n",
|
|
2701
|
+
);
|
|
2702
|
+
await harness.emit("tool_result", { toolName: "write", input: { path: manualPage } });
|
|
2703
|
+
await harness.emit("turn_end", {}, { cwd: root, hasUI: false });
|
|
2704
|
+
await harness.emit("session_shutdown");
|
|
2705
|
+
const registryAfterManual = readJson<{ pages: Record<string, unknown> }>(
|
|
2706
|
+
join(paths.meta, "registry.json"),
|
|
2707
|
+
{ pages: {} },
|
|
2708
|
+
);
|
|
2709
|
+
expect(registryAfterManual.pages["concepts/legacy-link"]).toBeDefined();
|
|
2710
|
+
expect(readFileSync(eventPath, "utf8")).toBe(eventsBeforeManual);
|
|
2711
|
+
|
|
2712
|
+
const conceptFiles = collectConceptFiles(paths.wiki);
|
|
2713
|
+
for (const file of conceptFiles) {
|
|
2714
|
+
const parsed = parseKnowledgeDocument(
|
|
2715
|
+
readFileSync(join(paths.wiki, file), "utf8"),
|
|
2716
|
+
file,
|
|
2717
|
+
);
|
|
2718
|
+
expect(parsed.ok, file).toBe(true);
|
|
2719
|
+
}
|
|
2720
|
+
|
|
2721
|
+
for (const file of [
|
|
2722
|
+
"entities/foundation-entity.md",
|
|
2723
|
+
"concepts/foundation-concept.md",
|
|
2724
|
+
]) {
|
|
2725
|
+
const parsed = parseKnowledgeDocument(readFileSync(join(paths.wiki, file), "utf8"), file);
|
|
2726
|
+
expect(parsed.ok).toBe(true);
|
|
2727
|
+
if (parsed.ok) expect(parsed.document.sources.kind).toBe("canonical");
|
|
2728
|
+
}
|
|
2729
|
+
const source = parseKnowledgeDocument(
|
|
2730
|
+
readFileSync(join(paths.wiki, "sources", `${sourceId}.md`), "utf8"),
|
|
2731
|
+
`sources/${sourceId}.md`,
|
|
2732
|
+
);
|
|
2733
|
+
expect(source.ok).toBe(true);
|
|
2734
|
+
if (source.ok) expect(source.document.sources.kind).toBe("absent");
|
|
2735
|
+
|
|
2736
|
+
const backlinks = readJson<Record<string, string[]>>(join(paths.meta, "backlinks.json"), {});
|
|
2737
|
+
expect(backlinks["sources/foundation-insight"]).toContain("concepts/legacy-link");
|
|
2738
|
+
|
|
2739
|
+
const projectionFiles = collectProjectionFiles(paths);
|
|
2740
|
+
const deterministicFiles = collectDeterministicProjectionFiles(paths);
|
|
2741
|
+
const deterministic = projectionSnapshot(paths, deterministicFiles);
|
|
2742
|
+
expect(rebuildMetadata(paths).ok).toBe(true);
|
|
2743
|
+
expect(projectionSnapshot(paths, deterministicFiles)).toEqual(deterministic);
|
|
2744
|
+
const knownGood = projectionSnapshot(paths, projectionFiles);
|
|
2745
|
+
|
|
2746
|
+
const corrupt = join(paths.wiki, "concepts", "foundation-concept.md");
|
|
2747
|
+
const original = readFileSync(corrupt, "utf8");
|
|
2748
|
+
writeFileSync(corrupt, "malformed\n");
|
|
2749
|
+
await harness.emit("tool_result", { toolName: "edit", input: { path: corrupt } });
|
|
2750
|
+
await harness.emit("turn_end", {}, { cwd: root, hasUI: false });
|
|
2751
|
+
await harness.emit("session_shutdown");
|
|
2752
|
+
expect(projectionSnapshot(paths, projectionFiles)).toEqual(knownGood);
|
|
2753
|
+
expect(readFileSync(eventPath, "utf8")).toBe(eventsBeforeManual);
|
|
2754
|
+
|
|
2755
|
+
writeFileSync(corrupt, original);
|
|
2756
|
+
await harness.emit("tool_result", { toolName: "write", input: { path: corrupt } });
|
|
2757
|
+
await harness.emit("turn_end", {}, { cwd: root, hasUI: false });
|
|
2758
|
+
await harness.emit("session_shutdown");
|
|
2759
|
+
expect(readJson<{ pages: Record<string, unknown> }>(join(paths.meta, "registry.json"), {
|
|
2760
|
+
pages: {},
|
|
2761
|
+
}).pages["concepts/foundation-concept"]).toBeDefined();
|
|
2762
|
+
});
|
|
2763
|
+
|
|
2764
|
+
it("opens an existing valid vault without bootstrapping it again", async () => {
|
|
2765
|
+
const root = join(import.meta.dirname, "..", "tmp", `okf-existing-${Date.now()}`);
|
|
2766
|
+
vaultRoots.push(root);
|
|
2767
|
+
const paths = getVaultPaths(root);
|
|
2768
|
+
ensureVaultStructure(paths);
|
|
2769
|
+
const config = JSON.stringify({ name: "Existing", knowledge_format: "legacy" });
|
|
2770
|
+
writeFileSync(join(paths.dotWiki, "config.json"), config);
|
|
2771
|
+
const statuses: string[] = [];
|
|
2772
|
+
const harness = registerFullExtensionHarness(root);
|
|
2773
|
+
await harness.emit("session_start", {}, {
|
|
2774
|
+
cwd: root,
|
|
2775
|
+
hasUI: true,
|
|
2776
|
+
ui: { setStatus: (_key: string, value: string) => statuses.push(value) },
|
|
2777
|
+
model: { id: "test" },
|
|
2778
|
+
});
|
|
2779
|
+
expect(readFileSync(join(paths.dotWiki, "config.json"), "utf8")).toBe(config);
|
|
2780
|
+
expect(existsSync(join(paths.meta, "events.jsonl"))).toBe(false);
|
|
2781
|
+
expect(statuses.some((status) => status.includes("setup blocked"))).toBe(false);
|
|
2782
|
+
expect(harness.messages.length).toBeGreaterThan(0);
|
|
2783
|
+
});
|
|
2784
|
+
|
|
2785
|
+
it("blocks an existing invalid vault before status notices or lifecycle writes", async () => {
|
|
2786
|
+
const root = join(import.meta.dirname, "..", "tmp", `okf-blocked-${Date.now()}`);
|
|
2787
|
+
vaultRoots.push(root);
|
|
2788
|
+
const paths = getVaultPaths(root);
|
|
2789
|
+
ensureVaultStructure(paths);
|
|
2790
|
+
const config = JSON.stringify({ knowledge_format: "future" });
|
|
2791
|
+
writeFileSync(join(paths.dotWiki, "config.json"), config);
|
|
2792
|
+
const statuses: string[] = [];
|
|
2793
|
+
const harness = registerFullExtensionHarness(root);
|
|
2794
|
+
await harness.emit("session_start", {}, {
|
|
2795
|
+
cwd: root,
|
|
2796
|
+
hasUI: true,
|
|
2797
|
+
ui: { setStatus: (_key: string, value: string) => statuses.push(value) },
|
|
2798
|
+
model: { id: "test" },
|
|
2799
|
+
});
|
|
2800
|
+
expect(statuses.some((status) => status.includes("setup blocked"))).toBe(true);
|
|
2801
|
+
expect(harness.messages).toEqual([]);
|
|
2802
|
+
expect(readFileSync(join(paths.dotWiki, "config.json"), "utf8")).toBe(config);
|
|
2803
|
+
expect(existsSync(join(paths.meta, "events.jsonl"))).toBe(false);
|
|
2804
|
+
});
|
|
2805
|
+
```
|
|
2806
|
+
|
|
2807
|
+
Do not substitute direct calls for silent bootstrap, capture, observation, retro, or ensure-page. Deterministic ingestion remains a direct call to the shared `commitSynthesis` service because the production subagent itself is nondeterministic; Task 5 separately tests its delayed commit seam.
|
|
2808
|
+
|
|
2809
|
+
- [x] **Step 2: Correct the temporary-file assertion**
|
|
2810
|
+
|
|
2811
|
+
In `test/okf-projections.test.ts`, change:
|
|
2812
|
+
|
|
2813
|
+
```ts
|
|
2814
|
+
if (entry.startsWith("tmp-")) results.push(fullPath);
|
|
2815
|
+
```
|
|
2816
|
+
|
|
2817
|
+
To:
|
|
2818
|
+
|
|
2819
|
+
```ts
|
|
2820
|
+
if (entry.includes(".tmp-")) results.push(fullPath);
|
|
2821
|
+
```
|
|
2822
|
+
|
|
2823
|
+
- [x] **Step 3: Add mutation-bypass and scope source gates**
|
|
2824
|
+
|
|
2825
|
+
Add `readdirSync` to the existing `node:fs` import and define this helper above the package-structure `describe`:
|
|
2826
|
+
|
|
2827
|
+
```ts
|
|
2828
|
+
function readProductionFiles(directory: string): string[] {
|
|
2829
|
+
const contents: string[] = [];
|
|
2830
|
+
for (const entry of readdirSync(directory, { withFileTypes: true })) {
|
|
2831
|
+
if (["node_modules", "dist", "coverage"].includes(entry.name)) continue;
|
|
2832
|
+
const path = join(directory, entry.name);
|
|
2833
|
+
if (entry.isDirectory()) contents.push(...readProductionFiles(path));
|
|
2834
|
+
else if (entry.isFile() && entry.name.endsWith(".ts")) contents.push(readFile(path));
|
|
2835
|
+
}
|
|
2836
|
+
return contents;
|
|
2837
|
+
}
|
|
2838
|
+
```
|
|
2839
|
+
|
|
2840
|
+
Then add:
|
|
2841
|
+
|
|
2842
|
+
```ts
|
|
2843
|
+
it("routes every authoritative writer through strict vault validation", () => {
|
|
2844
|
+
const files = [
|
|
2845
|
+
"source-packet.ts",
|
|
2846
|
+
"ingest-worker.ts",
|
|
2847
|
+
"observation.ts",
|
|
2848
|
+
"retro.ts",
|
|
2849
|
+
"trajectory.ts",
|
|
2850
|
+
"metadata.ts",
|
|
2851
|
+
"embeddings.ts",
|
|
2852
|
+
];
|
|
2853
|
+
for (const file of files) {
|
|
2854
|
+
const source = readFile(join(rootDir, "extensions/llm-wiki/lib", file));
|
|
2855
|
+
expect(source, file).toContain("assertWritableVault");
|
|
2856
|
+
}
|
|
2857
|
+
});
|
|
2858
|
+
|
|
2859
|
+
it("keeps Foundation free of later-phase operation surfaces", () => {
|
|
2860
|
+
const roots = [
|
|
2861
|
+
join(rootDir, "extensions"),
|
|
2862
|
+
join(rootDir, "mcp"),
|
|
2863
|
+
];
|
|
2864
|
+
const source = roots.flatMap(readProductionFiles).join("\n");
|
|
2865
|
+
for (const forbidden of [
|
|
2866
|
+
"wiki_okf_import",
|
|
2867
|
+
"wiki_okf_export",
|
|
2868
|
+
"wiki_okf_migrate",
|
|
2869
|
+
"transaction journal",
|
|
2870
|
+
"trust factor",
|
|
2871
|
+
]) {
|
|
2872
|
+
expect(source).not.toContain(forbidden);
|
|
2873
|
+
}
|
|
2874
|
+
});
|
|
2875
|
+
```
|
|
2876
|
+
|
|
2877
|
+
- [x] **Step 4: Run acceptance tests and verify they expose any remaining seam gaps**
|
|
2878
|
+
|
|
2879
|
+
```bash
|
|
2880
|
+
pnpm vitest run test/okf-integration.test.ts test/okf-projections.test.ts test/package-structure.test.ts
|
|
2881
|
+
```
|
|
2882
|
+
|
|
2883
|
+
Expected: PASS after Tasks 1–7. If a test fails, stop and identify which earlier task's stated contract was not completed; add the missing regression and minimal fix to that task's commit before continuing. Do not weaken assertions or add later-phase behavior.
|
|
2884
|
+
|
|
2885
|
+
- [x] **Step 5: Verify the named entrypoint branches, then add enforceable coverage thresholds**
|
|
2886
|
+
|
|
2887
|
+
Before changing configuration, run:
|
|
2888
|
+
|
|
2889
|
+
```bash
|
|
2890
|
+
pnpm vitest run --coverage test/bootstrap.test.ts test/okf-integration.test.ts test/background-tools.test.ts
|
|
2891
|
+
```
|
|
2892
|
+
|
|
2893
|
+
The concrete tests now exercise new-vault startup, one-time `before_agent_start` topic inference, existing-valid startup and notice dispatch, blocked-existing startup, mutating Pi tools, `tool_result`/`turn_end` scheduling, and `session_shutdown` background drainage. Confirm the HTML/text report marks those `index.ts` branches executed; do not satisfy entrypoint coverage with helper-only tests.
|
|
2894
|
+
|
|
2895
|
+
Then update `vitest.config.ts` coverage configuration:
|
|
2896
|
+
|
|
2897
|
+
```ts
|
|
2898
|
+
coverage: {
|
|
2899
|
+
provider: "v8",
|
|
2900
|
+
reporter: ["text", "lcov", "html"],
|
|
2901
|
+
include: ["extensions/**/*.ts", "skills/**/*.md"],
|
|
2902
|
+
thresholds: {
|
|
2903
|
+
statements: 70,
|
|
2904
|
+
branches: 80,
|
|
2905
|
+
functions: 85,
|
|
2906
|
+
lines: 70,
|
|
2907
|
+
"extensions/llm-wiki/index.ts": {
|
|
2908
|
+
statements: 55,
|
|
2909
|
+
branches: 50,
|
|
2910
|
+
functions: 50,
|
|
2911
|
+
lines: 55,
|
|
2912
|
+
},
|
|
2913
|
+
"extensions/llm-wiki/lib/knowledge-document.ts": {
|
|
2914
|
+
statements: 90,
|
|
2915
|
+
branches: 85,
|
|
2916
|
+
functions: 85,
|
|
2917
|
+
lines: 90,
|
|
2918
|
+
},
|
|
2919
|
+
"extensions/llm-wiki/lib/vault-format.ts": {
|
|
2920
|
+
statements: 85,
|
|
2921
|
+
branches: 80,
|
|
2922
|
+
functions: 90,
|
|
2923
|
+
lines: 85,
|
|
2924
|
+
},
|
|
2925
|
+
},
|
|
2926
|
+
},
|
|
2927
|
+
```
|
|
2928
|
+
|
|
2929
|
+
These thresholds exceed the reviewed baseline and are release requirements. The concrete startup/lifecycle tests above plus Tasks 1–7 must satisfy them. A failure is a blocked remediation—not permission to lower a threshold or add assertion-free coverage calls.
|
|
2930
|
+
|
|
2931
|
+
- [x] **Step 6: Run complete release gates**
|
|
2932
|
+
|
|
2933
|
+
Run in this order:
|
|
2934
|
+
|
|
2935
|
+
```bash
|
|
2936
|
+
pnpm test
|
|
2937
|
+
pnpm typecheck
|
|
2938
|
+
pnpm lint
|
|
2939
|
+
pnpm test:coverage
|
|
2940
|
+
```
|
|
2941
|
+
|
|
2942
|
+
Expected: every command exits 0; coverage reports satisfy global and trusted-boundary thresholds.
|
|
2943
|
+
|
|
2944
|
+
- [x] **Step 7: Run exact scope and bypass gates**
|
|
2945
|
+
|
|
2946
|
+
```bash
|
|
2947
|
+
grep -RInE 'wiki_okf_import|wiki_okf_export|wiki_okf_migrate|transaction journal|trust factor' extensions mcp || true
|
|
2948
|
+
grep -RInE 'parseFrontmatter\(|findWikiPages\(|extractWikilinks\(' extensions mcp || true
|
|
2949
|
+
grep -RInE 'exec: async \(\) => \(\{ stdout: "", stderr: "", code: 0' mcp test || true
|
|
2950
|
+
grep -RInE '^---\\n|\[\[' extensions/llm-wiki/lib/{source-packet,ingest-worker,observation,retro,tools}.ts || true
|
|
2951
|
+
find .llm-wiki -type f -name '*.tmp-*' -print 2>/dev/null
|
|
2952
|
+
```
|
|
2953
|
+
|
|
2954
|
+
Expected:
|
|
2955
|
+
|
|
2956
|
+
- no later-phase production surface
|
|
2957
|
+
- no obsolete YAML/page/wikilink scanner
|
|
2958
|
+
- no fake MCP executor
|
|
2959
|
+
- no generated frontmatter template; remaining `[[` occurrences are compatibility/guidance text identified by tests
|
|
2960
|
+
- no projection temporary files
|
|
2961
|
+
|
|
2962
|
+
- [x] **Step 8: Inspect final diff and operation-table evidence**
|
|
2963
|
+
|
|
2964
|
+
```bash
|
|
2965
|
+
git status --short
|
|
2966
|
+
git diff --check
|
|
2967
|
+
git diff --stat fd3c46f9942b6c434383177086c8f3254333704b...HEAD
|
|
2968
|
+
git log --oneline --decorate -10
|
|
2969
|
+
```
|
|
2970
|
+
|
|
2971
|
+
Then run targeted operation evidence:
|
|
2972
|
+
|
|
2973
|
+
```bash
|
|
2974
|
+
pnpm vitest run test/bootstrap.test.ts test/mutation-guards.test.ts test/indexing-fail-closed.test.ts test/mcp-exec.test.ts test/okf-integration.test.ts
|
|
2975
|
+
```
|
|
2976
|
+
|
|
2977
|
+
Expected: clean diff checks, only Foundation remediation files changed, and all trusted-seam tests pass.
|
|
2978
|
+
|
|
2979
|
+
- [x] **Step 9: Commit final acceptance and release gates**
|
|
2980
|
+
|
|
2981
|
+
```bash
|
|
2982
|
+
git add test/okf-integration.test.ts test/okf-projections.test.ts test/package-structure.test.ts vitest.config.ts
|
|
2983
|
+
git commit -m "test: enforce OKF Foundation release gates"
|
|
2984
|
+
```
|
|
2985
|
+
|
|
2986
|
+
---
|
|
2987
|
+
|
|
2988
|
+
## Final Acceptance Traceability
|
|
2989
|
+
|
|
2990
|
+
| Foundation criterion | Remediation evidence |
|
|
2991
|
+
|---|---|
|
|
2992
|
+
| Every page producer uses shared API | Tasks 1, 4, package-structure gate |
|
|
2993
|
+
| Existing vaults remain legacy without reserved projections | Task 3 bootstrap tests |
|
|
2994
|
+
| New vaults persist OKF 0.2 and generated files | Task 3 real `session_start` test; Task 8 acceptance |
|
|
2995
|
+
| Nested/unknown metadata round-trips semantically | Task 1 adversarial round-trip tests |
|
|
2996
|
+
| Markdown and wikilinks produce backlinks | Task 2 link tests; Task 8 exact backlink assertion |
|
|
2997
|
+
| Projection bytes deterministic | Existing goldens plus Task 8 deterministic OKF index/log/backlink snapshot (volatile registry timestamps excluded) |
|
|
2998
|
+
| Invalid mode/version/malformed concepts preserve known-good projections | Tasks 3–6 and Task 8 full blocked-rebuild snapshot through `tool_result`/`turn_end` |
|
|
2999
|
+
| Pi and MCP share parsed identity/metadata behavior | Tasks 6–7 parity and diagnostic tests |
|
|
3000
|
+
| Every mutation fails closed | Task 4 service/adapter/embedding-boundary matrix; Task 5 ingestion/indexing races; Task 8 manual-edit hooks |
|
|
3001
|
+
| Tests, typecheck, lint, coverage gates pass | Task 8 release commands and thresholds |
|
|
3002
|
+
|
|
3003
|
+
## Completion Report Requirements
|
|
3004
|
+
|
|
3005
|
+
After execution, report exact command results, test count, coverage percentages, and commit SHAs. Explicitly list any deliberate deviation from this plan and tie it to the normative Foundation spec. Do not claim Foundation completion from helper tests alone; cite the bootstrap, mutation, concurrency, MCP, and acceptance seam tests.
|