textus 0.55.1 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (329) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +175 -2768
  3. data/README.md +17 -18
  4. data/lib/textus/artifact.rb +8 -0
  5. data/lib/textus/errors.rb +2 -39
  6. data/lib/textus/human.rb +7 -0
  7. data/lib/textus/infra/adapters/concurrency_adapter.rb +15 -0
  8. data/lib/textus/infra/clock.rb +7 -0
  9. data/lib/textus/infra/database.rb +127 -0
  10. data/lib/textus/infra/file_system.rb +54 -0
  11. data/lib/textus/infra/locks.rb +50 -0
  12. data/lib/textus/infra/port/clock.rb +18 -0
  13. data/lib/textus/infra/port/publisher.rb +71 -0
  14. data/lib/textus/infra/port/sentinel.rb +25 -0
  15. data/lib/textus/infra/port/sentinel_store.rb +93 -0
  16. data/lib/textus/infra/store/audit_log.rb +42 -0
  17. data/lib/textus/infra/store/base.rb +25 -0
  18. data/lib/textus/infra/store/cursor_store.rb +27 -0
  19. data/lib/textus/infra/store/entry_index.rb +31 -0
  20. data/lib/textus/infra/store/envelope/meta.rb +63 -0
  21. data/lib/textus/infra/store/file_store.rb +23 -0
  22. data/lib/textus/infra/store/index/builder.rb +28 -0
  23. data/lib/textus/infra/store/index/lookup.rb +23 -0
  24. data/lib/textus/infra/store/link_store.rb +43 -0
  25. data/lib/textus/infra/store/workflow_queue.rb +54 -0
  26. data/lib/textus/infra/store.rb +29 -0
  27. data/lib/textus/ingest.rb +8 -0
  28. data/lib/textus/lanes/artifact/cache.rb +35 -0
  29. data/lib/textus/lanes/artifact/handlers.rb +29 -0
  30. data/lib/textus/lanes/artifact/lifecycle.rb +23 -0
  31. data/lib/textus/lanes/artifact/verbs.rb +18 -0
  32. data/lib/textus/lanes/ingest/dedup.rb +17 -0
  33. data/lib/textus/lanes/ingest/entry_types/asset.rb +45 -0
  34. data/lib/textus/lanes/ingest/entry_types/link.rb +16 -0
  35. data/lib/textus/lanes/ingest/entry_types/text.rb +17 -0
  36. data/lib/textus/lanes/ingest/entry_types.rb +25 -0
  37. data/lib/textus/lanes/ingest/handlers.rb +54 -0
  38. data/lib/textus/lanes/ingest/index_rebuilder.rb +13 -0
  39. data/lib/textus/lanes/ingest/key_builder.rb +33 -0
  40. data/lib/textus/lanes/ingest/resolver.rb +21 -0
  41. data/lib/textus/lanes/knowledge/freeform.rb +15 -0
  42. data/lib/textus/lanes/knowledge/handlers.rb +53 -0
  43. data/lib/textus/lanes/knowledge/runner.rb +45 -0
  44. data/lib/textus/lanes/knowledge/verbs.rb +16 -0
  45. data/lib/textus/lanes/scratchpad/handlers.rb +164 -0
  46. data/lib/textus/lanes/scratchpad/verbs.rb +73 -0
  47. data/lib/textus/{gate → protocol}/binder.rb +2 -10
  48. data/lib/textus/protocol/boot/catalog.rb +34 -0
  49. data/lib/textus/protocol/boot/context.rb +124 -0
  50. data/lib/textus/protocol/boot/protocol.rb +33 -0
  51. data/lib/textus/protocol/boot.rb +42 -0
  52. data/lib/textus/protocol/builder/domain.rb +17 -0
  53. data/lib/textus/protocol/builder/infrastructure.rb +17 -0
  54. data/lib/textus/protocol/builder/runtime.rb +46 -0
  55. data/lib/textus/protocol/builder.rb +11 -0
  56. data/lib/textus/protocol/container.rb +22 -0
  57. data/lib/textus/protocol/diff.rb +170 -0
  58. data/lib/textus/protocol/entry_constraint.rb +54 -0
  59. data/lib/textus/protocol/envelope.rb +70 -0
  60. data/lib/textus/protocol/format/base.rb +80 -0
  61. data/lib/textus/protocol/format/bash.rb +10 -0
  62. data/lib/textus/protocol/format/javascript.rb +10 -0
  63. data/lib/textus/protocol/format/json.rb +82 -0
  64. data/lib/textus/protocol/format/markdown.rb +53 -0
  65. data/lib/textus/protocol/format/python.rb +10 -0
  66. data/lib/textus/protocol/format/ruby.rb +10 -0
  67. data/lib/textus/protocol/format/script.rb +55 -0
  68. data/lib/textus/protocol/format/text.rb +48 -0
  69. data/lib/textus/protocol/format/yaml.rb +105 -0
  70. data/lib/textus/protocol/format.rb +73 -0
  71. data/lib/textus/protocol/freshness/ttl_evaluator.rb +61 -0
  72. data/lib/textus/protocol/gate.rb +61 -0
  73. data/lib/textus/protocol/handlers/command_context.rb +19 -0
  74. data/lib/textus/protocol/handlers/concern.rb +15 -0
  75. data/lib/textus/protocol/handlers/ops/audit_filter.rb +37 -0
  76. data/lib/textus/protocol/handlers/ops/bulk_planner.rb +38 -0
  77. data/lib/textus/protocol/handlers/ops/data_mv.rb +49 -0
  78. data/lib/textus/protocol/handlers/ops/git_blame.rb +65 -0
  79. data/lib/textus/protocol/handlers/ops/pulse_coordinator.rb +37 -0
  80. data/lib/textus/protocol/handlers/ops/source_expander.rb +41 -0
  81. data/lib/textus/protocol/handlers/query_context.rb +41 -0
  82. data/lib/textus/protocol/handlers/read.rb +67 -0
  83. data/lib/textus/protocol/handlers/system.rb +93 -0
  84. data/lib/textus/protocol/handlers/write.rb +57 -0
  85. data/lib/textus/protocol/init.rb +143 -0
  86. data/lib/textus/protocol/key/grammar.rb +35 -0
  87. data/lib/textus/protocol/key/matching.rb +23 -0
  88. data/lib/textus/protocol/key/path.rb +23 -0
  89. data/lib/textus/protocol/key.rb +25 -0
  90. data/lib/textus/protocol/lane.rb +12 -0
  91. data/lib/textus/{store/geometry.rb → protocol/layout.rb} +32 -22
  92. data/lib/textus/protocol/links/resolver.rb +33 -0
  93. data/lib/textus/protocol/links/uri_rewriter.rb +30 -0
  94. data/lib/textus/protocol/links.rb +10 -0
  95. data/lib/textus/protocol/manifest/data.rb +90 -0
  96. data/lib/textus/protocol/manifest/entry/base.rb +95 -0
  97. data/lib/textus/protocol/manifest/entry/leaf.rb +19 -0
  98. data/lib/textus/protocol/manifest/entry/nested.rb +19 -0
  99. data/lib/textus/protocol/manifest/entry/parser.rb +71 -0
  100. data/lib/textus/protocol/manifest/entry/publish.rb +209 -0
  101. data/lib/textus/protocol/manifest/entry.rb +10 -0
  102. data/lib/textus/protocol/manifest/policy/publish_target.rb +36 -0
  103. data/lib/textus/protocol/manifest/policy.rb +69 -0
  104. data/lib/textus/protocol/manifest/resolver.rb +156 -0
  105. data/lib/textus/protocol/manifest/schema/contract.rb +53 -0
  106. data/lib/textus/protocol/manifest/schema/validator.rb +52 -0
  107. data/lib/textus/protocol/manifest/schema.rb +40 -0
  108. data/lib/textus/protocol/manifest.rb +62 -0
  109. data/lib/textus/protocol/produce/render/context.rb +43 -0
  110. data/lib/textus/protocol/produce/render.rb +44 -0
  111. data/lib/textus/protocol/schema/registry.rb +44 -0
  112. data/lib/textus/protocol/schema/tools.rb +104 -0
  113. data/lib/textus/protocol/schema.rb +117 -0
  114. data/lib/textus/protocol/session/bridge.rb +11 -0
  115. data/lib/textus/protocol/session/context.rb +5 -0
  116. data/lib/textus/protocol/session.rb +87 -0
  117. data/lib/textus/protocol/store_engine/delete.rb +37 -0
  118. data/lib/textus/protocol/store_engine/move.rb +109 -0
  119. data/lib/textus/protocol/store_engine/pipeline.rb +61 -0
  120. data/lib/textus/protocol/store_engine/put.rb +104 -0
  121. data/lib/textus/protocol/store_engine.rb +86 -0
  122. data/lib/textus/protocol/v4.rb +25 -0
  123. data/lib/textus/protocol/verb_registry/arg_spec.rb +19 -0
  124. data/lib/textus/protocol/verb_registry/core_verbs.rb +164 -0
  125. data/lib/textus/protocol/verb_registry/verb_spec.rb +42 -0
  126. data/lib/textus/protocol/verb_registry.rb +42 -0
  127. data/lib/textus/surface/cli/group/{rule.rb → web.rb} +2 -2
  128. data/lib/textus/surface/cli/runner.rb +25 -25
  129. data/lib/textus/surface/cli/sources.rb +1 -3
  130. data/lib/textus/surface/cli/verb/get.rb +3 -6
  131. data/lib/textus/surface/cli/verb/init.rb +1 -1
  132. data/lib/textus/surface/cli/verb/put.rb +8 -6
  133. data/lib/textus/surface/cli/verb/web_serve.rb +22 -0
  134. data/lib/textus/surface/cli.rb +25 -6
  135. data/lib/textus/surface/dispatch.rb +17 -0
  136. data/lib/textus/surface/mcp/adapters/mcp_adapter.rb +28 -0
  137. data/lib/textus/surface/mcp/catalog.rb +26 -62
  138. data/lib/textus/surface/mcp/cursor_manager.rb +22 -0
  139. data/lib/textus/surface/mcp/drift_annotation.rb +19 -0
  140. data/lib/textus/surface/mcp/errors.rb +0 -10
  141. data/lib/textus/surface/mcp/server.rb +46 -46
  142. data/lib/textus/surface/watcher.rb +18 -12
  143. data/lib/textus/surface/web.rb +50 -0
  144. data/lib/textus/value/call.rb +1 -1
  145. data/lib/textus/{core → value}/duration.rb +1 -4
  146. data/lib/textus/value/etag.rb +7 -7
  147. data/lib/textus/value/payload.rb +7 -0
  148. data/lib/textus/version.rb +1 -1
  149. data/lib/textus/workflow/consumer.rb +39 -0
  150. data/lib/textus/workflow/dsl/lifecycle.rb +32 -0
  151. data/lib/textus/workflow/dsl/pattern_matcher.rb +20 -0
  152. data/lib/textus/workflow/dsl/step_builder.rb +56 -0
  153. data/lib/textus/workflow/dsl.rb +111 -21
  154. data/lib/textus/workflow/event_emitter.rb +21 -0
  155. data/lib/textus/workflow/publisher.rb +59 -0
  156. data/lib/textus/workflow/registry.rb +31 -8
  157. data/lib/textus/workflow/runner.rb +25 -61
  158. data/lib/textus/workflow/scheduler.rb +45 -0
  159. data/lib/textus/workflow/step_executor.rb +78 -0
  160. data/lib/textus/workflow/step_helpers.rb +102 -0
  161. data/lib/textus/workflow.rb +2 -5
  162. data/lib/textus.rb +16 -80
  163. metadata +167 -183
  164. data/SPEC.md +0 -908
  165. data/docs/architecture/README.md +0 -273
  166. data/docs/reference/conventions.md +0 -138
  167. data/lib/textus/action/accept.rb +0 -46
  168. data/lib/textus/action/audit.rb +0 -94
  169. data/lib/textus/action/base.rb +0 -42
  170. data/lib/textus/action/blame.rb +0 -79
  171. data/lib/textus/action/boot.rb +0 -15
  172. data/lib/textus/action/data_mv.rb +0 -58
  173. data/lib/textus/action/deps.rb +0 -19
  174. data/lib/textus/action/doctor.rb +0 -17
  175. data/lib/textus/action/drain.rb +0 -31
  176. data/lib/textus/action/enqueue.rb +0 -37
  177. data/lib/textus/action/get.rb +0 -34
  178. data/lib/textus/action/ingest.rb +0 -199
  179. data/lib/textus/action/jobs.rb +0 -27
  180. data/lib/textus/action/key_delete.rb +0 -26
  181. data/lib/textus/action/key_delete_prefix.rb +0 -35
  182. data/lib/textus/action/key_mv.rb +0 -122
  183. data/lib/textus/action/key_mv_prefix.rb +0 -48
  184. data/lib/textus/action/list.rb +0 -28
  185. data/lib/textus/action/propose.rb +0 -42
  186. data/lib/textus/action/published.rb +0 -22
  187. data/lib/textus/action/pulse.rb +0 -49
  188. data/lib/textus/action/put.rb +0 -38
  189. data/lib/textus/action/rdeps.rb +0 -24
  190. data/lib/textus/action/reject.rb +0 -28
  191. data/lib/textus/action/rule_explain.rb +0 -81
  192. data/lib/textus/action/rule_lint.rb +0 -62
  193. data/lib/textus/action/rule_list.rb +0 -38
  194. data/lib/textus/action/schema_envelope.rb +0 -22
  195. data/lib/textus/action/uid.rb +0 -19
  196. data/lib/textus/action/where.rb +0 -21
  197. data/lib/textus/boot.rb +0 -202
  198. data/lib/textus/contract/arg.rb +0 -10
  199. data/lib/textus/contract/dsl.rb +0 -88
  200. data/lib/textus/contract/spec.rb +0 -25
  201. data/lib/textus/contract.rb +0 -12
  202. data/lib/textus/core/freshness/evaluator.rb +0 -150
  203. data/lib/textus/core/freshness/verdict.rb +0 -34
  204. data/lib/textus/core/freshness.rb +0 -11
  205. data/lib/textus/core/retention/sweep.rb +0 -57
  206. data/lib/textus/core/retention.rb +0 -11
  207. data/lib/textus/core/sentinel.rb +0 -30
  208. data/lib/textus/doctor/check/audit_log.rb +0 -34
  209. data/lib/textus/doctor/check/generator_drift.rb +0 -29
  210. data/lib/textus/doctor/check/illegal_keys.rb +0 -60
  211. data/lib/textus/doctor/check/manifest_files.rb +0 -25
  212. data/lib/textus/doctor/check/notebook_sources.rb +0 -50
  213. data/lib/textus/doctor/check/orphaned_publish_targets.rb +0 -35
  214. data/lib/textus/doctor/check/proposal_targets.rb +0 -45
  215. data/lib/textus/doctor/check/protocol_version.rb +0 -47
  216. data/lib/textus/doctor/check/publish_tree_index_overlap.rb +0 -48
  217. data/lib/textus/doctor/check/raw_asset_paths.rb +0 -50
  218. data/lib/textus/doctor/check/rule_ambiguity.rb +0 -49
  219. data/lib/textus/doctor/check/schema_parse_error.rb +0 -28
  220. data/lib/textus/doctor/check/schema_violations.rb +0 -28
  221. data/lib/textus/doctor/check/schemas.rb +0 -26
  222. data/lib/textus/doctor/check/sentinels.rb +0 -60
  223. data/lib/textus/doctor/check/stale_reviewed_stamp.rb +0 -54
  224. data/lib/textus/doctor/check/templates.rb +0 -29
  225. data/lib/textus/doctor/check/unowned_schema_fields.rb +0 -40
  226. data/lib/textus/doctor/check.rb +0 -41
  227. data/lib/textus/doctor/validator.rb +0 -93
  228. data/lib/textus/doctor.rb +0 -54
  229. data/lib/textus/format/base.rb +0 -49
  230. data/lib/textus/format/json.rb +0 -102
  231. data/lib/textus/format/markdown.rb +0 -80
  232. data/lib/textus/format/shared.rb +0 -17
  233. data/lib/textus/format/text.rb +0 -55
  234. data/lib/textus/format/yaml.rb +0 -125
  235. data/lib/textus/format.rb +0 -46
  236. data/lib/textus/gate/auth.rb +0 -212
  237. data/lib/textus/gate.rb +0 -92
  238. data/lib/textus/init.rb +0 -157
  239. data/lib/textus/jobs.rb +0 -9
  240. data/lib/textus/key/distance.rb +0 -55
  241. data/lib/textus/key/grammar.rb +0 -33
  242. data/lib/textus/key/matching.rb +0 -24
  243. data/lib/textus/key/path.rb +0 -28
  244. data/lib/textus/manifest/capabilities.rb +0 -29
  245. data/lib/textus/manifest/data.rb +0 -102
  246. data/lib/textus/manifest/entry/base.rb +0 -105
  247. data/lib/textus/manifest/entry/ignore_matcher.rb +0 -46
  248. data/lib/textus/manifest/entry/leaf.rb +0 -17
  249. data/lib/textus/manifest/entry/nested.rb +0 -37
  250. data/lib/textus/manifest/entry/parser.rb +0 -99
  251. data/lib/textus/manifest/entry/produced.rb +0 -36
  252. data/lib/textus/manifest/entry/publish/mode.rb +0 -45
  253. data/lib/textus/manifest/entry/publish/none.rb +0 -14
  254. data/lib/textus/manifest/entry/publish/subtree_mirror.rb +0 -73
  255. data/lib/textus/manifest/entry/publish/template.rb +0 -16
  256. data/lib/textus/manifest/entry/publish/to_paths.rb +0 -76
  257. data/lib/textus/manifest/entry/publish/tree.rb +0 -50
  258. data/lib/textus/manifest/entry/publish.rb +0 -45
  259. data/lib/textus/manifest/entry/validators/format_matrix.rb +0 -21
  260. data/lib/textus/manifest/entry/validators/ignore.rb +0 -28
  261. data/lib/textus/manifest/entry/validators/publish.rb +0 -30
  262. data/lib/textus/manifest/entry/validators.rb +0 -18
  263. data/lib/textus/manifest/entry.rb +0 -8
  264. data/lib/textus/manifest/policy/matcher.rb +0 -51
  265. data/lib/textus/manifest/policy/publish_target.rb +0 -34
  266. data/lib/textus/manifest/policy/react.rb +0 -30
  267. data/lib/textus/manifest/policy/retention.rb +0 -29
  268. data/lib/textus/manifest/policy/source.rb +0 -30
  269. data/lib/textus/manifest/policy.rb +0 -95
  270. data/lib/textus/manifest/resolver.rb +0 -118
  271. data/lib/textus/manifest/rules.rb +0 -86
  272. data/lib/textus/manifest/schema/contract.rb +0 -61
  273. data/lib/textus/manifest/schema/keys.rb +0 -90
  274. data/lib/textus/manifest/schema/semantics/cross_field.rb +0 -53
  275. data/lib/textus/manifest/schema/semantics/invariants.rb +0 -125
  276. data/lib/textus/manifest/schema/semantics/migration.rb +0 -83
  277. data/lib/textus/manifest/schema/semantics.rb +0 -27
  278. data/lib/textus/manifest/schema/validator.rb +0 -48
  279. data/lib/textus/manifest/schema/vocabulary.rb +0 -25
  280. data/lib/textus/manifest/schema.rb +0 -30
  281. data/lib/textus/manifest.rb +0 -65
  282. data/lib/textus/meta.rb +0 -54
  283. data/lib/textus/port/audit_log.rb +0 -268
  284. data/lib/textus/port/build_lock.rb +0 -64
  285. data/lib/textus/port/clock.rb +0 -10
  286. data/lib/textus/port/publisher.rb +0 -60
  287. data/lib/textus/port/sentinel_store.rb +0 -83
  288. data/lib/textus/port/storage/file_stat.rb +0 -19
  289. data/lib/textus/port/storage/file_store.rb +0 -26
  290. data/lib/textus/port/store.rb +0 -93
  291. data/lib/textus/port/watcher_lock.rb +0 -48
  292. data/lib/textus/produce/engine.rb +0 -50
  293. data/lib/textus/produce/render.rb +0 -20
  294. data/lib/textus/schema/tools.rb +0 -115
  295. data/lib/textus/schema.rb +0 -100
  296. data/lib/textus/schemas.rb +0 -54
  297. data/lib/textus/store/compositor.rb +0 -34
  298. data/lib/textus/store/container.rb +0 -43
  299. data/lib/textus/store/cursor.rb +0 -26
  300. data/lib/textus/store/envelope/reader.rb +0 -43
  301. data/lib/textus/store/envelope/writer.rb +0 -195
  302. data/lib/textus/store/index/builder.rb +0 -74
  303. data/lib/textus/store/index/lookup.rb +0 -60
  304. data/lib/textus/store/jobs/base.rb +0 -13
  305. data/lib/textus/store/jobs/index.rb +0 -15
  306. data/lib/textus/store/jobs/materialize.rb +0 -15
  307. data/lib/textus/store/jobs/plan.rb +0 -11
  308. data/lib/textus/store/jobs/planner.rb +0 -104
  309. data/lib/textus/store/jobs/queue.rb +0 -154
  310. data/lib/textus/store/jobs/registry.rb +0 -19
  311. data/lib/textus/store/jobs/retention.rb +0 -50
  312. data/lib/textus/store/jobs/sweep.rb +0 -21
  313. data/lib/textus/store/jobs/worker.rb +0 -64
  314. data/lib/textus/store/session.rb +0 -37
  315. data/lib/textus/store.rb +0 -99
  316. data/lib/textus/surface/cli/verb/doctor.rb +0 -19
  317. data/lib/textus/surface/cli/verb/schema_diff.rb +0 -17
  318. data/lib/textus/surface/cli/verb/schema_init.rb +0 -21
  319. data/lib/textus/surface/cli/verb/schema_migrate.rb +0 -21
  320. data/lib/textus/surface/mcp.rb +0 -8
  321. data/lib/textus/surface/projector.rb +0 -27
  322. data/lib/textus/surface/role_scope.rb +0 -34
  323. data/lib/textus/value/command.rb +0 -16
  324. data/lib/textus/value/envelope.rb +0 -89
  325. data/lib/textus/value/result.rb +0 -26
  326. data/lib/textus/workflow/collector.rb +0 -27
  327. data/lib/textus/workflow/context.rb +0 -5
  328. data/lib/textus/workflow/loader.rb +0 -17
  329. data/lib/textus/workflow/pattern.rb +0 -18
data/README.md CHANGED
@@ -1,4 +1,4 @@
1
- <!-- Generated from knowledge.readme.* fragments by the artifacts.docs.readme workflow. Edit the fragments under .textus/data/knowledge/readme/ and run `textus drain`. -->
1
+ <!-- Generated from knowledge.intent.readme-* fragments by the artifacts.docs.readme workflow. Edit the fragments under .textus/data/knowledge/intent/ and run `textus drain`. -->
2
2
  <!-- This is a fragment of the README. Edit here; the README is composed from fragments by a workflow. -->
3
3
  <p align="center">
4
4
  <picture>
@@ -39,7 +39,7 @@ flowchart LR
39
39
  end
40
40
 
41
41
  human -->|author| knowledge["knowledge<br/>(canon)"]
42
- agent -->|keep| notebook["notebook<br/>(workspace)"]
42
+ agent -->|keep| scratchpad["scratchpad<br/>(workspace)"]
43
43
  agent -->|propose| proposals["proposals<br/>(queue)"]
44
44
  automation -->|drain| artifacts["artifacts<br/>(machine)"]
45
45
  human -->|ingest| raw["raw<br/>(intake)"]
@@ -68,7 +68,7 @@ The point of those lanes is to **build context you can trust**. Place each lane
68
68
  LOW TRUST HIGH TRUST
69
69
  (unreviewed) (authoritative)
70
70
  ┌──────────────────────────┬───────────────────────────────┐
71
- DURABLE │ notebook │ knowledge ★ the goal │
71
+ DURABLE │ scratchpad │ knowledge ★ the goal │
72
72
  (kept) │ agent's working truth │ canon — a human authors │
73
73
  │ durable, but low-trust │ here · the context you ship │
74
74
  ├──────────────────────────┼───────────────────────────────┤
@@ -84,12 +84,12 @@ Without coordination, they overwrite each other and nothing remembers why. textu
84
84
 
85
85
  ```
86
86
  knowledge/ author only — who you are, what you decide, how you sound
87
- notebook/ keep only — agent's own durable lane (bytes climb to knowledge only via propose→accept)
87
+ scratchpad/ keep only — agent's own durable lane (bytes climb to knowledge only via propose→accept)
88
88
  proposals/ propose (agent+human) — proposals waiting on a human accept
89
89
  artifacts/ converge only — machine-maintained: computed outputs + external inputs
90
90
  ```
91
91
 
92
- An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `notebook/` (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
92
+ An agent that tries to write directly into `knowledge/` gets `write_forbidden`. It writes to `proposals/` (to change authoritative content) or its own `scratchpad/` (for working memory). You accept the good proposals; textus promotes them, records the move, and audits both halves. Stable per-entry `uid:` means a reorganization doesn't break references. A monotonic audit cursor (`textus pulse --since=N`) means the next session — possibly a different agent, possibly a different model — picks up exactly where the last one left off.
93
93
 
94
94
  That's the load-bearing claim: **coordination is a protocol invariant, not a library convenience.**
95
95
 
@@ -125,7 +125,7 @@ Try the gate the other way (`textus put knowledge.notes.X --as=agent`) and you g
125
125
 
126
126
  This Ruby gem is the reference implementation of **`textus/4`** — a wire format and storage convention any language can speak. The protocol owns the envelope shape, the role/lane gate, the audit log format, and the key grammar. The gem version (semver, see badge) and the protocol version (`textus/4`) move independently; envelopes carry the `protocol` field so consumers can pin to the contract, not the implementation.
127
127
 
128
- - Specification: [`SPEC.md`](SPEC.md)
128
+ - Specification: the wire protocol spec (`textus get knowledge.specs.*`)
129
129
  - Architecture: [`docs/architecture/README.md`](docs/architecture/README.md)
130
130
  - Per-release notes: [`CHANGELOG.md`](CHANGELOG.md)
131
131
 
@@ -156,7 +156,7 @@ roles:
156
156
 
157
157
  lanes:
158
158
  - { name: knowledge, kind: canon } # author — canonical truth
159
- - { name: notebook, kind: workspace } # keep — agent's own durable lane
159
+ - { name: scratchpad, kind: workspace } # keep — agent's own durable lane
160
160
  - { name: proposals, kind: queue } # propose — proposals awaiting accept
161
161
  - { name: artifacts, kind: machine } # converge — computed outputs + external inputs
162
162
  ```
@@ -170,7 +170,7 @@ lanes:
170
170
  .gitignore # generated — ignores .state/ and any tracked:false entries
171
171
  data/ # one dir per lane; kinds + capabilities are in the manifest above
172
172
  knowledge/ # e.g. identity (knowledge.identity.*), voice, decisions, notes
173
- notebook/
173
+ scratchpad/
174
174
  proposals/
175
175
  artifacts/ # machine lane: computed outputs + external inputs
176
176
  .state/ # disposable runtime state — gitignored, safe to delete (ADR 0038)
@@ -201,20 +201,20 @@ For a worked store — knowledge entries, a staged proposal, schemas, ERB templa
201
201
 
202
202
  ## What's shipped
203
203
 
204
- - **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. ([SPEC §5.2–5.3](SPEC.md))
204
+ - **Per-entry formats & publish.** `format: markdown|json|yaml|text` per entry; a typed `publish:` block (`to:` for file fan-out, `tree:` for a whole-subtree mirror) byte-copies derived files to their consumer paths. (the wire protocol spec §5.2–5.3)
205
205
  - **Stable identity.** Auto-minted `uid:` survives writes and `textus key mv`; reorganising never breaks references.
206
- - **Capability × lane-kind gate.** Writes carry `--as=<role>`; a role may write a lane iff it holds the capability the lane's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. ([SPEC §5](SPEC.md))
206
+ - **Capability × lane-kind gate.** Writes carry `--as=<role>`; a role may write a lane iff it holds the capability the lane's `kind:` requires (`canon`→`author`, `workspace`→`keep`, `machine`→`converge`, `queue`→`propose`). The wrong role gets `write_forbidden` naming the capability needed and the roles that hold it. (the wire protocol spec §5)
207
207
  - **Agent loop.** `textus boot` orients a fresh session; `textus pulse --since=N` is the per-turn heartbeat (changed entries, pending proposals, index etag for catalog drift detection). ([docs/how-to/agents-mcp.md](docs/how-to/agents-mcp.md))
208
208
  - **MCP surface.** The official `mcp` Ruby SDK drives the stdio JSON-RPC server; protocol version auto-negotiated up to `2025-11-25`. Wire textus into Claude Code, Cursor, or any MCP host in one config block.
209
209
  - **`textus doctor`.** Health checks across schemas, workflow registrations, keys, sentinels, and the audit log.
210
- - **`raw` lane and `ingest` verb.** Write-once intake lane for external URL bookmarks, files, and binary assets. Three source kinds (`url`/`file`/`asset`); daily key derivation; notebook stub per ingest. See "Intake and ingest" section below.
210
+ - **`raw` lane and `ingest` verb.** Write-once intake lane for external URL bookmarks, files, and binary assets. Three source kinds (`url`/`file`/`asset`); daily key derivation; scratchpad stub per ingest. See "Intake and ingest" section below.
211
211
 
212
212
  ## CLI and lanes
213
213
 
214
- Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` ([SPEC §3.1](SPEC.md)). Write verbs require `--as=<role>`, resolved as: `--as` flag → **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` ([SPEC §5.1](SPEC.md)). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in [SPEC §8](SPEC.md).
214
+ Every command operates on one store, located in this order: `--root <path>` flag → **`TEXTUS_ROOT`** env → walk up from the working directory for a `.textus/` (the wire protocol spec §3.1). Write verbs require `--as=<role>`, resolved as: `--as` flag → **`TEXTUS_ROLE`** env → `.textus/role` file → default `human` (the wire protocol spec §5.1). Default roles: `human`, `agent`, `automation` (rename or add your own in the manifest's `roles:` block). All verbs accept `--output=json` and return the envelope defined in the wire protocol spec §8.
215
215
 
216
- - Full verb table — read, write, health, scaffolding — is in [SPEC §9](SPEC.md).
217
- - Lane semantics and the capability × lane-kind mapping live in [SPEC §5](SPEC.md), with the reference in [`docs/reference/lanes.md`](docs/reference/lanes.md).
216
+ - Full verb table — read, write, health, scaffolding — is in the wire protocol spec §9.
217
+ - Lane semantics and the capability × lane-kind mapping live in the wire protocol spec §5, with the reference in [`docs/reference/lanes.md`](docs/reference/lanes.md).
218
218
 
219
219
  `textus boot` prints the same information for the current store: lanes, entry families with schemas, registered workflows, write flows, and the verb catalog. Run it inside a store and you get the live picture; reach for the SPEC when you want the contract.
220
220
 
@@ -225,7 +225,7 @@ Produced entries (`kind: produced`) declare how they're acquired in one `source:
225
225
  - **`source: { from: external, command: "...", sources: [...] }`** — *externally managed*: an out-of-band command or workflow writes the file; textus tracks staleness via declared `sources`.
226
226
  - **`source: { from: external, command: "true", sources: [] }` + a workflow** — *workflow-driven*: a `Textus.workflow` block (in `.textus/workflows/`) acquires and shapes the data on `drain`.
227
227
 
228
- Publishing is one typed `publish:` block (ADR 0052/0094). Each target is either `{ to: path, template?: name }` for a single file (optionally rendered through an ERB template) or `{ tree: "dir" }` to mirror a whole stored subtree. Sentinels for every published file live under `.textus/.state/sentinels/` (git-ignored, regenerated on drain). See SPEC §5.2, §5.3, §5.12.
228
+ Publishing is one typed `publish:` block (ADR 0052/0094). Each target is either `{ to: path, template?: name }` for a single file (optionally rendered through an ERB template) or `{ tree: "dir" }` to mirror a whole stored subtree. Sentinels for every published file live under `.textus/track/sentinels/` (git-ignored, regenerated on drain). See SPEC §5.2, §5.3, §5.12.
229
229
 
230
230
  Templates live in `.textus/templates/` as ERB files (`.erb`). The template receives the entry's `content` hash as local variables via `ERB#result_with_hash`. If `inject_boot: true`, a `boot` variable is also available with the live orientation context.
231
231
 
@@ -281,8 +281,8 @@ textus ingest url agentskills-io-brainstorming \
281
281
  # see what landed in the raw lane
282
282
  textus list --lane=raw
283
283
 
284
- # a notebook stub was created alongside — annotate it
285
- textus get notebook.notes.raw
284
+ # a scratchpad stub was created alongside — annotate it
285
+ textus get scratchpad.notes.raw
286
286
  ```
287
287
 
288
288
  Stale produced entries are re-materialised by `drain`, not by reads — `get` is a pure read (ADR 0089).
@@ -322,4 +322,3 @@ Lefthook hooks (`brew bundle install` then `lefthook install`) run rubocop on `p
322
322
 
323
323
  [MIT](LICENSE)
324
324
 
325
-
@@ -0,0 +1,8 @@
1
+ module Textus
2
+ module Artifact
3
+ end
4
+ end
5
+
6
+ require_relative "lanes/artifact/cache"
7
+ require_relative "lanes/artifact/lifecycle"
8
+ require_relative "lanes/artifact/handlers"
data/lib/textus/errors.rb CHANGED
@@ -131,18 +131,6 @@ module Textus
131
131
  def initialize(m) = super("io_error", m, exit_code: 64)
132
132
  end
133
133
 
134
- class BuildInProgress < Error
135
- def initialize(holder)
136
- super(
137
- "build_in_progress",
138
- "a textus maintenance pass is already running (#{holder})",
139
- details: { "holder" => holder },
140
- exit_code: 75,
141
- hint: "wait for the running maintenance pass to finish, or check for a recursive hook trigger"
142
- )
143
- end
144
- end
145
-
146
134
  class UsageError < Error
147
135
  def initialize(m, hint: nil) = super("usage", m, exit_code: 2, hint: hint)
148
136
  end
@@ -166,18 +154,6 @@ module Textus
166
154
  end
167
155
  end
168
156
 
169
- class BadRender < Error
170
- def initialize(m, format: nil)
171
- hint =
172
- if format
173
- "the template rendered invalid #{format}; try rendering with mock data and parsing the output before re-running drain"
174
- else
175
- "the template rendered invalid content; try rendering with mock data and parsing the output before re-running drain"
176
- end
177
- super("bad_render", m, hint: hint)
178
- end
179
- end
180
-
181
157
  class PublishError < Error
182
158
  def initialize(m, target: nil)
183
159
  hint =
@@ -190,21 +166,6 @@ module Textus
190
166
  def initialize(m) = super("proposal_error", m)
191
167
  end
192
168
 
193
- class GuardFailed < Error
194
- def initialize(failed)
195
- # failed: [[predicate_name, reason], ...]
196
- rows = failed.map { |name, reason| { "predicate" => name, "reason" => reason } }
197
- names = failed.map(&:first)
198
- super(
199
- "guard_failed",
200
- "guard refused crossing: #{failed.map { |n, r| "#{n} (#{r})" }.join("; ")}",
201
- details: { "failed" => rows },
202
- hint: "run 'textus policy explain <key> --output=json' to see the full guard; " \
203
- "unmet: #{names.join(", ")}",
204
- )
205
- end
206
- end
207
-
208
169
  class ContractDrift < Error
209
170
  JSONRPC_CODE = -32_001
210
171
 
@@ -232,6 +193,8 @@ module Textus
232
193
  end
233
194
 
234
195
  class CursorExpired < Error
196
+ JSONRPC_CODE = -32_002
197
+
235
198
  attr_reader :requested, :min_available
236
199
 
237
200
  def initialize(requested:, min_available:)
@@ -0,0 +1,7 @@
1
+ module Textus
2
+ module Human
3
+ end
4
+ end
5
+
6
+ require_relative "lanes/knowledge/runner"
7
+ require_relative "lanes/knowledge/freeform"
@@ -0,0 +1,15 @@
1
+ module Textus
2
+ module Infra
3
+ module Adapters
4
+ class ConcurrencyAdapter
5
+ def future(&)
6
+ Concurrent::Promises.future(&)
7
+ end
8
+
9
+ def zip_futures(*promises)
10
+ Concurrent::Promises.zip_futures(*promises)
11
+ end
12
+ end
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,7 @@
1
+ module Textus
2
+ module Infra
3
+ class Clock
4
+ def now = Time.now
5
+ end
6
+ end
7
+ end
@@ -0,0 +1,127 @@
1
+ require "sequel"
2
+ require "fileutils"
3
+
4
+ module Textus
5
+ module Infra
6
+ class Database
7
+ def initialize(layout:)
8
+ @layout = layout
9
+ @db = nil
10
+ end
11
+
12
+ def connection = @db
13
+
14
+ def setup!
15
+ path = @layout.db_path
16
+ FileUtils.mkdir_p(File.dirname(path))
17
+ @db = Sequel.sqlite(path)
18
+ @db.run("PRAGMA journal_mode=WAL")
19
+ @db.run("PRAGMA foreign_keys=ON")
20
+ create_tables
21
+ self
22
+ end
23
+
24
+ def transaction(&) = @db.transaction(&)
25
+ def close = @db.disconnect
26
+
27
+ private
28
+
29
+ def create_tables
30
+ @db.create_table?(:workflow_events) do
31
+ primary_key :id
32
+ String :event_type, null: false
33
+ String :workflow_type, null: false
34
+ String :key
35
+ String :lane
36
+ String :payload, text: true
37
+ DateTime :created_at, default: Sequel::CURRENT_TIMESTAMP
38
+ DateTime :scheduled_at
39
+ String :state, null: false, default: "pending"
40
+ Integer :retry_count, null: false, default: 0
41
+ Integer :max_attempts, null: false, default: 3
42
+ String :error, text: true
43
+ index %i[state scheduled_at], where: "state = 'pending'"
44
+ end
45
+
46
+ @db.create_table?(:entries) do
47
+ String :key, primary_key: true
48
+ String :lane, null: false
49
+ String :format, null: false
50
+ String :schema
51
+ String :content, text: true
52
+ DateTime :indexed_at, null: false
53
+ end
54
+
55
+ @db.create_table?(:link_edges) do
56
+ String :from_key, null: false
57
+ String :to_key, null: false
58
+ primary_key %i[from_key to_key]
59
+ index :to_key
60
+ index :from_key
61
+ end
62
+
63
+ @db.create_table?(:audit_events) do
64
+ primary_key :seq
65
+ String :ts, null: false
66
+ String :role, null: false
67
+ String :verb, null: false
68
+ String :key, null: false
69
+ String :etag_before
70
+ String :etag_after
71
+ String :correlation_id
72
+ index :key
73
+ index %i[role verb]
74
+ index :correlation_id
75
+ end
76
+
77
+ @db.create_table?(:cursors) do
78
+ String :role, primary_key: true
79
+ Integer :seq, null: false, default: 0
80
+ DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
81
+ end
82
+
83
+ @db.create_table?(:loop_positions) do
84
+ String :role, primary_key: true
85
+ Integer :index, null: false, default: 0
86
+ DateTime :updated_at, null: false, default: Sequel::CURRENT_TIMESTAMP
87
+ end
88
+
89
+ create_entries_fts
90
+ end
91
+
92
+ def create_entries_fts
93
+ @db.run(<<~SQL)
94
+ CREATE VIRTUAL TABLE IF NOT EXISTS entries_fts USING fts5(
95
+ key, lane, schema, content,
96
+ content=entries, content_rowid=rowid
97
+ )
98
+ SQL
99
+
100
+ @db.run(<<~SQL)
101
+ CREATE TRIGGER IF NOT EXISTS entries_ai AFTER INSERT ON entries BEGIN
102
+ INSERT INTO entries_fts(rowid, key, lane, schema, content)
103
+ VALUES (new.rowid, new.key, new.lane, new.schema, new.content);
104
+ END
105
+ SQL
106
+
107
+ @db.run(<<~SQL)
108
+ CREATE TRIGGER IF NOT EXISTS entries_ad AFTER DELETE ON entries BEGIN
109
+ INSERT INTO entries_fts(entries_fts, rowid, key, lane, schema, content)
110
+ VALUES ('delete', old.rowid, old.key, old.lane, old.schema, old.content);
111
+ END
112
+ SQL
113
+
114
+ @db.run(<<~SQL)
115
+ CREATE TRIGGER IF NOT EXISTS entries_au AFTER UPDATE ON entries BEGIN
116
+ INSERT INTO entries_fts(entries_fts, rowid, key, lane, schema, content)
117
+ VALUES ('delete', old.rowid, old.key, old.lane, old.schema, old.content);
118
+ INSERT INTO entries_fts(rowid, key, lane, schema, content)
119
+ VALUES (new.rowid, new.key, new.lane, new.schema, new.content);
120
+ END
121
+ SQL
122
+ rescue StandardError
123
+ nil
124
+ end
125
+ end
126
+ end
127
+ end
@@ -0,0 +1,54 @@
1
+ require "fileutils"
2
+
3
+ module Textus
4
+ module Infra
5
+ class FileSystem
6
+ module Interface
7
+ def read(path) = raise NotImplementedError
8
+ def write(path, bytes) = raise NotImplementedError
9
+ def delete(path) = raise NotImplementedError
10
+ def exists?(path) = raise NotImplementedError
11
+ def mkdir_p(path) = raise NotImplementedError
12
+ def mv(from, to) = raise NotImplementedError
13
+ def copy(source, target) = raise NotImplementedError
14
+ def glob(pattern) = raise NotImplementedError
15
+ def etag(path) = raise NotImplementedError
16
+ end
17
+
18
+ include Interface
19
+
20
+ def read(path) = File.binread(path)
21
+
22
+ def write(path, bytes)
23
+ FileUtils.mkdir_p(File.dirname(path))
24
+ File.binwrite(path, bytes)
25
+ end
26
+
27
+ def delete(path) = File.delete(path)
28
+ def exists?(path) = File.exist?(path)
29
+ def size(path) = File.size(path)
30
+ def mkdir_p(path) = FileUtils.mkdir_p(path)
31
+ def rmdir(path) = Dir.rmdir(path)
32
+ def dir_empty?(dir) = Dir.empty?(dir)
33
+ def glob(pattern) = Dir.glob(pattern)
34
+ def mv(from, to) = File.rename(from, to)
35
+ def copy(source, target) = FileUtils.cp(source, target)
36
+ def symlink?(path) = File.symlink?(path)
37
+ def identical?(left, right) = FileUtils.identical?(left, right)
38
+ def mtime(path) = File.mtime(path)
39
+ def directory?(path) = File.directory?(path)
40
+
41
+ def etag(path)
42
+ Value::Etag.for_file(path)
43
+ end
44
+
45
+ def flock_exclusive(path, nonblock: false)
46
+ File.open(path, File::RDWR | File::CREAT, 0o644) do |f|
47
+ f.close_on_exec = true
48
+ f.flock(nonblock ? File::LOCK_EX | File::LOCK_NB : File::LOCK_EX)
49
+ yield f
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,50 @@
1
+ require "fileutils"
2
+ require "socket"
3
+ require "time"
4
+
5
+ module Textus
6
+ module Infra
7
+ module Locks
8
+ class WatcherLock
9
+ def initialize(root, file_system: Infra::FileSystem.new)
10
+ @root = File.expand_path(root)
11
+ @file_system = file_system
12
+ @path = Textus::Protocol::Layout.new(@root).lock_path("watcher")
13
+ @file = nil
14
+ @file_system.mkdir_p(File.dirname(@path))
15
+ end
16
+
17
+ def self.running?(root, file_system: Infra::FileSystem.new)
18
+ path = Textus::Protocol::Layout.new(root).lock_path("watcher")
19
+ return false unless file_system.exists?(path)
20
+
21
+ File.open(path, "r+") do |file|
22
+ got = file.flock(File::LOCK_EX | File::LOCK_NB)
23
+ file.flock(File::LOCK_UN) if got
24
+ return !got
25
+ end
26
+ rescue Errno::ENOENT
27
+ false
28
+ end
29
+
30
+ def acquire
31
+ @file = File.open(@path, File::RDWR | File::CREAT, 0o644)
32
+ raise "watcher already running" unless @file.flock(File::LOCK_EX | File::LOCK_NB)
33
+
34
+ @file.write("pid=#{Process.pid} host=#{Socket.gethostname}\n")
35
+ @file.flush
36
+ self
37
+ end
38
+
39
+ def release
40
+ return unless @file
41
+
42
+ @file.flock(File::LOCK_UN)
43
+ @file.close
44
+ @file_system.delete(@path) if @file_system.exists?(@path)
45
+ @file = nil
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,18 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ # The wall clock. An instantiable class (ADR 0109) — uniform with the other
5
+ # ports; `now` reads the system time. Callers that need a fixed time still
6
+ # pass it as data via `Call#now`.
7
+ class Clock
8
+ module Interface
9
+ def now = raise NotImplementedError
10
+ end
11
+
12
+ include Interface
13
+
14
+ def now = Time.now
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,71 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ # Publishes built artifacts from the store to repo-relative consumer paths.
5
+ # Publish = copy + sentinel. The in-store file is already the consumer-shaped
6
+ # artifact; no parsing or stripping.
7
+ #
8
+ # Sentinel I/O is delegated to Textus::Infra::Port::SentinelStore. Sentinels live
9
+ # under `<store_root>/track/sentinels/` (runtime, git-ignored — ADR 0070) and
10
+ # mirror the target's repo-relative layout so consumer directories aren't
11
+ # polluted with `.textus-managed.json` siblings.
12
+ #
13
+ # An instantiable class (ADR 0109).
14
+ class Publisher
15
+ module Interface
16
+ def publish(source:, target:, store_root:, provenance_source: nil) = raise NotImplementedError
17
+ def unpublish(target:, store_root:) = raise NotImplementedError
18
+ end
19
+ include Interface
20
+
21
+ def initialize(file_system: Infra::FileSystem.new)
22
+ @file_system = file_system
23
+ end
24
+
25
+ def publish(source:, target:, store_root:, provenance_source: source)
26
+ @file_system.mkdir_p(File.dirname(target))
27
+ guard_clobber(source, target, store_root)
28
+ @file_system.delete(target) if @file_system.symlink?(target)
29
+ @file_system.copy(source, target)
30
+ Textus::Infra::Port::SentinelStore.new(file_system: @file_system).write!(target: target, source: provenance_source,
31
+ store_root: store_root)
32
+ end
33
+
34
+ # Removes a previously-published file and its sentinel. No-op unless the
35
+ # target is textus-managed — never deletes an unmanaged file.
36
+ def unpublish(target:, store_root:)
37
+ return unless managed?(target, store_root)
38
+
39
+ @file_system.delete(target)
40
+ sentinel = Textus::Infra::Port::SentinelStore.new(file_system: @file_system).sentinel_path(target, store_root)
41
+ @file_system.delete(sentinel)
42
+ end
43
+
44
+ private
45
+
46
+ # Refuse to clobber an unmanaged target — EXCEPT adopt one whose bytes
47
+ # already equal the source (ADR 0050: a migration copies files into the
48
+ # store and publishes them back to where they already live, so the target
49
+ # is byte-identical — nothing is at risk). Adoption writes no sentinel
50
+ # here; the normal publish path below does, and the cp is a content no-op.
51
+ # An unmanaged target whose content DIFFERS, or any unmanaged symlink, is
52
+ # still refused — that is the guard's real job.
53
+ def guard_clobber(source, target, store_root)
54
+ return unless @file_system.exists?(target) || @file_system.symlink?(target)
55
+ return if managed?(target, store_root)
56
+ return if adoptable?(source, target)
57
+
58
+ raise PublishError.new("refusing to clobber unmanaged file at #{target}", target: target)
59
+ end
60
+
61
+ def adoptable?(source, target)
62
+ !@file_system.symlink?(target) && File.file?(target) && @file_system.identical?(source, target)
63
+ end
64
+
65
+ def managed?(target, store_root)
66
+ @file_system.exists?(Textus::Infra::Port::SentinelStore.new(file_system: @file_system).sentinel_path(target, store_root))
67
+ end
68
+ end
69
+ end
70
+ end
71
+ end
@@ -0,0 +1,25 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ class Sentinel
5
+ attr_reader :target, :source, :sha256, :mode
6
+
7
+ def initialize(target:, source:, sha256:, mode:)
8
+ @target = target
9
+ @source = source
10
+ @sha256 = sha256
11
+ @mode = mode
12
+ end
13
+
14
+ def orphan?(file_stat) = @target.nil? || !file_stat.exists?(@target)
15
+
16
+ def drift?(file_stat)
17
+ return false if orphan?(file_stat)
18
+ return false if @sha256.nil?
19
+
20
+ Digest::SHA256.hexdigest(file_stat.read(@target)) != @sha256
21
+ end
22
+ end
23
+ end
24
+ end
25
+ end
@@ -0,0 +1,93 @@
1
+ module Textus
2
+ module Infra
3
+ module Port
4
+ # Persistence adapter for sentinel files. Owns the on-disk JSON shape, the
5
+ # path layout (<store_root>/track/sentinels/<target-rel-to-repo>.textus-managed.json
6
+ # — runtime, git-ignored, ADR 0070), and all File/FileUtils I/O.
7
+ # Core::Sentinel is a pure value object that depends on this port for
8
+ # reads and writes.
9
+ class SentinelStore
10
+ module Interface
11
+ def write!(target:, source:, store_root:) = raise NotImplementedError
12
+ def load(path, repo_root) = raise NotImplementedError
13
+ def sentinel_path(target, store_root) = raise NotImplementedError
14
+ def targets_under(target_dir, store_root) = raise NotImplementedError
15
+ end
16
+ include Interface
17
+
18
+ SUFFIX = ".textus-managed.json".freeze
19
+
20
+ def initialize(file_system: Infra::FileSystem.new)
21
+ @file_system = file_system
22
+ end
23
+
24
+ def write!(target:, source:, store_root:)
25
+ path = sentinel_path(target, store_root)
26
+ @file_system.mkdir_p(File.dirname(path))
27
+ repo_root = File.dirname(store_root)
28
+ @file_system.write(path, JSON.generate(
29
+ "source" => rel_or_abs(source, repo_root),
30
+ "target" => rel_or_abs(target, repo_root),
31
+ "sha256" => Digest::SHA256.hexdigest(@file_system.read(target)),
32
+ "mode" => "copy",
33
+ ))
34
+ end
35
+
36
+ def load(path, repo_root)
37
+ raw = JSON.parse(@file_system.read(path))
38
+ Textus::Infra::Port::Sentinel.new(
39
+ target: absolutize(raw["target"], repo_root),
40
+ source: absolutize(raw["source"], repo_root),
41
+ sha256: raw["sha256"],
42
+ mode: raw["mode"],
43
+ )
44
+ rescue JSON::ParserError, Errno::ENOENT
45
+ nil
46
+ end
47
+
48
+ def sentinel_path(target, store_root)
49
+ repo_root = File.dirname(store_root)
50
+ rel = relative_to(target, repo_root) || File.basename(target)
51
+ File.join(Textus::Protocol::Layout.new(store_root).sentinels_root, rel + SUFFIX)
52
+ end
53
+
54
+ # Absolute target paths of every sentinel recorded under `target_dir`.
55
+ def targets_under(target_dir, store_root)
56
+ repo_root = File.dirname(store_root)
57
+ rel = relative_to(target_dir, repo_root) or return []
58
+ root = Textus::Protocol::Layout.new(store_root).sentinels_root
59
+ sdir = File.join(root, rel)
60
+ return [] unless @file_system.directory?(sdir)
61
+
62
+ prefix = root + "/"
63
+ @file_system.glob(File.join(sdir, "**", "*#{SUFFIX}")).map do |spath|
64
+ # strip the sentinel-store prefix and the .textus-managed.json suffix to recover the repo-relative target path
65
+ trel = spath.delete_prefix(prefix).delete_suffix(SUFFIX)
66
+ File.join(repo_root, trel)
67
+ end
68
+ end
69
+
70
+ private
71
+
72
+ def rel_or_abs(path, repo_root)
73
+ relative_to(path, repo_root) || File.expand_path(path)
74
+ end
75
+
76
+ def relative_to(path, repo_root)
77
+ path = File.expand_path(path)
78
+ base = File.expand_path(repo_root)
79
+ return nil unless path.start_with?(base + File::SEPARATOR)
80
+
81
+ path[(base.length + 1)..]
82
+ end
83
+
84
+ def absolutize(path, repo_root)
85
+ return path if path.nil?
86
+ return path if File.absolute_path?(path)
87
+
88
+ File.expand_path(path, repo_root)
89
+ end
90
+ end
91
+ end
92
+ end
93
+ end