naive-framework 0.1.0 → 0.3.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 (281) hide show
  1. package/AGENTS.md +2 -1
  2. package/docs/decisions/decisions.json +42 -0
  3. package/docs/reference/ROUTES.md +36 -37
  4. package/evidence/skills/fidelity-ladder-audit/ladder-census.mjs +2 -1
  5. package/governance/layer.json +2 -1
  6. package/governance/venture-template/roles/venture-roles.json +62 -0
  7. package/interface/acting.mjs +83 -0
  8. package/interface/adapters/claude-code.json +16 -0
  9. package/interface/adapters/codex.json +14 -0
  10. package/interface/adapters/cursor.json +16 -0
  11. package/interface/adapters.mjs +183 -0
  12. package/interface/api/server.mjs +4 -2
  13. package/interface/boundaries.mjs +219 -0
  14. package/interface/brand.mjs +215 -0
  15. package/interface/cli/freshness.mjs +59 -0
  16. package/interface/cli/guard.mjs +6 -0
  17. package/interface/cli/mentura.mjs +82 -11
  18. package/interface/cli/naive.mjs +44 -14
  19. package/interface/context.mjs +525 -0
  20. package/interface/demo/board.mjs +9 -8
  21. package/interface/demo/decision-harness.mjs +4 -3
  22. package/interface/dev/setup.mjs +84 -0
  23. package/interface/gate-hooks.mjs +37 -0
  24. package/interface/harness/evals.mjs +10 -9
  25. package/interface/harness/run.mjs +16 -15
  26. package/interface/instrumentation.mjs +87 -0
  27. package/interface/kit-boundary.mjs +73 -0
  28. package/interface/layer.json +8 -2
  29. package/interface/mcp/server.mjs +47 -7
  30. package/interface/package-routes.mjs +197 -0
  31. package/interface/provenance.mjs +28 -4
  32. package/interface/reads.mjs +174 -0
  33. package/interface/release-card.mjs +43 -0
  34. package/interface/router.mjs +110 -36
  35. package/interface/skills/naive-setup/SKILL.md +71 -0
  36. package/interface/skills/naive-setup/artifact.html +83 -0
  37. package/interface/vitals.mjs +116 -0
  38. package/interface/writes.mjs +190 -0
  39. package/knowledge/connectors/catalog.mjs +3 -2
  40. package/knowledge/connectors/filesystem.mjs +10 -5
  41. package/knowledge/connectors/index.mjs +3 -2
  42. package/knowledge/freshness.json +85 -0
  43. package/knowledge/layer.json +11 -3
  44. package/knowledge/templates/glossary.md +23 -0
  45. package/operations/layer.json +10 -3
  46. package/operations/marketing/check-public.mjs +7 -6
  47. package/operations/marketing/site/plan.mjs +4 -3
  48. package/operations/marketing/site/presence.mjs +4 -3
  49. package/operations/templates/marketing-brand.md +46 -0
  50. package/operations/templates/public-assets.md +39 -0
  51. package/package.json +3 -1
  52. package/packages/catalog.json +8 -0
  53. package/packages/customers/package.json +21 -1
  54. package/packages/deals/package.json +27 -0
  55. package/packages/decisions/package.json +12 -1
  56. package/packages/finance/frames/fund-without-ceding-control.frame.json +177 -0
  57. package/packages/foundation/package.json +8 -1
  58. package/packages/fund/package.json +48 -0
  59. package/packages/marketing/package.json +10 -4
  60. package/packages/money/package.json +24 -3
  61. package/packages/validation/package.json +13 -0
  62. package/platform/README.md +6 -0
  63. package/platform/adapter.json +15 -0
  64. package/platform/integrations/remote-image.mjs +79 -0
  65. package/platform/integrations/rho.mjs +9 -7
  66. package/platform/kits/README.md +8 -1
  67. package/platform/kits/agent-upgrade/kit.json +15 -0
  68. package/platform/kits/agent-upgrade/run.mjs +82 -0
  69. package/platform/kits/agent-upgrade/stand-in-agent.mjs +32 -0
  70. package/platform/kits/validation-kickoff/kit.json +29 -0
  71. package/platform/webhooks/server.mjs +70 -0
  72. package/purpose/frameworks/idea-map/ideamap.mjs +4 -2
  73. package/purpose/frameworks/idea-map/route.mjs +5 -4
  74. package/purpose/frameworks/product-anatomy/anatomy.mjs +2 -1
  75. package/schema/README.md +1 -0
  76. package/schema/access.mjs +58 -0
  77. package/schema/adapter.schema.json +33 -0
  78. package/schema/add-department.mjs +3 -2
  79. package/schema/assess-problem.mjs +3 -2
  80. package/schema/assets.mjs +213 -0
  81. package/schema/brand.mjs +374 -0
  82. package/schema/build-release.mjs +61 -0
  83. package/schema/caching.mjs +506 -0
  84. package/schema/check-assets.mjs +24 -0
  85. package/schema/check-brand.mjs +24 -0
  86. package/schema/check-config.mjs +111 -0
  87. package/schema/check-data-model.mjs +2 -1
  88. package/schema/check-data-packages.mjs +5 -4
  89. package/schema/check-execution.mjs +4 -3
  90. package/schema/check-nextjs-status.mjs +3 -1
  91. package/schema/check-people.mjs +84 -0
  92. package/schema/check-private.mjs +2 -1
  93. package/schema/check-ready.mjs +6 -5
  94. package/schema/check-sources.mjs +11 -2
  95. package/schema/check-where-used.mjs +4 -3
  96. package/schema/data-model.json +334 -8
  97. package/schema/deal.schema.json +80 -0
  98. package/schema/decision-analytics.mjs +2 -1
  99. package/schema/decisions.mjs +21 -12
  100. package/schema/examples/frames/judge.mjs +10 -0
  101. package/schema/examples/venture-type/jev.mjs +12 -0
  102. package/schema/examples/workspace/ventures/acme-labs/AGENTS.md +17 -0
  103. package/schema/examples/workspace/ventures/acme-labs/accounts/.gitkeep +0 -0
  104. package/schema/examples/workspace/ventures/acme-labs/decisions/discovery-goal.decision.json +23 -0
  105. package/schema/examples/workspace/ventures/acme-labs/decisions/statement-icp.decision.json +22 -0
  106. package/schema/examples/workspace/ventures/acme-labs/decisions/statement-problem.decision.json +22 -0
  107. package/schema/examples/workspace/ventures/acme-labs/decisions/venture.team.decision.json +37 -0
  108. package/schema/examples/workspace/ventures/acme-labs/knowledge/README.md +3 -0
  109. package/schema/examples/workspace/ventures/acme-labs/knowledge/sources.json +46 -0
  110. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000001.json +12 -0
  111. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000002.json +12 -0
  112. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000003.json +12 -0
  113. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000004.json +12 -0
  114. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000005.json +12 -0
  115. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000006.json +12 -0
  116. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000007.json +12 -0
  117. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000008.json +12 -0
  118. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000009.json +12 -0
  119. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000010.json +12 -0
  120. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000011.json +12 -0
  121. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000012.json +12 -0
  122. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000013.json +12 -0
  123. package/schema/examples/workspace/ventures/acme-labs/money/30000000-0000-4000-8000-000000000014.json +12 -0
  124. package/schema/examples/workspace/ventures/acme-labs/money/_streams/transactions/stream.json +5 -0
  125. package/schema/examples/workspace/ventures/acme-labs/money/_summary.json +30 -0
  126. package/schema/examples/workspace/ventures/acme-labs/operations/departments/customer-service/README.md +5 -0
  127. package/schema/examples/workspace/ventures/acme-labs/operations/departments/customer-service/department.json +5 -0
  128. package/schema/examples/workspace/ventures/acme-labs/operations/departments/finance/README.md +5 -0
  129. package/schema/examples/workspace/ventures/acme-labs/operations/departments/finance/department.json +5 -0
  130. package/schema/examples/workspace/ventures/acme-labs/operations/departments/human-capital/README.md +5 -0
  131. package/schema/examples/workspace/ventures/acme-labs/operations/departments/human-capital/department.json +5 -0
  132. package/schema/examples/workspace/ventures/acme-labs/operations/departments/it/README.md +5 -0
  133. package/schema/examples/workspace/ventures/acme-labs/operations/departments/it/department.json +5 -0
  134. package/schema/examples/workspace/ventures/acme-labs/operations/departments/marketing-and-sales/README.md +5 -0
  135. package/schema/examples/workspace/ventures/acme-labs/operations/departments/marketing-and-sales/department.json +5 -0
  136. package/schema/examples/workspace/ventures/acme-labs/operations/departments/products/README.md +5 -0
  137. package/schema/examples/workspace/ventures/acme-labs/operations/departments/products/department.json +5 -0
  138. package/schema/examples/workspace/ventures/acme-labs/operations/departments/strategy/README.md +5 -0
  139. package/schema/examples/workspace/ventures/acme-labs/operations/departments/strategy/department.json +5 -0
  140. package/schema/examples/workspace/ventures/acme-labs/people/founders/maya-chen.json +15 -0
  141. package/schema/examples/workspace/ventures/acme-labs/people/people.json +22 -0
  142. package/schema/examples/workspace/ventures/acme-labs/pillars/brand.md +5 -0
  143. package/schema/examples/workspace/ventures/acme-labs/pillars/business.md +5 -0
  144. package/schema/examples/workspace/ventures/acme-labs/pillars/product.md +5 -0
  145. package/schema/examples/workspace/ventures/acme-labs/products/.gitkeep +0 -0
  146. package/schema/examples/workspace/ventures/acme-labs/products/decisions/product.core-product.decision.json +28 -0
  147. package/schema/examples/workspace/ventures/acme-labs/products/decisions/product.exists.decision.json +38 -0
  148. package/schema/examples/workspace/ventures/acme-labs/products/decisions/product.flagship.decision.json +27 -0
  149. package/schema/examples/workspace/ventures/acme-labs/profile.md +12 -0
  150. package/schema/examples/workspace/ventures/acme-labs/provenance.jsonl +12 -0
  151. package/schema/examples/workspace/ventures/acme-labs/raw/dump.md +6 -0
  152. package/schema/examples/workspace/ventures/acme-labs/venture.json +27 -0
  153. package/schema/examples/workspace/ventures/northstar/AGENTS.md +17 -0
  154. package/schema/examples/workspace/ventures/northstar/accounts/.gitkeep +0 -0
  155. package/schema/examples/workspace/ventures/northstar/deals/ductline.json +8 -0
  156. package/schema/examples/workspace/ventures/northstar/deals/kilnworks.json +14 -0
  157. package/schema/examples/workspace/ventures/northstar/decisions/venture.team.decision.json +31 -0
  158. package/schema/examples/workspace/ventures/northstar/evidence/README.md +3 -0
  159. package/schema/examples/workspace/ventures/northstar/fund/decisions/terms.decision.json +27 -0
  160. package/schema/examples/workspace/ventures/northstar/fund/decisions/terms.first-close.decision.json +27 -0
  161. package/schema/examples/workspace/ventures/northstar/fund/decisions/thesis.decision.json +26 -0
  162. package/schema/examples/workspace/ventures/northstar/fund/terms.json +22 -0
  163. package/schema/examples/workspace/ventures/northstar/fund/thesis.json +22 -0
  164. package/schema/examples/workspace/ventures/northstar/governance/README.md +3 -0
  165. package/schema/examples/workspace/ventures/northstar/governance/departments/external-relationships/charter.md +5 -0
  166. package/schema/examples/workspace/ventures/northstar/governance/departments/external-relationships/department.json +5 -0
  167. package/schema/examples/workspace/ventures/northstar/governance/departments/finance/charter.md +5 -0
  168. package/schema/examples/workspace/ventures/northstar/governance/departments/finance/department.json +5 -0
  169. package/schema/examples/workspace/ventures/northstar/governance/departments/human-capital/charter.md +5 -0
  170. package/schema/examples/workspace/ventures/northstar/governance/departments/human-capital/department.json +5 -0
  171. package/schema/examples/workspace/ventures/northstar/governance/departments/risk-and-compliance/charter.md +5 -0
  172. package/schema/examples/workspace/ventures/northstar/governance/departments/risk-and-compliance/department.json +5 -0
  173. package/schema/examples/workspace/ventures/northstar/governance/departments/strategy/charter.md +5 -0
  174. package/schema/examples/workspace/ventures/northstar/governance/departments/strategy/department.json +5 -0
  175. package/schema/examples/workspace/ventures/northstar/knowledge/README.md +3 -0
  176. package/schema/examples/workspace/ventures/northstar/operations/README.md +3 -0
  177. package/schema/examples/workspace/ventures/northstar/operations/departments/external-relationships/README.md +5 -0
  178. package/schema/examples/workspace/ventures/northstar/operations/departments/external-relationships/department.json +5 -0
  179. package/schema/examples/workspace/ventures/northstar/operations/departments/finance/README.md +5 -0
  180. package/schema/examples/workspace/ventures/northstar/operations/departments/finance/department.json +5 -0
  181. package/schema/examples/workspace/ventures/northstar/operations/departments/human-capital/README.md +5 -0
  182. package/schema/examples/workspace/ventures/northstar/operations/departments/human-capital/department.json +5 -0
  183. package/schema/examples/workspace/ventures/northstar/operations/departments/risk-and-compliance/README.md +5 -0
  184. package/schema/examples/workspace/ventures/northstar/operations/departments/risk-and-compliance/department.json +5 -0
  185. package/schema/examples/workspace/ventures/northstar/operations/departments/strategy/README.md +5 -0
  186. package/schema/examples/workspace/ventures/northstar/operations/departments/strategy/department.json +5 -0
  187. package/schema/examples/workspace/ventures/northstar/people/founders/ana-ruiz.json +13 -0
  188. package/schema/examples/workspace/ventures/northstar/people/founders/ben-okafor.json +7 -0
  189. package/schema/examples/workspace/ventures/northstar/people/people.json +27 -0
  190. package/schema/examples/workspace/ventures/northstar/pillars/brand.md +5 -0
  191. package/schema/examples/workspace/ventures/northstar/pillars/business.md +5 -0
  192. package/schema/examples/workspace/ventures/northstar/pillars/product.md +5 -0
  193. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/AGENTS.md +17 -0
  194. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/accounts/.gitkeep +0 -0
  195. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/decisions/venture.team.decision.json +31 -0
  196. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/knowledge/README.md +3 -0
  197. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/customer-service/README.md +5 -0
  198. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/customer-service/department.json +5 -0
  199. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/finance/README.md +5 -0
  200. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/finance/department.json +5 -0
  201. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/human-capital/README.md +5 -0
  202. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/human-capital/department.json +5 -0
  203. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/it/README.md +5 -0
  204. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/it/department.json +5 -0
  205. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/marketing-and-sales/README.md +5 -0
  206. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/marketing-and-sales/department.json +5 -0
  207. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/products/README.md +5 -0
  208. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/products/department.json +5 -0
  209. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/strategy/README.md +5 -0
  210. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/operations/departments/strategy/department.json +5 -0
  211. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/pillars/brand.md +5 -0
  212. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/pillars/business.md +5 -0
  213. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/pillars/product.md +5 -0
  214. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/products/.gitkeep +0 -0
  215. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/products/decisions/product.core-product.decision.json +28 -0
  216. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/products/decisions/product.exists.decision.json +32 -0
  217. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/products/decisions/product.flagship.decision.json +27 -0
  218. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/profile.md +5 -0
  219. package/schema/examples/workspace/ventures/northstar/portfolio/kilnworks/venture.json +27 -0
  220. package/schema/examples/workspace/ventures/northstar/products/.gitkeep +0 -0
  221. package/schema/examples/workspace/ventures/northstar/profile.md +5 -0
  222. package/schema/examples/workspace/ventures/northstar/provenance.jsonl +3 -0
  223. package/schema/examples/workspace/ventures/northstar/purpose/README.md +3 -0
  224. package/schema/examples/workspace/ventures/northstar/venture.json +49 -0
  225. package/schema/foundation.mjs +8 -7
  226. package/schema/founder.schema.json +72 -0
  227. package/schema/frame.schema.json +240 -0
  228. package/schema/frames.mjs +229 -0
  229. package/schema/freshness-log.schema.json +40 -0
  230. package/schema/freshness-profiles.schema.json +44 -0
  231. package/schema/freshness.mjs +474 -0
  232. package/schema/fund-terms.schema.json +107 -0
  233. package/schema/fund-thesis.schema.json +87 -0
  234. package/schema/gate-drafts.mjs +131 -0
  235. package/schema/gate.mjs +105 -11
  236. package/schema/glossary.schema.json +25 -0
  237. package/schema/init-venture.mjs +69 -21
  238. package/schema/judgment.schema.json +38 -0
  239. package/schema/kit.schema.json +47 -0
  240. package/schema/kits.mjs +24 -0
  241. package/schema/knowledge-sources.schema.json +145 -15
  242. package/schema/layer.json +16 -3
  243. package/schema/marketing-brand.schema.json +116 -0
  244. package/schema/migrate-venture.mjs +8 -7
  245. package/schema/migrations/kit-to-package.mjs +95 -0
  246. package/schema/migrations/package-layout.mjs +90 -0
  247. package/schema/model-steps.mjs +718 -0
  248. package/schema/money-summary.schema.json +138 -0
  249. package/schema/naive-config.mjs +166 -0
  250. package/schema/naive-config.schema.json +268 -0
  251. package/schema/need-now.json +171 -0
  252. package/schema/need-now.mjs +8 -6
  253. package/schema/packages.mjs +21 -15
  254. package/schema/payouts.mjs +3 -2
  255. package/schema/people.schema.json +85 -0
  256. package/schema/port-venture.mjs +206 -132
  257. package/schema/public-assets.schema.json +76 -0
  258. package/schema/release-read.mjs +112 -0
  259. package/schema/releases.mjs +498 -0
  260. package/schema/run-handoff.mjs +5 -4
  261. package/schema/setup.mjs +165 -0
  262. package/schema/stale.mjs +112 -0
  263. package/schema/start-release.mjs +41 -0
  264. package/schema/structure.mjs +187 -0
  265. package/schema/systems-catalog.json +126 -0
  266. package/schema/systems-catalog.schema.json +78 -0
  267. package/schema/time-to-ready.mjs +7 -6
  268. package/schema/trigger.schema.json +44 -0
  269. package/schema/triggers.mjs +238 -0
  270. package/schema/upgrade-registry.schema.json +60 -0
  271. package/schema/upgrade-venture.mjs +96 -118
  272. package/schema/upgrades.json +69 -0
  273. package/schema/upgrades.mjs +386 -0
  274. package/schema/validate.py +19 -10
  275. package/schema/venture-paths.mjs +85 -0
  276. package/schema/venture-role.schema.json +64 -0
  277. package/schema/venture-type.mjs +62 -0
  278. package/schema/venture-types.json +346 -0
  279. package/schema/venture-types.schema.json +129 -0
  280. package/schema/venture.schema.json +61 -0
  281. package/schema/workspace.schema.json +5 -1
@@ -0,0 +1,474 @@
1
+ #!/usr/bin/env node
2
+ // Freshness: revalidating records (the Next.js chapter Revalidating, V-1 to V-8). Next.js keeps
3
+ // cached data fresh two ways: by time (cacheLife) and on demand (revalidateTag, revalidatePath).
4
+ // A venture has to keep its knowledge fresh the same two ways, so this module applies both to
5
+ // records:
6
+ //
7
+ // by time each record type has a freshness profile (knowledge/freshness.json, overridden
8
+ // under freshness in the venture's naive.config.json): stale after revalidate, expired
9
+ // after expire, and, a profile Next.js has no need for, per_phase: stale once the
10
+ // venture changes phase after the record was last checked (V-2)
11
+ // on demand a record is stale when something it depends on changed after it was last checked:
12
+ // a record it was derived from (the provenance log's derived_from), the parent of a
13
+ // where-used fact it repeats (drift), or the decision above it (EE-057); and when
14
+ // someone marks it, by tag (one record and what depends on it, V-5), by path (a whole
15
+ // folder, V-7) or by package (a pivot revalidates the validation package)
16
+ // live holes records with a live profile (pipeline, money, streams) are never frozen into a
17
+ // release; they stay holes, read from their system of record each time (V-3)
18
+ //
19
+ // A stale record is still readable: the router adds a freshness flag to its read until it is
20
+ // redone or checked (stale-while-revalidate). An expired one is not frozen into a release. Writing
21
+ // a record again, or `mentura verify`, makes it fresh; the founder's next read after their own
22
+ // write shows it fresh at once (V-6). Tags are preferred over paths (V-8): a plain target that is
23
+ // a folder is refused with the exact records to revalidate instead, unless it says path:.
24
+ //
25
+ // "Now" is an input (C-9): --now <iso> or --now +<duration> (from the clock), else NAIVE_NOW, else the clock.
26
+ //
27
+ // node schema/freshness.mjs <venture dir> [--json] [--now <iso>] what is fresh, stale, expired or a hole
28
+ // node schema/freshness.mjs <venture dir> --revalidate <target> [--reason <text>] [--json]
29
+ // target: tag:<route or file>, path:<folder>, package:<name>, pivot, or a plain route or file
30
+ // node schema/freshness.mjs <venture dir> --freeze <out dir> [--json] freeze the fresh records into a
31
+ // static shell at <out dir>, leaving live records as holes; exit 1 if any record has expired
32
+
33
+ import { spawnSync } from "node:child_process";
34
+ import { createHash } from "node:crypto";
35
+ import { appendFileSync, copyFileSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, statSync, writeFileSync } from "node:fs";
36
+ import { tmpdir } from "node:os";
37
+ import { dirname, isAbsolute, join, relative, resolve as resolvePath } from "node:path";
38
+ import { agentFromEnv, entries as provEntries, record as prov } from "../interface/provenance.mjs";
39
+ import { files as ventureFiles, glob, resolve as resolveRoute, routes } from "../interface/router.mjs";
40
+ import { connect, LOCAL, sourcesOf } from "../knowledge/connectors/index.mjs";
41
+ import { decisions as decisionMatrix, PHASES } from "./decisions.mjs";
42
+ import { ventureFile } from "./venture-paths.mjs";
43
+
44
+ const repo = new URL("..", import.meta.url).pathname;
45
+ const load = (f) => JSON.parse(readFileSync(f, "utf8"));
46
+ export const LOG = "knowledge/freshness.jsonl";
47
+ export const DEFAULTS = "knowledge/freshness.json";
48
+ const UNITS = { s: 1, m: 60, h: 3600, d: 86400, w: 604800 };
49
+ // Activities that change a record's content, and those that only say a person looked at it.
50
+ const CONTENT = new Set(["write", "accept", "baseline", "move", "port", "create"]);
51
+ const CHECKED = new Set(["confirm"]);
52
+ const EXACT = new Set(["tag", "changed", "drift", "decision", "depends"]);
53
+ const ORDER = ["tag", "changed", "drift", "decision", "depends", "path", "package", "phase", "age", "expired"];
54
+ // Times keep milliseconds, so a mark and a write in the same second stay in order.
55
+ const iso = (ms) => new Date(ms).toISOString();
56
+ const count = (n, word) => `${n} ${word}${n === 1 ? "" : "s"}`;
57
+ const isUrl = (s) => /^[a-z][a-z0-9+.-]*:\/\//.test(s);
58
+
59
+ /** The time freshness is judged at: an explicit input, else NAIVE_NOW, else the clock (C-9). */
60
+ export function nowOf(now) {
61
+ const v = now ?? process.env.NAIVE_NOW;
62
+ if (v === undefined || v === null || v === "") return Date.now();
63
+ // +100d: that long from the clock, so a founder can ask what will be stale in 100 days.
64
+ if (typeof v === "string" && v.startsWith("+")) return Date.now() + seconds(v.slice(1)) * 1000;
65
+ const t = typeof v === "number" ? v : Date.parse(v);
66
+ if (Number.isNaN(t)) throw new Error(`not a time: ${v}`);
67
+ return t;
68
+ }
69
+
70
+ /** A duration in seconds: a whole number of seconds, or a number with s, m, h, d or w. null when absent. */
71
+ export function seconds(d) {
72
+ if (d === undefined || d === null) return null;
73
+ if (typeof d === "number" && Number.isInteger(d) && d >= 0) return d;
74
+ const m = String(d).match(/^(\d+)(s|m|h|d|w)$/);
75
+ if (!m) throw new Error(`not a duration: ${JSON.stringify(d)} (seconds, or a number with s, m, h, d or w)`);
76
+ return Number(m[1]) * UNITS[m[2]];
77
+ }
78
+ const human = (s) => s % 604800 === 0 && s ? `${s / 604800}w` : s % 86400 === 0 && s ? `${s / 86400}d` : s % 3600 === 0 && s ? `${s / 3600}h` : `${s}s`;
79
+
80
+ /**
81
+ * The freshness profiles for a venture: the framework's defaults, with the venture's own profiles
82
+ * and assignments from naive.config.json (freshness) laid over them. Throws on an unknown profile
83
+ * or a bad duration, so a typo never silently means "never stale".
84
+ */
85
+ export function profiles(dir) {
86
+ const base = load(join(repo, DEFAULTS));
87
+ const cfg = dir && existsSync(join(ventureFile(dir, "naive.config.json"))) ? load(join(ventureFile(dir, "naive.config.json"))).freshness ?? {} : {};
88
+ const out = { default: cfg.default ?? base.default, profiles: { ...base.profiles, ...(cfg.profiles ?? {}) }, records: { ...base.records, ...(cfg.records ?? {}) }, overridden: Object.keys(cfg).length > 0 };
89
+ const where = (k) => (cfg.profiles?.[k] || cfg.records?.[k] || k === "default") && out.overridden ? "naive.config.json freshness" : DEFAULTS;
90
+ for (const [name, p] of Object.entries(out.profiles)) {
91
+ for (const k of Object.keys(p)) if (!["stale", "revalidate", "expire", "per_phase", "live", "note"].includes(k)) throw new Error(`${where(name)}: profile ${name} has an unknown field ${k}`);
92
+ for (const k of ["stale", "revalidate", "expire"]) try { seconds(p[k]); } catch (e) { throw new Error(`${where(name)}: profile ${name}.${k}: ${e.message}`); }
93
+ if (p.expire !== undefined && p.revalidate !== undefined && seconds(p.expire) < seconds(p.revalidate)) throw new Error(`${where(name)}: profile ${name} expires before it is due to be revalidated`);
94
+ }
95
+ for (const [k, name] of [["default", out.default], ...Object.entries(out.records)]) if (!out.profiles[name]) throw new Error(`${where(k)}: ${k} names profile ${name}, which is not defined (${Object.keys(out.profiles).join(", ")})`);
96
+ return out;
97
+ }
98
+
99
+ /** The profile name for one record: a route prefix assignment beats the record type; the longest prefix wins. */
100
+ export function profileFor(P, record, route) {
101
+ const types = new Set(routes().filter((r) => r.kind === "record").map((r) => r.record));
102
+ const prefixes = Object.keys(P.records).filter((k) => !types.has(k) && (route === k || route.startsWith(`${k}/`))).sort((a, b) => b.length - a.length);
103
+ return prefixes.length ? P.records[prefixes[0]] : P.records[record] ?? P.default;
104
+ }
105
+
106
+ /** Every record file in a venture, with its record type, concrete route, package and route entry. */
107
+ export function recordFiles(dir) {
108
+ const table = routes().filter((r) => r.kind === "record" && r.collection !== "list");
109
+ const out = [];
110
+ for (const f of ventureFiles(dir)) {
111
+ for (const r of table) {
112
+ const g = r.files.find((x) => glob(x).test(f));
113
+ if (!g) continue;
114
+ let route = r.route;
115
+ if (r.collection === "item") {
116
+ const m = f.match(new RegExp("^" + g.split("*").map((s) => s.replace(/[.+^${}()|[\]\\]/g, "\\$&")).join("([^/]*)") + "$"));
117
+ route = r.route.replace("[id]", m?.[1] ?? f);
118
+ }
119
+ out.push({ path: f, record: r.record, route, package: r.package ?? null, layer: r.layer });
120
+ break;
121
+ }
122
+ }
123
+ return out;
124
+ }
125
+
126
+ /** The freshness log: revalidate, verify and phase events, oldest first. */
127
+ export function events(dir) {
128
+ const f = ventureFile(dir, LOG);
129
+ return existsSync(f) ? readFileSync(f, "utf8").split("\n").filter((l) => l.trim()).map((l) => JSON.parse(l)) : [];
130
+ }
131
+
132
+ // When the venture last changed phase. A phase changed by hand in venture.json, after the last
133
+ // recorded change, counts from venture.json's file time, with a warning.
134
+ function phaseChange(dir, venture, evs, warnings) {
135
+ const changes = evs.filter((e) => e.event === "phase");
136
+ const last = changes.at(-1);
137
+ if (last && last.to === venture.phase) return { at: Date.parse(last.at), from: last.from, to: last.to, basis: "freshness log" };
138
+ if (last) {
139
+ warnings.push(`venture.json says ${venture.phase} but the last recorded phase change was to ${last.to}: record it with mentura phase ${venture.phase}`);
140
+ return { at: statSync(join(dir, "venture.json")).mtimeMs, from: last.to, to: venture.phase, basis: "venture.json file time" };
141
+ }
142
+ return null;
143
+ }
144
+
145
+ function covers(target, item, dir) {
146
+ const v = target.value.replace(/\/$/, "");
147
+ if (target.kind === "tag") return item.path === v;
148
+ if (target.kind === "path") return item.path === v || item.path.startsWith(`${v}/`) || item.route === v || item.route.startsWith(`${v}/`);
149
+ if (target.kind === "package") return item.package === v;
150
+ return false;
151
+ }
152
+
153
+ // A dependency named in derived_from: a venture file, a route (to its file), or a draft (to what
154
+ // the draft was derived from). URLs and decision ids are not files and are left out.
155
+ function normalizeDeps(list, byRoute, entries, seen = new Set()) {
156
+ const out = [];
157
+ for (const d of list ?? []) {
158
+ if (isUrl(d) || /^[A-Z]+-\d+$/.test(d)) continue;
159
+ if (d.startsWith("_drafts/")) {
160
+ if (seen.has(d)) continue;
161
+ seen.add(d);
162
+ out.push(...normalizeDeps(entries.filter((e) => e.entity === d && e.derived_from).at(-1)?.derived_from, byRoute, entries, seen));
163
+ continue;
164
+ }
165
+ out.push(byRoute.get(d) ?? byRoute.get(resolveRoute(d).r ? (resolveRoute(d).r.collection === "item" ? resolveRoute(d).r.route.replace("[id]", resolveRoute(d).params.id) : resolveRoute(d).r.route) : "") ?? d);
166
+ }
167
+ return [...new Set(out)];
168
+ }
169
+
170
+ const at = (obj, pointer) => pointer.split("/").slice(1).map((k) => k.replace(/~1/g, "/").replace(/~0/g, "~")).reduce((o, k) => o == null ? undefined : o[k], obj);
171
+
172
+ // Where-used facts (EE-042) as dependencies: each live child in the venture folder depends on its
173
+ // parent record; a child that does not show the parent's value has drifted. Children elsewhere
174
+ // are read through their connector when it is a file system, else left to the platform.
175
+ async function whereUsed(dir, byRoute, byPath, unchecked) {
176
+ const f = join(ventureFile(dir, "knowledge/where-used.json"));
177
+ if (!existsSync(f)) return [];
178
+ const ids = new Map(sourcesOf(dir).sources.map((s) => [s.id, s]));
179
+ const out = [];
180
+ for (const fact of load(f).facts ?? []) {
181
+ const { r, params } = resolveRoute(fact.parent.route);
182
+ const route = r ? (r.collection === "item" ? r.route.replace("[id]", params.id) : r.route) : fact.parent.route;
183
+ const parent = byRoute.get(route);
184
+ if (!parent || !parent.endsWith(".json")) { unchecked.push(`${fact.id}: parent ${fact.parent.route} is not a JSON record in the venture folder`); continue; }
185
+ const value = at(load(ventureFile(dir, parent)), fact.parent.pointer);
186
+ for (const child of fact.children) {
187
+ if (child.status === "frozen") continue;
188
+ const s = ids.get(child.source);
189
+ if (!s || s.connector !== "filesystem") { unchecked.push(`${fact.id} -> ${child.source}://${child.path}: needs the platform to check`); continue; }
190
+ let content;
191
+ if (s.id === LOCAL) content = existsSync(ventureFile(dir, child.path)) ? readFileSync(ventureFile(dir, child.path), "utf8") : null;
192
+ else content = (await (await connect(s, dir)).read(child.path))?.content ?? null;
193
+ const shows = content === null ? false : child.match.kind === "json"
194
+ ? (() => { try { return JSON.stringify(at(JSON.parse(content), child.match.pointer)) === JSON.stringify(value); } catch { return false; } })()
195
+ : content.includes(child.match.template.replaceAll("{value}", String(value)));
196
+ out.push({ fact: fact.id, parent, child: s.id === LOCAL ? child.path : `${s.id}://${child.path}`, local: s.id === LOCAL, record: s.id === LOCAL && byPath.has(child.path), missing: content === null, drift: !shows, deliver: child.deliver });
197
+ }
198
+ }
199
+ return out;
200
+ }
201
+
202
+ /**
203
+ * The freshness of every record in a venture at a time. Each item: path, route, record, profile,
204
+ * state (fresh, stale or expired), hole (a live record, never frozen), when it was last checked
205
+ * and on what basis, when it is due, and the reasons it is stale, exact dependencies first (V-8).
206
+ * options: now; light (skip the decision matrix, for the router's read flag).
207
+ */
208
+ export async function freshness(dir, { now, light = false } = {}) {
209
+ const t = nowOf(now);
210
+ const venture = load(join(dir, "venture.json"));
211
+ const P = profiles(dir);
212
+ const warnings = [], unchecked = [];
213
+ const items = recordFiles(dir);
214
+ const byPath = new Map(items.map((i) => [i.path, i]));
215
+ const byRoute = new Map(items.map((i) => [i.route, i.path]));
216
+ const log = provEntries(dir).filter((e) => !e.source);
217
+ const evs = events(dir);
218
+ const change = phaseChange(dir, venture, evs, warnings);
219
+ const lastOf = (path, kinds) => { const e = log.filter((x) => x.entity === path && kinds.has(x.activity)).at(-1); return e ? Date.parse(e.at) : null; };
220
+ // The last time a file's content changed: its last logged write, else its file time.
221
+ const changedAt = (path) => lastOf(path, CONTENT) ?? (existsSync(ventureFile(dir, path)) ? statSync(ventureFile(dir, path)).mtimeMs : null);
222
+
223
+ for (const i of items) {
224
+ const write = lastOf(i.path, CONTENT), confirmed = lastOf(i.path, CHECKED);
225
+ const verified = evs.filter((e) => e.event === "verify" && e.target && covers(e.target, i, dir)).map((e) => Date.parse(e.at)).at(-1) ?? null;
226
+ const known = [[write, "provenance log: written"], [confirmed, "provenance log: confirmed"], [verified, "freshness log: verified"]].filter(([x]) => x !== null).sort((a, b) => b[0] - a[0]);
227
+ i.checked_at = known.length ? known[0][0] : statSync(ventureFile(dir, i.path)).mtimeMs;
228
+ i.basis = known.length ? known[0][1] : "file time (not in the provenance log)";
229
+ i.changed_at = write ?? statSync(ventureFile(dir, i.path)).mtimeMs;
230
+ const latest = log.filter((x) => x.entity === i.path && x.derived_from?.length && CONTENT.has(x.activity)).at(-1);
231
+ i.depends_on = normalizeDeps(latest?.derived_from, byRoute, log).filter((d) => d !== i.path && (byPath.has(d) || existsSync(ventureFile(dir, d))));
232
+ i.profile = profileFor(P, i.record, i.route);
233
+ i.reasons = [];
234
+ }
235
+
236
+ // Where-used: a live child repeats its parent's value and depends on it.
237
+ const uses = await whereUsed(dir, byRoute, byPath, unchecked);
238
+ const copies = [];
239
+ for (const u of uses) {
240
+ if (u.record) {
241
+ const c = byPath.get(u.child);
242
+ if (!c.depends_on.includes(u.parent)) c.depends_on.push(u.parent);
243
+ if (u.drift) c.reasons.push({ kind: "drift", dependency: u.parent, since: iso(changedAt(u.parent)), note: `does not show ${u.parent}'s value for ${u.fact}; deliver by ${u.deliver}` });
244
+ } else copies.push({ fact: u.fact, path: u.child, parent: u.parent, state: u.missing ? "missing" : u.drift ? "stale" : "fresh", deliver: u.deliver,
245
+ ...(u.drift ? { note: u.missing ? `${u.child} does not exist` : `does not show ${u.parent}'s value for ${u.fact}; deliver by ${u.deliver}` } : {}) });
246
+ }
247
+
248
+ // Decisions made before their parent last changed are stale (EE-057).
249
+ if (!light) {
250
+ try {
251
+ const ds = decisionMatrix(dir, t).decisions;
252
+ for (const d of ds.filter((x) => x.render === "stale" && x.file)) {
253
+ const i = byPath.get(d.file);
254
+ const up = ds.find((x) => x.id === d.parent);
255
+ const since = up?.provenance?.approved_at ?? up?.decided_at;
256
+ if (i) i.reasons.push({ kind: "decision", dependency: d.parent, since: since ? iso(Date.parse(since)) : iso(t), note: `decision ${d.parent} above it changed after it was made` });
257
+ }
258
+ } catch (e) { warnings.push(`decision matrix: ${String(e.message ?? e).split("\n")[0]}`); }
259
+ }
260
+
261
+ for (const i of items) {
262
+ const p = P.profiles[i.profile];
263
+ const rev = seconds(p.revalidate), exp = seconds(p.expire), age = (t - i.checked_at) / 1000;
264
+ i.hole = !!p.live;
265
+ if (p.per_phase && change && change.at > i.checked_at) i.reasons.push({ kind: "phase", since: iso(change.at), note: `the venture moved from ${change.from} to ${change.to} after it was last checked; records with the ${i.profile} profile are tested again each phase` });
266
+ if (rev !== null && age >= rev) i.reasons.push({ kind: "age", since: iso(i.checked_at + rev * 1000), note: `last checked ${human(Math.floor(age / 86400) * 86400) || "0d"} ago; ${i.profile} records are stale after ${human(rev)}` });
267
+ if (exp !== null && age >= exp) i.reasons.push({ kind: "expired", since: iso(i.checked_at + exp * 1000), note: `${i.profile} records expire after ${human(exp)}; check or redo it before relying on it` });
268
+ for (const e of evs.filter((x) => x.event === "revalidate" && x.target && Date.parse(x.at) > i.checked_at && covers(x.target, i, dir)))
269
+ i.reasons.push({ kind: e.target.kind, since: e.at, ...(e.pivot ? { pivot: true } : {}), note: `${e.target.kind}:${e.target.value} was revalidated${e.reason ? `: ${e.reason}` : ""}` });
270
+ for (const d of i.depends_on) {
271
+ const c = changedAt(d);
272
+ if (c !== null && c > i.checked_at && !i.reasons.some((r) => r.kind === "drift" && r.dependency === d)) i.reasons.push({ kind: "changed", dependency: d, since: iso(c), note: `${d}, which it was derived from, changed after it was last checked` });
273
+ }
274
+ i.revalidate_at = rev === null ? null : iso(i.checked_at + rev * 1000);
275
+ i.expire_at = exp === null ? null : iso(i.checked_at + exp * 1000);
276
+ i.reuse_for = seconds(p.stale);
277
+ }
278
+
279
+ // On-demand staleness flows to what depends on it, transitively; age and phase do not, since a
280
+ // record built on aged evidence is not itself wrong until the evidence is redone.
281
+ const onDemand = (i) => i.reasons.filter((r) => !["age", "expired", "phase"].includes(r.kind));
282
+ for (let pass = 0, moved = true; moved && pass <= items.length; pass++) {
283
+ moved = false;
284
+ for (const i of items) for (const d of i.depends_on) {
285
+ const up = byPath.get(d);
286
+ if (!up) continue;
287
+ const since = onDemand(up).map((r) => Date.parse(r.since)).filter((x) => x > i.checked_at).sort((a, b) => a - b)[0];
288
+ if (since !== undefined && !i.reasons.some((r) => r.kind === "depends" && r.dependency === d)) {
289
+ i.reasons.push({ kind: "depends", dependency: d, since: iso(since), note: `${d}, which it depends on, is stale` });
290
+ moved = true;
291
+ }
292
+ }
293
+ }
294
+
295
+ for (const i of items) {
296
+ i.reasons.sort((a, b) => ORDER.indexOf(a.kind) - ORDER.indexOf(b.kind));
297
+ i.state = i.reasons.some((r) => r.kind === "expired") ? "expired" : i.reasons.length ? "stale" : "fresh";
298
+ i.precise = i.reasons.some((r) => EXACT.has(r.kind));
299
+ i.checked_at = iso(i.checked_at); i.changed_at = iso(i.changed_at);
300
+ i.fix = i.state === "fresh" ? null : `redo it (a write), or check it is still right: mentura verify ${i.route} --venture ${venture.id}`;
301
+ }
302
+ const holes = items.filter((i) => i.hole);
303
+ return {
304
+ venture: venture.id, phase: venture.phase, now: iso(t),
305
+ phase_changed: change ? { from: change.from, to: change.to, at: iso(change.at), basis: change.basis } : null,
306
+ summary: { records: items.length, fresh: items.filter((i) => i.state === "fresh").length, stale: items.filter((i) => i.state === "stale").length, expired: items.filter((i) => i.state === "expired").length, holes: holes.length,
307
+ stale_by_dependency: items.filter((i) => i.state !== "fresh" && i.precise).length, stale_by_folder_only: items.filter((i) => i.state !== "fresh" && !i.precise && i.reasons.some((r) => r.kind === "path" || r.kind === "package")).length },
308
+ shell: { frozen: items.filter((i) => !i.hole && i.state !== "expired").map((i) => i.path), holes: holes.map((i) => i.path), blocked: items.filter((i) => !i.hole && i.state === "expired").map((i) => i.path) },
309
+ items: items.map(({ layer, ...i }) => i),
310
+ copies, unchecked, warnings,
311
+ };
312
+ }
313
+
314
+ /** For the router's read (V-5): the freshness flag of the files a read returned, when any is stale, expired or a live hole. */
315
+ export async function readFlag(dir, paths, list = false) {
316
+ if (!dir || !paths?.length) return {};
317
+ let f;
318
+ try { f = await freshness(dir, { light: true }); } catch (e) { return { freshness: { error: String(e.message ?? e) } }; }
319
+ const flag = (i) => ({ state: i.state, hole: i.hole, profile: i.profile, checked_at: i.checked_at, revalidate_at: i.revalidate_at, expire_at: i.expire_at, reasons: i.reasons, ...(i.fix ? { fix: i.fix } : {}) });
320
+ const hits = f.items.filter((i) => paths.includes(i.path) && (i.state !== "fresh" || i.hole));
321
+ if (!hits.length) return {};
322
+ return !list && paths.length === 1 ? { freshness: flag(hits[0]) } : { freshness: Object.fromEntries(hits.map((i) => [i.path, flag(i)])) };
323
+ }
324
+
325
+ // A plain target: a record file or route is a tag; a folder is refused in favour of tags (V-8).
326
+ function parseTarget(dir, target, items) {
327
+ const m = String(target).match(/^(tag|path|package):(.+)$/);
328
+ const kind = m?.[1], value = (m?.[2] ?? String(target)).replace(/^\.\//, "").replace(/\/$/, "");
329
+ if (/^\/|(^|\/)\.\.(\/|$)/.test(value)) return { error: `${value} is outside the venture` };
330
+ const byRoute = new Map(items.map((i) => [i.route, i.path]));
331
+ const asRecord = () => {
332
+ if (items.some((i) => i.path === value)) return value;
333
+ if (byRoute.has(value)) return byRoute.get(value);
334
+ const { r, params } = resolveRoute(value);
335
+ if (r && r.kind === "record") { const route = r.collection === "item" ? r.route.replace("[id]", params.id) : r.route; return byRoute.get(route) ?? null; }
336
+ return null;
337
+ };
338
+ if (kind === "package") {
339
+ const pkgs = new Set(routes().map((r) => r.package).filter(Boolean));
340
+ if (!pkgs.has(value)) return { error: `no package ${value} owns record types (${[...pkgs].sort().join(", ")})` };
341
+ return { kind, value };
342
+ }
343
+ if (kind === "path") return { kind, value };
344
+ const file = asRecord();
345
+ if (file) return { kind: "tag", value: file };
346
+ if (kind === "tag") return { error: `${value} is not a record in this venture (a record route, such as validation/pricing, or its file)` };
347
+ const under = items.filter((i) => covers({ kind: "path", value }, i));
348
+ if (under.length) return { error: `${value} is a folder of ${count(under.length, "record")}. Prefer tags, which are exact: revalidate the record that changed, and what depends on it follows`, suggest: under.map((i) => `tag:${i.route}`), or: `path:${value}` };
349
+ return { error: `${value} is not a record, folder or package in this venture${resolveRoute(value).r ? " (no record at that route yet)" : ""}` };
350
+ }
351
+
352
+ function append(dir, event, by) {
353
+ const line = { at: event.at, event: event.event, ...(event.target ? { target: event.target } : {}), ...(event.from ? { from: event.from, to: event.to } : {}), ...(event.reason ? { reason: event.reason } : {}), ...(event.pivot ? { pivot: true } : {}), by };
354
+ const scratch = mkdtempSync(join(tmpdir(), "freshness-"));
355
+ writeFileSync(join(scratch, "line.json"), JSON.stringify(line));
356
+ const v = spawnSync("python3", [join(repo, "schema/validate.py"), "--schema", "freshness-log.schema.json", "--file", join(scratch, "line.json")], { encoding: "utf8" });
357
+ rmSync(scratch, { recursive: true, force: true });
358
+ if (v.status !== 0) throw new Error(`does not match schema/freshness-log.schema.json:\n${(v.stdout + v.stderr).trim()}`);
359
+ mkdirSync(join(dir, dirname(LOG)), { recursive: true });
360
+ appendFileSync(ventureFile(dir, LOG), JSON.stringify(line) + "\n");
361
+ const what = event.target ? `${event.target.kind}:${event.target.value}` : `${event.from} -> ${event.to}`;
362
+ const entry = prov(dir, { entity: LOG, route: "knowledge/freshness-log", activity: "write", agent: by, note: `${event.event} ${what}${event.reason ? `: ${event.reason}` : ""}` });
363
+ return { line, provenance: entry.hash };
364
+ }
365
+
366
+ const byDefault = (by) => by ?? agentFromEnv(process.env.MENTURA_ACTOR === "person" ? "person" : "agent", "router");
367
+
368
+ /**
369
+ * Marks records stale on demand (revalidateTag, revalidatePath). target: tag:<route or file> (the
370
+ * record and what depends on it), path:<folder>, package:<name>, pivot (the validation package), or
371
+ * a plain route or file (a tag). A plain folder is refused with the exact tags to use instead.
372
+ * Returns { ok, result: { target, records, dependents, provenance } } or { ok: false, error, suggest }.
373
+ */
374
+ export async function revalidate(dir, target, { reason, by, now } = {}) {
375
+ const pivot = target === "pivot";
376
+ const items = recordFiles(dir);
377
+ const t = pivot ? { kind: "package", value: "validation" } : parseTarget(dir, target, items);
378
+ if (t.error) return { ok: false, error: t.error, ...(t.suggest ? { suggest: t.suggest, or: t.or } : {}) };
379
+ const direct = items.filter((i) => covers(t, i));
380
+ if (!direct.length) return { ok: false, error: `${t.kind}:${t.value} covers no record in this venture, so nothing would be marked` };
381
+ const stamp = iso(Math.max(nowOf(now), ...events(dir).map((e) => Date.parse(e.at) + 1)));
382
+ const w = append(dir, { at: stamp, event: "revalidate", target: t, reason: reason ?? (pivot ? "the venture pivoted: every assumption is tested again" : undefined), pivot }, byDefault(by));
383
+ const f = await freshness(dir, { now: Math.max(Date.parse(stamp), nowOf(now)) });
384
+ const records = direct.map((i) => i.path);
385
+ const dependents = f.items.filter((i) => !records.includes(i.path) && i.reasons.some((r) => r.kind === "depends" && Date.parse(r.since) >= Date.parse(stamp))).map((i) => i.path);
386
+ return { ok: true, result: { target: `${t.kind}:${t.value}`, at: stamp, ...(pivot ? { pivot: true } : {}), records, dependents, provenance: w.provenance,
387
+ note: t.kind === "tag" ? (dependents.length ? `${records[0]} and ${count(dependents.length, "record")} that depend on it are stale` : `${records[0]} is stale; nothing recorded depends on it (pass --derived-from on writes so a tag finds what uses it)`) : `${count(records.length, "record")} under ${t.kind}:${t.value} ${records.length === 1 ? "is" : "are"} stale${dependents.length ? `, and ${count(dependents.length, "record")} that depend on ${records.length === 1 ? "it" : "them"}` : ""}` } };
388
+ }
389
+
390
+ /** Records that a person or agent checked a record (or a folder of them) is still right, unchanged: its age starts again. */
391
+ export function verify(dir, target, { reason, by, now } = {}) {
392
+ const items = recordFiles(dir);
393
+ const t = parseTarget(dir, target, items);
394
+ if (t.error && !t.or) return { ok: false, error: t.error };
395
+ const tt = t.error ? { kind: "path", value: t.or.slice(5) } : t;
396
+ const covered = items.filter((i) => covers(tt, i));
397
+ if (!covered.length) return { ok: false, error: `${tt.kind}:${tt.value} covers no record in this venture` };
398
+ const stamp = iso(Math.max(nowOf(now), ...events(dir).map((e) => Date.parse(e.at) + 1)));
399
+ const w = append(dir, { at: stamp, event: "verify", target: tt, reason }, byDefault(by));
400
+ return { ok: true, result: { verified: `${tt.kind}:${tt.value}`, at: stamp, records: covered.map((i) => i.path), provenance: w.provenance, note: `${covered.length === 1 ? covered[0].path : count(covered.length, "record")} checked and still right; fresh again from ${stamp}` } };
401
+ }
402
+
403
+ /** Moves the venture to another phase and records when, so per-phase records go stale (V-2). */
404
+ export function phase(dir, to, { reason, by, now } = {}) {
405
+ if (!PHASES.includes(to)) return { ok: false, error: `${to} is not a phase (${PHASES.join(", ")})` };
406
+ const file = join(dir, "venture.json");
407
+ const v = load(file);
408
+ if (v.phase === to) return { ok: false, error: `${v.id} is already in ${to}` };
409
+ const from = v.phase;
410
+ // Leaving Discovery needs the core product, as schema/init-venture.mjs scaffolds it for a later phase.
411
+ const next = { ...v, phase: to, ...(to !== "discovery" && !v.core ? { core: { promise: "", promised_to: "", must_not: "", competence: "" } } : {}) };
412
+ const scratch = mkdtempSync(join(tmpdir(), "freshness-"));
413
+ writeFileSync(join(scratch, "venture.json"), JSON.stringify(next));
414
+ const ok = spawnSync("python3", [join(repo, "schema/validate.py"), "--schema", "venture.schema.json", "--file", join(scratch, "venture.json")], { encoding: "utf8" });
415
+ rmSync(scratch, { recursive: true, force: true });
416
+ if (ok.status !== 0) return { ok: false, error: `venture.json would not match its schema:\n${(ok.stdout + ok.stderr).trim()}` };
417
+ const agent = by ?? agentFromEnv("person", "router");
418
+ writeFileSync(file, JSON.stringify(next, null, 2) + "\n");
419
+ prov(dir, { entity: "venture.json", activity: "write", agent, note: `phase ${from} -> ${to}${reason ? `: ${reason}` : ""}` });
420
+ const stamp = iso(Math.max(nowOf(now), ...events(dir).map((e) => Date.parse(e.at) + 1)));
421
+ const w = append(dir, { at: stamp, event: "phase", from, to, reason }, agent);
422
+ const P = profiles(dir);
423
+ const due = recordFiles(dir).filter((i) => P.profiles[profileFor(P, i.record, i.route)].per_phase).map((i) => i.path);
424
+ return { ok: true, result: { venture: v.id, from, to, at: stamp, stale: due, provenance: w.provenance, note: `${count(due.length, "record")} with a per-phase profile ${due.length === 1 ? "is" : "are"} stale until checked or redone in ${to}${next.core && !v.core ? "; fill in the core product in venture.json" : ""}` } };
425
+ }
426
+
427
+ /**
428
+ * Freezes the venture into a static shell at out (V-3, with C-11): every record that is not a live
429
+ * hole and has not expired is copied in; live records stay holes, named in shell.json with the
430
+ * route that reads them. Refuses when a record has expired (it must be checked first) or when out
431
+ * is a routed folder inside the venture.
432
+ */
433
+ export async function freeze(dir, out, { now } = {}) {
434
+ const f = await freshness(dir, { now });
435
+ if (f.shell.blocked.length) return { ok: false, error: `cannot freeze: ${count(f.shell.blocked.length, "record")} ${f.shell.blocked.length === 1 ? "has" : "have"} expired (${f.shell.blocked.join(", ")}); check or redo them first`, blocked: f.shell.blocked };
436
+ const target = resolvePath(out);
437
+ const rel = relative(dir, target);
438
+ if (!rel.startsWith("..") && !isAbsolute(rel) && !rel.split("/")[0].startsWith(".")) return { ok: false, error: `${out} is inside the venture: freeze into a folder outside it, or under a dot folder such as .release/` };
439
+ const byPath = new Map(f.items.map((i) => [i.path, i]));
440
+ mkdirSync(target, { recursive: true });
441
+ for (const p of f.shell.frozen) { mkdirSync(dirname(join(target, p)), { recursive: true }); copyFileSync(ventureFile(dir, p), join(target, p)); }
442
+ const manifest = {
443
+ venture: f.venture, phase: f.phase, frozen_at: f.now,
444
+ frozen: f.shell.frozen.map((p) => { const i = byPath.get(p); return { path: p, route: i.route, record: i.record, state: i.state, sha256: createHash("sha256").update(readFileSync(ventureFile(dir, p))).digest("hex"), ...(i.state === "stale" ? { reasons: i.reasons.map((r) => r.note) } : {}) }; }),
445
+ holes: f.shell.holes.map((p) => { const i = byPath.get(p); return { path: p, route: i.route, record: i.record, profile: i.profile, read: `mentura ${i.route} read --venture ${f.venture}` }; }),
446
+ };
447
+ writeFileSync(join(target, "shell.json"), JSON.stringify(manifest, null, 2) + "\n");
448
+ return { ok: true, result: { out: target, frozen: manifest.frozen.length, stale: manifest.frozen.filter((x) => x.state === "stale").length, holes: manifest.holes.map((h) => h.path), note: `froze ${count(manifest.frozen.length, "record")} into ${target}/shell.json${manifest.holes.length ? `; ${count(manifest.holes.length, "live hole")} ${manifest.holes.length === 1 ? "stays" : "stay"} read from their source: ${manifest.holes.map((h) => h.route).join(", ")}` : ""}` } };
449
+ }
450
+
451
+ function text(f) {
452
+ const lines = [`${f.venture} (${f.phase}) at ${f.now}: ${f.summary.fresh} fresh, ${f.summary.stale} stale, ${f.summary.expired} expired, ${count(f.summary.holes, "live hole")}, of ${count(f.summary.records, "record")}.`];
453
+ for (const i of f.items.filter((x) => x.state !== "fresh")) lines.push(` ${i.state.padEnd(7)} ${i.route} (${i.profile})${i.hole ? " [hole]" : ""}: ${i.reasons.map((r) => r.note).join("; ")}`);
454
+ for (const c of f.copies.filter((x) => x.state !== "fresh")) lines.push(` ${c.state.padEnd(7)} ${c.path} (copy of ${c.parent}): ${c.note}`);
455
+ for (const w of f.warnings) lines.push(`warning: ${w}`);
456
+ return lines.join("\n");
457
+ }
458
+
459
+ if (import.meta.url === `file://${process.argv[1]}`) {
460
+ const argv = process.argv.slice(2);
461
+ const opt = (n) => { const i = argv.indexOf(`--${n}`); return i >= 0 ? argv[i + 1] : undefined; };
462
+ const json = argv.includes("--json");
463
+ const dir = argv[0] && !argv[0].startsWith("--") ? resolvePath(argv[0]) : null;
464
+ if (!dir || !existsSync(join(dir, "venture.json"))) { console.error("usage: node schema/freshness.mjs <venture dir> [--json] [--now <iso>] | --revalidate <target> [--reason <text>] | --freeze <out dir>"); process.exit(2); }
465
+ try {
466
+ const now = opt("now");
467
+ let r;
468
+ if (opt("revalidate") !== undefined) r = await revalidate(dir, opt("revalidate"), { reason: opt("reason"), now });
469
+ else if (opt("freeze") !== undefined) r = await freeze(dir, opt("freeze"), { now });
470
+ else { const f = await freshness(dir, { now }); console.log(json ? JSON.stringify(f, null, 2) : text(f)); process.exit(0); }
471
+ if (!r.ok) { console.error(r.error + (r.suggest ? `\n ${r.suggest.join("\n ")}\n or, for the whole folder: ${r.or}` : "")); process.exit(1); }
472
+ console.log(json ? JSON.stringify(r.result, null, 2) : r.result.note ?? JSON.stringify(r.result, null, 2));
473
+ } catch (e) { console.error(String(e.message ?? e)); process.exit(1); }
474
+ }
@@ -0,0 +1,107 @@
1
+ {
2
+ "$schema": "https://json-schema.org/draft/2020-12/schema",
3
+ "$id": "fund-terms.schema.json",
4
+ "title": "Fund terms",
5
+ "description": "The fund's economics and structure (fund/terms.json): target size, vintage, fees, carry and term, and the closes so far.",
6
+ "type": "object",
7
+ "required": [
8
+ "venture",
9
+ "target_size",
10
+ "vintage",
11
+ "management_fee",
12
+ "carry",
13
+ "term_years"
14
+ ],
15
+ "additionalProperties": false,
16
+ "properties": {
17
+ "venture": {
18
+ "type": "string"
19
+ },
20
+ "target_size": {
21
+ "type": "object",
22
+ "required": [
23
+ "amount",
24
+ "currency"
25
+ ],
26
+ "additionalProperties": false,
27
+ "properties": {
28
+ "amount": {
29
+ "type": "number",
30
+ "minimum": 0
31
+ },
32
+ "currency": {
33
+ "type": "string",
34
+ "pattern": "^[A-Z]{3}$"
35
+ }
36
+ }
37
+ },
38
+ "vintage": {
39
+ "type": "integer",
40
+ "minimum": 1900
41
+ },
42
+ "structure": {
43
+ "enum": [
44
+ "limited-partnership",
45
+ "llc",
46
+ "rolling-fund",
47
+ "syndicate",
48
+ "evergreen"
49
+ ]
50
+ },
51
+ "management_fee": {
52
+ "type": "number",
53
+ "minimum": 0,
54
+ "maximum": 1,
55
+ "description": "Annual fee as a fraction of commitments"
56
+ },
57
+ "carry": {
58
+ "type": "number",
59
+ "minimum": 0,
60
+ "maximum": 1
61
+ },
62
+ "term_years": {
63
+ "type": "integer",
64
+ "minimum": 1
65
+ },
66
+ "gp_commit": {
67
+ "type": "number",
68
+ "minimum": 0,
69
+ "maximum": 1
70
+ },
71
+ "closes": {
72
+ "type": "array",
73
+ "items": {
74
+ "type": "object",
75
+ "required": [
76
+ "date",
77
+ "committed"
78
+ ],
79
+ "additionalProperties": false,
80
+ "properties": {
81
+ "date": {
82
+ "type": "string",
83
+ "format": "date"
84
+ },
85
+ "committed": {
86
+ "type": "object",
87
+ "required": [
88
+ "amount",
89
+ "currency"
90
+ ],
91
+ "additionalProperties": false,
92
+ "properties": {
93
+ "amount": {
94
+ "type": "number",
95
+ "minimum": 0
96
+ },
97
+ "currency": {
98
+ "type": "string",
99
+ "pattern": "^[A-Z]{3}$"
100
+ }
101
+ }
102
+ }
103
+ }
104
+ }
105
+ }
106
+ }
107
+ }