@foldrun/core 0.2.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.
Files changed (166) hide show
  1. package/LICENSE +202 -0
  2. package/README.md +239 -0
  3. package/dist/index.d.ts +32 -0
  4. package/dist/index.js +43 -0
  5. package/dist/src/agent-tools.d.ts +31 -0
  6. package/dist/src/agent-tools.js +99 -0
  7. package/dist/src/agents-md.d.ts +9 -0
  8. package/dist/src/agents-md.js +83 -0
  9. package/dist/src/api-tools.d.ts +38 -0
  10. package/dist/src/api-tools.js +313 -0
  11. package/dist/src/approvals.d.ts +39 -0
  12. package/dist/src/approvals.js +103 -0
  13. package/dist/src/arrange.d.ts +8 -0
  14. package/dist/src/arrange.js +40 -0
  15. package/dist/src/catalog.d.ts +108 -0
  16. package/dist/src/catalog.js +255 -0
  17. package/dist/src/completions.d.ts +61 -0
  18. package/dist/src/completions.js +578 -0
  19. package/dist/src/confine.d.ts +31 -0
  20. package/dist/src/confine.js +298 -0
  21. package/dist/src/container.d.ts +30 -0
  22. package/dist/src/container.js +172 -0
  23. package/dist/src/context-tools.d.ts +81 -0
  24. package/dist/src/context-tools.js +347 -0
  25. package/dist/src/deploy.d.ts +100 -0
  26. package/dist/src/deploy.js +377 -0
  27. package/dist/src/egress.d.ts +75 -0
  28. package/dist/src/egress.js +133 -0
  29. package/dist/src/evals.d.ts +150 -0
  30. package/dist/src/evals.js +651 -0
  31. package/dist/src/flow-lint.d.ts +16 -0
  32. package/dist/src/flow-lint.js +177 -0
  33. package/dist/src/git-hook.d.ts +1 -0
  34. package/dist/src/git-hook.js +70 -0
  35. package/dist/src/git.d.ts +37 -0
  36. package/dist/src/git.js +101 -0
  37. package/dist/src/gitrepo.d.ts +95 -0
  38. package/dist/src/gitrepo.js +355 -0
  39. package/dist/src/history.d.ts +82 -0
  40. package/dist/src/history.js +259 -0
  41. package/dist/src/kinds.d.ts +95 -0
  42. package/dist/src/kinds.js +421 -0
  43. package/dist/src/library.d.ts +94 -0
  44. package/dist/src/library.js +341 -0
  45. package/dist/src/linkable.d.ts +20 -0
  46. package/dist/src/linkable.js +113 -0
  47. package/dist/src/model-probe.d.ts +21 -0
  48. package/dist/src/model-probe.js +81 -0
  49. package/dist/src/notify.d.ts +61 -0
  50. package/dist/src/notify.js +252 -0
  51. package/dist/src/oauth-presets.d.ts +13 -0
  52. package/dist/src/oauth-presets.js +39 -0
  53. package/dist/src/okf.d.ts +220 -0
  54. package/dist/src/okf.js +656 -0
  55. package/dist/src/openapi.d.ts +66 -0
  56. package/dist/src/openapi.js +342 -0
  57. package/dist/src/paths.d.ts +33 -0
  58. package/dist/src/paths.js +78 -0
  59. package/dist/src/platform.d.ts +32 -0
  60. package/dist/src/platform.js +48 -0
  61. package/dist/src/providers.d.ts +47 -0
  62. package/dist/src/providers.js +114 -0
  63. package/dist/src/refs.d.ts +20 -0
  64. package/dist/src/refs.js +78 -0
  65. package/dist/src/run-container.d.ts +227 -0
  66. package/dist/src/run-container.js +760 -0
  67. package/dist/src/runner.d.ts +241 -0
  68. package/dist/src/runner.js +3215 -0
  69. package/dist/src/runtime.d.ts +41 -0
  70. package/dist/src/runtime.js +357 -0
  71. package/dist/src/script-tools.d.ts +62 -0
  72. package/dist/src/script-tools.js +296 -0
  73. package/dist/src/secret-files.d.ts +12 -0
  74. package/dist/src/secret-files.js +92 -0
  75. package/dist/src/secrets.d.ts +147 -0
  76. package/dist/src/secrets.js +626 -0
  77. package/dist/src/starter.d.ts +21 -0
  78. package/dist/src/starter.js +399 -0
  79. package/dist/src/step-exec.d.ts +136 -0
  80. package/dist/src/step-exec.js +391 -0
  81. package/dist/src/storage.d.ts +123 -0
  82. package/dist/src/storage.js +640 -0
  83. package/dist/src/store.d.ts +1052 -0
  84. package/dist/src/store.js +2378 -0
  85. package/dist/src/tar.d.ts +30 -0
  86. package/dist/src/tar.js +111 -0
  87. package/dist/src/tool-names.d.ts +39 -0
  88. package/dist/src/tool-names.js +79 -0
  89. package/dist/src/tool-programs.d.ts +18 -0
  90. package/dist/src/tool-programs.js +52 -0
  91. package/dist/src/tool-test.d.ts +19 -0
  92. package/dist/src/tool-test.js +335 -0
  93. package/dist/src/translator.d.ts +159 -0
  94. package/dist/src/translator.js +986 -0
  95. package/dist/src/triggers.d.ts +60 -0
  96. package/dist/src/triggers.js +270 -0
  97. package/dist/src/webhook.d.ts +47 -0
  98. package/dist/src/webhook.js +148 -0
  99. package/index.ts +44 -0
  100. package/package.json +64 -0
  101. package/src/agent-tools.ts +138 -0
  102. package/src/agents-md.ts +81 -0
  103. package/src/api-tools.ts +389 -0
  104. package/src/approvals.ts +129 -0
  105. package/src/arrange.ts +42 -0
  106. package/src/catalog.ts +321 -0
  107. package/src/completions.ts +652 -0
  108. package/src/confine.ts +350 -0
  109. package/src/container.ts +220 -0
  110. package/src/context-tools.ts +435 -0
  111. package/src/deploy.ts +458 -0
  112. package/src/egress.ts +176 -0
  113. package/src/evals.ts +812 -0
  114. package/src/flow-lint.ts +215 -0
  115. package/src/git-hook.ts +73 -0
  116. package/src/git.ts +117 -0
  117. package/src/gitrepo.ts +391 -0
  118. package/src/history.ts +329 -0
  119. package/src/kinds.ts +507 -0
  120. package/src/library.ts +419 -0
  121. package/src/linkable.ts +123 -0
  122. package/src/model-probe.ts +112 -0
  123. package/src/notify.ts +281 -0
  124. package/src/oauth-presets.ts +54 -0
  125. package/src/okf.ts +768 -0
  126. package/src/openapi.ts +383 -0
  127. package/src/paths.ts +79 -0
  128. package/src/platform.ts +87 -0
  129. package/src/providers.ts +147 -0
  130. package/src/refs.ts +83 -0
  131. package/src/run-container.ts +883 -0
  132. package/src/runner.ts +3446 -0
  133. package/src/runtime.ts +404 -0
  134. package/src/script-tools.ts +383 -0
  135. package/src/secret-files.ts +101 -0
  136. package/src/secrets.ts +799 -0
  137. package/src/starter.ts +415 -0
  138. package/src/step-exec.ts +498 -0
  139. package/src/storage.ts +771 -0
  140. package/src/store.ts +2985 -0
  141. package/src/tar.ts +124 -0
  142. package/src/tool-names.ts +91 -0
  143. package/src/tool-programs.ts +65 -0
  144. package/src/tool-test.ts +401 -0
  145. package/src/translator.ts +1029 -0
  146. package/src/triggers.ts +287 -0
  147. package/src/webhook.ts +183 -0
  148. package/templates/blog-desk/AGENTS.md +13 -0
  149. package/templates/blog-desk/agents/researcher/agent.md +11 -0
  150. package/templates/blog-desk/agents/writer/agent.md +8 -0
  151. package/templates/blog-desk/evals/writer-quality.md +10 -0
  152. package/templates/blog-desk/flows/publish.md +10 -0
  153. package/templates/blog-desk/knowledge/house-style.md +9 -0
  154. package/templates/blog-desk/knowledge/index.md +9 -0
  155. package/templates/hello/AGENTS.md +18 -0
  156. package/templates/hello/agents/curator/agent.md +32 -0
  157. package/templates/hello/agents/notetaker/agent.md +24 -0
  158. package/templates/hello/evals/house-style.md +16 -0
  159. package/templates/hello/flows/memory-review.md +7 -0
  160. package/templates/hello/flows/note.md +7 -0
  161. package/templates/hello/knowledge/house-style.md +15 -0
  162. package/templates/hello/knowledge/index.md +9 -0
  163. package/templates/hello/skills/plain-english/SKILL.md +11 -0
  164. package/templates/hello/state/publishing.md +7 -0
  165. package/templates/hello/tools/wordcount/run.py +13 -0
  166. package/templates/hello/tools/wordcount/tool.md +22 -0
@@ -0,0 +1,656 @@
1
+ // Open Knowledge Format — Google Cloud's vendor-neutral spec for handing
2
+ // curated context to agents (v0.1 June 2026, v0.2 July 2026, Apache 2.0,
3
+ // GoogleCloudPlatform/knowledge-catalog).
4
+ //
5
+ // Our `knowledge/` and `memory/` directories are OKF bundles. Same format,
6
+ // different write permission: knowledge is what a person gave the agent,
7
+ // memory is what the agent learned. v0.2's provenance fields make that
8
+ // distinction machine-readable rather than a folder convention —
9
+ //
10
+ // generated: { by: human:matt } → knowledge you verified
11
+ // generated: { by: producer/foldrun } → a fact an agent wrote
12
+ //
13
+ // which matters because on disk those two files used to look identical.
14
+ //
15
+ // Conformance per the spec:
16
+ // · every non-reserved .md file has parseable frontmatter with a non-empty
17
+ // `type`
18
+ // · index.md carries no frontmatter, except `okf_version` at bundle root
19
+ // · consumers must NOT reject a bundle for unknown keys, unknown `type`
20
+ // values, missing optional fields, or broken links — so our own `name:`
21
+ // and `description:` stay exactly where they are.
22
+ import fs from "node:fs";
23
+ import path from "node:path";
24
+ import matter from "gray-matter";
25
+ export const OKF_VERSION = "0.2";
26
+ /** This producer's actor string, in the spec's `<producer>/<version>` form. */
27
+ export const PRODUCER = "foldrun/0.1.0";
28
+ /**
29
+ * Our stand-in when a v0.1 document carries only `timestamp`. Not an actor —
30
+ * it says we don't know who, which is different from knowing it was a machine.
31
+ */
32
+ export const UNKNOWN_ACTOR = "unknown (v0.1 timestamp)";
33
+ /**
34
+ * §7 defines three actor forms, and only one of them is a person:
35
+ *
36
+ * human:<id> a person → human
37
+ * <producer>/<version> an agent or tool → machine
38
+ * process:<id> an automated job → machine
39
+ *
40
+ * So the test is for `human:`, not for any machine form. Testing the other way
41
+ * round meant matching a literal "producer/" prefix — but the producer's *name*
42
+ * is the first segment, so the spec's own `reference_agent/gemini-2.5-pro` did
43
+ * not match, `process:` did not match, and neither did this platform's own
44
+ * PRODUCER. The mark existed and fired for nothing real.
45
+ */
46
+ export const isHumanActor = (actor) => actor.startsWith("human:");
47
+ /**
48
+ * The spec's reserved filenames — exactly these two. A consumer treats every
49
+ * other `.md` in the bundle as a concept and requires a `type:` on it.
50
+ */
51
+ const OKF_RESERVED = new Set(["index.md", "log.md"]);
52
+ /**
53
+ * Files that are bundle structure rather than concepts.
54
+ *
55
+ * Exactly the spec's two. There used to be a third — MEMORY.md, a curated
56
+ * index of ours — and inventing a reserved name inside someone else's format
57
+ * is what made a bundle we emit fail an outside validator while passing our
58
+ * own: a consumer applies the spec's set, sees a file with no `type`, and
59
+ * rejects the bundle over something we introduced. The lesson generalises, so
60
+ * this set is the spec's and stays that way.
61
+ */
62
+ const NOT_A_CONCEPT = OKF_RESERVED;
63
+ function parseComputation(data) {
64
+ const isAttested = String(data.type ?? "").toLowerCase() === "attested computation";
65
+ if (!isAttested && !data.attester && !data.executor)
66
+ return null;
67
+ const obj = (v) => (v && typeof v === "object" ? v : {});
68
+ const executor = obj(data.executor);
69
+ const attester = obj(data.attester);
70
+ const parameters = Array.isArray(data.parameters)
71
+ ? data.parameters
72
+ .filter((x) => !!x && typeof x === "object")
73
+ .map((x) => ({
74
+ name: String(x.name ?? ""),
75
+ type: String(x.type ?? "string"),
76
+ required: x.required !== false,
77
+ }))
78
+ .filter((x) => x.name)
79
+ : [];
80
+ const issues = [];
81
+ if (!data.runtime)
82
+ issues.push("no `runtime:` — nothing says how to execute it");
83
+ if (!data.computation && !String(data.type))
84
+ issues.push("no computation declared");
85
+ if (!attester.resource)
86
+ issues.push("no `attester.resource` — nothing can check the run");
87
+ // The decisive one, and it is the spec's own limitation rather than a
88
+ // defect in the bundle.
89
+ issues.push("no verdict available — OKF defers the receipt and verdict wire formats to a future revision, " +
90
+ "so this computation cannot be attested by any consumer yet");
91
+ return {
92
+ runtime: String(data.runtime ?? ""),
93
+ parameters,
94
+ computation: typeof data.computation === "string" ? data.computation : null,
95
+ executorResource: executor.resource ? String(executor.resource) : null,
96
+ receiptFields: Array.isArray(executor.receipt) ? executor.receipt.map(String) : [],
97
+ attesterResource: attester.resource ? String(attester.resource) : null,
98
+ attestationIssues: issues,
99
+ };
100
+ }
101
+ /**
102
+ * The signals that decide whether a concept is worth opening: who produced it,
103
+ * and whether anyone has confirmed it.
104
+ *
105
+ * v0.2's argument for putting these in frontmatter is that most interactions
106
+ * with a concept never reach its body — a consumer first has to judge relevance
107
+ * and trustworthiness, and should be able to do that without paying for prose.
108
+ * That only works if the signals actually reach the index. `generated` was
109
+ * parsed and stored here and then surfaced nowhere, so on disk an agent's
110
+ * invention and a person's verified note were distinguishable and at the moment
111
+ * of choosing between them they were not — which is the problem the field was
112
+ * added to solve.
113
+ *
114
+ * Only the machine case is named. A person writing is the default assumption,
115
+ * and a mark on every line costs tokens on the one thing whose whole job is to
116
+ * be cheap to scan.
117
+ *
118
+ * One definition, used by both index builders. They render different prose
119
+ * around it on purpose — the memory index speaks to a model mid-run — but they
120
+ * must not disagree about what the signals *are*.
121
+ */
122
+ export function provenanceMarks(doc) {
123
+ const marks = [];
124
+ // "machine", not "agent": `process:` is neither a person nor an agent, and
125
+ // the decision it drives is the same one — a person did not write this.
126
+ if (doc.generatedBy && doc.generatedBy !== UNKNOWN_ACTOR && !isHumanActor(doc.generatedBy)) {
127
+ marks.push("machine-written");
128
+ }
129
+ // Always stated, including the positive tiers. Marking only the bad case
130
+ // meant a human-reviewed fact and an unreviewed one were both rendered by
131
+ // saying nothing, so trust could not be filtered on — only its absence.
132
+ //
133
+ // Dated where the bundle dated it, because "reviewed" answers a weaker
134
+ // question than it looks like it answers: a fact checked in 2019 and one
135
+ // checked last week are not the same claim, and the tier alone conflates
136
+ // them. The day is enough — the hour never changes a decision here.
137
+ marks.push(doc.verifiedAt ? `${doc.trust} ${doc.verifiedAt.slice(0, 10)}` : doc.trust);
138
+ return marks;
139
+ }
140
+ /** Derived, never stored — the spec is explicit that consumers compute this. */
141
+ export function trustTier(verifiedBy) {
142
+ if (verifiedBy.length === 0)
143
+ return "unverified";
144
+ return verifiedBy.some(isHumanActor) ? "human-reviewed" : "machine-confirmed";
145
+ }
146
+ /**
147
+ * A date from frontmatter, as `YYYY-MM-DD`.
148
+ *
149
+ * YAML parses an unquoted `2026-12-31` into a Date, not a string — which is
150
+ * how the spec's own examples are written. Every date field here tested for
151
+ * `typeof === "string"`, so the canonical spelling was silently discarded:
152
+ * `stale_after: 2026-12-31` set no expiry at all, and nothing reported that a
153
+ * declared one had been dropped. Quoting it happened to work, which is why it
154
+ * survived: the tests wrote `"2026-12-31"` and the spec does not.
155
+ */
156
+ /**
157
+ * The spec's date shapes: `YYYY-MM-DD` for a day, ISO 8601 for an instant.
158
+ * Anything else is not a date this platform will compare.
159
+ */
160
+ const ISO_DAY = /^\d{4}-\d{2}-\d{2}$/;
161
+ const ISO_INSTANT = /^\d{4}-\d{2}-\d{2}[T ]\d{2}:\d{2}(:\d{2}(\.\d+)?)?(Z|[+-]\d{2}:?\d{2})?$/;
162
+ /**
163
+ * A date from frontmatter, normalised, or null if it is not one.
164
+ *
165
+ * Null rather than the raw string, because every use of these values compares
166
+ * them: `stale` is `today >= stale_after`, and `latestAt` picks the most recent
167
+ * verification with `>`. Both are string comparisons that are only meaningful
168
+ * on ISO. Keeping whatever was written meant `at: yesterday` sorted above every
169
+ * real timestamp — lowercase letters beat digits — so one sloppy value silently
170
+ * made "most recently verified" return the wrong entry, and the index rendered
171
+ * "human-reviewed yesterday".
172
+ *
173
+ * A dropped value is reported by dateIssues(), never swallowed.
174
+ */
175
+ function isoDay(value) {
176
+ if (value instanceof Date)
177
+ return value.toISOString().slice(0, 10);
178
+ const s = typeof value === "string" ? value.trim() : "";
179
+ if (!s)
180
+ return null;
181
+ if (ISO_DAY.test(s))
182
+ return s;
183
+ // A full instant where a day was expected is the author being more precise
184
+ // than asked, not an error.
185
+ if (ISO_INSTANT.test(s))
186
+ return s.slice(0, 10);
187
+ return null;
188
+ }
189
+ /** As isoDay, but keeps the time when one was given — `timestamp` is an instant. */
190
+ function isoInstant(value) {
191
+ if (value instanceof Date)
192
+ return value.toISOString();
193
+ const s = typeof value === "string" ? value.trim() : "";
194
+ if (!s)
195
+ return null;
196
+ if (ISO_INSTANT.test(s)) {
197
+ const t = Date.parse(s);
198
+ return Number.isNaN(t) ? null : new Date(t).toISOString();
199
+ }
200
+ // A bare day is a valid instant — midnight UTC, which is what YAML's own
201
+ // unquoted date form produces.
202
+ if (ISO_DAY.test(s))
203
+ return `${s}T00:00:00.000Z`;
204
+ return null;
205
+ }
206
+ /** Was something written here that isoDay/isoInstant refused? */
207
+ const isUnusableDate = (value) => value !== undefined &&
208
+ value !== null &&
209
+ value !== "" &&
210
+ !(value instanceof Date) &&
211
+ isoInstant(value) === null;
212
+ /**
213
+ * A `{ from, to }` date range, or undefined.
214
+ *
215
+ * Through isoDay for the same reason every other date here is: YAML hands back
216
+ * a Date for the unquoted form the spec writes, and String() on one produces
217
+ * "Mon Jun 15 2026 10:00:00 GMT+1000" — a value nothing can compare or sort.
218
+ */
219
+ function usageWindow(value) {
220
+ if (!value || typeof value !== "object")
221
+ return undefined;
222
+ const raw = value;
223
+ const from = isoDay(raw.from) ?? undefined;
224
+ const to = isoDay(raw.to) ?? undefined;
225
+ return from || to ? { from, to } : undefined;
226
+ }
227
+ function actorList(value) {
228
+ const list = Array.isArray(value) ? value : value ? [value] : [];
229
+ return list.flatMap((v) => {
230
+ // The spec's form is a mapping; a bare string is accepted too, since a
231
+ // producer that only knows who — not when — should not be unreadable.
232
+ if (v && typeof v === "object") {
233
+ const by = v.by;
234
+ return typeof by === "string" && by
235
+ ? [{ by, at: isoInstant(v.at) }]
236
+ : [];
237
+ }
238
+ return typeof v === "string" && v ? [{ by: v, at: null }] : [];
239
+ });
240
+ }
241
+ /** The most recent `at` among these actors, or null if none carried one. */
242
+ export function latestAt(list) {
243
+ return list.reduce((best, a) => (a.at && (!best || a.at > best) ? a.at : best), null);
244
+ }
245
+ export function readDoc(dir, file, today = new Date()) {
246
+ try {
247
+ const { data } = matter(fs.readFileSync(path.join(dir, file), "utf8"));
248
+ const staleAfter = isoDay(data.stale_after);
249
+ const verified = actorList(data.verified);
250
+ const verifiedBy = verified.map((a) => a.by);
251
+ const generated = data.generated && typeof data.generated === "object" ? data.generated : null;
252
+ const status = ["draft", "stable", "deprecated"].includes(String(data.status))
253
+ ? data.status
254
+ : "stable"; // the spec's default when absent
255
+ return {
256
+ file,
257
+ type: typeof data.type === "string" ? data.type.trim() : "",
258
+ // OKF names it `title`; we've always used `name`. Both are read, so a
259
+ // bundle from either world works untouched.
260
+ title: String(data.title ?? data.name ?? file.replace(/\.md$/, "")),
261
+ description: String(data.description ?? ""),
262
+ tags: Array.isArray(data.tags) ? data.tags.map(String) : [],
263
+ status,
264
+ staleAfter,
265
+ stale: staleAfter ? today.toISOString().slice(0, 10) >= staleAfter : false,
266
+ // A v0.1 document has no `generated`; the spec allows falling back to the
267
+ // legacy `timestamp` so an older bundle still reports when it was made.
268
+ generatedBy: generated
269
+ ? String(generated.by ?? "") || null
270
+ : isoInstant(data.timestamp)
271
+ ? UNKNOWN_ACTOR
272
+ : null,
273
+ // A v0.1 document has no `generated.at`, but its `timestamp` said the
274
+ // same thing, so an older bundle still reports when it was made.
275
+ generatedAt: generated
276
+ ? isoInstant(generated.at)
277
+ : isoInstant(data.timestamp),
278
+ verified,
279
+ verifiedBy,
280
+ verifiedAt: latestAt(verified),
281
+ trust: trustTier(verifiedBy),
282
+ resource: typeof data.resource === "string" ? data.resource : null,
283
+ timestamp: isoInstant(data.timestamp),
284
+ computation: parseComputation(data),
285
+ sources: Array.isArray(data.sources)
286
+ ? data.sources
287
+ .filter((s) => !!s && typeof s === "object")
288
+ .map((s) => ({
289
+ resource: String(s.resource ?? ""),
290
+ id: s.id ? String(s.id) : undefined,
291
+ title: s.title ? String(s.title) : undefined,
292
+ author: s.author ? String(s.author) : undefined,
293
+ usageCount: typeof s.usage_count === "number" ? s.usage_count : undefined,
294
+ lastModified: isoDay(s.last_modified) ?? undefined,
295
+ // Per the spec, an entry's own window overrides the document's,
296
+ // and an entry without one inherits it. Reading only the entry's
297
+ // meant a shared window declared once at the top applied to
298
+ // nothing, so every usage_count it framed was left unframed.
299
+ usageWindow: usageWindow(s.usage_window) ?? usageWindow(data.usage_window),
300
+ }))
301
+ .filter((s) => s.resource)
302
+ : [],
303
+ };
304
+ }
305
+ catch {
306
+ return null;
307
+ }
308
+ }
309
+ /**
310
+ * Every concept in a bundle, including nested directories — Google's own
311
+ * example is a tree (`sales/tables/orders.md`), and the path is the identity.
312
+ * @param depth guards against a pathological tree; bundles are documentation,
313
+ * not filesystems.
314
+ */
315
+ export function readBundle(dir, today = new Date(), prefix = "", depth = 0) {
316
+ if (!fs.existsSync(dir) || depth > 6)
317
+ return [];
318
+ const out = [];
319
+ for (const entry of fs.readdirSync(dir).sort()) {
320
+ const full = path.join(dir, entry);
321
+ let stat;
322
+ try {
323
+ stat = fs.statSync(full);
324
+ }
325
+ catch {
326
+ continue;
327
+ }
328
+ if (stat.isDirectory()) {
329
+ out.push(...readBundle(full, today, `${prefix}${entry}/`, depth + 1));
330
+ }
331
+ else if (entry.endsWith(".md") && !NOT_A_CONCEPT.has(entry)) {
332
+ const doc = readDoc(dir, entry, today);
333
+ if (doc)
334
+ out.push({ ...doc, file: `${prefix}${entry}` });
335
+ }
336
+ }
337
+ return out;
338
+ }
339
+ /** Immediate children of one level, for that level's own index.md. */
340
+ function levelDocs(dir, today = new Date()) {
341
+ if (!fs.existsSync(dir))
342
+ return [];
343
+ return fs
344
+ .readdirSync(dir)
345
+ .filter((f) => f.endsWith(".md") && !NOT_A_CONCEPT.has(f))
346
+ .sort()
347
+ .map((f) => readDoc(dir, f, today))
348
+ .filter((d) => d !== null);
349
+ }
350
+ /**
351
+ * Files that would fail somebody else's conformance check, with the reason.
352
+ *
353
+ * Deliberately walks the directory rather than readBundle(): readBundle answers
354
+ * "what should this platform show", and the two questions differ by exactly the
355
+ * files we chose not to display. A validator that isn't ours applies the spec's
356
+ * reserved set, so this one has to as well — otherwise the check agrees with us
357
+ * about a bundle a consumer will reject.
358
+ */
359
+ export function conformanceIssues(dir, prefix = "", depth = 0) {
360
+ if (!fs.existsSync(dir) || depth > 6)
361
+ return [];
362
+ const out = [];
363
+ for (const entry of fs.readdirSync(dir).sort()) {
364
+ const full = path.join(dir, entry);
365
+ let stat;
366
+ try {
367
+ stat = fs.statSync(full);
368
+ }
369
+ catch {
370
+ continue;
371
+ }
372
+ if (stat.isDirectory()) {
373
+ out.push(...conformanceIssues(full, `${prefix}${entry}/`, depth + 1));
374
+ continue;
375
+ }
376
+ if (!entry.endsWith(".md") || OKF_RESERVED.has(entry))
377
+ continue;
378
+ const file = `${prefix}${entry}`;
379
+ let data;
380
+ try {
381
+ ({ data } = matter(fs.readFileSync(full, "utf8")));
382
+ }
383
+ catch {
384
+ out.push({ file, issue: "frontmatter is not parseable YAML — OKF requires it to parse" });
385
+ continue;
386
+ }
387
+ const type = typeof data.type === "string" ? data.type.trim() : "";
388
+ if (!type) {
389
+ out.push({
390
+ file,
391
+ issue: "missing `type:` — OKF requires a non-empty type on every concept",
392
+ });
393
+ }
394
+ }
395
+ return out;
396
+ }
397
+ /**
398
+ * The bundle's index.md. Progressive disclosure, per the spec: headings and
399
+ * bulleted links with descriptions, and no frontmatter — except `okf_version`
400
+ * at a bundle root, which is the only place the spec permits it.
401
+ */
402
+ export function buildIndex(docs, title, isRoot, children = []) {
403
+ const head = isRoot ? `---\nokf_version: "${OKF_VERSION}"\n---\n\n` : "";
404
+ const sub = children.length
405
+ ? `\n\n## Sections\n\n${children.map((c) => `- [${c}](${c}/index.md)`).join("\n")}`
406
+ : "";
407
+ if (docs.length === 0)
408
+ return `${head}# ${title}${sub || "\n\nNothing here yet."}\n`;
409
+ // Group by type — that's what makes the index useful to skim.
410
+ const byType = new Map();
411
+ for (const d of docs)
412
+ byType.set(d.type || "Untyped", [...(byType.get(d.type || "Untyped") ?? []), d]);
413
+ const sections = [...byType.entries()]
414
+ .sort(([a], [b]) => a.localeCompare(b))
415
+ .map(([type, list]) => {
416
+ const lines = list.map((d) => {
417
+ const marks = [
418
+ d.status !== "stable" ? d.status : null,
419
+ d.stale ? `stale since ${d.staleAfter}` : null,
420
+ ...provenanceMarks(d),
421
+ d.computation ? "UNATTESTED — output not checked against the contract" : null,
422
+ ].filter(Boolean);
423
+ return `- [${d.title}](${d.file})${d.description ? ` — ${d.description}` : ""}${marks.length ? ` _(${marks.join(", ")})_` : ""}`;
424
+ });
425
+ return `## ${type}\n\n${lines.join("\n")}`;
426
+ });
427
+ return `${head}# ${title}\n\n${sections.join("\n\n")}${sub}\n`;
428
+ }
429
+ /**
430
+ * Write index.md for a bundle and for every directory inside it. The spec puts
431
+ * an index at each level, so a nested bundle is navigable without a full walk.
432
+ */
433
+ export function syncIndex(dir, title, isRoot = false, depth = 0) {
434
+ if (!fs.existsSync(dir) || depth > 6)
435
+ return;
436
+ const here = levelDocs(dir);
437
+ const children = fs
438
+ .readdirSync(dir)
439
+ .filter((e) => {
440
+ try {
441
+ return fs.statSync(path.join(dir, e)).isDirectory();
442
+ }
443
+ catch {
444
+ return false;
445
+ }
446
+ })
447
+ .sort();
448
+ fs.writeFileSync(path.join(dir, "index.md"), buildIndex(here, title, isRoot, children));
449
+ for (const child of children) {
450
+ syncIndex(path.join(dir, child), child, false, depth + 1);
451
+ }
452
+ }
453
+ /**
454
+ * Dates the platform had to drop, with where they were.
455
+ *
456
+ * Deliberately not part of conformanceIssues. That function answers one
457
+ * question — would an outside validator accept this bundle — and the spec's
458
+ * three rules say nothing about the *shape* of a date, so a bad one is
459
+ * conformant and still unusable here. Two questions, two functions, and the
460
+ * conformance answer stays exactly the spec's.
461
+ */
462
+ export function dateIssues(dir, prefix = "", depth = 0) {
463
+ if (!fs.existsSync(dir) || depth > 6)
464
+ return [];
465
+ const out = [];
466
+ for (const entry of fs.readdirSync(dir).sort()) {
467
+ const full = path.join(dir, entry);
468
+ let stat;
469
+ try {
470
+ stat = fs.statSync(full);
471
+ }
472
+ catch {
473
+ continue;
474
+ }
475
+ if (stat.isDirectory()) {
476
+ out.push(...dateIssues(full, `${prefix}${entry}/`, depth + 1));
477
+ continue;
478
+ }
479
+ if (!entry.endsWith(".md") || OKF_RESERVED.has(entry))
480
+ continue;
481
+ let data;
482
+ try {
483
+ ({ data } = matter(fs.readFileSync(full, "utf8")));
484
+ }
485
+ catch {
486
+ continue; // unparseable frontmatter is conformanceIssues' to report
487
+ }
488
+ const file = `${prefix}${entry}`;
489
+ const check = (field, value) => {
490
+ if (isUnusableDate(value))
491
+ out.push({ file, field, value: String(value) });
492
+ };
493
+ const obj = (v) => (v && typeof v === "object" ? v : {});
494
+ const list = (v) => (Array.isArray(v) ? v : v ? [v] : []);
495
+ check("stale_after", data.stale_after);
496
+ check("timestamp", data.timestamp);
497
+ check("generated.at", obj(data.generated).at);
498
+ check("usage_window.from", obj(data.usage_window).from);
499
+ check("usage_window.to", obj(data.usage_window).to);
500
+ list(data.verified).forEach((v, i) => check(`verified[${i}].at`, obj(v).at));
501
+ list(data.sources).forEach((s, i) => {
502
+ check(`sources[${i}].last_modified`, obj(s).last_modified);
503
+ check(`sources[${i}].usage_window.from`, obj(obj(s).usage_window).from);
504
+ check(`sources[${i}].usage_window.to`, obj(obj(s).usage_window).to);
505
+ });
506
+ }
507
+ return out;
508
+ }
509
+ /**
510
+ * Stamp provenance on every concept in a bundle an agent has just written to.
511
+ *
512
+ * Recursive, and it skips the spec's reserved names. Doing this by hand with a
513
+ * flat readdir and an `!== "index.md"` filter missed every concept in a nested
514
+ * section, and — worse — stamped `log.md`, writing a concept's frontmatter onto
515
+ * a reserved file that §9 gives its own structure. Nothing reported that,
516
+ * because a conformance check skips reserved names by definition: the file
517
+ * ends up malformed in a way only its readers notice.
518
+ *
519
+ * Returns the bundle-relative paths it stamped, so the caller can record each
520
+ * one in the log. Returning a bare boolean meant the caller had nothing to
521
+ * name, so it re-synced the index and wrote no log entry at all — and a run
522
+ * that taught the agent something left no trace in the one file whose job is
523
+ * to say what changed and when.
524
+ */
525
+ export function stampBundle(dir, agent, at = new Date(), prefix = "", depth = 0) {
526
+ if (!fs.existsSync(dir) || depth > 6)
527
+ return [];
528
+ const stamped = [];
529
+ for (const entry of fs.readdirSync(dir).sort()) {
530
+ const full = path.join(dir, entry);
531
+ let stat;
532
+ try {
533
+ stat = fs.statSync(full);
534
+ }
535
+ catch {
536
+ continue;
537
+ }
538
+ if (stat.isDirectory()) {
539
+ stamped.push(...stampBundle(full, agent, at, `${prefix}${entry}/`, depth + 1));
540
+ continue;
541
+ }
542
+ if (!entry.endsWith(".md") || OKF_RESERVED.has(entry))
543
+ continue;
544
+ if (stampGenerated(full, agent, at))
545
+ stamped.push(`${prefix}${entry}`);
546
+ }
547
+ return stamped;
548
+ }
549
+ /**
550
+ * Make every OKF bundle in a workspace a bundle.
551
+ *
552
+ * A concept file with a `type` is conformant on its own, but a *bundle* is
553
+ * only self-describing once its root index.md exists to carry `okf_version`.
554
+ * Both scaffold paths wrote their starter files straight to disk, so a new
555
+ * workspace's knowledge/ was a directory of valid concepts and nothing that
556
+ * said which version of the format they were written against — the one thing
557
+ * a consumer reads first.
558
+ *
559
+ * An agent's own pair is nested rather than a root, so it gets an index
560
+ * without a version, which is what the spec permits.
561
+ */
562
+ export function syncWorkspaceBundles(root) {
563
+ const sync = (dir, kind, isRoot) => {
564
+ if (!fs.existsSync(dir))
565
+ return;
566
+ // An index for a bundle holding nothing is a file whose entire content is
567
+ // "nothing here yet" — true, and noise in a repository. index.md is
568
+ // optional in the spec, so there is no reason to write one before there is
569
+ // something to list. An agent's empty memory/ is the common case, and it
570
+ // put three of these into the examples before anyone looked.
571
+ if (readBundle(dir).length === 0)
572
+ return;
573
+ syncIndex(dir, kind === "memory" ? "Memory" : "Knowledge", isRoot);
574
+ };
575
+ for (const kind of ["knowledge", "memory"]) {
576
+ sync(path.join(root, kind), kind, true);
577
+ }
578
+ const agentsDir = path.join(root, "agents");
579
+ if (!fs.existsSync(agentsDir))
580
+ return;
581
+ for (const agent of fs.readdirSync(agentsDir)) {
582
+ for (const kind of ["knowledge", "memory"]) {
583
+ sync(path.join(agentsDir, agent, kind), kind, false);
584
+ }
585
+ }
586
+ }
587
+ /**
588
+ * log.md — the bundle's change history. The spec asks for date-grouped
589
+ * entries, newest first, ISO 8601 headings, with the **Creation** / **Update**
590
+ * convention. Appending here means the history is a real artifact rather than
591
+ * something you reconstruct from git.
592
+ */
593
+ export function appendLog(dir, file, kind, at = new Date()) {
594
+ if (!fs.existsSync(dir))
595
+ return;
596
+ const day = at.toISOString().slice(0, 10);
597
+ const entry = `- **${kind}** [${file.replace(/\.md$/, "")}](${file})`;
598
+ const logFile = path.join(dir, "log.md");
599
+ let body = fs.existsSync(logFile) ? fs.readFileSync(logFile, "utf8") : "# Log\n";
600
+ if (body.includes(`## ${day}`)) {
601
+ // Same day: add the line under today's heading, skipping exact repeats.
602
+ const lines = body.split("\n");
603
+ const at_ = lines.indexOf(`## ${day}`);
604
+ const next = lines.findIndex((l, i) => i > at_ && l.startsWith("## "));
605
+ const end = next === -1 ? lines.length : next;
606
+ if (!lines.slice(at_, end).includes(entry))
607
+ lines.splice(at_ + 2, 0, entry);
608
+ body = lines.join("\n");
609
+ }
610
+ else {
611
+ // A new day goes directly under the title — newest first.
612
+ const [title, ...rest] = body.split("\n");
613
+ body = [title, "", `## ${day}`, "", entry, ...rest].join("\n");
614
+ }
615
+ fs.writeFileSync(logFile, body.replace(/\n{3,}/g, "\n\n"));
616
+ }
617
+ /**
618
+ * Stamp provenance on a file that doesn't declare it. Used after a run so a
619
+ * memory an agent wrote is attributable — without this, a fact the model
620
+ * invented and a fact a person verified are indistinguishable on disk.
621
+ */
622
+ export function stampGenerated(file, agent, at = new Date()) {
623
+ try {
624
+ const raw = fs.readFileSync(file, "utf8");
625
+ const parsed = matter(raw);
626
+ if (parsed.data.generated)
627
+ return false; // never overwrite a real claim
628
+ // `by` follows the spec's actor convention; which agent wrote it is kept
629
+ // as an extension key, which the spec explicitly permits.
630
+ //
631
+ // Null where a run had several agents and the file is at workspace scope:
632
+ // any one of them could have written it, and naming a guess is worse than
633
+ // saying only what is known — that a machine produced it.
634
+ parsed.data.generated = {
635
+ by: PRODUCER,
636
+ at: at.toISOString(),
637
+ ...(agent ? { agent } : {}),
638
+ };
639
+ // Agents write `name:` whether or not you ask them to — a real run did it
640
+ // one line after being told to add no frontmatter at all. It reads fine
641
+ // here, because readDoc falls back to it, and reads as a slug to anyone
642
+ // else, because OKF has no such field. A bundle carries the format's
643
+ // vocabulary, so the key is moved rather than left to mean nothing.
644
+ if (!parsed.data.title && typeof parsed.data.name === "string") {
645
+ parsed.data.title = parsed.data.name;
646
+ delete parsed.data.name;
647
+ }
648
+ if (!parsed.data.type)
649
+ parsed.data.type = "Memory";
650
+ fs.writeFileSync(file, matter.stringify(parsed.content, parsed.data));
651
+ return true;
652
+ }
653
+ catch {
654
+ return false;
655
+ }
656
+ }