@cleocode/core 2026.10.18 → 2026.10.19

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 (259) hide show
  1. package/dist/build-provenance.json +2 -2
  2. package/dist/check/pr-gate.js +1 -1
  3. package/dist/check/pr-gate.js.map +1 -1
  4. package/dist/cloud/nexus-vault.d.ts +4 -3
  5. package/dist/cloud/nexus-vault.d.ts.map +1 -1
  6. package/dist/cloud/nexus-vault.js +62 -22
  7. package/dist/cloud/nexus-vault.js.map +1 -1
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/config.js +11 -0
  10. package/dist/config.js.map +1 -1
  11. package/dist/docs/conduit-history-backfill.d.ts +210 -0
  12. package/dist/docs/conduit-history-backfill.d.ts.map +1 -0
  13. package/dist/docs/conduit-history-backfill.js +451 -0
  14. package/dist/docs/conduit-history-backfill.js.map +1 -0
  15. package/dist/docs/docs-ops.d.ts +33 -0
  16. package/dist/docs/docs-ops.d.ts.map +1 -1
  17. package/dist/docs/docs-ops.js +72 -0
  18. package/dist/docs/docs-ops.js.map +1 -1
  19. package/dist/docs/docs-update.d.ts.map +1 -1
  20. package/dist/docs/docs-update.js +71 -0
  21. package/dist/docs/docs-update.js.map +1 -1
  22. package/dist/docs/doctor.d.ts +18 -4
  23. package/dist/docs/doctor.d.ts.map +1 -1
  24. package/dist/docs/doctor.js +124 -3
  25. package/dist/docs/doctor.js.map +1 -1
  26. package/dist/docs/section-merge.d.ts +70 -9
  27. package/dist/docs/section-merge.d.ts.map +1 -1
  28. package/dist/docs/section-merge.js +167 -10
  29. package/dist/docs/section-merge.js.map +1 -1
  30. package/dist/docs/section-write-path.d.ts.map +1 -1
  31. package/dist/docs/section-write-path.js +23 -8
  32. package/dist/docs/section-write-path.js.map +1 -1
  33. package/dist/gateway-client/generated/types.gen.d.ts +4 -0
  34. package/dist/gateway-client/generated/types.gen.d.ts.map +1 -1
  35. package/dist/go/driver.d.ts +29 -145
  36. package/dist/go/driver.d.ts.map +1 -1
  37. package/dist/go/driver.js +75 -168
  38. package/dist/go/driver.js.map +1 -1
  39. package/dist/go/index.d.ts +1 -1
  40. package/dist/go/index.d.ts.map +1 -1
  41. package/dist/go/index.js +1 -1
  42. package/dist/go/index.js.map +1 -1
  43. package/dist/goal/close.d.ts +1 -1
  44. package/dist/goal/close.js +2 -2
  45. package/dist/goal/close.js.map +1 -1
  46. package/dist/goal/draft.d.ts +10 -3
  47. package/dist/goal/draft.d.ts.map +1 -1
  48. package/dist/goal/draft.js +14 -9
  49. package/dist/goal/draft.js.map +1 -1
  50. package/dist/goal/index.d.ts +9 -12
  51. package/dist/goal/index.d.ts.map +1 -1
  52. package/dist/goal/index.js +9 -12
  53. package/dist/goal/index.js.map +1 -1
  54. package/dist/goal/runtime.d.ts +126 -0
  55. package/dist/goal/runtime.d.ts.map +1 -0
  56. package/dist/goal/runtime.js +289 -0
  57. package/dist/goal/runtime.js.map +1 -0
  58. package/dist/goal/status.d.ts +39 -0
  59. package/dist/goal/status.d.ts.map +1 -0
  60. package/dist/goal/status.js +101 -0
  61. package/dist/goal/status.js.map +1 -0
  62. package/dist/hooks/project-runner.d.ts +8 -3
  63. package/dist/hooks/project-runner.d.ts.map +1 -1
  64. package/dist/hooks/project-runner.js +104 -14
  65. package/dist/hooks/project-runner.js.map +1 -1
  66. package/dist/hooks/project-state.js +1 -1
  67. package/dist/hooks/project-state.js.map +1 -1
  68. package/dist/internal.d.ts +7 -3
  69. package/dist/internal.d.ts.map +1 -1
  70. package/dist/internal.js +5 -3
  71. package/dist/internal.js.map +1 -1
  72. package/dist/lifecycle/engine-ops.d.ts.map +1 -1
  73. package/dist/lifecycle/engine-ops.js +9 -6
  74. package/dist/lifecycle/engine-ops.js.map +1 -1
  75. package/dist/memory/retrieval/observe.d.ts.map +1 -1
  76. package/dist/memory/retrieval/observe.js +4 -8
  77. package/dist/memory/retrieval/observe.js.map +1 -1
  78. package/dist/paths.d.ts.map +1 -1
  79. package/dist/paths.js +4 -1
  80. package/dist/paths.js.map +1 -1
  81. package/dist/playbooks/cantbook-profile.d.ts +3 -3
  82. package/dist/playbooks/cantbook-profile.js +3 -3
  83. package/dist/playbooks/pi-runner-wiring.d.ts +3 -3
  84. package/dist/playbooks/pi-runner-wiring.js +3 -3
  85. package/dist/release/ci-evidence.d.ts +28 -0
  86. package/dist/release/ci-evidence.d.ts.map +1 -1
  87. package/dist/release/ci-evidence.js +76 -20
  88. package/dist/release/ci-evidence.js.map +1 -1
  89. package/dist/release/open.d.ts +11 -0
  90. package/dist/release/open.d.ts.map +1 -1
  91. package/dist/release/open.js +49 -10
  92. package/dist/release/open.js.map +1 -1
  93. package/dist/release/preflight-skip.d.ts +50 -0
  94. package/dist/release/preflight-skip.d.ts.map +1 -1
  95. package/dist/release/preflight-skip.js +151 -34
  96. package/dist/release/preflight-skip.js.map +1 -1
  97. package/dist/release/reconcile.d.ts.map +1 -1
  98. package/dist/release/reconcile.js +6 -3
  99. package/dist/release/reconcile.js.map +1 -1
  100. package/dist/scaffold/ensure-config.d.ts +1 -1
  101. package/dist/scaffold/ensure-config.d.ts.map +1 -1
  102. package/dist/scaffold/ensure-config.js +2 -0
  103. package/dist/scaffold/ensure-config.js.map +1 -1
  104. package/dist/security/index.d.ts +1 -1
  105. package/dist/security/index.d.ts.map +1 -1
  106. package/dist/security/index.js +1 -1
  107. package/dist/security/index.js.map +1 -1
  108. package/dist/security/override-cap-setting.d.ts +180 -0
  109. package/dist/security/override-cap-setting.d.ts.map +1 -0
  110. package/dist/security/override-cap-setting.js +657 -0
  111. package/dist/security/override-cap-setting.js.map +1 -0
  112. package/dist/security/override-cap.d.ts +20 -29
  113. package/dist/security/override-cap.d.ts.map +1 -1
  114. package/dist/security/override-cap.js +47 -71
  115. package/dist/security/override-cap.js.map +1 -1
  116. package/dist/security/owner-override-auth.d.ts +58 -0
  117. package/dist/security/owner-override-auth.d.ts.map +1 -1
  118. package/dist/security/owner-override-auth.js +82 -1
  119. package/dist/security/owner-override-auth.js.map +1 -1
  120. package/dist/security/owner-override-authority.d.ts +67 -0
  121. package/dist/security/owner-override-authority.d.ts.map +1 -0
  122. package/dist/security/owner-override-authority.js +100 -0
  123. package/dist/security/owner-override-authority.js.map +1 -0
  124. package/dist/sessions/handoff.d.ts.map +1 -1
  125. package/dist/sessions/handoff.js +4 -5
  126. package/dist/sessions/handoff.js.map +1 -1
  127. package/dist/sessions/index.d.ts.map +1 -1
  128. package/dist/sessions/index.js +22 -25
  129. package/dist/sessions/index.js.map +1 -1
  130. package/dist/store/conduit-doc-history.d.ts +39 -7
  131. package/dist/store/conduit-doc-history.d.ts.map +1 -1
  132. package/dist/store/conduit-doc-history.js +62 -12
  133. package/dist/store/conduit-doc-history.js.map +1 -1
  134. package/dist/store/data-safety-central.d.ts.map +1 -1
  135. package/dist/store/data-safety-central.js +3 -2
  136. package/dist/store/data-safety-central.js.map +1 -1
  137. package/dist/store/json.d.ts.map +1 -1
  138. package/dist/store/json.js +7 -7
  139. package/dist/store/json.js.map +1 -1
  140. package/dist/store/portable-bundle-scan.d.ts +23 -1
  141. package/dist/store/portable-bundle-scan.d.ts.map +1 -1
  142. package/dist/store/portable-bundle-scan.js +85 -3
  143. package/dist/store/portable-bundle-scan.js.map +1 -1
  144. package/dist/store/portable-bundle.d.ts.map +1 -1
  145. package/dist/store/portable-bundle.js +6 -1
  146. package/dist/store/portable-bundle.js.map +1 -1
  147. package/dist/store/row-identity-registry.d.ts +5 -3
  148. package/dist/store/row-identity-registry.d.ts.map +1 -1
  149. package/dist/store/row-identity-registry.js +54 -38
  150. package/dist/store/row-identity-registry.js.map +1 -1
  151. package/dist/store/schema/cleo-project/conduit.d.ts.map +1 -1
  152. package/dist/store/schema/cleo-project/conduit.js +4 -3
  153. package/dist/store/schema/cleo-project/conduit.js.map +1 -1
  154. package/dist/store/schema/cleo-project/runtime.d.ts +32 -0
  155. package/dist/store/schema/cleo-project/runtime.d.ts.map +1 -1
  156. package/dist/store/schema/cleo-project/runtime.js +7 -3
  157. package/dist/store/schema/cleo-project/runtime.js.map +1 -1
  158. package/dist/store/session-store.d.ts +45 -0
  159. package/dist/store/session-store.d.ts.map +1 -1
  160. package/dist/store/session-store.js +96 -1
  161. package/dist/store/session-store.js.map +1 -1
  162. package/dist/store/sqlite.d.ts +2 -0
  163. package/dist/store/sqlite.d.ts.map +1 -1
  164. package/dist/store/sqlite.js +12 -0
  165. package/dist/store/sqlite.js.map +1 -1
  166. package/dist/store/sync/merge/rules.d.ts.map +1 -1
  167. package/dist/store/sync/merge/rules.js +0 -16
  168. package/dist/store/sync/merge/rules.js.map +1 -1
  169. package/dist/store/sync/sealer.d.ts.map +1 -1
  170. package/dist/store/sync/sealer.js +27 -1
  171. package/dist/store/sync/sealer.js.map +1 -1
  172. package/dist/store/sync/sequencing.d.ts +3 -1
  173. package/dist/store/sync/sequencing.d.ts.map +1 -1
  174. package/dist/store/sync/sequencing.js +5 -3
  175. package/dist/store/sync/sequencing.js.map +1 -1
  176. package/dist/store/sync/timestamp-canon.d.ts.map +1 -1
  177. package/dist/store/sync/timestamp-canon.js +4 -0
  178. package/dist/store/sync/timestamp-canon.js.map +1 -1
  179. package/dist/store/table-classification.d.ts.map +1 -1
  180. package/dist/store/table-classification.js +12 -8
  181. package/dist/store/table-classification.js.map +1 -1
  182. package/dist/store/twin-collapse.d.ts +21 -0
  183. package/dist/store/twin-collapse.d.ts.map +1 -1
  184. package/dist/store/twin-collapse.js +235 -18
  185. package/dist/store/twin-collapse.js.map +1 -1
  186. package/dist/system/health.d.ts +17 -0
  187. package/dist/system/health.d.ts.map +1 -1
  188. package/dist/system/health.js +96 -0
  189. package/dist/system/health.js.map +1 -1
  190. package/dist/tasks/affected-packages.d.ts +67 -2
  191. package/dist/tasks/affected-packages.d.ts.map +1 -1
  192. package/dist/tasks/affected-packages.js +149 -21
  193. package/dist/tasks/affected-packages.js.map +1 -1
  194. package/dist/tasks/affected-scope.d.ts.map +1 -1
  195. package/dist/tasks/affected-scope.js +91 -5
  196. package/dist/tasks/affected-scope.js.map +1 -1
  197. package/dist/tasks/changeset-plan.d.ts +33 -0
  198. package/dist/tasks/changeset-plan.d.ts.map +1 -0
  199. package/dist/tasks/changeset-plan.js +152 -0
  200. package/dist/tasks/changeset-plan.js.map +1 -0
  201. package/dist/tasks/complete.d.ts.map +1 -1
  202. package/dist/tasks/complete.js +9 -1
  203. package/dist/tasks/complete.js.map +1 -1
  204. package/dist/tasks/done-plan.d.ts.map +1 -1
  205. package/dist/tasks/done-plan.js +28 -14
  206. package/dist/tasks/done-plan.js.map +1 -1
  207. package/dist/tasks/done-record.d.ts.map +1 -1
  208. package/dist/tasks/done-record.js +5 -1
  209. package/dist/tasks/done-record.js.map +1 -1
  210. package/dist/tasks/evidence.d.ts.map +1 -1
  211. package/dist/tasks/evidence.js +149 -18
  212. package/dist/tasks/evidence.js.map +1 -1
  213. package/dist/tasks/gate-runner.d.ts.map +1 -1
  214. package/dist/tasks/gate-runner.js +76 -4
  215. package/dist/tasks/gate-runner.js.map +1 -1
  216. package/dist/tasks/qa-run-binding.d.ts +18 -0
  217. package/dist/tasks/qa-run-binding.d.ts.map +1 -1
  218. package/dist/tasks/qa-run-binding.js +41 -1
  219. package/dist/tasks/qa-run-binding.js.map +1 -1
  220. package/dist/tasks/test-run-binding.d.ts +62 -0
  221. package/dist/tasks/test-run-binding.d.ts.map +1 -1
  222. package/dist/tasks/test-run-binding.js +126 -21
  223. package/dist/tasks/test-run-binding.js.map +1 -1
  224. package/dist/tasks/tool-resolver.d.ts +7 -0
  225. package/dist/tasks/tool-resolver.d.ts.map +1 -1
  226. package/dist/tasks/tool-resolver.js +3 -1
  227. package/dist/tasks/tool-resolver.js.map +1 -1
  228. package/dist/validation/engine-ops.d.ts.map +1 -1
  229. package/dist/validation/engine-ops.js +24 -29
  230. package/dist/validation/engine-ops.js.map +1 -1
  231. package/migrations/drizzle-cleo-project/20261016120000_t12535-playbook-row-uids/migration.sql +24 -0
  232. package/package.json +13 -13
  233. package/schemas/project-context.schema.json +5 -0
  234. package/templates/CLEO-INJECTION.md +1 -1
  235. package/templates/CLEO-REFERENCE.md +42 -1
  236. package/templates/cleo-gitignore +3 -0
  237. package/templates/git-hooks/legacy-hashes.json +1 -0
  238. package/templates/git-hooks/pre-push +35 -7
  239. package/templates/workflows/release-prepare.yml.tmpl +82 -4
  240. package/dist/goal/advance-with-persist.d.ts +0 -80
  241. package/dist/goal/advance-with-persist.d.ts.map +0 -1
  242. package/dist/goal/advance-with-persist.js +0 -105
  243. package/dist/goal/advance-with-persist.js.map +0 -1
  244. package/dist/goal/arm.d.ts +0 -61
  245. package/dist/goal/arm.d.ts.map +0 -1
  246. package/dist/goal/arm.js +0 -65
  247. package/dist/goal/arm.js.map +0 -1
  248. package/dist/goal/continuation.d.ts +0 -66
  249. package/dist/goal/continuation.d.ts.map +0 -1
  250. package/dist/goal/continuation.js +0 -111
  251. package/dist/goal/continuation.js.map +0 -1
  252. package/dist/goal/judge.d.ts +0 -113
  253. package/dist/goal/judge.d.ts.map +0 -1
  254. package/dist/goal/judge.js +0 -193
  255. package/dist/goal/judge.js.map +0 -1
  256. package/dist/goal/loop.d.ts +0 -75
  257. package/dist/goal/loop.d.ts.map +0 -1
  258. package/dist/goal/loop.js +0 -132
  259. package/dist/goal/loop.js.map +0 -1
@@ -147,6 +147,13 @@
147
147
  * retried at the next open; and a long backlog drains in bounded transactions.
148
148
  * See `TOKEN_USAGE`.
149
149
  *
150
+ * `playbook_runs` + `playbook_approvals` → `tasks_playbook_runs` +
151
+ * `tasks_playbook_approvals` (T12535) is a lossless CARRY then a freeze: this
152
+ * build reads and writes only the twins (which sync); every bare row the twins
153
+ * lack is copied (at the initial collapse a differing bare row wins and the
154
+ * twin's value is archived), nothing is deleted, and the bare tables get the
155
+ * same freeze triggers as the docs pair. See `PLAYBOOKS`.
156
+ *
150
157
  * ## Contract
151
158
  *
152
159
  * 1. **Snapshot first** (initial collapse only, only when the merge changes
@@ -832,10 +839,14 @@ const STICKY_TAGS = {
832
839
  deleted: counts.deleted + synced.deleted,
833
840
  };
834
841
  },
842
+ // @sync-invariant none:local-only fills this connection's TEMP read shadow; main is never written
835
843
  shadow(db, plan) {
836
- db.exec('CREATE TEMP TABLE IF NOT EXISTS brain_sticky_tags (sticky_id TEXT NOT NULL, tag TEXT NOT NULL, PRIMARY KEY (sticky_id, tag))');
844
+ // Every column of the twin (its uid too), so a reader of the shadow sees
845
+ // the twin's shape (T12535: the table syncs).
846
+ db.exec('CREATE TEMP TABLE IF NOT EXISTS brain_sticky_tags AS SELECT * FROM main.brain_sticky_tags WHERE 0');
847
+ db.exec('CREATE UNIQUE INDEX IF NOT EXISTS temp.brain_sticky_tags_shadow_key ON brain_sticky_tags (sticky_id, tag)');
837
848
  db.exec('DELETE FROM temp.brain_sticky_tags');
838
- db.exec('INSERT INTO temp.brain_sticky_tags (sticky_id, tag) SELECT sticky_id, tag FROM main.brain_sticky_tags');
849
+ db.exec('INSERT INTO temp.brain_sticky_tags SELECT * FROM main.brain_sticky_tags');
839
850
  applyStickyRows(db, 'temp', plan);
840
851
  syncStickyJunction(db, 'temp');
841
852
  },
@@ -965,7 +976,11 @@ export const DOCS_FROZEN_BY_KEY = 'twin_collapse_frozen_by:attachments';
965
976
  * @task T12535
966
977
  */
967
978
  export function docsFrozenVersion(db) {
968
- const recorded = readKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY);
979
+ return frozenVersion(db, DOCS_FROZEN_BY_KEY);
980
+ }
981
+ /** The version a freeze recorded under `byKey` names ({@link docsFrozenVersion}). */
982
+ function frozenVersion(db, byKey) {
983
+ const recorded = readKv(db, 'tasks_schema_meta', byKey);
969
984
  if (recorded !== undefined)
970
985
  return recorded.replace(/[^0-9A-Za-z.+-]/g, '');
971
986
  return releaseBuildVersion();
@@ -1006,8 +1021,9 @@ export const DOCS_FROZEN_FORBIDDEN = [
1006
1021
  'not a database',
1007
1022
  ];
1008
1023
  /**
1009
- * BEFORE INSERT/UPDATE/DELETE triggers on the bare docs tables that abort
1010
- * every write with {@link docsFrozenMessage} (option (a), owner decision
1024
+ * BEFORE INSERT/UPDATE/DELETE triggers on a pair's frozen bare tables (the
1025
+ * docs tables, the playbook tables) that abort every write with the pair's
1026
+ * message, e.g. {@link docsFrozenMessage} (option (a), owner decision
1011
1027
  * D11160). SQLite enforces them whatever build writes, so the 2026.9.20 build
1012
1028
  * can no longer write rows this build treats as frozen: no duplicate content
1013
1029
  * or slug, no split ref counts, no stale-read deletes, no ADR number reuse.
@@ -1018,25 +1034,34 @@ export const DOCS_FROZEN_FORBIDDEN = [
1018
1034
  * the triggers go with a renamed or dropped table and are re-installed at the
1019
1035
  * next open, see {@link repairGuards}).
1020
1036
  */
1021
- function ensureDocsFreeze(db) {
1022
- if (readKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY) === undefined) {
1037
+ function ensureFreeze(db, spec) {
1038
+ if (readKv(db, 'tasks_schema_meta', spec.byKey) === undefined) {
1023
1039
  const version = releaseBuildVersion();
1024
1040
  if (version !== null)
1025
- writeKv(db, 'tasks_schema_meta', DOCS_FROZEN_BY_KEY, version);
1041
+ writeKv(db, 'tasks_schema_meta', spec.byKey, version);
1026
1042
  }
1027
- for (const table of DOCS_BARE) {
1043
+ for (const table of spec.tables) {
1028
1044
  for (const op of SHADOW_WRITE_OPS) {
1029
1045
  const name = freezeTrigger(table, op);
1030
1046
  // A trigger with another text (an older message or version) is replaced.
1031
- if (freezeSql(db, name) !== undefined && !freezeUpToDate(db, table, op))
1047
+ if (freezeSql(db, name) !== undefined && !freezeUpToDate(db, spec, table, op))
1032
1048
  db.exec(`DROP TRIGGER main.${name}`);
1033
1049
  db.exec(`CREATE TRIGGER IF NOT EXISTS main.${name} BEFORE ${op} ON ${table} ` +
1034
- `BEGIN ${freezeBody(db)} END`);
1050
+ `BEGIN ${freezeBody(db, spec)} END`);
1035
1051
  }
1036
1052
  }
1037
1053
  }
1054
+ const DOCS_FREEZE = {
1055
+ tables: DOCS_BARE,
1056
+ byKey: DOCS_FROZEN_BY_KEY,
1057
+ message: docsFrozenMessage,
1058
+ };
1038
1059
  /** The freeze trigger's body (it carries the message). */
1039
- const freezeBody = (db) => `SELECT RAISE(ABORT, '${docsFrozenMessage(docsFrozenVersion(db)).replace(/'/g, "''")}');`;
1060
+ const freezeBody = (db, spec) => `SELECT RAISE(ABORT, '${spec.message(frozenVersion(db, spec.byKey)).replace(/'/g, "''")}');`;
1061
+ /** Whether every freeze trigger of `spec` exists with its current text. */
1062
+ function freezeIntact(db, spec) {
1063
+ return spec.tables.every((t) => SHADOW_WRITE_OPS.every((op) => freezeUpToDate(db, spec, t, op)));
1064
+ }
1040
1065
  /** The stored SQL of a trigger, or `undefined` when it does not exist. */
1041
1066
  function freezeSql(db, name) {
1042
1067
  return db
@@ -1044,9 +1069,9 @@ function freezeSql(db, name) {
1044
1069
  .get(name)?.sql;
1045
1070
  }
1046
1071
  /** Whether a freeze trigger exists with the current table, event and message. */
1047
- function freezeUpToDate(db, table, op) {
1072
+ function freezeUpToDate(db, spec, table, op) {
1048
1073
  const sql = freezeSql(db, freezeTrigger(table, op));
1049
- return sql?.includes(`BEFORE ${op} ON ${table} `) === true && sql.includes(freezeBody(db));
1074
+ return sql?.includes(`BEFORE ${op} ON ${table} `) === true && sql.includes(freezeBody(db, spec));
1050
1075
  }
1051
1076
  /** Whether every freeze and change trigger is in place. */
1052
1077
  function docsGuardsIntact(db) {
@@ -1056,11 +1081,11 @@ function docsGuardsIntact(db) {
1056
1081
  .get(...names).n;
1057
1082
  if (present !== names.length)
1058
1083
  return false;
1059
- return DOCS_BARE.every((t) => SHADOW_WRITE_OPS.every((op) => freezeUpToDate(db, t, op)));
1084
+ return freezeIntact(db, DOCS_FREEZE);
1060
1085
  }
1061
1086
  /** Install the freeze and change triggers (idempotent). */
1062
1087
  function ensureDocsGuards(db) {
1063
- ensureDocsFreeze(db);
1088
+ ensureFreeze(db, DOCS_FREEZE);
1064
1089
  ensureDocsTracking(db);
1065
1090
  }
1066
1091
  /** The bare side's change counter, or `undefined` when a change trigger is missing. */
@@ -1809,8 +1834,198 @@ const AUDIT_LOG = drainPair({
1809
1834
  rename: { idempotency_key: 'dispatch_idempotency_key' },
1810
1835
  savepoint: 'audit_log_row',
1811
1836
  });
1837
+ // ── playbook_runs + playbook_approvals ───────────────────────────────────────
1838
+ /** The bare playbook tables, each with its twin and key column. */
1839
+ const PLAYBOOK_TABLES = [
1840
+ { bare: 'playbook_runs', twin: 'tasks_playbook_runs', key: 'run_id' },
1841
+ { bare: 'playbook_approvals', twin: 'tasks_playbook_approvals', key: 'approval_id' },
1842
+ ];
1843
+ /** Key recording the version of the build that first froze the bare playbook tables. */
1844
+ export const PLAYBOOKS_FROZEN_BY_KEY = 'twin_collapse_frozen_by:playbook_runs';
1845
+ /** Twin rows the initial collapse replaced by their bare rows, as `<bare>\t<id>` → twin JSON. */
1846
+ export const PLAYBOOKS_ARCHIVE_KEY = 'twin_collapse_archive_rows:playbook_runs';
1847
+ /**
1848
+ * The message an older build's write to a frozen bare playbook table aborts
1849
+ * with. Same rules as {@link docsFrozenMessage}: it names the upgrade and
1850
+ * none of the {@link DOCS_FROZEN_FORBIDDEN} phrases.
1851
+ *
1852
+ * @param version - The release that froze the store, or `null`.
1853
+ * @returns The abort message.
1854
+ * @task T12535
1855
+ */
1856
+ export function playbooksFrozenMessage(version) {
1857
+ const needs = version === null ? 'the latest cleo' : `cleo ${version} or newer`;
1858
+ return (`CLEO: playbook runs moved to tasks_playbook_runs (T12535); this project needs ${needs} ` +
1859
+ 'to run playbooks and record approvals. Run: npm i -g @cleocode/cleo@latest');
1860
+ }
1861
+ const PLAYBOOKS_FREEZE = {
1862
+ tables: PLAYBOOK_TABLES.map((t) => t.bare),
1863
+ byKey: PLAYBOOKS_FROZEN_BY_KEY,
1864
+ message: playbooksFrozenMessage,
1865
+ };
1866
+ /** Rows of both sides of one playbook table, by key, over the columns both hold. */
1867
+ function playbookSides(db, t) {
1868
+ const cols = sharedColumns(db, t.bare, t.twin);
1869
+ if (!cols.includes(t.key))
1870
+ return null;
1871
+ return {
1872
+ cols,
1873
+ bare: carriedRowsOf(db, 'main', t.bare, cols, [t.key]),
1874
+ twin: carriedRowsOf(db, 'main', t.twin, cols, [t.key]),
1875
+ };
1876
+ }
1877
+ function playbookHashes(db) {
1878
+ const out = {};
1879
+ for (const t of PLAYBOOK_TABLES) {
1880
+ const cols = sharedColumns(db, t.bare, t.twin);
1881
+ if (!cols.includes(t.key))
1882
+ continue;
1883
+ for (const [id, row] of carriedRowsOf(db, 'main', t.bare, cols, [t.key]))
1884
+ out[`${t.bare}\t${id}`] = sha(row.json);
1885
+ }
1886
+ return out;
1887
+ }
1888
+ /** Row count and highest rowid of both bare tables: moves on any write to them. */
1889
+ function playbookChangeSeq(db) {
1890
+ try {
1891
+ return PLAYBOOK_TABLES.map((t) => {
1892
+ const r = db.prepare(`SELECT count(*) AS n, max(rowid) AS m FROM main.${t.bare}`).get();
1893
+ return `${r.n}:${r.m ?? 0}`;
1894
+ }).join('/');
1895
+ }
1896
+ catch {
1897
+ return undefined;
1898
+ }
1899
+ }
1900
+ /**
1901
+ * Plan the playbook carry. A bare row the twin lacks is carried; an identical
1902
+ * one is left alone. A row both hold with different values: at the initial
1903
+ * collapse the bare row wins (the runtime wrote only the bare tables until
1904
+ * this build, so the twin holds an older exodus copy), the twin's value
1905
+ * archived under {@link PLAYBOOKS_ARCHIVE_KEY}; later (the freeze was missing
1906
+ * while an older build wrote) the twin wins, listed as a conflict.
1907
+ */
1908
+ function planPlaybooks(db, state) {
1909
+ const plan = emptyPlan();
1910
+ for (const t of PLAYBOOK_TABLES) {
1911
+ const sides = playbookSides(db, t);
1912
+ if (sides === null)
1913
+ continue;
1914
+ for (const [id, row] of sides.bare) {
1915
+ const twin = sides.twin.get(id)?.json;
1916
+ if (twin === row.json)
1917
+ continue;
1918
+ const key = `${t.bare}\t${id}`;
1919
+ if (twin === undefined)
1920
+ plan.set.set(key, row.json);
1921
+ else if (state === undefined) {
1922
+ plan.set.set(key, row.json);
1923
+ plan.archived.push(key);
1924
+ }
1925
+ else
1926
+ plan.conflicts.push(`${key}: kept ${t.twin} (both builds changed it)`);
1927
+ }
1928
+ }
1929
+ return plan;
1930
+ }
1931
+ /**
1932
+ * Upsert each planned row into its twin in its own savepoint, re-read before
1933
+ * it counts. A row the twin refuses (a CHECK, or the UNIQUE token) is rolled
1934
+ * back, listed as a conflict and stays in the bare table, where the strand
1935
+ * check (`store/sync/flags.ts`) still sees it.
1936
+ */
1937
+ function applyPlaybooks(db, plan) {
1938
+ let inserted = 0;
1939
+ let replaced = 0;
1940
+ const archive = parseJson(readKv(db, 'tasks_schema_meta', PLAYBOOKS_ARCHIVE_KEY) ?? '{}');
1941
+ const archived = archive !== null && typeof archive === 'object' && !Array.isArray(archive)
1942
+ ? archive
1943
+ : {};
1944
+ for (const t of PLAYBOOK_TABLES) {
1945
+ const cols = sharedColumns(db, t.bare, t.twin);
1946
+ if (!cols.includes(t.key))
1947
+ continue;
1948
+ const list = cols.map(quoteIdent).join(', ');
1949
+ const upsert = db.prepare(`INSERT INTO main.${t.twin} (${list}) VALUES (${cols.map(() => '?').join(', ')}) ` +
1950
+ `ON CONFLICT(${quoteIdent(t.key)}) DO UPDATE SET ` +
1951
+ cols.map((c) => `${quoteIdent(c)} = excluded.${quoteIdent(c)}`).join(', '));
1952
+ const twinRow = db.prepare(`SELECT ${list} FROM main.${t.twin} WHERE ${quoteIdent(t.key)} = ?`);
1953
+ const jsonOf = (id) => {
1954
+ const r = twinRow.get(id);
1955
+ return r === undefined ? undefined : rowJson(r, cols);
1956
+ };
1957
+ for (const [key, json] of plan.set) {
1958
+ const [bare, id] = key.split('\t');
1959
+ if (bare !== t.bare)
1960
+ continue;
1961
+ const before = jsonOf(id);
1962
+ db.exec('SAVEPOINT playbook_row');
1963
+ try {
1964
+ upsert.run(...JSON.parse(json));
1965
+ if (jsonOf(id) !== json)
1966
+ // @sync-invariant none:local-only re-reads a row this device's own collapse just wrote; the savepoint rolls it back and the row stays in the bare table
1967
+ throw new Error('the stored row differs');
1968
+ db.exec('RELEASE playbook_row');
1969
+ }
1970
+ catch (error) {
1971
+ db.exec('ROLLBACK TO playbook_row');
1972
+ db.exec('RELEASE playbook_row');
1973
+ plan.conflicts.push(`${key}: not carried (${error instanceof Error ? error.message : error})`);
1974
+ plan.skipped++;
1975
+ continue;
1976
+ }
1977
+ if (before === undefined)
1978
+ inserted++;
1979
+ else {
1980
+ replaced++;
1981
+ archived[key] = before;
1982
+ }
1983
+ }
1984
+ }
1985
+ if (replaced > 0)
1986
+ writeKv(db, 'tasks_schema_meta', PLAYBOOKS_ARCHIVE_KEY, JSON.stringify(archived));
1987
+ ensureFreeze(db, PLAYBOOKS_FREEZE);
1988
+ return { inserted, replaced, deleted: 0 };
1989
+ }
1990
+ /**
1991
+ * `playbook_runs` + `playbook_approvals` → `tasks_playbook_runs` +
1992
+ * `tasks_playbook_approvals` (T12535), one pair. This build reads and writes
1993
+ * only the twins (`packages/playbooks`), which sync; the bare tables are
1994
+ * carried into them and then FROZEN like the docs pair (option (a), D11160),
1995
+ * so an older build cannot start a run or decide an approval the twins would
1996
+ * never see: it gets {@link playbooksFrozenMessage}.
1997
+ *
1998
+ * - Lossless: no bare row is deleted, and a twin row the initial collapse
1999
+ * replaces is archived, so no snapshot is taken; a failed merge degrades
2000
+ * nothing and is retried at the next open.
2001
+ * - The carry is idempotent: every bare key ends in the twin, so the strand
2002
+ * check passes without accounting; a row the twin refuses stays a strand.
2003
+ * - An unchanged pair costs one `count`/`max(rowid)` read per bare table.
2004
+ */
2005
+ const PLAYBOOKS = {
2006
+ table: 'playbook_runs',
2007
+ twin: 'tasks_playbook_runs',
2008
+ kvTable: 'tasks_schema_meta',
2009
+ tables: [...PLAYBOOK_TABLES.flatMap((t) => [t.bare, t.twin]), 'tasks_schema_meta'],
2010
+ lossless: true,
2011
+ bareHashes: playbookHashes,
2012
+ // The twins are live and written by this build; only the bare side is compared.
2013
+ twinHashes: () => ({}),
2014
+ changeSeq: playbookChangeSeq,
2015
+ guardsIntact: (db) => freezeIntact(db, PLAYBOOKS_FREEZE),
2016
+ installGuards: (db) => ensureFreeze(db, PLAYBOOKS_FREEZE),
2017
+ plan: planPlaybooks,
2018
+ apply: applyPlaybooks,
2019
+ };
1812
2020
  /** The pairs this build collapses, in order. */
1813
- const PAIRS = [SCHEMA_META, STICKY_TAGS, DOCS, TOKEN_USAGE, AUDIT_LOG];
2021
+ const PAIRS = [
2022
+ SCHEMA_META,
2023
+ STICKY_TAGS,
2024
+ DOCS,
2025
+ TOKEN_USAGE,
2026
+ AUDIT_LOG,
2027
+ PLAYBOOKS,
2028
+ ];
1814
2029
  /** Connections serving TEMP shadows because their collapse failed. */
1815
2030
  const degraded = new WeakMap();
1816
2031
  /**
@@ -1966,7 +2181,9 @@ function collapsePair(db, pair, snapshotPath, dbPath, continuation = false) {
1966
2181
  throw new Error(`bare ${pair.table} changed after the snapshot decision; retry the open`);
1967
2182
  // §2.3a rule 3 (T12775): a merge that wrote a synced twin marks its touch
1968
2183
  // set suspect, so the sealer's repair diff covers what capture may miss.
1969
- const { result: counts } = withSuspectAccounting(db, hasMainTable(db, 'tasks_tasks') ? 'project' : 'global', [pair.twin], () => pair.apply(db, plan));
2184
+ // Every table of the pair: a pair may write more than one synced twin
2185
+ // (playbooks); only sync-set tables are ever marked.
2186
+ const { result: counts } = withSuspectAccounting(db, hasMainTable(db, 'tasks_tasks') ? 'project' : 'global', pair.tables, () => pair.apply(db, plan));
1970
2187
  const now = new Date().toISOString();
1971
2188
  const next = {
1972
2189
  version: 3,