@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,473 +0,0 @@
1
- import { createHash } from 'node:crypto';
2
- import { spawnSync } from 'node:child_process';
3
- import { tmpdir } from 'node:os';
4
- import { fileURLToPath } from 'node:url';
5
- import { fs, join, dirname, safePath, readJSON, save, atomic, tree, materialize, digest, hash, withLock, exec, cleanEnv, fail, relPath, overlaps, resolve, within, redactUrls, displayRepo } from './io.mjs';
6
- import { metadata, noGit, gitTimeoutMs } from './config.mjs';
7
- const validator = fileURLToPath(new URL('./okf-validate.mjs', import.meta.url));
8
- // Never let local replace refs reinterpret frozen OIDs, including inside Git's
9
- // transport subprocesses. Override even an explicitly supplied command env.
10
- export const gitEnv = (env = cleanEnv()) => ({...env,GIT_NO_REPLACE_OBJECTS:'1'});
11
- export const git = (cwd,args,opts={}) => exec('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-c','protocol.ext.allow=never','-C',cwd,...args],{cwd,...opts,env:gitEnv(opts.env)});
12
- function baseError(code,message,base,alias,step,reason) {throw Object.assign(new Error(redactUrls(message)),{code,base:alias,repository:displayRepo(base.repository),step,reason});}
13
- function baseRemedy(alias) {return `fix the binding for base alias "${alias}" in the bindings file, or remove the base from the bindings`;}
14
- function classifyGitFailure(error) {
15
- const text=String(error?.message || '');
16
- if(error?.code==='ETIMEDOUT' || /ETIMEDOUT|timed out|timeout/i.test(text)) return 'timeout';
17
- if(/authentication|permission denied|publickey|access denied|could not read Username|authorization|not authorized/i.test(text)) return 'auth';
18
- if(/not found|repository .*not exist|does not exist|no such (file|repository)|couldn't find remote ref|Repository not found/i.test(text)) return 'not-found';
19
- if(/could not resolve host|failed to connect|connection refused|network is unreachable|no route to host|proxy/i.test(text)) return 'network';
20
- return 'unknown';
21
- }
22
- export function unavailable(base,alias,step,error) {
23
- const reason=classifyGitFailure(error),detail=reason==='unknown'?`; original Git failure: ${redactUrls(String(error?.message || 'unknown failure'))}`:'';
24
- const message=`Git base "${alias}" repository "${displayRepo(base.repository)}" is required by the deployment's bindings, but ${step} failed (reason: ${reason}${detail}); ${baseRemedy(alias)}`;
25
- baseError('E_BASE_UNAVAILABLE',message,base,alias,step,reason);
26
- }
27
- export function requireNotShallow(base,alias,cwd) {
28
- const shallow=git(cwd,['rev-parse','--is-shallow-repository']);
29
- if(shallow==='true') baseError('E_BASE_SHALLOW',`Git base "${alias}" repository "${displayRepo(base.repository)}" is required by the deployment's bindings, but the repository is shallow; oats.okf requires full accepted history before staging; ${baseRemedy(alias)}`,base,alias,'clone','shallow');
30
- }
31
- export function preflightLocalRepository(base,alias) {
32
- if(!base.repository.startsWith('/')) return;
33
- if(!fs.existsSync(base.repository)) unavailable(base,alias,'clone',Object.assign(new Error('repository not found'),{code:'ENOENT'}));
34
- try { requireNotShallow(base,alias,base.repository); }
35
- catch(e) { if(e.code==='E_BASE_SHALLOW') throw e; unavailable(base,alias,'clone',e); }
36
- }
37
- export const baseLock = b => `${b.path}.okf-lock`;
38
- // okf consult readers hold a directory base's lock briefly; staging and
39
- // publication queue behind them for a bounded time instead of failing.
40
- const CONSULT_READER_WAIT_MS = 10000;
41
- export const journalPath = b => `${b.path}.okf-publication.json`;
42
- export function validateBase(root, base) {
43
- const files=tree(root,{git:base.kind==='git' && base.root==='.'}); const meta=metadata(files,base);
44
- const result=JSON.parse(exec(process.execPath,[validator,root,'--strict','--json'],{acceptedStatus:[0,1]}));
45
- if(result.errors.length || result.warnings.length) fail('E_VALIDATION', [...result.errors,...result.warnings].join('; '));
46
- return {files,meta,digest:digest(files)};
47
- }
48
- /** Write a blob straight to a file descriptor: no in-memory buffer, so object
49
- * size never limits what a base may hold. The remote budget applies because a
50
- * partial clone fetches a missing blob on its first read. */
51
- function writeBlob(cwd,oid,target,mode) {
52
- const fd=fs.openSync(target,'wx',mode);
53
- let result; try { result=spawnSync('git',['--no-replace-objects','-c','core.hooksPath=/dev/null','-C',cwd,'cat-file','blob',oid],{cwd,env:gitEnv(),timeout:gitTimeoutMs(),stdio:['ignore',fd,'pipe']}); } finally { fs.closeSync(fd); }
54
- if(result.error || result.status!==0) fail('E_COMMAND','Git object read failed');
55
- fs.chmodSync(target,mode);
56
- }
57
- /** A tree entry the store never materializes: outside the knowledge base root.
58
- * Its absence from a staging tree is by construction; its presence is a
59
- * worker's doing and is judged like any other change. */
60
- const outsideBase=(base,p)=>!(base.root==='.' || p===base.root || p.startsWith(base.root+'/'));
61
- function materializeGitObjects(base,dest,head) {
62
- immutableCommit(dest,head);
63
- const entries=gitTreeEntries(dest,head);
64
- // Preflight paths/modes before materialization. No checkout/smudge/process/
65
- // working-tree-encoding conversion: accepted bytes come directly from OIDs.
66
- for(const [p,entry] of entries) {
67
- relPath(p);const inBase=base.root==='.' || p===base.root || p.startsWith(base.root+'/');
68
- if(!['100644','100755','120000','160000'].includes(entry.mode) || (entry.mode==='160000'?entry.type!=='commit':entry.type!=='blob')) fail('E_PATH','unsupported Git tree entry');
69
- if(inBase && !['100644','100755'].includes(entry.mode)) fail('E_PATH','symlink or submodule in knowledge base is not allowed');
70
- }
71
- // Index/HEAD updates do not apply content filters. They preserve the normal
72
- // Git worktree needed by existing diff/publication guards without checkout.
73
- git(dest,['read-tree',head]);git(dest,['update-ref','--no-deref','HEAD',head]);
74
- // Only the knowledge base is materialized: the index carries the whole tree
75
- // for publication, but bytes outside the base root are never read, so the
76
- // size of the rest of the repository does not matter. The scope checks know
77
- // that an entry outside the root is expected to be absent (see outsideBase).
78
- for(const [p,entry] of entries) {
79
- if(outsideBase(base,p)) continue;
80
- // relPath above already refused traversal; containment is asserted again
81
- // on the resolved target before any write (okf 4.0.1 #1).
82
- const target=resolve(dest,p);if(target===resolve(dest) || !within(dest,target)) fail('E_PATH','Git tree entry escapes the stage');safePath(target);fs.mkdirSync(dirname(target),{recursive:true});
83
- writeBlob(dest,entry.oid,target,entry.mode==='100755'?0o755:0o644);
84
- }
85
- }
86
- function clone(base, dest, selectedHead, { alias = base.id } = {}) {
87
- safePath(dest);
88
- if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
89
- preflightLocalRepository(base,alias);
90
- fs.mkdirSync(dirname(dest),{recursive:true});
91
- // Fetch only what the store reads: the accepted branch, trees now and blobs
92
- // on demand. Only a remote that does not offer object filtering gets a plain
93
- // single-branch clone instead; any other failure is the failure it is. The
94
- // ancestry the publication checks walk is present either way.
95
- const cloneArgs=['clone','--no-hardlinks','--no-checkout','--single-branch','--branch',base.acceptedBranch];
96
- try { git(dirname(dest),[...cloneArgs,'--filter=blob:none','--',base.repository,dest],{timeout:gitTimeoutMs()}); }
97
- catch(e) {
98
- if(e.code==='E_COMMAND' && /filter/i.test(e.message)) {
99
- fs.rmSync(dest,{recursive:true,force:true});
100
- try { git(dirname(dest),[...cloneArgs,'--',base.repository,dest],{timeout:gitTimeoutMs()}); }
101
- catch(error) { unavailable(base,alias,'clone',error); }
102
- } else unavailable(base,alias,'clone',e);
103
- }
104
- try { requireNotShallow(base,alias,dest); }
105
- catch(e) { if(e.code==='E_BASE_SHALLOW') throw e; unavailable(base,alias,'clone',e); }
106
- try { git(dest,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()}); }
107
- catch(e) { unavailable(base,alias,'fetch',e); }
108
- const head=selectedHead ?? git(dest,['rev-parse','FETCH_HEAD']);
109
- try { requireNotShallow(base,alias,dest);verifyRemote(base,dest);materializeGitObjects(base,dest,head); }
110
- catch(e) { if(['E_BASE_SHALLOW','E_OWNER','E_CONFIRM','E_PATH','E_BASE','E_VALIDATION'].includes(e.code)) throw e; unavailable(base,alias,'checkout',e); }
111
- // Reject a linked bundle even if Git happily checked the link out.
112
- safePath(join(dest,base.root));
113
- return head;
114
- }
115
- export function stageBase(base, dest, { alias = base.id } = {}) {
116
- safePath(dest);
117
- if(fs.existsSync(dest)) fail('E_PATH',`staging destination exists: ${dest}`);
118
- const acceptedPath=base.kind==='directory'?base.path:(base.repository.startsWith('/')?resolve(base.repository,base.root):null);
119
- if(acceptedPath && overlaps(acceptedPath,dest)) fail('E_PATH','stage overlaps accepted base');
120
- if(base.kind==='git') {
121
- const head=clone(base,dest,undefined,{alias}); const root=join(dest,base.root);
122
- const validated=validateBase(root,base);
123
- verifyPublicationTree(base,dest,head,head,validated.files);
124
- return { ...validated, head, root, checkout:dest };
125
- }
126
- noGit(base.path);
127
- return withLock(baseLock(base),()=>{
128
- if(fs.existsSync(journalPath(base))) fail('E_RECOVERY',`publication pending: ${journalPath(base)}; retry its recorded run before reading`);
129
- const result=validateBase(base.path,base); materialize(dest,result.files);
130
- return {...result,root:dest};
131
- },{waitMs:CONSULT_READER_WAIT_MS});
132
- }
133
- export function allowedChanges(before, after, meta, owned) {
134
- const changed=[...new Set([...Object.keys(before),...Object.keys(after)])].filter(p=>before[p]!==after[p]);
135
- if(changed.includes('okf-base.json')) fail('E_OWNER','harvest cannot change base identity/ownership');
136
- const paths=owned.map(n=>meta.nodes[n]?.path || fail('E_OWNER',`unknown owned node ${n}`));
137
- for(const p of changed) if(!['index.md','log.md'].includes(p) && !paths.some(n=>p.startsWith(n+'/'))) fail('E_OWNER',`unauthorized touched path: ${p}`);
138
- // Base navigation can only gain/remove listings for this worker's nodes.
139
- if(changed.includes('index.md')) {
140
- const filter=value=>Buffer.from(value || '', 'base64').toString().split('\n').filter(l=>!paths.some(p=>l.includes(`](${p}/`) || l.includes(`](/${p}/`))).join('\n');
141
- if(filter(before['index.md'])!==filter(after['index.md'])) fail('E_OWNER','base index edits must be navigation for owned nodes only');
142
- }
143
- if(changed.includes('log.md')) {
144
- const old=Buffer.from(before['log.md']||'','base64').toString();
145
- const next=Buffer.from(after['log.md']||'','base64').toString();
146
- if(!next.includes(old)) fail('E_OWNER','base log is append/prepend-only, preserve history bytes');
147
- }
148
- return changed;
149
- }
150
- const INDEX_SNAPSHOT_LIMIT=16*1024*1024;
151
- const indexFailure=()=>fail('E_INDEX','Git verification requires a stable regular self-contained index within 16 MiB');
152
- function readVerificationIndex(source) {
153
- let fd;
154
- try {
155
- const before=fs.lstatSync(safePath(source),{bigint:true});
156
- const regular=stat=>stat.isFile() && stat.size>=0n && stat.size<=BigInt(INDEX_SNAPSHOT_LIMIT);
157
- const same=stat=>regular(stat) && ['dev','ino','size','mtimeNs','ctimeNs'].every(key=>stat[key]===before[key]);
158
- if(!regular(before) || typeof fs.constants.O_NOFOLLOW!=='number' || typeof fs.constants.O_NONBLOCK!=='number') indexFailure();
159
- // lstat alone races. NONBLOCK prevents a swapped FIFO from hanging open;
160
- // NOFOLLOW plus descriptor identity/type checks precede every byte read.
161
- fd=fs.openSync(source,fs.constants.O_RDONLY|fs.constants.O_NOFOLLOW|fs.constants.O_NONBLOCK);
162
- if(!same(fs.fstatSync(fd,{bigint:true})) || !same(fs.lstatSync(safePath(source),{bigint:true}))) indexFailure();
163
- const bytes=Buffer.alloc(Number(before.size));
164
- for(let offset=0;offset<bytes.length;) {
165
- const count=fs.readSync(fd,bytes,offset,bytes.length-offset,offset);
166
- if(!count) indexFailure();offset+=count;
167
- }
168
- if(!same(fs.fstatSync(fd,{bigint:true})) || !same(fs.lstatSync(safePath(source),{bigint:true}))) indexFailure();
169
- return bytes;
170
- } catch {indexFailure();}
171
- finally {if(fd!==undefined) fs.closeSync(fd);}
172
- }
173
- function withVerificationIndex(cwd,fn) {
174
- // Git 2.44 diff refreshes even with optional locks disabled. Use a COPY,
175
- // never a worker-index write/restore or the private publication index.
176
- const scratch=fs.mkdtempSync(join(fs.realpathSync(tmpdir()),'oats-okf-verify-index-')),owned=fs.lstatSync(scratch);
177
- try {
178
- const index=join(scratch,'index'),source=resolve(cwd,git(cwd,['rev-parse','--git-path','index']));
179
- fs.writeFileSync(index,readVerificationIndex(source),{flag:'wx',mode:0o600});
180
- const env={...cleanEnv(),GIT_INDEX_FILE:index},format=git(cwd,['rev-parse','--show-object-format']);
181
- if(!['sha1','sha256'].includes(format)) indexFailure();
182
- // Native split-index parsing freshens backing files BEFORE splitIndex=false
183
- // takes effect. Preflight in an EMPTY private gitdir: original sharedindex
184
- // files are never searched/opened, and missing dependencies refuse. Merely
185
- // putting a backing copy beside GIT_INDEX_FILE would not isolate that read.
186
- const gitdir=join(scratch,'repository');
187
- git(scratch,['init','--bare','--quiet','--template=',`--object-format=${format}`,gitdir]);
188
- try {
189
- const shared=git(scratch,['--no-optional-locks','-c','core.splitIndex=false','-c','core.fsmonitor=false','rev-parse','--shared-index-path'],{env:{...env,GIT_DIR:gitdir}});
190
- if(shared) indexFailure();
191
- } catch {indexFailure();}
192
- return fn(args=>git(cwd,['--no-optional-locks','-c','diff.autoRefreshIndex=true','-c','core.splitIndex=false',...args],{env}));
193
- } finally {
194
- const current=fs.lstatSync(scratch);
195
- if(!current.isDirectory() || current.dev!==owned.dev || current.ino!==owned.ino) fail('E_INDEX','Git verification scratch ownership changed; cleanup refused');
196
- fs.rmSync(scratch,{recursive:true,force:true});
197
- }
198
- }
199
- export function verifyGitScope(base, dest, baseline, { checkModes = true } = {}) {
200
- return withVerificationIndex(dest,read=>{
201
- // Entries outside the root are never materialized, so their absence is
202
- // not a deletion; a present outside file that differs still is a change.
203
- const names=read(['diff','--name-only','-z',baseline,'--']).split('\0').filter(Boolean).filter(p=>!(outsideBase(base,p) && !fs.existsSync(join(dest,p))));
204
- // A restored working file can conceal a staged, unauthorized index entry.
205
- names.push(...read(['diff','--cached','--name-only','-z',baseline,'--']).split('\0').filter(Boolean));
206
- names.push(...read(['ls-files','--others','--exclude-standard','-z']).split('\0').filter(Boolean));
207
- if(base.root!=='.' && names.some(p=>!p.startsWith(base.root+'/'))) fail('E_OWNER','Git worker touched files outside its knowledge base');
208
- // --others without exclude-standard includes ignored additions as well.
209
- // Unchanged code symlinks outside the base are not knowledge and need not be
210
- // traversed. Bundle symlinks are rejected by validateBase/tree.
211
- const extra=read(['ls-files','--others','-z']).split('\0').filter(Boolean);
212
- if(base.root!=='.' && extra.some(p=>!p.startsWith(base.root+'/'))) fail('E_OWNER','untracked/ignored file outside knowledge');
213
- if(checkModes) {
214
- // Harvest judgments authorize content, not executable-bit edits. Inspect
215
- // the filesystem against frozen objects, not core.fileMode or index flags.
216
- for(const [p,entry] of gitTreeEntries(dest,baseline)) {
217
- if(!['100644','100755'].includes(entry.mode)) continue;
218
- const path=join(dest,p);
219
- let stat;try {stat=fs.lstatSync(path);} catch(e) {if(e.code==='ENOENT' || e.code==='ENOTDIR') continue;throw e;}
220
- if(stat.isFile() && (stat.mode & 0o100 ? '100755':'100644')!==entry.mode) fail('E_OWNER',`harvest cannot change Git file mode: ${p}`);
221
- }
222
- }
223
- verifyRemote(base,dest);
224
- });
225
- }
226
- // Caller MUST hold the cooperative base lock. Intent is durable before journal
227
- // installation; publishing is durable AFTER it and BEFORE the first accepted
228
- // write. The journal is removed only after a durable accepted receipt. Thus an
229
- // absent journal can cancel an intent, but never a write-authorized publication.
230
- export function reconcileDirectoryIntent(base, receipt, persist) {
231
- if(fs.existsSync(journalPath(base))) return;
232
- if(receipt.status==='publication-intent') {
233
- receipt.status='validated'; persist();
234
- } else if(!['validated','staged','accepted','no-change'].includes(receipt.status)) {
235
- fail('E_RECOVERY','directory publication journal missing after writes were authorized; inspect and restore custody before retrying or rejudging');
236
- }
237
- }
238
- export function directoryPublish(base, proposal, receipt, persist, { afterWrite } = {}) {
239
- noGit(base.path);
240
- return withLock(baseLock(base),()=>{
241
- const jp=journalPath(base);
242
- reconcileDirectoryIntent(base,receipt,persist);
243
- if(receipt.status==='accepted' && !fs.existsSync(jp)) return receipt;
244
- const changed=[...new Set([...Object.keys(proposal.before),...Object.keys(proposal.after)])].filter(p=>proposal.before[p]!==proposal.after[p]);
245
- // A mount boundary cannot support our cross-directory atomic rename.
246
- // Detect it BEFORE journal intent or any deletion/replacement, rather than
247
- // leaving a partially applied publication that can only fail with EXDEV.
248
- const device=fs.statSync(baseLock(base)).dev;
249
- for(const p of changed.filter(p=>proposal.after[p]!==undefined)) {
250
- let parent=dirname(safePath(join(base.path,relPath(p))));
251
- while(!fs.existsSync(parent)) parent=dirname(parent);
252
- if(fs.statSync(parent).dev!==device) fail('E_PATH','directory publication targets and sibling lock must share a filesystem');
253
- }
254
- let journal;
255
- if(fs.existsSync(jp)) {
256
- journal=readJSON(jp);
257
- if(journal.run!==proposal.run || journal.proposalHash!==hash(proposal)) fail('E_RECOVERY',`another publication must recover first: ${jp}`);
258
- } else {
259
- const current=validateBase(base.path,base);
260
- if(current.digest!==digest(proposal.before)) fail('E_BASELINE','directory base changed; retain evidence and rejudge on a fresh run');
261
- journal={version:1,run:proposal.run,proposalHash:hash(proposal),proposalFile:proposal.file,status:'publishing'};
262
- receipt.status='publication-intent'; persist();
263
- atomic(jp,JSON.stringify(journal,null,2)+'\n',{tempDir:baseLock(base)});
264
- }
265
- if(receipt.status==='accepted') {
266
- // A death after receipt persistence needs only journal cleanup, never
267
- // another accepted write (nor a replay over unexpected newer bytes).
268
- const final=validateBase(base.path,base);
269
- if(final.digest!==digest(proposal.after) || receipt.acceptedDigest!==final.digest) fail('E_CONFIRM','accepted directory receipt differs from journalled bytes');
270
- fs.rmSync(jp); syncParent(jp); return receipt;
271
- }
272
- const current=tree(base.path);
273
- for(const p of new Set([...Object.keys(current),...Object.keys(proposal.before),...Object.keys(proposal.after)])) {
274
- if(current[p]!==proposal.before[p] && current[p]!==proposal.after[p]) fail('E_BASELINE',`unexpected bytes during publication recovery: ${p}`);
275
- if(!changed.includes(p) && current[p]!==proposal.before[p]) fail('E_BASELINE',`unchanged file differs: ${p}`);
276
- }
277
- receipt.status='publishing'; persist();
278
- let count=0;
279
- for(const p of changed) {
280
- const target=safePath(join(base.path,relPath(p)));
281
- // A previous death may follow rename/unlink but precede its directory
282
- // fsync. Confirm that metadata without replacing accepted bytes again.
283
- if(current[p]===proposal.after[p]) {syncParent(target);continue;}
284
- if(proposal.after[p]===undefined) { fs.rmSync(target,{force:true}); syncParent(target); }
285
- // The lock owns scratch files as well as owner.json. A dead-owner unlock
286
- // removes incomplete scratch; it never leaves temp files in the bundle.
287
- else atomic(target,Buffer.from(proposal.after[p],'base64'),{tempDir:baseLock(base)});
288
- afterWrite?.(++count); // fault injection is programmatic tests only, never an environment switch
289
- }
290
- const final=validateBase(base.path,base);
291
- if(final.digest!==digest(proposal.after)) fail('E_CONFIRM','directory publication confirmation differs');
292
- receipt.status='accepted'; receipt.acceptedDigest=final.digest; receipt.acceptedAt=new Date().toISOString(); persist();
293
- fs.rmSync(jp); syncParent(jp);
294
- return receipt;
295
- },{waitMs:CONSULT_READER_WAIT_MS});
296
- }
297
- function syncParent(p) { const fd=fs.openSync(dirname(p),'r'); try {fs.fsyncSync(fd);} finally {fs.closeSync(fd);} }
298
- function prRows(base,branch,cwd,{identity,allBases=false}={}) {
299
- const fields='number,url,state,headRefOid,baseRefName,headRefName,mergedAt,mergeCommit';
300
- // Once observed, a PR is addressed by repository + number, never a mutable
301
- // head/base filter. A missing/inaccessible identity is an error, not absence.
302
- if(identity) {
303
- if(!Number.isInteger(identity.number) || identity.number<1 || !/^https:\/\//.test(identity.url)) fail('E_RECOVERY','invalid recorded PR identity');
304
- const pr=JSON.parse(exec('gh',['pr','view',String(identity.number),'--repo',base.pr.repository,'--json',fields],{cwd,env:gitEnv()}));
305
- if(!pr || Array.isArray(pr) || typeof pr!=='object') fail('E_RECOVERY','known PR missing; reconcile custody before rejudging');
306
- return [pr];
307
- }
308
- const raw=exec('gh',['pr','list','--repo',base.pr.repository,'--head',branch,...(allBases?[]:['--base',base.acceptedBranch]),'--state','all','--json',fields],{cwd,env:gitEnv()});
309
- const rows=JSON.parse(raw); if(!Array.isArray(rows)) fail('E_PR','invalid gh PR list'); return rows;
310
- }
311
- export function verifyRemote(base,cwd) {
312
- for(const mode of [[],['--push']]) {
313
- const urls=git(cwd,['remote','get-url',...mode,'--all','origin']).split('\n');
314
- if(!urls.length || urls.some(url=>url!==base.repository)) fail('E_OWNER','worker changed frozen Git publication remote or effective push destination');
315
- }
316
- }
317
- function changedPaths(before,after) {
318
- return [...new Set([...Object.keys(before),...Object.keys(after)])].filter(p=>before[p]!==after[p]);
319
- }
320
- function gitTreeEntries(cwd,treeId) {
321
- return new Map(git(cwd,['ls-tree','-r','-z',treeId]).split('\0').filter(Boolean).map(entry=>{
322
- const tab=entry.indexOf('\t'),[mode,type,oid]=entry.slice(0,tab).split(' ');
323
- return [entry.slice(tab+1),{mode,type,oid}];
324
- }));
325
- }
326
- // Verify the REAL tree, not Git's working-tree view. Blob hashes include the
327
- // exact bytes (no text trimming); Git filters/attributes and ignores cannot
328
- // substitute something different from the validated proposal. Modes come only
329
- // from the frozen baseline (new files are non-executable), never worker state.
330
- function verifyPublicationTree(base,cwd,baseline,treeId,after,before=after) {
331
- const names=git(cwd,['diff-tree','--no-commit-id','--no-renames','--name-only','-r','-z',baseline,treeId,'--']).split('\0').filter(Boolean);
332
- if(base.root!=='.' && names.some(p=>!p.startsWith(base.root+'/'))) fail('E_OWNER','publication tree changes files outside its knowledge base');
333
- const format=git(cwd,['rev-parse','--show-object-format']);
334
- const prefix=base.root==='.'?'':base.root+'/';
335
- const changed=new Set(changedPaths(before,after).map(p=>prefix+p));
336
- if(names.some(p=>!changed.has(p))) fail('E_OWNER','publication tree changes paths outside the validated content changes');
337
- const baselineEntries=gitTreeEntries(cwd,baseline);
338
- const found=new Set();
339
- for(const [path,{mode,type,oid}] of gitTreeEntries(cwd,treeId)) {
340
- if(!path.startsWith(prefix)) continue;
341
- const p=path.slice(prefix.length);
342
- if(!Object.hasOwn(after,p) || type!=='blob' || !['100644','100755'].includes(mode)) fail('E_CONFIRM',`publication tree differs from validated proposal: ${p}`);
343
- if(mode!==(baselineEntries.get(path)?.mode || '100644')) fail('E_OWNER',`publication tree changes frozen Git file mode: ${p}`);
344
- const bytes=Buffer.from(after[p],'base64');
345
- const expected=createHash(format).update(`blob ${bytes.length}\0`).update(bytes).digest('hex');
346
- if(oid!==expected) fail('E_CONFIRM',`publication tree bytes differ from validated proposal: ${p}`);
347
- found.add(p);
348
- }
349
- if(found.size!==Object.keys(after).length) fail('E_CONFIRM','publication tree omits validated proposal files (check Git ignores/attributes)');
350
- }
351
- function immutableCommit(cwd,oid) {
352
- if(typeof oid!=='string' || !/^(?:[0-9a-f]{40}|[0-9a-f]{64})$/.test(oid)) fail('E_CONFIRM','publication requires an immutable full commit OID');
353
- if(git(cwd,['cat-file','-t',oid])!=='commit') fail('E_CONFIRM','publication OID must identify a commit object, not a peelable tag');
354
- // Read object headers, not revision history (which can honor grafts).
355
- const header=git(cwd,['cat-file','commit',oid]).split('\n\n')[0].split('\n');
356
- return {tree:header.find(l=>l.startsWith('tree '))?.slice(5),parents:header.filter(l=>l.startsWith('parent ')).map(l=>l.slice(7))};
357
- }
358
- export function gitPublish(base, stage, proposal, receipt, persist, {beforePublish=()=>{},prIdentity=receipt.pr,pr:presentation}={}) {
359
- const cwd=stage.checkout, branch=`okf/${proposal.attempt || proposal.run}-${base.id}`;
360
- verifyRemote(base,cwd);
361
- const baseline=immutableCommit(cwd,stage.head);
362
- verifyPublicationTree(base,cwd,baseline.tree,baseline.tree,proposal.before);
363
- // Baseline must still be accepted. Never rebase model output without rejudging it.
364
- git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
365
- const accepted=git(cwd,['rev-parse','FETCH_HEAD']);
366
- if(accepted!==stage.head) {
367
- // A known or uncertain previously created PR may be reconciled, but a
368
- // committed/pushed proposal alone does not authorize a NEW stale-base PR.
369
- const prior=receipt.commit?prRows(base,branch,cwd,{identity:prIdentity}).filter(p=>p.headRefOid===receipt.commit && p.headRefName===branch && p.baseRefName===base.acceptedBranch):[];
370
- if(prior.length!==1) fail('E_BASELINE','accepted Git head changed before verified PR delivery; explicit rejudgment required');
371
- }
372
- if(!receipt.commit) {
373
- // Publication consumes a frozen content proposal, also on migration/retry
374
- // where materialization may have reset filesystem permissions. Its modes
375
- // are reconstructed below, not authorized by the mutable worktree.
376
- verifyGitScope(base,cwd,stage.head,{checkModes:false});
377
- if(digest(tree(stage.root,{git:base.root==='.'}))!==digest(proposal.after)) fail('E_BASELINE','staged proposal changed after validation');
378
- beforePublish();
379
- receipt.status='commit-intent'; receipt.branch=branch; persist();
380
- // Never trust the model's index. Start a private publication index from
381
- // the frozen accepted commit, and leave the worker's own index intact.
382
- const scratch=fs.mkdtempSync(join(dirname(proposal.file),'git-index-'));
383
- let treeId;
384
- try {
385
- const env={...cleanEnv(),GIT_INDEX_FILE:join(scratch,'index')};
386
- git(cwd,['read-tree',baseline.tree],{env});
387
- const prefix=base.root==='.'?'':base.root+'/';
388
- const entries=gitTreeEntries(cwd,baseline.tree);
389
- // Recompute the delta from validated bytes, never trust proposal.changed
390
- // or stage an entire directory (which also collects mode-only edits).
391
- for(const p of changedPaths(proposal.before,proposal.after)) {
392
- const path=prefix+relPath(p);
393
- // An ignored addition can exit 1. The exact tree check below must still
394
- // reject its omission; no ignore/filter failure becomes a partial PR.
395
- git(cwd,['--literal-pathspecs','add','--all','--chmod=-x','--',path],{env,acceptedStatus:[0,1]});
396
- if(Object.hasOwn(proposal.after,p) && entries.get(path)?.mode==='100755') git(cwd,['--literal-pathspecs','update-index','--chmod=+x','--',path],{env});
397
- }
398
- treeId=git(cwd,['write-tree'],{env});
399
- verifyPublicationTree(base,cwd,baseline.tree,treeId,proposal.after,proposal.before);
400
- } finally {fs.rmSync(scratch,{recursive:true,force:true});}
401
- // Deterministic commit-tree permits replay after process death between the
402
- // real Git write and receipt persistence; no guessed commit or fake repository.
403
- const stamp=proposal.created;
404
- const env={...cleanEnv(),GIT_AUTHOR_NAME:'OKF harvest',GIT_AUTHOR_EMAIL:'okf@localhost',GIT_COMMITTER_NAME:'OKF harvest',GIT_COMMITTER_EMAIL:'okf@localhost',GIT_AUTHOR_DATE:stamp,GIT_COMMITTER_DATE:stamp};
405
- receipt.commit=git(cwd,['commit-tree',treeId,'-p',stage.head,'-m',`okf-harvest: ${proposal.run}`],{env});
406
- receipt.status='committed'; persist();
407
- }
408
- const publication=immutableCommit(cwd,receipt.commit);
409
- if(publication.parents.length!==1 || publication.parents[0]!==stage.head) fail('E_CONFIRM','publication commit must have exactly the frozen baseline as its parent');
410
- verifyPublicationTree(base,cwd,baseline.tree,publication.tree,proposal.after,proposal.before);
411
- const remoteTip=()=>git(cwd,['ls-remote','--heads','origin',`refs/heads/${branch}`],{timeout:gitTimeoutMs()}).split(/\s/)[0] || null;
412
- let tip=remoteTip();
413
- if(tip && tip!==receipt.commit) fail('E_PR','publication branch has unexpected commit; never force push');
414
- if(tip!==receipt.commit) {
415
- beforePublish();
416
- receipt.status='push-intent'; persist();
417
- try { verifyRemote(base,cwd);git(cwd,['push','--no-follow-tags','--recurse-submodules=no','origin',`${receipt.commit}:refs/heads/${branch}`],{timeout:gitTimeoutMs()}); }
418
- catch(e) { receipt.status='push-unknown'; receipt.error=e.message; persist(); throw e; }
419
- tip=remoteTip(); if(tip!==receipt.commit) {receipt.status='push-unknown';persist();fail('E_CONFIRM','pushed head not confirmed');}
420
- }
421
- receipt.status='pushed'; persist();
422
- let rows;
423
- try {rows=prRows(base,branch,cwd,{identity:prIdentity});} catch(e) {receipt.status='pr-unknown';receipt.error=e.message;persist();throw e;}
424
- if(!rows.length) {
425
- beforePublish();
426
- receipt.status='pr-intent'; persist();
427
- const title=presentation?.title || `okf-harvest: ${proposal.run}`,body=presentation?.body || `Knowledge-only proposal from durable OKF run ${proposal.run}. Review provenance and promotion judgment.`;
428
- const label=presentation?.label;
429
- // The label is what the harvest-review trigger watches: make sure the
430
- // repository has it (idempotent), then open the PR carrying it.
431
- if(label) try {exec('gh',['label','create',label,'--repo',base.pr.repository,'--force','--color','0E8A16','--description','OKF harvest PR (oats.okf)'],{cwd,env:gitEnv()});} catch { /* may exist already or be unmanageable; pr create reports a real problem */ }
432
- try { exec('gh',['pr','create','--repo',base.pr.repository,'--head',branch,'--base',base.acceptedBranch,'--title',title,'--body',body,...(label?['--label',label]:[])],{cwd,env:gitEnv()}); }
433
- catch(e) {receipt.status='pr-unknown';receipt.error=e.message;persist();throw e;}
434
- rows=prRows(base,branch,cwd);
435
- }
436
- const matching=rows.filter(p=>p.headRefOid===receipt.commit && p.headRefName===branch && p.baseRefName===base.acceptedBranch && Number.isInteger(p.number) && /^https:\/\//.test(p.url) && (!prIdentity || (p.number===prIdentity.number && p.url===prIdentity.url)));
437
- if(matching.length!==1 || rows.length!==1) {receipt.status='pr-unknown';persist();fail('E_PR','actual PR identity/head/base could not be uniquely verified');}
438
- const pr=matching[0];receipt.pr=pr;receipt.status='delivered';receipt.deliveredAt ||= new Date().toISOString();persist();
439
- if(pr.state==='CLOSED' && !pr.mergedAt) {receipt.status='rejected';persist();fail('E_PR','PR closed without merge; retained proposal requires operator review');}
440
- if(pr.mergedAt) {
441
- git(cwd,['fetch','origin',`refs/heads/${base.acceptedBranch}`],{timeout:gitTimeoutMs()});
442
- const merge=pr.mergeCommit?.oid;
443
- if(!merge) fail('E_CONFIRM','merged PR lacks merge commit');
444
- git(cwd,['merge-base','--is-ancestor',merge,'FETCH_HEAD']);
445
- receipt.status='accepted';receipt.acceptedCommit=git(cwd,['rev-parse','FETCH_HEAD']);receipt.acceptedAt=new Date().toISOString();persist();
446
- }
447
- return receipt;
448
- }
449
-
450
- export function recoveryStage(base, stage, proposal, dest) {
451
- clone(base,dest,stage.head);
452
- const root=join(dest,base.root);
453
- for(const p of Object.keys(proposal.before)) if(!(p in proposal.after)) fs.rmSync(safePath(join(root,p)),{force:true});
454
- materialize(root,proposal.after);validateBase(root,base);
455
- return {...stage,root,checkout:dest};
456
- }
457
-
458
- // Remote-read-only recovery gate. --repo makes this independent of any deleted
459
- // worker checkout. The caller durably saves first observations separately from
460
- // historical receipts, even if a later gate prevents recovery from completing.
461
- export function gitRecoveryState(base,receipt,cwd,{identity=receipt.pr,onObserve=()=>{}}={}) {
462
- if(['accepted','no-change'].includes(receipt.status)) return 'settled';
463
- if(!receipt.branch) return 'unresolved';
464
- const rows=prRows(base,receipt.branch,cwd,{identity,allBases:true});
465
- if(!rows.length) {
466
- if(identity || ['delivered','rejected'].includes(receipt.status)) fail('E_RECOVERY','known PR missing; reconcile custody before rejudging');
467
- return 'unresolved';
468
- }
469
- const pr=rows[0];
470
- if(rows.length!==1 || pr.headRefOid!==receipt.commit || pr.headRefName!==receipt.branch || pr.baseRefName!==base.acceptedBranch || !Number.isInteger(pr.number) || pr.number<1 || !/^https:\/\//.test(pr.url) || [identity,receipt.pr].some(known=>known && (pr.number!==known.number || pr.url!==known.url)) || !['OPEN','CLOSED','MERGED'].includes(pr.state) || (pr.state==='MERGED' && !pr.mergedAt)) fail('E_RECOVERY','actual PR identity/head/base could not be uniquely verified for recovery');
471
- onObserve(pr);
472
- return pr.state==='CLOSED' && !pr.mergedAt?'unresolved':'settled';
473
- }