@awebai/oats 0.29.4 → 0.30.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (263) hide show
  1. package/README.md +12 -6
  2. package/bin/oats.mjs +194 -50
  3. package/docs/capabilities.md +160 -171
  4. package/docs/capability-manifest.schema.json +6 -11
  5. package/docs/configuration.md +213 -64
  6. package/docs/design/2026-09-16-knowledge-capability-contract.md +36 -50
  7. package/docs/design/2026-09-23-workspace-module-contracts.md +377 -544
  8. package/docs/design/2026-09-26-okf-knowledge-operations.md +132 -357
  9. package/docs/design/2026-09-27-team-model-v2.md +97 -117
  10. package/docs/design/2026-09-28-automations-trust.md +38 -0
  11. package/docs/design/2026-09-28-soul-launch-preference.md +63 -0
  12. package/docs/design/HISTORY.md +65 -0
  13. package/docs/design/README.md +23 -54
  14. package/docs/desktop-cli-api.md +1787 -1777
  15. package/docs/desktop.md +30 -91
  16. package/docs/execution-targets.md +146 -292
  17. package/docs/first-team.md +31 -17
  18. package/docs/implementation.md +77 -288
  19. package/docs/integrations.md +118 -320
  20. package/docs/knowledge-capability-authoring.md +25 -52
  21. package/docs/knowledge-reference/acceptance.md +3 -3
  22. package/docs/knowledge-reference/adoption.md +1 -1
  23. package/docs/knowledge-reference/harvester.md +2 -2
  24. package/docs/knowledge-reference/package-craft.md +3 -3
  25. package/docs/knowledge-reference/provider-mapping.md +3 -6
  26. package/docs/knowledge-reference/reader-capture.md +3 -3
  27. package/docs/knowledge-theory.md +62 -166
  28. package/docs/knowledge.md +225 -404
  29. package/docs/layers.md +42 -97
  30. package/docs/oats-local.schema.json +58 -5
  31. package/docs/oats-membership.schema.json +1 -8
  32. package/docs/oats-package.schema.json +5 -5
  33. package/docs/oats-workspace.schema.json +8 -22
  34. package/docs/official-catalog.md +25 -28
  35. package/docs/packages.md +45 -63
  36. package/docs/plans/0.30-close-out.md +83 -0
  37. package/docs/release-lane.md +82 -0
  38. package/docs/release-notes/oats-framework-v1.1.3.md +10 -8
  39. package/docs/release-notes/v0.19.0.md +48 -147
  40. package/docs/release-notes/v0.19.1.md +2 -3
  41. package/docs/release-notes/v0.19.3.md +2 -15
  42. package/docs/release-notes/v0.20.0.md +0 -15
  43. package/docs/release-notes/v0.22.0.md +71 -138
  44. package/docs/release-notes/v0.22.1.md +42 -90
  45. package/docs/release-notes/v0.22.10.md +1 -1
  46. package/docs/release-notes/v0.22.11.md +1 -47
  47. package/docs/release-notes/v0.22.12.md +4 -13
  48. package/docs/release-notes/v0.22.13.md +1 -42
  49. package/docs/release-notes/v0.22.14.md +3 -11
  50. package/docs/release-notes/v0.22.15.md +1 -46
  51. package/docs/release-notes/v0.22.16.md +6 -8
  52. package/docs/release-notes/v0.22.18.md +1 -99
  53. package/docs/release-notes/v0.22.19.md +3 -14
  54. package/docs/release-notes/v0.22.2.md +6 -15
  55. package/docs/release-notes/v0.22.3.md +0 -1
  56. package/docs/release-notes/v0.22.4.md +1 -14
  57. package/docs/release-notes/v0.22.5.md +2 -12
  58. package/docs/release-notes/v0.22.6.md +0 -3
  59. package/docs/release-notes/v0.23.0.md +9 -25
  60. package/docs/release-notes/v0.23.1.md +9 -25
  61. package/docs/release-notes/v0.23.2.md +2 -4
  62. package/docs/release-notes/v0.24.0.md +56 -97
  63. package/docs/release-notes/v0.24.1.md +7 -11
  64. package/docs/release-notes/v0.24.10.md +34 -45
  65. package/docs/release-notes/v0.24.11.md +12 -20
  66. package/docs/release-notes/v0.24.12.md +35 -48
  67. package/docs/release-notes/v0.24.13.md +34 -41
  68. package/docs/release-notes/v0.24.2.md +9 -13
  69. package/docs/release-notes/v0.24.3.md +7 -11
  70. package/docs/release-notes/v0.24.4.md +6 -6
  71. package/docs/release-notes/v0.24.5.md +6 -10
  72. package/docs/release-notes/v0.24.6.md +2 -5
  73. package/docs/release-notes/v0.24.7.md +46 -75
  74. package/docs/release-notes/v0.24.8.md +58 -96
  75. package/docs/release-notes/v0.24.9.md +38 -54
  76. package/docs/release-notes/v0.25.0.md +59 -76
  77. package/docs/release-notes/v0.25.1.md +57 -81
  78. package/docs/release-notes/v0.25.2.md +51 -70
  79. package/docs/release-notes/v0.25.3.md +11 -13
  80. package/docs/release-notes/v0.25.4.md +9 -13
  81. package/docs/release-notes/v0.25.5.md +3 -5
  82. package/docs/release-notes/v0.25.6.md +20 -29
  83. package/docs/release-notes/v0.25.7.md +5 -7
  84. package/docs/release-notes/v0.25.8.md +26 -39
  85. package/docs/release-notes/v0.26.0.md +175 -646
  86. package/docs/release-notes/v0.27.0.md +4 -5
  87. package/docs/release-notes/v0.27.1.md +4 -6
  88. package/docs/release-notes/v0.27.2.md +1 -1
  89. package/docs/release-notes/v0.28.0.md +57 -124
  90. package/docs/release-notes/v0.29.0.md +89 -208
  91. package/docs/release-notes/v0.29.1.md +1 -1
  92. package/docs/release-notes/v0.29.2.md +3 -4
  93. package/docs/release-notes/v0.30.0.md +205 -0
  94. package/docs/release-notes/v0.30.1.md +123 -0
  95. package/docs/schedules.md +280 -363
  96. package/docs/servers.md +99 -117
  97. package/docs/soul.schema.json +2 -9
  98. package/docs/souls-and-instances.md +145 -158
  99. package/docs/workspaces.md +137 -215
  100. package/lib/automations.mjs +21 -6
  101. package/lib/core.mjs +226 -74
  102. package/lib/instance-events.mjs +1 -1
  103. package/lib/instance-inspect.mjs +109 -34
  104. package/lib/instance-lifecycle.mjs +14 -1
  105. package/lib/instance-resolution.mjs +26 -27
  106. package/lib/launch-preference.mjs +87 -0
  107. package/lib/materialize.mjs +3 -3
  108. package/lib/packages.mjs +1 -1
  109. package/lib/resolve.mjs +30 -88
  110. package/lib/schedule.mjs +1 -1
  111. package/lib/teams-verbs.mjs +195 -0
  112. package/lib/teams.mjs +190 -0
  113. package/lib/triggers.mjs +2 -2
  114. package/lib/workspace.mjs +54 -147
  115. package/package-catalog.json +10 -16
  116. package/package.json +1 -3
  117. package/skills/oats-getting-started/SKILL.md +25 -13
  118. package/capabilities/oats-authoring/LICENSE +0 -21
  119. package/capabilities/oats-authoring/oats-package.json +0 -11
  120. package/capabilities/oats-authoring/oats.json +0 -12
  121. package/capabilities/oats-authoring/skills/integration-authoring/SKILL.md +0 -84
  122. package/capabilities/oats-authoring/skills/skill-craft/SKILL.md +0 -109
  123. package/capabilities/oats-authoring/skills/soul-craft/SKILL.md +0 -116
  124. package/capabilities/oats-aweb/bin/oats-aweb-binding.mjs +0 -11
  125. package/capabilities/oats-aweb/bin/oats-aweb.mjs +0 -1338
  126. package/capabilities/oats-aweb/injects/aweb.md +0 -47
  127. package/capabilities/oats-aweb/lib/binding-wire.mjs +0 -356
  128. package/capabilities/oats-aweb/lib/captured-execution.mjs +0 -91
  129. package/capabilities/oats-aweb/lib/captured-native.mjs +0 -91
  130. package/capabilities/oats-aweb/lib/grant-custody.mjs +0 -38
  131. package/capabilities/oats-aweb/lib/invocation-shape.mjs +0 -135
  132. package/capabilities/oats-aweb/lib/portable-binding.mjs +0 -146
  133. package/capabilities/oats-aweb/lib/session-readiness.mjs +0 -56
  134. package/capabilities/oats-aweb/lib/wake-receive.mjs +0 -56
  135. package/capabilities/oats-aweb/oats.json +0 -208
  136. package/capabilities/oats-aweb/skills/LICENSE +0 -21
  137. package/capabilities/oats-aweb/skills/VENDORED.md +0 -31
  138. package/capabilities/oats-aweb/skills/aweb-identity/SKILL.md +0 -201
  139. package/capabilities/oats-aweb/skills/aweb-messaging/SKILL.md +0 -161
  140. package/capabilities/oats-aweb/skills/aweb-messaging/references/messaging-scenarios.md +0 -61
  141. package/capabilities/oats-aweb/skills/aweb-team-membership/SKILL.md +0 -116
  142. package/capabilities/oats-aweb/skills/aweb-team-membership/references/team-membership-reference.md +0 -74
  143. package/capabilities/oats-aweb/skills/oats-aweb/SKILL.md +0 -216
  144. package/capabilities/oats-jira/bin/oats-jira.mjs +0 -40
  145. package/capabilities/oats-jira/injects/jira.md +0 -10
  146. package/capabilities/oats-jira/oats.json +0 -22
  147. package/capabilities/oats-jira/skills/jira-tasks/SKILL.md +0 -179
  148. package/capabilities/oats-linear/bin/oats-linear-hook.mjs +0 -34
  149. package/capabilities/oats-linear/bin/oats-linear.mjs +0 -344
  150. package/capabilities/oats-linear/injects/linear.md +0 -8
  151. package/capabilities/oats-linear/oats.json +0 -24
  152. package/capabilities/oats-linear/skills/linear-tasks/SKILL.md +0 -223
  153. package/capabilities/oats-okf/bin/oats-okf-binding.mjs +0 -14
  154. package/capabilities/oats-okf/bin/oats-okf.mjs +0 -209
  155. package/capabilities/oats-okf/injects/okf.md +0 -42
  156. package/capabilities/oats-okf/lib/binding-wire.mjs +0 -348
  157. package/capabilities/oats-okf/lib/captured-worker.mjs +0 -109
  158. package/capabilities/oats-okf/lib/config.mjs +0 -124
  159. package/capabilities/oats-okf/lib/consult.mjs +0 -518
  160. package/capabilities/oats-okf/lib/harvest-status.mjs +0 -88
  161. package/capabilities/oats-okf/lib/harvest-switch.mjs +0 -94
  162. package/capabilities/oats-okf/lib/inspection.mjs +0 -119
  163. package/capabilities/oats-okf/lib/invocation-context.mjs +0 -111
  164. package/capabilities/oats-okf/lib/invocation-shape.mjs +0 -135
  165. package/capabilities/oats-okf/lib/io.mjs +0 -118
  166. package/capabilities/oats-okf/lib/migration.mjs +0 -137
  167. package/capabilities/oats-okf/lib/okf-validate.mjs +0 -123
  168. package/capabilities/oats-okf/lib/portable-binding.mjs +0 -199
  169. package/capabilities/oats-okf/lib/source-contract.mjs +0 -46
  170. package/capabilities/oats-okf/lib/sources.mjs +0 -424
  171. package/capabilities/oats-okf/lib/stores.mjs +0 -473
  172. package/capabilities/oats-okf/lib/worker.mjs +0 -497
  173. package/capabilities/oats-okf/oats.json +0 -148
  174. package/capabilities/oats-okf/schemas/okf-base.schema.json +0 -46
  175. package/capabilities/oats-okf/schemas/okf-bindings.schema.json +0 -112
  176. package/capabilities/oats-okf/schemas/okf-portable-declaration.schema.json +0 -87
  177. package/capabilities/oats-okf/schemas/okf-portable-payload.schema.json +0 -113
  178. package/capabilities/oats-okf/schemas/okf-soul.schema.json +0 -37
  179. package/capabilities/oats-okf/skills/okf-consultation/SKILL.md +0 -144
  180. package/capabilities/oats-okf/skills/okf-consultation/references/consult.md +0 -86
  181. package/capabilities/oats-okf/skills/okf-instance-knowledge/SKILL.md +0 -104
  182. package/capabilities/oats-okf-harvest/bin/okf-harvest.mjs +0 -140
  183. package/capabilities/oats-okf-harvest/injects/harvester.md +0 -12
  184. package/capabilities/oats-okf-harvest/oats.json +0 -26
  185. package/capabilities/oats-okf-harvest/skills/knowledge-harvest/SKILL.md +0 -168
  186. package/capabilities/oats-okf-harvest/skills/knowledge-theory/SKILL.md +0 -192
  187. package/capabilities/oats-okf-harvest/skills/okf-authoring/SKILL.md +0 -151
  188. package/capabilities/oats-okf-harvest/skills/okf-authoring/scripts/okf-validate.mjs +0 -123
  189. package/capabilities/oats-okf-maintenance/bin/okf-maintenance.mjs +0 -170
  190. package/capabilities/oats-okf-maintenance/injects/maintainer.md +0 -12
  191. package/capabilities/oats-okf-maintenance/lib/provenance.mjs +0 -50
  192. package/capabilities/oats-okf-maintenance/oats.json +0 -21
  193. package/capabilities/oats-okf-maintenance/skills/knowledge-review/SKILL.md +0 -159
  194. package/capabilities/oats-okf-maintenance/skills/knowledge-theory/SKILL.md +0 -192
  195. package/capabilities/oats-okf-maintenance/skills/okf-authoring/SKILL.md +0 -151
  196. package/capabilities/oats-okf-maintenance/skills/okf-authoring/scripts/okf-validate.mjs +0 -123
  197. package/capabilities/oats-okf-maintenance/skills/okf-trigger-setup/SKILL.md +0 -146
  198. package/capabilities/oats-review/injects/review.md +0 -69
  199. package/capabilities/oats-review/oats.json +0 -10
  200. package/capabilities/oats-review/skills/code-review/SKILL.md +0 -44
  201. package/capabilities/oats-review/skills/security-review/SKILL.md +0 -59
  202. package/docs/conventions.md +0 -90
  203. package/docs/design/2026-09-07-architecture-reassessment.md +0 -131
  204. package/docs/design/2026-09-07-desktop-souls-capabilities.md +0 -50
  205. package/docs/design/2026-09-07-mobile-agent-management-proposal.md +0 -228
  206. package/docs/design/2026-09-08-expert-assisted-deployment-proposal.md +0 -558
  207. package/docs/design/2026-09-13-knowledge-and-memory-direction.md +0 -744
  208. package/docs/design/2026-09-13-knowledge-implementation.md +0 -127
  209. package/docs/design/2026-09-13-knowledge-location-contract.md +0 -340
  210. package/docs/design/2026-09-14-artifact-retention-contract.md +0 -190
  211. package/docs/design/2026-09-14-portable-souls-and-git-workspaces.md +0 -708
  212. package/docs/design/2026-09-14-portable-souls-contract-amendments.md +0 -85
  213. package/docs/design/2026-09-14-portable-souls-explainer.md +0 -750
  214. package/docs/design/2026-09-15-captured-dispatch.md +0 -127
  215. package/docs/design/2026-09-15-captured-resolution-records.md +0 -143
  216. package/docs/design/2026-09-15-package-preparation.md +0 -100
  217. package/docs/design/2026-09-15-portable-data-contract.md +0 -121
  218. package/docs/design/2026-09-15-portable-declarations.md +0 -189
  219. package/docs/design/2026-09-15-portable-souls-handoff.md +0 -150
  220. package/docs/design/2026-09-15-portable-souls-implementation.md +0 -417
  221. package/docs/design/2026-09-15-selection-lock-and-approval.md +0 -122
  222. package/docs/design/2026-09-15-source-observation.md +0 -119
  223. package/docs/design/2026-09-16-captured-admission.md +0 -77
  224. package/docs/design/2026-09-16-captured-helper-dispatch.md +0 -105
  225. package/docs/design/2026-09-16-captured-launch-inputs.md +0 -42
  226. package/docs/design/2026-09-16-command-profile-preparation.md +0 -86
  227. package/docs/design/2026-09-16-fresh-install-first-rollout.md +0 -47
  228. package/docs/design/2026-09-16-fresh-operator-walkthrough.md +0 -282
  229. package/docs/design/2026-09-16-messaging-capability-contract.md +0 -59
  230. package/docs/design/2026-09-16-portable-migration-evidence.md +0 -158
  231. package/docs/design/2026-09-16-portable-onboarding.md +0 -179
  232. package/docs/design/2026-09-16-prepare-request-transport.md +0 -26
  233. package/docs/design/2026-09-16-provider-binding-codecs.md +0 -98
  234. package/docs/design/2026-09-16-provider-binding-wire.md +0 -274
  235. package/docs/design/2026-09-17-capability-helper-input-contract.md +0 -95
  236. package/docs/design/2026-09-17-captured-backend-parity.md +0 -53
  237. package/docs/design/2026-09-17-captured-native-start.md +0 -58
  238. package/docs/design/2026-09-17-portable-boundary-hookup.md +0 -19
  239. package/docs/design/2026-09-17-portable-boundary-resources.md +0 -52
  240. package/docs/design/2026-09-17-public-captured-start.md +0 -108
  241. package/docs/design/2026-09-17-public-prepare-request.md +0 -90
  242. package/docs/design/2026-09-18-captured-pi-host.md +0 -205
  243. package/docs/design/2026-09-18-first-cut-release-checklist.md +0 -131
  244. package/docs/design/2026-09-18-herdr-protocol-compatibility.md +0 -60
  245. package/docs/design/2026-09-20-redesign-program-board.md +0 -142
  246. package/docs/design/2026-09-20-workspace-and-portable-adoption-plan.md +0 -289
  247. package/docs/design/2026-09-20-workspace-onboarding-public.md +0 -207
  248. package/docs/design/2026-09-22-desktop-parity-seams.md +0 -58
  249. package/docs/design/2026-09-23-simplified-workspace-model.md +0 -711
  250. package/docs/design/2026-09-23-workspace-v2-implementation-plan.md +0 -65
  251. package/docs/design/2026-09-24-desktop-phase-f-boundary.md +0 -242
  252. package/docs/design/2026-09-24-phase-d-plan.md +0 -305
  253. package/docs/design/2026-09-25-teams-contract.md +0 -258
  254. package/docs/design/2026-09-26-desktop-design-brief-architecture.md +0 -241
  255. package/docs/design/desktop-ux-plan.md +0 -362
  256. package/docs/design/launch-configurations.md +0 -168
  257. package/docs/design/okf-mirror-provenance.md +0 -105
  258. package/docs/design/operations-contract.md +0 -141
  259. package/docs/oats-member.schema.json +0 -38
  260. package/skills/integration-authoring/SKILL.md +0 -84
  261. package/skills/oats-support/SKILL.md +0 -79
  262. package/skills/skill-craft/SKILL.md +0 -109
  263. package/skills/soul-craft/SKILL.md +0 -116
@@ -1,518 +0,0 @@
1
- // okf 3.0.0 consult: souls read their knowledge REMOTELY, at the accepted
2
- // state, with no per-instance copy. The only local bytes are one host-wide bare
3
- // partial clone per Git base (blobs arrive on first read) under the bindings'
4
- // stateDir; directory bases are read in place under their cooperative lock.
5
- import { randomUUID } from 'node:crypto';
6
- import { hostname } from 'node:os';
7
- import { spawnSync } from 'node:child_process';
8
- import { fs, join, dirname, resolve, safePath, readJSON, save, digest, tree, withLock, fail, syncDir, identifier, relPath, within, displayRepo } from './io.mjs';
9
- import { noGit, gitTimeoutMs, consultMaxAgeMs, splitRef, resolveNodes } from './config.mjs';
10
- import { git, gitEnv, validateBase, baseLock, journalPath, preflightLocalRepository, requireNotShallow, verifyRemote, unavailable } from './stores.mjs';
11
-
12
- const HIT_LIMIT = 50, SNIPPET = 200, FRONTMATTER_BYTES = 8 * 1024, TEXT_BYTES = 64 * 1024 * 1024;
13
- const WALK_FILES = 20000, WALK_BYTES = 256 * 1024 * 1024, DIRECTORY_WAIT_MS = 30000, LOCAL_GIT_MS = 30000;
14
- const obj = v => v !== null && typeof v === 'object' && !Array.isArray(v);
15
-
16
- // ---------------------------------------------------------------- paths
17
- /** Resolve a user path or OKF link to a canonical path relative to the base
18
- * root ('' is the root). `/node/x.md` is base-root absolute, a relative path
19
- * resolves against the directory of `from` (itself a base path), and a bare
20
- * path without `from` resolves from the root. Nothing may leave the root. */
21
- export function resolveBasePath(input, { from = null, fsRoots = [] } = {}) {
22
- if (typeof input !== 'string' || !input.trim()) fail('E_USAGE', 'a base path is required, for example /node/index.md');
23
- const raw = input.trim();
24
- if (/[\0\\]/.test(raw)) fail('E_PATH', `not a base path: ${JSON.stringify(input)}`);
25
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(raw) || raw.startsWith('//')) fail('E_PATH', `${input} is a URL, not a path in the knowledge base`);
26
- let decoded;
27
- try { decoded = decodeURIComponent(raw.replace(/[?#].*$/, '')); } catch { fail('E_PATH', `malformed percent-encoding in ${input}`); }
28
- if (!decoded || /[\0\\]/.test(decoded)) fail('E_PATH', `not a base path: ${JSON.stringify(input)}`);
29
- for (const root of fsRoots.filter(Boolean)) if (decoded === root || decoded.startsWith(root + '/')) fail('E_PATH', `${input} is a filesystem path; base paths start at the base root, like /node/concept.md`);
30
- const segments = decoded.startsWith('/') ? decoded.slice(1).split('/')
31
- : from !== null ? [...from.split('/').slice(0, -1), ...decoded.split('/')] : decoded.split('/');
32
- const out = [];
33
- for (const segment of segments) {
34
- if (segment === '' || segment === '.') continue;
35
- if (segment === '..') { if (!out.length) fail('E_PATH', `${input} escapes the base root`); out.pop(); continue; }
36
- if (segment.startsWith('.')) fail('E_PATH', `${input}: hidden paths are not knowledge`);
37
- out.push(segment);
38
- }
39
- return out.join('/');
40
- }
41
- const parent = p => p.includes('/') ? p.slice(0, p.lastIndexOf('/')) : '';
42
- const shown = p => '/' + p;
43
-
44
- // ---------------------------------------------------------------- git cache
45
- const cacheDir = (bindings, base) => join(bindings.stateDir, 'cache', `${identifier(base.id)}.git`);
46
- const cacheLock = (bindings, base) => join(bindings.stateDir, 'cache', `${base.id}.lock`);
47
- const stateFile = cache => join(cache, 'okf-consult.json');
48
- function gitRun(cwd, args, { input, timeout = LOCAL_GIT_MS, maxBuffer = TEXT_BYTES, encoding = 'utf8' } = {}) {
49
- return spawnSync('git', ['--no-replace-objects', '--literal-pathspecs', '-c', 'core.hooksPath=/dev/null', '-c', 'protocol.ext.allow=never', '-C', cwd, ...args],
50
- { cwd, env: gitEnv(), input, timeout, maxBuffer, encoding });
51
- }
52
- const gitError = r => Object.assign(new Error(`git failed: ${r.error?.message || r.stderr || `exit ${r.status}`}`), { code: r.error?.code === 'ETIMEDOUT' ? 'ETIMEDOUT' : 'E_COMMAND' });
53
- function acceptedCommit(cache, base) {
54
- const r = gitRun(cache, ['rev-parse', '--verify', '--quiet', `refs/heads/${base.acceptedBranch}^{commit}`]);
55
- const oid = r.status === 0 ? r.stdout.trim() : '';
56
- return /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid) ? oid : null;
57
- }
58
- /** The cache's bookkeeping, or null when the cache is absent or belongs to a
59
- * different repository/branch (then it is rebuilt, never reinterpreted). */
60
- function usableCache(cache, base) {
61
- if (!fs.existsSync(cache)) return null;
62
- safePath(cache);
63
- let state;
64
- try { state = readJSON(stateFile(cache)); } catch { return null; }
65
- if (!obj(state) || state.version !== 1 || state.repository !== base.repository || state.acceptedBranch !== base.acceptedBranch) return null;
66
- try { verifyRemote(base, cache); } catch { return null; }
67
- return acceptedCommit(cache, base) ? state : null;
68
- }
69
- function cloneCache(base, alias, dest) {
70
- preflightLocalRepository(base, alias);
71
- // --no-local: a local path gets the same filtered transport as a remote
72
- // (git ignores --filter for local clones and would copy every object).
73
- const args = ['clone', '--bare', ...(base.repository.startsWith('/') ? ['--no-local'] : []), '--single-branch', '--branch', base.acceptedBranch];
74
- try { git(dirname(dest), [...args, '--filter=blob:none', '--', base.repository, dest], { timeout: gitTimeoutMs() }); }
75
- catch (e) {
76
- fs.rmSync(dest, { recursive: true, force: true });
77
- if (!(e.code === 'E_COMMAND' && /filter/i.test(e.message))) unavailable(base, alias, 'clone', e);
78
- try { git(dirname(dest), [...args, '--', base.repository, dest], { timeout: gitTimeoutMs() }); }
79
- catch (error) { unavailable(base, alias, 'clone', error); }
80
- }
81
- try { requireNotShallow(base, alias, dest); } catch (e) { if (e.code === 'E_BASE_SHALLOW') throw e; unavailable(base, alias, 'clone', e); }
82
- verifyRemote(base, dest);
83
- if (!acceptedCommit(dest, base)) unavailable(base, alias, 'clone', new Error(`accepted branch ${base.acceptedBranch} not found`));
84
- }
85
- function fetchAccepted(base, alias, cache) {
86
- // Only the accepted branch, forced to what the remote accepts now.
87
- try { git(cache, ['fetch', '--no-tags', '--update-head-ok', 'origin', `+refs/heads/${base.acceptedBranch}:refs/heads/${base.acceptedBranch}`], { timeout: gitTimeoutMs() }); }
88
- catch (e) { unavailable(base, alias, 'fetch', e); }
89
- try { requireNotShallow(base, alias, cache); } catch (e) { if (e.code === 'E_BASE_SHALLOW') throw e; unavailable(base, alias, 'fetch', e); }
90
- verifyRemote(base, cache);
91
- }
92
- function gitContext(bindings, alias, base, cache, state) {
93
- const commit = acceptedCommit(cache, base);
94
- if (!commit) unavailable(base, alias, 'fetch', new Error('cached accepted branch is missing'));
95
- const receipt = { base: alias, id: base.id, kind: 'git', commit, fetchedAt: state.fetchedAt, stale: !!state.error };
96
- if (state.error) receipt.reason = state.error;
97
- return { alias, base, bindings, kind: 'git', cache, commit, receipt };
98
- }
99
- function resolveGit(bindings, alias, base, { fresh = false } = {}) {
100
- const cache = cacheDir(bindings, base), lock = cacheLock(bindings, base);
101
- fs.mkdirSync(dirname(cache), { recursive: true, mode: 0o700 });
102
- const due = s => fresh || Date.now() - Math.max(Date.parse(s.fetchedAt) || 0, Date.parse(s.attemptedAt) || 0) >= consultMaxAgeMs();
103
- let state = usableCache(cache, base);
104
- if (state && !due(state)) return gitContext(bindings, alias, base, cache, state);
105
- clearDeadCacheLock(lock);
106
- try {
107
- return withLock(lock, () => {
108
- state = usableCache(cache, base);
109
- if (state && !due(state)) return gitContext(bindings, alias, base, cache, state); // a peer just fetched
110
- const now = new Date().toISOString();
111
- if (!state) {
112
- const temp = join(dirname(cache), `.${base.id}.git.tmp-${randomUUID()}`);
113
- try {
114
- cloneCache(base, alias, temp);
115
- const created = { version: 1, repository: base.repository, acceptedBranch: base.acceptedBranch, fetchedAt: now, attemptedAt: now, error: null };
116
- save(stateFile(temp), created);
117
- // Readers only ever see a complete cache: build aside, then rename.
118
- const old = fs.existsSync(cache) ? `${cache}.old-${randomUUID()}` : null;
119
- if (old) fs.renameSync(cache, old);
120
- fs.renameSync(temp, cache); syncDir(dirname(cache));
121
- if (old) fs.rmSync(old, { recursive: true, force: true });
122
- return gitContext(bindings, alias, base, cache, created);
123
- } finally { fs.rmSync(temp, { recursive: true, force: true }); }
124
- }
125
- try { fetchAccepted(base, alias, cache); state = { ...state, fetchedAt: now, attemptedAt: now, error: null }; }
126
- catch (e) {
127
- if (e.code !== 'E_BASE_UNAVAILABLE') throw e;
128
- // Serve the last fetched accepted commit, loudly; retry after max-age or --fresh.
129
- state = { ...state, attemptedAt: now, error: `fetch of the accepted branch failed at ${now} (reason: ${e.reason || 'unknown'}); serving the last fetched accepted commit` };
130
- }
131
- save(stateFile(cache), state);
132
- return gitContext(bindings, alias, base, cache, state);
133
- }, { waitMs: state ? Math.min(gitTimeoutMs(), 60000) : gitTimeoutMs() }); // a cached commit can be served sooner
134
- } catch (e) {
135
- if (e.code === 'E_LOCKED' && state) return gitContext(bindings, alias, base, cache, { ...state, error: `host cache lock is busy (${lock}); serving the last fetched accepted commit` });
136
- throw e;
137
- }
138
- }
139
- /** The cache is disposable host state, not accepted custody: a lock whose
140
- * owner process on this host is gone is released (an interrupted clone is
141
- * built aside and never renamed in; an interrupted fetch leaves refs whole).
142
- * A live, foreign-host or unreadable owner is always waited for. */
143
- function clearDeadCacheLock(lock) {
144
- let owner; try { owner = readJSON(join(lock, 'owner.json')); } catch { return; }
145
- if (owner?.host !== hostname() || !Number.isInteger(owner.pid)) return;
146
- try { process.kill(owner.pid, 0); } catch (e) {
147
- if (e.code !== 'ESRCH') return;
148
- let current; try { current = readJSON(join(lock, 'owner.json')); } catch { return; }
149
- if (current?.token === owner.token) fs.rmSync(lock, { recursive: true, force: true }); // still the dead owner's lock
150
- }
151
- }
152
- const repoPath = (ctx, p) => ctx.base.root === '.' ? p : (p ? `${ctx.base.root}/${p}` : ctx.base.root);
153
- const basePathOf = (ctx, p) => ctx.base.root === '.' ? p : p.slice(ctx.base.root.length + 1);
154
- function lsTree(ctx, p, { recursive = false } = {}) {
155
- const r = gitRun(ctx.cache, ['ls-tree', '-z', ...(recursive ? ['-r'] : []), `${ctx.commit}:${repoPath(ctx, p)}`]);
156
- if (r.status !== 0) throw gitError(r);
157
- return r.stdout.split('\0').filter(Boolean).map(line => {
158
- const tab = line.indexOf('\t'), [mode, type, oid] = line.slice(0, tab).split(' ');
159
- return { name: line.slice(tab + 1), mode, type, oid };
160
- });
161
- }
162
- function gitEntry(ctx, p) {
163
- if (!p) return { type: 'dir' };
164
- const r = gitRun(ctx.cache, ['ls-tree', '-z', ctx.commit, '--', repoPath(ctx, p)]);
165
- if (r.status !== 0) throw gitError(r);
166
- const line = r.stdout.split('\0').filter(Boolean)[0];
167
- if (!line) return null;
168
- const tab = line.indexOf('\t'), [mode, type, oid] = line.slice(0, tab).split(' ');
169
- if (line.slice(tab + 1) !== repoPath(ctx, p)) return null;
170
- if (mode === '040000' && type === 'tree') return { type: 'dir', oid };
171
- if (['100644', '100755'].includes(mode) && type === 'blob') return { type: 'file', oid };
172
- fail('E_PATH', `${ctx.alias}${shown(p)}: symlink or submodule in knowledge base is not allowed`);
173
- }
174
- /** One batched fetch for the blobs a command is about to read, instead of one
175
- * lazy round trip per blob. Best effort: a failure surfaces on the read. */
176
- function prefetch(ctx, p, only = null) {
177
- const r = gitRun(ctx.cache, ['rev-list', '--objects', '--missing=print', `${ctx.commit}:${repoPath(ctx, p)}`]);
178
- if (r.status !== 0) return;
179
- const missing = r.stdout.split('\n').filter(l => l.startsWith('?')).map(l => l.slice(1).trim()).filter(oid => !only || only.has(oid));
180
- if (!missing.length) return;
181
- try {
182
- withLock(cacheLock(ctx.bindings, ctx.base), () => gitRun(ctx.cache, ['-c', 'fetch.negotiationAlgorithm=noop', 'fetch', '--no-tags', '--no-write-fetch-head', '--recurse-submodules=no', '--filter=blob:none', 'origin', '--stdin'],
183
- { input: missing.join('\n') + '\n', timeout: gitTimeoutMs() }), { waitMs: gitTimeoutMs() });
184
- } catch { /* lazy reads remain authoritative */ }
185
- }
186
- function gitBlob(ctx, oid, p, { limit } = {}) {
187
- const r = gitRun(ctx.cache, ['cat-file', 'blob', oid], { timeout: gitTimeoutMs(), maxBuffer: limit ?? TEXT_BYTES });
188
- if (r.error?.code === 'ENOBUFS') { if (limit) return r.stdout; fail('E_TOO_LARGE', `${ctx.alias}${shown(p)} exceeds ${TEXT_BYTES} bytes`); }
189
- if (r.error || r.status !== 0) unavailable(ctx.base, ctx.alias, 'read', gitError(r));
190
- return r.stdout;
191
- }
192
-
193
- // ---------------------------------------------------------------- stores
194
- function dirEntry(ctx, p) {
195
- const file = safePath(join(ctx.base.path, p));
196
- let stat; try { stat = fs.lstatSync(file); } catch (e) { if (['ENOENT', 'ENOTDIR'].includes(e.code)) return null; throw e; }
197
- if (stat.isDirectory()) return { type: 'dir', file };
198
- if (stat.isFile()) { if (stat.nlink !== 1) fail('E_PATH', `${ctx.alias}${shown(p)}: hardlink not allowed`); return { type: 'file', file }; }
199
- fail('E_PATH', `${ctx.alias}${shown(p)}: unsupported entry in knowledge base`);
200
- }
201
- function readBounded(file, limit) {
202
- const fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK);
203
- try {
204
- const size = fs.fstatSync(fd).size;
205
- if (!limit && size > TEXT_BYTES) fail('E_TOO_LARGE', `${file} exceeds ${TEXT_BYTES} bytes`);
206
- const bytes = Buffer.alloc(Math.min(size, limit ?? size)); let length = 0;
207
- while (length < bytes.length) { const n = fs.readSync(fd, bytes, length, bytes.length - length, null); if (!n) break; length += n; }
208
- return bytes.subarray(0, length).toString('utf8');
209
- } finally { fs.closeSync(fd); }
210
- }
211
- const entry = (ctx, p) => ctx.kind === 'git' ? gitEntry(ctx, p) : dirEntry(ctx, p);
212
- function read(ctx, p, e = entry(ctx, p), opts) { return ctx.kind === 'git' ? gitBlob(ctx, e.oid, p, opts) : readBounded(e.file, opts?.limit); }
213
- function children(ctx, p) {
214
- const rows = ctx.kind === 'git'
215
- ? lsTree(ctx, p).map(r => ({ name: r.name, oid: r.oid, type: r.mode === '040000' ? 'dir' : ['100644', '100755'].includes(r.mode) ? 'file' : 'unsupported' }))
216
- : fs.readdirSync(safePath(join(ctx.base.path, p)), { withFileTypes: true }).map(d => ({ name: d.name, file: join(ctx.base.path, p, d.name), type: d.isDirectory() ? 'dir' : d.isFile() ? 'file' : 'unsupported' }));
217
- return rows.filter(r => !r.name.startsWith('.')).sort((a, b) => (a.type === 'dir') !== (b.type === 'dir') ? (a.type === 'dir' ? -1 : 1) : a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
218
- }
219
- function notFound(ctx, p) {
220
- let dir = parent(p);
221
- while (dir && entry(ctx, dir)?.type !== 'dir') dir = parent(dir);
222
- const rows = children(ctx, dir), names = rows.slice(0, 20).map(r => r.type === 'dir' ? `${r.name}/` : r.name);
223
- fail('E_NOT_FOUND', `${ctx.alias}${shown(p)} does not exist at ${short(ctx.receipt)}; \`oats okf ls --base ${ctx.alias} ${shown(dir)}\` lists: ${names.join(', ') || '(empty)'}${rows.length > 20 ? `, and ${rows.length - 20} more` : ''}`);
224
- }
225
- function requireFile(ctx, p) {
226
- if (!p.endsWith('.md')) fail('E_NOT_MARKDOWN', `${ctx.alias}${shown(p)} is not a Markdown file; cat reads only .md concepts (use ls for directories)`);
227
- const e = entry(ctx, p);
228
- if (!e) notFound(ctx, p);
229
- if (e.type !== 'file') fail('E_NOT_MARKDOWN', `${ctx.alias}${shown(p)} is a directory; use ls`);
230
- return e;
231
- }
232
-
233
- /** Run fn against one base at its accepted state. Git: the host cache (fetched
234
- * per freshness rules). Directory: in place, under the base lock, refusing a
235
- * pending publication exactly as staging does. */
236
- export function withBase(bindings, alias, { fresh = false } = {}, fn) {
237
- if (typeof alias !== 'string' || !Object.hasOwn(bindings.bases, alias)) fail('E_BASE_UNKNOWN', `unknown base ${JSON.stringify(alias ?? null)}; bound bases: ${Object.keys(bindings.bases).join(', ')}`);
238
- const base = bindings.bases[alias];
239
- if (base.kind === 'git') return fn(resolveGit(bindings, alias, base, { fresh }));
240
- noGit(base.path);
241
- return withLock(baseLock(base), () => {
242
- if (fs.existsSync(journalPath(base))) fail('E_RECOVERY', `publication pending: ${journalPath(base)}; retry its recorded run before reading`);
243
- if (!fs.existsSync(safePath(base.path))) fail('E_BASE_UNAVAILABLE', `directory base "${alias}" is missing at ${base.path}`);
244
- const ctx = { alias, base, bindings, kind: 'directory' };
245
- ctx.receipt = { base: alias, id: base.id, kind: 'directory', digest: digest(tree(base.path)), fetchedAt: new Date().toISOString(), stale: false };
246
- return fn(ctx);
247
- }, { waitMs: DIRECTORY_WAIT_MS });
248
- }
249
-
250
- // ---------------------------------------------------------------- validation
251
- /** A tree entry name, from an untrusted accepted tree, as a path under the base
252
- * root. Git accepts literal `..`, absolute-looking and control-character entry
253
- * names (`hash-object --literally`), and they survive a partial clone, so every
254
- * name gets relPath's canonical rules (no empty, `.`, `..`, absolute, backslash,
255
- * NUL or `.git` segment) plus no control characters or percent-escapes. */
256
- export function treeEntryPath(name) {
257
- const bad = () => fail('E_PATH', `invalid tree entry in knowledge base: ${JSON.stringify(String(name)).slice(0, 200)}`);
258
- if (typeof name !== 'string' || /[\u0000-\u001f\u007f]/.test(name) || /%[0-9a-fA-F]{2}/.test(name)) bad();
259
- try { return relPath(name); } catch { return bad(); }
260
- }
261
- /** Where a tree entry lands under `root`, refused unless it stays strictly
262
- * inside it after resolution (the second, independent guard). */
263
- export function containedTarget(root, name) {
264
- const target = resolve(root, treeEntryPath(name));
265
- if (target === resolve(root) || !within(root, target)) fail('E_PATH', `tree entry escapes its materialization root: ${JSON.stringify(name).slice(0, 200)}`);
266
- return safePath(target);
267
- }
268
- const VALIDATION_CODES = new Set(['E_VALIDATION', 'E_BASE', 'E_PATH', 'E_ID', 'E_CONFIG']);
269
- /** Whether the accepted state is a valid OKF base. Git verdicts are cached per
270
- * commit inside the host cache; computing one materializes the base root into
271
- * a transient host scratch that is removed before returning. */
272
- export function verdict(ctx) {
273
- const judge = run => {
274
- try { const v = run(); return { version: 1, ok: true, digest: v.digest, nodes: v.meta.nodes }; }
275
- catch (e) { if (!VALIDATION_CODES.has(e.code)) throw e; return { version: 1, ok: false, error: { code: e.code, message: e.message } }; }
276
- };
277
- if (ctx.kind === 'directory') return judge(() => validateBase(ctx.base.path, ctx.base));
278
- const file = join(ctx.cache, 'okf-validation', `${ctx.commit}.json`);
279
- if (fs.existsSync(file)) { const cached = readJSON(file); if (cached?.version === 1) return cached; }
280
- const scratch = fs.mkdtempSync(join(dirname(ctx.cache), '.validate-'));
281
- try {
282
- const result = judge(() => {
283
- const rows = lsTree(ctx, '', { recursive: true });
284
- // Every entry is judged before anything is written: the first bad name or
285
- // mode refuses the whole base, and nothing lands outside the scratch.
286
- for (const r of rows) {
287
- if (!['100644', '100755'].includes(r.mode) || r.type !== 'blob') fail('E_PATH', 'symlink or submodule in knowledge base is not allowed');
288
- r.target = containedTarget(scratch, r.name);
289
- }
290
- prefetch(ctx, '');
291
- for (const r of rows) {
292
- const target = r.target; fs.mkdirSync(dirname(target), { recursive: true });
293
- const fd = fs.openSync(target, 'wx', 0o600);
294
- let w; try { w = spawnSync('git', ['--no-replace-objects', '-c', 'core.hooksPath=/dev/null', '-C', ctx.cache, 'cat-file', 'blob', r.oid], { env: gitEnv(), timeout: gitTimeoutMs(), stdio: ['ignore', fd, 'pipe'] }); } finally { fs.closeSync(fd); }
295
- if (w.error || w.status !== 0) unavailable(ctx.base, ctx.alias, 'read', gitError(w));
296
- }
297
- return validateBase(scratch, ctx.base);
298
- });
299
- save(file, { ...result, commit: ctx.commit, at: new Date().toISOString() });
300
- return result;
301
- } finally { fs.rmSync(scratch, { recursive: true, force: true }); }
302
- }
303
- /** Registration's accepted resolution: per base, the accepted commit/digest and
304
- * its nodes, validated, and the soul's declaration resolved against them. No
305
- * files are materialized for the instance. */
306
- export function acceptedResolution(bindings, decl) {
307
- const view = {}, accepted = {};
308
- for (const alias of Object.keys(bindings.bases)) withBase(bindings, alias, {}, ctx => {
309
- const v = verdict(ctx);
310
- if (!v.ok) fail(v.error.code, `base "${alias}" at ${short(ctx.receipt)}: ${v.error.message}`);
311
- accepted[alias] = { nodes: v.nodes };
312
- view[alias] = { ...ctx.receipt, digest: v.digest, nodes: v.nodes };
313
- });
314
- resolveNodes(decl, bindings, accepted);
315
- return view;
316
- }
317
-
318
- // ---------------------------------------------------------------- helpers
319
- function nodesOf(ctx) {
320
- let m;
321
- const e = entry(ctx, 'okf-base.json');
322
- try { m = JSON.parse(e ? read(ctx, 'okf-base.json', e) : ''); } catch { fail('E_BASE', `base "${ctx.alias}" has no valid okf-base.json at ${short(ctx.receipt)}`); }
323
- if (!obj(m?.nodes)) fail('E_BASE', `base "${ctx.alias}" okf-base.json has no nodes`);
324
- return m.nodes;
325
- }
326
- function nodePath(ctx, node) {
327
- const nodes = nodesOf(ctx);
328
- if (!Object.hasOwn(nodes, node) || typeof nodes[node]?.path !== 'string') fail('E_NOT_FOUND', `base "${ctx.alias}" has no node "${node}"; nodes: ${Object.keys(nodes).join(', ')}`);
329
- return resolveBasePath(nodes[node].path);
330
- }
331
- export function frontmatter(text) {
332
- const m = /^---\r?\n([\s\S]*?)\r?\n---\r?(?:\n|$)/.exec(text);
333
- if (!m) return null;
334
- const out = {};
335
- for (const line of m[1].split(/\r?\n/)) {
336
- const kv = /^(type|title|description):\s*(.*)$/.exec(line);
337
- if (kv) out[kv[1]] = kv[2].trim().replace(/^(['"])(.*)\1$/, '$2').slice(0, 300);
338
- }
339
- return out;
340
- }
341
- export function markdownLinks(text) {
342
- const clean = text.replace(/^(```|~~~)[^\n]*\n[\s\S]*?^\1[^\n]*$/gm, '').replace(/`[^`\n]*`/g, '');
343
- const links = [], seen = new Set();
344
- const add = (label, target) => { if (!target || seen.has(target)) return; seen.add(target); links.push({ text: label, target }); };
345
- for (const m of clean.matchAll(/!?\[([^\]]*)\]\(\s*<?([^)\s>]+)>?(?:\s+(?:"[^"]*"|'[^']*'))?\s*\)/g)) add(m[1], m[2]);
346
- for (const m of clean.matchAll(/^\s{0,3}\[([^\]]+)\]:\s*<?(\S+?)>?(?:\s+.*)?$/gm)) add(m[1], m[2]);
347
- return links;
348
- }
349
- function age(iso) {
350
- const s = Math.max(0, Math.round((Date.now() - Date.parse(iso)) / 1000));
351
- return s < 5 ? 'just now' : s < 90 ? `${s}s ago` : s < 5400 ? `${Math.round(s / 60)}m ago` : s < 172800 ? `${Math.round(s / 3600)}h ago` : `${Math.round(s / 86400)}d ago`;
352
- }
353
- export const short = r => `${r.base}@${r.kind === 'git' ? r.commit.slice(0, 12) : `dir:${r.digest.slice(0, 12)}`}`;
354
- export const footer = receipts => receipts.map(r => `— ${short(r)} (fetched ${age(r.fetchedAt)}${r.stale ? `; STALE: ${r.reason}` : ''})`).join('\n');
355
- const fsRoots = (source, ctx) => [source.home, source.work, source.bindings.stateDir, dirname(source.bindings.file), ctx.kind === 'directory' ? ctx.base.path : null];
356
- const refsOf = decl => [...decl.owns.map(ref => [ref, 'owns']), ...decl.reads.filter(ref => !decl.owns.includes(ref)).map(ref => [ref, 'reads'])];
357
- function needBase(source, flags) {
358
- if (!flags.base) fail('E_USAGE', `--base ALIAS is required; bound bases: ${Object.keys(source.bindings.bases).join(', ')}`);
359
- return flags.base;
360
- }
361
- function onePath(positionals, label) {
362
- if (positionals.length !== 1) fail('E_USAGE', `expected one ${label}`);
363
- return positionals[0];
364
- }
365
-
366
- // ---------------------------------------------------------------- commands
367
- export function bases(source, flags) {
368
- const rows = [], receipts = [];
369
- for (const [alias, base] of Object.entries(source.bindings.bases)) withBase(source.bindings, alias, { fresh: !!flags.fresh }, ctx => {
370
- const v = verdict(ctx), mine = relation => source.decl[relation].filter(ref => splitRef(ref)[0] === alias).map(ref => splitRef(ref)[1]);
371
- rows.push({ alias, id: base.id, kind: base.kind, ...(base.kind === 'git' ? { repository: displayRepo(base.repository), acceptedBranch: base.acceptedBranch, root: base.root, commit: ctx.commit } : { path: base.path, digest: ctx.receipt.digest }),
372
- fetchedAt: ctx.receipt.fetchedAt, stale: ctx.receipt.stale, ...(ctx.receipt.reason ? { reason: ctx.receipt.reason } : {}),
373
- validated: v.ok ? { ok: true } : { ok: false, error: v.error }, nodes: v.ok ? Object.keys(v.nodes) : null, owns: mine('owns'), reads: mine('reads'), receipt: ctx.receipt });
374
- receipts.push(ctx.receipt);
375
- });
376
- const text = rows.map(r => `${r.alias} (${r.kind}) ${r.kind === 'git' ? `${r.repository} ${r.acceptedBranch} root=${r.root}` : r.path}\n accepted ${short(r.receipt)} fetched ${age(r.fetchedAt)}${r.stale ? ` STALE: ${r.reason}` : ''}; ${r.validated.ok ? 'valid OKF base' : `INVALID: ${r.validated.error.message}`}\n owns: ${r.owns.join(', ') || '-'}; reads: ${r.reads.join(', ') || '-'}${r.nodes ? `; nodes: ${r.nodes.join(', ')}` : ''}`).join('\n');
377
- return { result: { bases: rows }, text: `${text}\n${footer(receipts)}` };
378
- }
379
- export function index(source, flags, positionals) {
380
- if (positionals.length > 1) fail('E_USAGE', 'index takes at most one node');
381
- let targets;
382
- const relation = (alias, node) => refsOf(source.decl).find(([ref]) => ref === `${alias}/${node}`)?.[1] ?? null;
383
- if (positionals.length) {
384
- let alias = flags.base, node = positionals[0];
385
- if (node.includes('/')) { const [a, n] = splitRef(node); if (alias && alias !== a) fail('E_USAGE', `--base ${alias} disagrees with ${node}`); alias = a; node = n; }
386
- else if (!alias) {
387
- const matches = refsOf(source.decl).filter(([ref]) => splitRef(ref)[1] === node);
388
- if (matches.length !== 1) fail('E_USAGE', matches.length ? `node "${node}" is in several bases (${matches.map(([r]) => r).join(', ')}); pass --base ALIAS` : `node "${node}" is not one of your nodes (${refsOf(source.decl).map(([r]) => r).join(', ') || 'none'}); pass --base ALIAS`);
389
- alias = splitRef(matches[0][0])[0];
390
- }
391
- targets = [{ alias, node }];
392
- } else if (flags.base) targets = [{ alias: flags.base, node: null }];
393
- else targets = refsOf(source.decl).map(([ref]) => { const [alias, node] = splitRef(ref); return { alias, node }; });
394
- if (!targets.length) fail('E_USAGE', 'this soul owns and reads no nodes; pass --base ALIAS [node]');
395
- const indexes = [], receipts = [];
396
- for (const alias of [...new Set(targets.map(t => t.alias))]) withBase(source.bindings, alias, { fresh: !!flags.fresh }, ctx => {
397
- receipts.push(ctx.receipt);
398
- for (const t of targets.filter(t => t.alias === alias)) {
399
- const path = t.node ? `${nodePath(ctx, t.node)}/index.md` : 'index.md', e = requireFile(ctx, path);
400
- indexes.push({ base: alias, node: t.node, relation: t.node ? relation(alias, t.node) : null, path, text: read(ctx, path, e), receipt: ctx.receipt });
401
- }
402
- });
403
- const order = new Map(targets.map((t, i) => [`${t.alias}/${t.node}`, i]));
404
- indexes.sort((a, b) => order.get(`${a.base}/${a.node}`) - order.get(`${b.base}/${b.node}`));
405
- const text = indexes.map(x => `## ${x.node ? `${x.base}/${x.node}` : x.base}${x.relation ? ` (${x.relation})` : ''}\n\n${x.text.trimEnd()}\n`).join('\n');
406
- return { result: { indexes }, text: `${text}\n${footer(receipts)}` };
407
- }
408
- export function cat(source, flags, positionals) {
409
- const input = onePath(positionals, 'path');
410
- return withBase(source.bindings, needBase(source, flags), { fresh: !!flags.fresh }, ctx => {
411
- const roots = fsRoots(source, ctx);
412
- const from = flags.from === undefined ? null : resolveBasePath(flags.from, { fsRoots: roots });
413
- const path = resolveBasePath(input, { from, fsRoots: roots }), e = requireFile(ctx, path);
414
- const text = read(ctx, path, e);
415
- return { result: { path, text, receipt: ctx.receipt }, text: `${text.trimEnd()}\n${footer([ctx.receipt])}` };
416
- });
417
- }
418
- export function ls(source, flags, positionals) {
419
- if (positionals.length > 1) fail('E_USAGE', 'ls takes at most one directory');
420
- return withBase(source.bindings, needBase(source, flags), { fresh: !!flags.fresh }, ctx => {
421
- const path = positionals.length ? resolveBasePath(positionals[0], { fsRoots: fsRoots(source, ctx) }) : '';
422
- const e = entry(ctx, path);
423
- if (!e) notFound(ctx, path);
424
- if (e.type !== 'dir') fail('E_USAGE', `${ctx.alias}${shown(path)} is a file; use cat`);
425
- const rows = children(ctx, path);
426
- if (ctx.kind === 'git') prefetch(ctx, path, new Set(rows.filter(r => r.type === 'file' && r.name.endsWith('.md')).map(r => r.oid)));
427
- const entries = rows.map(r => {
428
- const p = path ? `${path}/${r.name}` : r.name, out = { name: r.name, path: p, kind: r.type };
429
- if (r.type === 'file' && r.name.endsWith('.md')) {
430
- const fm = frontmatter(ctx.kind === 'git' ? gitBlob(ctx, r.oid, p, { limit: FRONTMATTER_BYTES }) : readBounded(safePath(r.file), FRONTMATTER_BYTES));
431
- if (fm) Object.assign(out, fm);
432
- }
433
- return out;
434
- });
435
- const text = entries.map(x => {
436
- if (x.kind === 'dir') return `${x.name}/`;
437
- if (x.kind === 'unsupported') return `${x.name} (unsupported entry, refused)`;
438
- const meta = [x.type ? `[${x.type}]` : '', x.title, x.description ? `— ${x.description}` : ''].filter(Boolean).join(' ');
439
- return meta ? `${x.name} ${meta}` : x.name;
440
- }).join('\n');
441
- return { result: { path, entries, receipt: ctx.receipt }, text: `${shown(path)}\n${text || '(empty)'}\n${footer([ctx.receipt])}` };
442
- });
443
- }
444
- export function links(source, flags, positionals) {
445
- const input = onePath(positionals, 'path');
446
- return withBase(source.bindings, needBase(source, flags), { fresh: !!flags.fresh }, ctx => {
447
- const roots = fsRoots(source, ctx), path = resolveBasePath(input, { fsRoots: roots }), e = requireFile(ctx, path);
448
- const rows = markdownLinks(read(ctx, path, e)).filter(l => !l.target.startsWith('#')).map(l => {
449
- if (/^[a-zA-Z][a-zA-Z0-9+.-]*:/.test(l.target) || l.target.startsWith('//')) return { ...l, external: true };
450
- try {
451
- const p = resolveBasePath(l.target, { from: path, fsRoots: roots }), found = p ? entry(ctx, p) : { type: 'dir' };
452
- return { ...l, path: p, exists: !!found, ...(found?.type === 'dir' ? { directory: true } : {}) };
453
- } catch (err) { if (!['E_PATH', 'E_USAGE'].includes(err.code)) throw err; return { ...l, exists: false, refused: err.message }; }
454
- });
455
- const text = rows.map(r => r.external ? `external ${r.target}` : r.refused ? `REFUSED ${r.target} (${r.refused})` : `${r.exists ? 'ok ' : 'MISSING '} ${shown(r.path)}${r.target !== shown(r.path) ? ` (${r.target})` : ''}`).join('\n');
456
- return { result: { path, links: rows, receipt: ctx.receipt }, text: `${shown(path)} links:\n${text || '(none)'}\n${footer([ctx.receipt])}` };
457
- });
458
- }
459
- function matcher(query, { regex, caseSensitive }) {
460
- if (regex) { let re; try { re = new RegExp(query, caseSensitive ? '' : 'i'); } catch (e) { fail('E_USAGE', `invalid --regex: ${e.message}`); } return line => { const m = re.exec(line); return m ? m.index : -1; }; }
461
- const needle = caseSensitive ? query : query.toLowerCase();
462
- return line => (caseSensitive ? line : line.toLowerCase()).indexOf(needle);
463
- }
464
- function snippet(line, at) {
465
- const text = line.trim(), offset = Math.max(0, (at ?? 0) - (line.length - line.trimStart().length) - 60);
466
- const cut = text.length <= SNIPPET ? text : text.slice(offset, offset + SNIPPET);
467
- return `${offset > 0 && text.length > SNIPPET ? '…' : ''}${cut}${offset + SNIPPET < text.length ? '…' : ''}`;
468
- }
469
- function searchBase(ctx, prefix, query, opts, hits) {
470
- const find = matcher(query, opts);
471
- if (ctx.kind === 'git') {
472
- prefetch(ctx, prefix);
473
- const r = gitRun(ctx.cache, ['grep', '-z', '-n', '-I', '--no-color', ...(opts.regex ? ['-E'] : ['-F']), ...(opts.caseSensitive ? [] : ['-i']), '-e', query, ctx.commit, '--', repoPath(ctx, prefix) || '.'], { timeout: gitTimeoutMs() });
474
- if (r.error?.code === 'ENOBUFS') fail('E_TOO_LARGE', 'search output is too large; narrow it with --node or a longer query');
475
- if (r.status === 1 && !r.stderr.trim()) return;
476
- if (r.error || r.status !== 0) unavailable(ctx.base, ctx.alias, 'search', gitError(r));
477
- for (const line of r.stdout.split('\n').filter(Boolean)) {
478
- const [where, number, ...rest] = line.split('\0'), repo = where.slice(ctx.commit.length + 1), text = rest.join('\0');
479
- if (!repo.endsWith('.md') || (ctx.base.root !== '.' && !repo.startsWith(ctx.base.root + '/'))) continue;
480
- const p = basePathOf(ctx, repo);
481
- if (p.split('/').some(s => s.startsWith('.'))) continue;
482
- hits.push({ base: ctx.alias, path: p, line: Number(number), snippet: snippet(text, find(text)) });
483
- }
484
- return;
485
- }
486
- let files = 0, bytes = 0;
487
- const walk = dir => {
488
- for (const r of children(ctx, dir)) {
489
- const p = dir ? `${dir}/${r.name}` : r.name;
490
- if (r.type === 'dir') { walk(p); continue; }
491
- if (r.type !== 'file' || !r.name.endsWith('.md')) continue;
492
- const e = dirEntry(ctx, p);
493
- if (++files > WALK_FILES || (bytes += fs.statSync(e.file).size) > WALK_BYTES) fail('E_TOO_LARGE', `directory search is bounded to ${WALK_FILES} files / ${WALK_BYTES} bytes; narrow it with --node`);
494
- readBounded(e.file).split(/\r?\n/).forEach((text, i) => { const at = find(text); if (at >= 0) hits.push({ base: ctx.alias, path: p, line: i + 1, snippet: snippet(text, at) }); });
495
- }
496
- };
497
- walk(prefix);
498
- }
499
- export function search(source, flags, positionals) {
500
- if (!positionals.length) fail('E_USAGE', 'search needs the text to look for');
501
- const query = positionals.join(' ');
502
- if (flags.all && flags.base) fail('E_USAGE', 'choose --base ALIAS or --all');
503
- let node = flags.node ?? null, only = flags.base ?? null;
504
- if (node && node.includes('/')) { const [a, n] = splitRef(node); if (only && only !== a) fail('E_USAGE', `--base ${only} disagrees with --node ${node}`); only = a; node = n; }
505
- const mine = [...new Set(refsOf(source.decl).map(([ref]) => splitRef(ref)[0]))];
506
- const scope = only ? [only] : flags.all || !mine.length ? Object.keys(source.bindings.bases) : mine;
507
- if (node && scope.length !== 1) fail('E_USAGE', '--node needs one base: pass --base ALIAS or --node ALIAS/NODE');
508
- const hits = [], receipts = [];
509
- for (const alias of scope) withBase(source.bindings, alias, { fresh: !!flags.fresh }, ctx => {
510
- receipts.push(ctx.receipt);
511
- searchBase(ctx, node ? nodePath(ctx, node) : '', query, { regex: !!flags.regex, caseSensitive: !!flags['case-sensitive'] }, hits);
512
- });
513
- const shownHits = hits.slice(0, HIT_LIMIT), more = hits.length - shownHits.length;
514
- const text = shownHits.map(h => `${h.base}${shown(h.path)}:${h.line}: ${h.snippet}`).join('\n');
515
- return { result: { query, regex: !!flags.regex, bases: scope, node, hits: shownHits, total: hits.length, more, receipts: Object.fromEntries(receipts.map(r => [r.base, r])) },
516
- text: `${text || `no matches for ${JSON.stringify(query)}`}${more ? `\n… and ${more} more (narrow with --node or a longer query)` : ''}\n${footer(receipts)}` };
517
- }
518
- export const CONSULT = { bases, index, cat, ls, links, search };
@@ -1,88 +0,0 @@
1
- // `oats okf harvest-status` and `oats okf setup --harvest on|off` (okf 4.0.0).
2
- import { fs, join, dirname, resolve, readJSON, atomic, fail } from './io.mjs';
3
- import { settings, loadBindings } from './config.mjs';
4
- import { harvestSwitch } from './harvest-switch.mjs';
5
- import { markerPath, harvestOffRecord } from './sources.mjs';
6
-
7
- const SOURCE_LIMIT = 500;
8
- /** The registered sources in the bound state directory, optionally for one soul. */
9
- function registeredSources(soul) {
10
- let bindings;
11
- try { bindings = loadBindings(); } catch (e) { return { error: `${e.code || 'E_CONFIG'}: ${e.message}` }; }
12
- const dir = join(bindings.stateDir, 'sources');
13
- if (!fs.existsSync(dir)) return { stateDir: bindings.stateDir, sources: [] };
14
- const rows = [];
15
- for (const id of fs.readdirSync(dir).sort()) {
16
- if (rows.length >= SOURCE_LIMIT) return { stateDir: bindings.stateDir, sources: rows, truncated: true };
17
- let source, status;
18
- try { source = readJSON(join(dir, id, 'source.json')); status = readJSON(join(dir, id, 'status.json')); } catch { continue; }
19
- if (soul && source.agent !== soul) continue;
20
- rows.push({ id, soul: source.agent, instance: source.instance, created: source.created, retired: status.retired === true, auto: status.auto === true, activeRun: status.activeRun || null, schedule: status.schedule?.id || null, file: join(dir, id, 'source.json') });
21
- }
22
- return { stateDir: bindings.stateDir, sources: rows };
23
- }
24
- export function harvestStatus({ home, flags = {} }) {
25
- const sw = harvestSwitch({ settings: settings(), soulDir: process.env.OATS_SOUL });
26
- const soul = flags.soul || process.env.OATS_AGENT || null;
27
- const inHome = !!process.env.OATS_INSTANCE_HOME && fs.existsSync(join(home, 'instance.json'));
28
- const instance = inHome ? { home, registered: fs.existsSync(markerPath(home)), spawnedWith: fs.existsSync(markerPath(home)) ? 'on' : harvestOffRecord(home) ? 'off' : 'unknown' } : null;
29
- return { harvest: sw.effective, reason: sw.reason, rows: sw.rows, warnings: sw.warnings, soul, instance, ...registeredSources(soul),
30
- note: 'harvest applies from the next spawn: switching it on never captures earlier sessions, and switching it off stops run-source capture for registered sources. `oats schedule disable okf-<source>` is the per-source emergency brake.' };
31
- }
32
-
33
- /** Find the deployment's oats-local.yaml: OATS_WORKSPACE, else up from cwd. */
34
- function localFile() {
35
- const candidates = [];
36
- if (process.env.OATS_WORKSPACE) candidates.push(join(process.env.OATS_WORKSPACE, 'oats-local.yaml'));
37
- for (let d = resolve(process.cwd()); ; d = dirname(d)) { candidates.push(join(d, 'oats-local.yaml')); if (dirname(d) === d) break; }
38
- return candidates.find((f) => fs.existsSync(f)) || null;
39
- }
40
- /** Edit `settings: / oats.okf: / harvest:` in block style; null when the file
41
- * has any shape this small editor will not touch (then the line is printed). */
42
- export function editLocalYaml(text, value) {
43
- if (/\t/.test(text)) return null;
44
- const lines = text.split('\n');
45
- const top = (i) => /^\S/.test(lines[i]) && !/^#/.test(lines[i]);
46
- const s = lines.findIndex((l) => /^settings\s*:/.test(l));
47
- if (s < 0) {
48
- const tail = text.endsWith('\n') || text === '' ? '' : '\n';
49
- return `${text}${tail}settings:\n oats.okf:\n harvest: ${value}\n`;
50
- }
51
- if (lines[s].replace(/^settings\s*:/, '').replace(/\s+#.*$/, '').trim() !== '') return null;
52
- let end = lines.length;
53
- for (let i = s + 1; i < lines.length; i++) if (top(i)) { end = i; break; }
54
- let o = -1, childIndent = null;
55
- for (let i = s + 1; i < end; i++) {
56
- const m = /^(\s+)(['"]?)oats\.okf\2\s*:(.*)$/.exec(lines[i]);
57
- if (m) { if (m[3].replace(/\s+#.*$/, '').trim() !== '') return null; o = i; childIndent = m[1].length; break; }
58
- }
59
- if (o < 0) {
60
- const first = lines.slice(s + 1, end).find((l) => /^\s+\S/.test(l) && !/^\s*#/.test(l));
61
- const ind = first ? /^(\s+)/.exec(first)[1] : ' ';
62
- lines.splice(s + 1, 0, `${ind}oats.okf:`, `${ind} harvest: ${value}`);
63
- return lines.join('\n');
64
- }
65
- let blockEnd = end, keyIndent = null;
66
- for (let i = o + 1; i < end; i++) {
67
- if (/^\s*(#.*)?$/.test(lines[i])) continue;
68
- const lead = /^(\s*)/.exec(lines[i])[1].length;
69
- if (lead <= childIndent) { blockEnd = i; break; }
70
- if (keyIndent === null) keyIndent = lead;
71
- if (lead === keyIndent && /^\s*harvest\s*:/.test(lines[i])) { lines[i] = `${' '.repeat(lead)}harvest: ${value}`; return lines.join('\n'); }
72
- }
73
- lines.splice(o + 1, 0, `${' '.repeat(keyIndent ?? childIndent + 2)}harvest: ${value}`);
74
- return lines.join('\n');
75
- }
76
- export function setupHarvest(value) {
77
- if (!['on', 'off'].includes(value)) fail('E_USAGE', '--harvest must be on or off');
78
- const line = `settings:\n oats.okf:\n harvest: ${value}`;
79
- const file = localFile();
80
- const note = value === 'on'
81
- ? 'Harvest applies to new spawns of souls that do not opt out (knowledge: { harvest: off }). Earlier sessions are never captured. Run `oats okf harvest-status` to confirm.'
82
- : 'New spawns register no source. Registered sources stop capturing at their next run-source. Nothing is deleted.';
83
- if (!file) return { written: false, harvest: value, reason: 'no oats-local.yaml found (OATS_WORKSPACE or up from the current directory)', add: line, note };
84
- const edited = editLocalYaml(fs.readFileSync(file, 'utf8'), value);
85
- if (edited === null) return { written: false, file, harvest: value, reason: 'oats-local.yaml has a shape this command does not edit (flow style or tabs); add the line by hand', add: line, note };
86
- atomic(file, edited);
87
- return { written: true, file, harvest: value, note };
88
- }