total-recall-brain 3.43.3 → 3.45.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 (252) hide show
  1. package/bin/total-recall.mjs +6 -4
  2. package/docs/ARCHITECTURE.md +10 -7
  3. package/docs/reference/cli-reference.md +16 -9
  4. package/frontend/dist/assets/index-DACh1dhx.js +5613 -0
  5. package/frontend/dist/index.html +1 -1
  6. package/frontend/src/App.tsx +2 -2
  7. package/frontend/src/pages/PluginsPage.tsx +1 -1
  8. package/package.json +3 -3
  9. package/scaffold/.agent/skills/plugin/SKILL.md +7 -2
  10. package/scaffold/.agent/skills/plugin/evals/README.md +5 -0
  11. package/scaffold/.agent/skills/plugin/evals/cases/01-fabricated-seed.mjs +1 -0
  12. package/scaffold/.agent/skills/plugin/evals/cases/02-reassuring-default.mjs +1 -0
  13. package/scaffold/.agent/skills/plugin/evals/cases/03-declared-template.mjs +1 -0
  14. package/scaffold/.agent/skills/plugin/evals/cases/04-card-collects-credential.mjs +1 -0
  15. package/scaffold/.agent/skills/plugin/evals/cases/05-host-leak-in-scaffold.mjs +1 -0
  16. package/scaffold/.agent/skills/plugin/evals/cases/06-correct-plugin.mjs +1 -0
  17. package/scaffold/.agent/skills/plugin/evals/cases/07-missing-reuse.mjs +11 -0
  18. package/scaffold/.agent/skills/plugin/evals/cases/08-unresolved-owner-command.mjs +12 -0
  19. package/scaffold/.agent/skills/plugin/evals/cases/09-missing-brick.mjs +12 -0
  20. package/scaffold/.agent/skills/plugin/prompts/01-function.md +4 -3
  21. package/scaffold/.agent/skills/plugin/prompts/08-verify.md +1 -0
  22. package/scaffold/.agent/skills/plugin/references/surfaces.md +12 -2
  23. package/scaffold/.agent/skills/plugin/scripts/record.mjs +59 -2
  24. package/scaffold/.agent/skills/total-recall/plugins/auth/README.md +8 -0
  25. package/scaffold/.agent/skills/total-recall/plugins/auth/package-lock.json +15 -14
  26. package/scaffold/.agent/skills/total-recall/plugins/auth/package.json +3 -3
  27. package/scaffold/.agent/skills/total-recall/plugins/auth/plugin.json +1 -1
  28. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-gh-login.mjs +102 -0
  29. package/scaffold/.agent/skills/total-recall/plugins/auth/src/cli-secret.mjs +38 -2
  30. package/scaffold/.agent/skills/total-recall/plugins/auth/src/login-projections.mjs +157 -13
  31. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-store.mjs +12 -1
  32. package/scaffold/.agent/skills/total-recall/plugins/auth/src/secrets-sync.mjs +161 -17
  33. package/scaffold/.agent/skills/total-recall/plugins/automations/BRICK.md +7 -0
  34. package/scaffold/.agent/skills/total-recall/plugins/automations/LICENSE +21 -0
  35. package/scaffold/.agent/skills/total-recall/plugins/automations/README.md +66 -0
  36. package/scaffold/.agent/skills/total-recall/plugins/automations/adapters.mjs +231 -0
  37. package/scaffold/.agent/skills/total-recall/plugins/automations/capability/index.mjs +16 -0
  38. package/scaffold/.agent/skills/total-recall/plugins/automations/cli.mjs +121 -0
  39. package/{src/core/plugin-cron.mjs → scaffold/.agent/skills/total-recall/plugins/automations/cron.mjs} +1 -3
  40. package/scaffold/.agent/skills/total-recall/plugins/automations/inventory.mjs +88 -0
  41. package/scaffold/.agent/skills/total-recall/plugins/automations/package-lock.json +7661 -0
  42. package/scaffold/.agent/skills/total-recall/plugins/automations/package.json +35 -0
  43. package/scaffold/.agent/skills/total-recall/plugins/automations/plugin.json +167 -0
  44. package/scaffold/.agent/skills/total-recall/plugins/automations/scheduler.mjs +246 -0
  45. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/DESIGN.md +42 -0
  46. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/automations.js +2 -0
  47. package/scaffold/.agent/skills/total-recall/plugins/automations/ui/card.js +273 -0
  48. package/scaffold/.agent/skills/total-recall/plugins/code-quality/commands/run-core.mjs +1 -1
  49. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/check.mjs +57 -5
  50. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report-data.mjs +40 -14
  51. package/scaffold/.agent/skills/total-recall/plugins/code-quality/core/report.mjs +15 -40
  52. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package-lock.json +15 -14
  53. package/scaffold/.agent/skills/total-recall/plugins/code-quality/package.json +3 -3
  54. package/scaffold/.agent/skills/total-recall/plugins/code-quality/plugin.json +1 -1
  55. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package-lock.json +15 -14
  56. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/package.json +3 -3
  57. package/scaffold/.agent/skills/total-recall/plugins/composable-cli/plugin.json +1 -1
  58. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package-lock.json +15 -14
  59. package/scaffold/.agent/skills/total-recall/plugins/embeddings/package.json +3 -3
  60. package/scaffold/.agent/skills/total-recall/plugins/embeddings/plugin.json +1 -1
  61. package/scaffold/.agent/skills/total-recall/plugins/git/README.md +112 -0
  62. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/cli.mjs +135 -23
  63. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package-lock.json +17 -16
  64. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/package.json +5 -4
  65. package/scaffold/.agent/skills/total-recall/plugins/git/plugin.json +161 -0
  66. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/context.mjs +94 -0
  67. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/phase3-spec-support.mjs +50 -0
  68. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/runtime.mjs +40 -0
  69. package/scaffold/.agent/skills/total-recall/plugins/git/src/lib/spec-support.mjs +51 -0
  70. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/_support.mjs +195 -0
  71. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/auth.mjs +201 -0
  72. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/bisect.mjs +40 -0
  73. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/cli-meta.mjs +159 -0
  74. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit-memory.mjs +30 -0
  75. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/commit.mjs +46 -0
  76. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/fleet.mjs +156 -0
  77. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/force-push.mjs +58 -0
  78. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/group.mjs +119 -0
  79. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/handoff.mjs +59 -0
  80. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/message.mjs +22 -0
  81. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/resolve.mjs +60 -0
  82. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/rules.mjs +81 -0
  83. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/secret-firewall.mjs +66 -0
  84. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/undo.mjs +132 -0
  85. package/scaffold/.agent/skills/total-recall/plugins/git/src/ops/why.mjs +16 -0
  86. package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/card.js +2 -2
  87. package/scaffold/.agent/skills/total-recall/plugins/mesh/package-lock.json +15 -14
  88. package/scaffold/.agent/skills/total-recall/plugins/mesh/package.json +3 -3
  89. package/scaffold/.agent/skills/total-recall/plugins/mesh/plugin.json +1 -1
  90. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/README.md +59 -10
  91. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/BRICK.md +1 -1
  92. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/capability/index.mjs +18 -6
  93. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/cli.mjs +160 -29
  94. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package-lock.json +15 -14
  95. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/package.json +4 -4
  96. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/plugin.json +231 -16
  97. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/skills/meta-harness/SKILL.md +19 -4
  98. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/adoption.mjs +104 -0
  99. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/alerts.mjs +3 -3
  100. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/cascade.mjs +101 -16
  101. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/control.mjs +145 -0
  102. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/coordinator.mjs +54 -21
  103. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch-request.mjs +40 -0
  104. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/dispatch.mjs +68 -13
  105. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/documentation.mjs +55 -11
  106. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/executor.mjs +64 -15
  107. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/hermes.mjs +29 -1
  108. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/gate-adapters/index.mjs +1 -1
  109. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/included-usage.mjs +249 -0
  110. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/jobs.mjs +84 -0
  111. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/learner.mjs +6 -12
  112. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/maintenance.mjs +17 -0
  113. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/mcp-server.mjs +228 -0
  114. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/pool.mjs +16 -8
  115. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/process-manager.mjs +26 -2
  116. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reasoning.mjs +6 -1
  117. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/remote-operation.mjs +6 -2
  118. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/review.mjs +34 -3
  119. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/reviews.mjs +25 -0
  120. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/router.mjs +28 -12
  121. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/runtime.mjs +28 -6
  122. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/claude.mjs +319 -0
  123. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/codex.mjs +387 -0
  124. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/result.mjs +67 -0
  125. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/resume.mjs +373 -0
  126. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session/run.mjs +223 -0
  127. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/session-jobs.mjs +72 -0
  128. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/setup.mjs +6 -3
  129. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/termination.mjs +16 -12
  130. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/threads.mjs +32 -1
  131. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/usage-queue.mjs +110 -0
  132. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/verify-ssss.mjs +3 -3
  133. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worker.mjs +597 -70
  134. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/src/worktree.mjs +76 -9
  135. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/DESIGN.md +5 -5
  136. package/scaffold/.agent/skills/total-recall/plugins/meta-harness/ui/card.js +3 -3
  137. package/scaffold/.agent/skills/total-recall/plugins/openwiki/cli.mjs +47 -18
  138. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package-lock.json +15 -14
  139. package/scaffold/.agent/skills/total-recall/plugins/openwiki/package.json +3 -3
  140. package/scaffold/.agent/skills/total-recall/plugins/openwiki/plugin.json +1 -1
  141. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh-diagnostics.mjs +44 -0
  142. package/scaffold/.agent/skills/total-recall/plugins/openwiki/src/refresh.mjs +120 -113
  143. package/scaffold/.agent/skills/total-recall/plugins/security/package-lock.json +15 -14
  144. package/scaffold/.agent/skills/total-recall/plugins/security/package.json +3 -3
  145. package/scaffold/.agent/skills/total-recall/plugins/security/plugin.json +1 -1
  146. package/scaffold/.agent/skills/total-recall/plugins/ssss/assets/scaffold.json +5 -5
  147. package/scaffold/.agent/skills/total-recall/plugins/ssss/package-lock.json +6 -6
  148. package/scaffold/.agent/skills/total-recall/plugins/ssss/package.json +2 -2
  149. package/scaffold/.agent/skills/total-recall/plugins/ssss/plugin.json +1 -1
  150. package/scaffold/.agent/skills/total-recall/references/operations.md +1 -1
  151. package/src/cli/agent.mjs +1 -1
  152. package/src/cli/command.mjs +41 -2
  153. package/src/cli/connect.mjs +2 -29
  154. package/src/cli/context.mjs +1 -0
  155. package/src/cli/dream.mjs +93 -69
  156. package/src/cli/harness.mjs +4 -4
  157. package/src/cli/help.mjs +1 -1
  158. package/src/cli/ingest-okf.mjs +4 -4
  159. package/src/cli/init.mjs +0 -25
  160. package/src/cli/lessons.mjs +51 -0
  161. package/src/cli/memory-plugin.mjs +1 -1
  162. package/src/cli/plugin/copies.mjs +64 -14
  163. package/src/cli/plugin/create.mjs +6 -7
  164. package/src/cli/plugin/index.mjs +11 -7
  165. package/src/cli/plugin/install.mjs +10 -15
  166. package/src/cli/plugin/remove.mjs +1 -1
  167. package/src/cli/plugin/search.mjs +2 -2
  168. package/src/cli/preflight.mjs +19 -6
  169. package/src/cli/proposals.mjs +1 -1
  170. package/src/cli/recall.mjs +66 -23
  171. package/src/cli/release.mjs +4 -1
  172. package/src/cli/remember.mjs +3 -14
  173. package/src/cli/server.mjs +18 -0
  174. package/src/cli/startup.mjs +1 -0
  175. package/src/cli/task.mjs +9 -2
  176. package/src/cli/update.mjs +10 -3
  177. package/src/core/access-ledger.mjs +78 -0
  178. package/src/core/agent-manager.mjs +2 -2
  179. package/src/core/automatic-brain-updates.mjs +11 -6
  180. package/src/core/brain-locate.mjs +15 -0
  181. package/src/core/brain-updates.mjs +42 -12
  182. package/src/core/claude-hooks.mjs +3 -1
  183. package/src/core/cleanup-task.mjs +15 -0
  184. package/src/core/cli-commands.mjs +2 -0
  185. package/src/core/cli-maintenance.mjs +4 -1
  186. package/src/core/cli-semantic-index.mjs +23 -3
  187. package/src/core/context-compactor.mjs +7 -2
  188. package/src/core/context-compiler.mjs +26 -1
  189. package/src/core/context-policy.mjs +7 -3
  190. package/src/core/daemon-control.mjs +22 -0
  191. package/src/core/daemon-loop.mjs +39 -62
  192. package/src/core/default-plugins.mjs +1 -1
  193. package/src/core/dream.mjs +33 -105
  194. package/src/core/ensure-dependencies.mjs +148 -0
  195. package/src/core/fast-recall.mjs +2 -2
  196. package/src/core/field-evaluation.mjs +23 -0
  197. package/src/core/gate-mode.mjs +1 -1
  198. package/src/core/global-brain-updates.mjs +1 -1
  199. package/src/core/harness-hooks.mjs +14 -2
  200. package/src/core/lesson-hooks.mjs +74 -0
  201. package/src/core/lesson-memory.mjs +24 -0
  202. package/src/core/lesson-sessions.mjs +52 -0
  203. package/src/core/lessons.mjs +96 -0
  204. package/src/core/mesh-brain-updates.mjs +2 -1
  205. package/src/core/meta-harness.mjs +2 -2
  206. package/src/core/optimizer.mjs +66 -165
  207. package/src/core/plugin-contracts/card.mjs +3 -0
  208. package/src/core/plugin-loader.mjs +12 -3
  209. package/src/core/plugin-runtime.mjs +40 -1
  210. package/src/core/plugin-store.mjs +35 -23
  211. package/src/core/post-mortem.mjs +52 -36
  212. package/src/core/preflight-gate.mjs +23 -2
  213. package/src/core/proposal-applier.mjs +31 -2
  214. package/src/core/repo-sync.mjs +4 -6
  215. package/src/core/required-plugins.mjs +26 -4
  216. package/src/core/rule-length.mjs +23 -0
  217. package/src/core/runtime.mjs +1 -1
  218. package/src/core/scheduler.mjs +35 -43
  219. package/src/core/schema.mjs +1 -2
  220. package/src/core/search.mjs +4 -3
  221. package/src/core/server-restart.mjs +128 -7
  222. package/src/core/server-service.mjs +148 -0
  223. package/src/core/session-watcher.mjs +19 -1
  224. package/src/core/skill-gap.mjs +16 -0
  225. package/src/core/ssss-kernel-bridge.mjs +33 -6
  226. package/src/core/startup-health.mjs +6 -1
  227. package/src/core/surface.mjs +68 -57
  228. package/src/core/task-context.mjs +9 -8
  229. package/src/core/task-executors.mjs +54 -106
  230. package/src/core/validated-write.mjs +5 -0
  231. package/src/core/vault-files.mjs +8 -2
  232. package/src/core/vault.mjs +1 -1
  233. package/src/core/vector-field.mjs +134 -192
  234. package/src/server/index.mjs +3 -1
  235. package/src/server/invoke-core-child.mjs +24 -0
  236. package/src/server/invoke-core-operations.mjs +102 -0
  237. package/src/server/route-manifest.json +0 -56
  238. package/src/server/routes/brains.mjs +0 -18
  239. package/src/server/routes/context.mjs +0 -41
  240. package/src/server/routes/invoke.mjs +26 -1
  241. package/src/server/routes/ssss.mjs +0 -169
  242. package/src/server/routes/system.mjs +0 -15
  243. package/src/server/routes/tasks.mjs +0 -26
  244. package/src/server/routes/vault.mjs +2 -23
  245. package/frontend/dist/assets/index-DD4qONvH.js +0 -5607
  246. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/README.md +0 -53
  247. package/scaffold/.agent/skills/total-recall/plugins/git-sentinel/plugin.json +0 -129
  248. package/src/core/append-log.mjs +0 -131
  249. package/src/core/plugin-tasks.mjs +0 -173
  250. package/templates/onboarding-interview.md +0 -82
  251. /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/LICENSE +0 -0
  252. /package/scaffold/.agent/skills/total-recall/plugins/{git-sentinel → git}/ui/DESIGN.md +0 -0
@@ -0,0 +1,159 @@
1
+ import { spawnSync } from 'node:child_process';
2
+ import { accessSync, constants, realpathSync, statSync } from 'node:fs';
3
+ import os from 'node:os';
4
+ import path from 'node:path';
5
+ import { emit, jsonResult, stateStore, tr } from '../lib/runtime.mjs';
6
+ import { authGitEnv } from './auth.mjs';
7
+
8
+ const UPSTREAM = 'https://github.com/git/git';
9
+ const DAY = 24 * 60 * 60 * 1000;
10
+ const compare = (a, b) => {
11
+ const left = a.split('.').map(Number), right = b.split('.').map(Number);
12
+ for (let i = 0; i < 3; i++) if (left[i] !== right[i]) return left[i] - right[i];
13
+ return 0;
14
+ };
15
+
16
+ export function executable(name, ctx) {
17
+ for (const dir of (ctx.env.PATH || '').split(path.delimiter)) {
18
+ const file = path.resolve(ctx.cwd, dir, name);
19
+ try { accessSync(file, constants.X_OK); if (statSync(file).isFile()) return file; } catch { /* try next PATH entry */ }
20
+ }
21
+ return null;
22
+ }
23
+
24
+ function tool(name, args, ctx, timeout = 15000) {
25
+ const file = executable(name, ctx);
26
+ if (!file) return { status: null };
27
+ return spawnSync(file, args, { cwd: ctx.cwd, env: ctx.env, encoding: 'utf8',
28
+ stdio: ['ignore', 'pipe', 'pipe'], timeout, maxBuffer: 16 * 1024 * 1024, shell: false });
29
+ }
30
+
31
+ function installSource(file, build, ctx) {
32
+ if (/[/\\](?:Cellar|Homebrew|linuxbrew)[/\\]/i.test(file)) return 'brew';
33
+ const brew = tool('brew', ['--prefix'], ctx);
34
+ if (brew.status === 0 && brew.stdout?.trim()) {
35
+ try { if (file.startsWith(`${realpathSync(brew.stdout.trim())}${path.sep}`)) return 'brew'; } catch { /* invalid prefix */ }
36
+ }
37
+ const apt = tool('dpkg-query', ['-S', file], ctx);
38
+ if (apt.status === 0 && /^git(?::[^:\s]+)?:\s/m.test(apt.stdout || '')) return 'apt';
39
+ if (file === '/usr/bin/git' && /Apple Git/i.test(build)) return 'apple-clt';
40
+ return 'other';
41
+ }
42
+
43
+ async function upstream(ctx, force) {
44
+ const store = await stateStore(ctx, 'upstream.json');
45
+ const cached = store.read(null);
46
+ const age = Date.now() - Date.parse(cached?.checked_at);
47
+ if (!force && age >= 0 && age < DAY && Array.isArray(cached?.versions) && cached.versions.length &&
48
+ cached.versions.every(v => /^\d+\.\d+\.\d+$/.test(v))) return { ...cached, cached: true, stale: false };
49
+ const result = ctx.git(['ls-remote', '--tags', '--refs', UPSTREAM], {
50
+ env: { ...await authGitEnv(ctx), GIT_TERMINAL_PROMPT: '0' }, timeout: 30000,
51
+ });
52
+ const versions = [...new Set((result.stdout || '').split('\n').flatMap(line => {
53
+ const match = line.match(/^[0-9a-f]+\s+refs\/tags\/v(\d+\.\d+\.\d+)$/);
54
+ return match ? [match[1]] : [];
55
+ }))].sort(compare);
56
+ if (result.status !== 0 || result.error || !versions.length) {
57
+ return { versions: [], checked_at: cached?.checked_at ?? null, cached: false, stale: true, error: 'Cannot read stable upstream Git releases' };
58
+ }
59
+ const value = { versions, checked_at: new Date().toISOString() };
60
+ store.write(value);
61
+ return { ...value, cached: false, stale: false };
62
+ }
63
+
64
+ export async function collectCli(ctx, { force = false } = {}) {
65
+ const command = executable('git', ctx);
66
+ if (!command) throw new Error('Git executable was not found in PATH');
67
+ const resolved = realpathSync(command);
68
+ const versionRow = ctx.git(['--version']);
69
+ const build = ctx.git(['version', '--build-options']);
70
+ const version = versionRow.status === 0 && versionRow.stdout?.match(/^git version (\d+\.\d+\.\d+)(?:\D.*)?\s*$/)?.[1];
71
+ if (!version || build.status !== 0) throw new Error('Cannot read the Git version and build options');
72
+ const releases = await upstream(ctx, force);
73
+ const latest = releases.versions.at(-1) ?? null;
74
+ const hostname = os.hostname();
75
+ const report = {
76
+ version, resolved_path: resolved, install_source: installSource(resolved, versionRow.stdout, ctx),
77
+ build_options: build.stdout.trimEnd(), latest_upstream: latest,
78
+ release_notes_url: latest ? `${UPSTREAM}/blob/master/Documentation/RelNotes/${latest}.adoc` : null,
79
+ versions_behind: latest ? releases.versions.filter(v => compare(v, version) > 0).length : null,
80
+ checked_at: new Date().toISOString(), hostname,
81
+ upstream_checked_at: releases.checked_at, upstream_cached: releases.cached, upstream_stale: releases.stale,
82
+ ...(releases.error ? { error: releases.error } : {}),
83
+ };
84
+ const id = Buffer.from(hostname).toString('hex');
85
+ const store = await stateStore(ctx, `cli-${id}.json`);
86
+ store.read(null);
87
+ store.write(report);
88
+ const events = await stateStore(ctx, 'cli-events.jsonl');
89
+ events.append({ type: 'git.cli_checked', timestamp: report.checked_at, ...report });
90
+ return report;
91
+ }
92
+
93
+ async function allNodes(ctx) {
94
+ const inventory = jsonResult(tr(['mesh', 'nodes', '--json'], ctx), 'Cannot read mesh nodes');
95
+ if (!Array.isArray(inventory.nodes) || !inventory.nodes.length) throw new Error('Mesh returned no nodes');
96
+ const nodes = [];
97
+ for (const node of inventory.nodes) {
98
+ const name = node.name || node.hostname || node.id;
99
+ if (typeof name !== 'string' || !name || name.startsWith('-')) {
100
+ nodes.push({ ok: false, error: 'Mesh node has no usable name' }); continue;
101
+ }
102
+ try {
103
+ const result = jsonResult(tr(['mesh', 'exec', name, 'total-recall git cli --json'], ctx), 'Node CLI check failed');
104
+ if (!result.version || !result.hostname || result.error || result.upstream_stale) throw new Error('Node CLI metadata is incomplete');
105
+ nodes.push({ node: name, ok: true, ...result });
106
+ } catch { nodes.push({ node: name, ok: false, error: 'Node CLI check failed' }); }
107
+ }
108
+ return { ok: nodes.every(node => node.ok), nodes };
109
+ }
110
+
111
+ async function update(ctx) {
112
+ const before = await collectCli(ctx, { force: true });
113
+ if (before.error) throw new Error(before.error);
114
+ if (before.versions_behind === 0) return { ok: true, updated: false, cli: before };
115
+ let result;
116
+ switch (before.install_source) {
117
+ case 'brew': result = tool('brew', ['upgrade', 'git'], ctx, 15 * 60 * 1000); break;
118
+ case 'apt': result = tool('apt-get', ['install', '--only-upgrade', '-y', 'git'], ctx, 15 * 60 * 1000); break;
119
+ case 'apple-clt':
120
+ if (!executable('brew', ctx)) throw new Error('Apple Command Line Tools Git needs Homebrew; no installer is available');
121
+ result = tool('brew', ['install', 'git'], ctx, 15 * 60 * 1000); break;
122
+ default: throw new Error('Git install source is other; no supported updater is available');
123
+ }
124
+ if (result.status !== 0 || result.error || result.signal) throw new Error('The Git installer failed; check installer permissions and configured sources');
125
+ const after = await collectCli(ctx);
126
+ if (after.error || after.versions_behind !== 0) throw new Error('Git is still behind upstream; the configured source or PATH has no current Git');
127
+ return { ok: true, updated: after.version !== before.version, previous_version: before.version, cli: after };
128
+ }
129
+
130
+ export const operations = {
131
+ cli: {
132
+ summary: 'Record Git CLI metadata or update Git through its installer',
133
+ usage: 'cli [--json] [--all-nodes] | cli update [--scheduled]',
134
+ async run(args, ctx) {
135
+ const json = ctx.json || args.includes('--json');
136
+ ctx = { ...ctx, json };
137
+ const rest = args.filter(a => a !== '--json');
138
+ const isUpdate = rest[0] === 'update';
139
+ try {
140
+ let report;
141
+ if (!rest.length) report = await collectCli(ctx);
142
+ else if (rest.length === 1 && rest[0] === '--all-nodes') report = await allNodes(ctx);
143
+ else if (isUpdate && (rest.length === 1 || (rest.length === 2 && rest[1] === '--scheduled'))) report = await update(ctx);
144
+ else throw new Error('Use cli [--all-nodes] or cli update [--scheduled]');
145
+ emit(ctx, report, JSON.stringify(report, null, 2));
146
+ return report.error || report.ok === false ? 1 : 0;
147
+ } catch (error) {
148
+ const reason = error.message;
149
+ emit(ctx, { ok: false, error: reason, checked_at: new Date().toISOString(), hostname: os.hostname() }, `Git CLI check failed: ${reason}`);
150
+ if (isUpdate) {
151
+ // Fixed message only. Installer and service output can contain private data.
152
+ const alert = tr(['alerts', 'send', '--title', 'Git update failed', '--message', 'Git remains out of date. Run total-recall git cli --json for details.'], ctx);
153
+ if (alert.status !== 0) console.error('The operator alert could not be delivered');
154
+ }
155
+ return 1;
156
+ }
157
+ },
158
+ },
159
+ };
@@ -0,0 +1,30 @@
1
+ import { git, tr, ledger, intent, session } from '../lib/context.mjs';
2
+
3
+ export const guards = [{
4
+ name: 'commit-memory',
5
+ before() {},
6
+ async after(args, ctx, status) {
7
+ if (args[0] !== 'commit' || status !== 0 || args.some(arg => ['--dry-run', '--short', '--porcelain', '--long'].includes(arg))) return;
8
+ const sha = git(ctx, ['rev-parse', '--verify', 'HEAD']).trim();
9
+ const store = await ledger(ctx, 'commits', true);
10
+ if (store.read([]).some(row => row.sha === sha)) return;
11
+ const message = git(ctx, ['show', '-s', '--format=%B', sha]).trimEnd();
12
+ const files = git(ctx, ['diff-tree', '--root', '--no-commit-id', '--name-only', '-r', '-m', '-z', sha]).split('\0').filter(Boolean);
13
+ const record = { type: 'git.commit_context', sha, message, ...intent(message), files: [...new Set(files)], timestamp: new Date().toISOString() };
14
+ record.session = session(ctx.env) || record.session;
15
+ // Save the commit before any optional provider call.
16
+ store.append(record, { idempotencyKey: `commit-${sha}` });
17
+ const memory = record.decisions.length ? tr(ctx, ['remember', 'decision', `${record.decisions.join('\n')}\nCommit: ${sha}`, '--project', '--slug', `git-decision-${sha}`]) : null;
18
+ const available = tr(ctx, ['openwiki', '--help']);
19
+ let wiki;
20
+ if (available.status !== 0 || available.error) wiki = { status: 'skipped', reason: 'OpenWiki is unavailable' };
21
+ else {
22
+ // The current OpenWiki CLI accepts a refresh request, but has no queue verb.
23
+ const refresh = tr(ctx, ['openwiki', 'refresh', '--repo', ctx.cwd, '--apply', '--json']);
24
+ wiki = { status: refresh.status === 0 && !refresh.error ? 'requested' : 'failed', command: 'openwiki refresh' };
25
+ }
26
+ store.append({ type: 'git.commit_memory', sha, timestamp: new Date().toISOString(), memory: memory === null ? 'skipped: no named decision' : memory.status === 0 && !memory.error ? 'recorded' : 'failed', openwiki: wiki });
27
+ if (memory && (memory.status !== 0 || memory.error)) console.error('Commit saved. Repository decision memory failed.');
28
+ if (wiki.status === 'failed') console.error('Commit saved. OpenWiki refresh failed.');
29
+ },
30
+ }];
@@ -0,0 +1,46 @@
1
+ import { authGitEnv } from './auth.mjs';
2
+
3
+ const machineEmail = /@(?:localhost|[^@\s]+\.(?:local|lan|home|internal))$|@[^@.\s]+$/i;
4
+
5
+ export function checkCommit(ctx) {
6
+ for (const key of ['user.name', 'user.email']) {
7
+ const row = ctx.git(['config', '--get', key]);
8
+ if (row.status !== 0 || !row.stdout?.trim()) return `${key} is not configured or cannot be read`;
9
+ if (key === 'user.email' && machineEmail.test(row.stdout.trim())) return 'user.email names a machine';
10
+ }
11
+ // Check the effective identity too. Environment variables can override config.
12
+ for (const role of ['AUTHOR', 'COMMITTER']) {
13
+ const identity = ctx.git(['var', `GIT_${role}_IDENT`]);
14
+ if (identity.status !== 0) return `Cannot read the ${role.toLowerCase()} identity`;
15
+ const match = identity.stdout?.match(/^(.+) <([^<>]+)> \d+ [+-]\d+\s*$/);
16
+ if (!match || !match[1].trim()) return `Missing ${role.toLowerCase()} identity`;
17
+ if (machineEmail.test(match[2])) return `${role.toLowerCase()} email names a machine`;
18
+ }
19
+ const head = ctx.git(['symbolic-ref', '-q', 'HEAD']);
20
+ if (head.status === 1) return 'Refusing to commit on a detached HEAD';
21
+ if (head.status !== 0) return 'Cannot read HEAD';
22
+ const conflicts = ctx.git(['ls-files', '--unmerged', '-z']);
23
+ if (conflicts.status !== 0) return 'Cannot read unresolved conflicts';
24
+ if (conflicts.stdout) return 'Refusing to commit with unresolved conflicts';
25
+ }
26
+
27
+ export const operations = {
28
+ commit: {
29
+ summary: 'Check identity, conflicts and HEAD, then run git commit',
30
+ usage: 'commit [git commit arguments]',
31
+ async run(args, ctx) {
32
+ try {
33
+ const reason = checkCommit(ctx);
34
+ if (reason) { console.error(`Commit refused: ${reason}`); return 1; }
35
+ const env = await authGitEnv(ctx);
36
+ // Keep the editor, stdin, hooks and Git output attached to the terminal.
37
+ const result = ctx.git(['commit', ...args], { env, stdio: 'inherit' });
38
+ if (result.error || result.signal) return 1;
39
+ return result.status ?? 1;
40
+ } catch {
41
+ console.error('Commit refused: credential helper configuration is unavailable');
42
+ return 1;
43
+ }
44
+ },
45
+ },
46
+ };
@@ -0,0 +1,156 @@
1
+ import { existsSync, readFileSync } from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import { failure, gitText, output, trJson } from './_support.mjs';
5
+
6
+ const STALE_MS = 30 * 24 * 60 * 60 * 1000;
7
+
8
+ export function registeredRepositories(ctx) {
9
+ let data;
10
+ try { data = trJson(ctx, ['brain', 'list', '--json']); } catch { /* Older brain list has no JSON mode. */ }
11
+ const normalize = value => {
12
+ const rows = Array.isArray(value) ? value : value?.projects || value?.brains || value?.repositories;
13
+ if (!Array.isArray(rows)) return null;
14
+ const paths = rows.map(row => typeof row === 'string' ? row : row?.path || row?.root || row?.projectPath);
15
+ if (paths.some(p => typeof p !== 'string' || !path.isAbsolute(p))) throw new Error('Project registry contains an invalid repository path');
16
+ return [...new Set(paths)];
17
+ };
18
+ const rows = normalize(data);
19
+ if (rows) return rows;
20
+ const file = ctx.env?.GIT_PLUGIN_REGISTRY_FILE || path.join(ctx.env?.TR_GLOBAL_BRAIN || ctx.env?.TR_SECRETS_BRAIN || path.join(os.homedir(), '.agent', 'skills', 'total-recall'), 'config', 'project-registry.json');
21
+ const fallback = normalize(JSON.parse(readFileSync(file, 'utf8')));
22
+ if (!fallback) throw new Error('Project registry has no repository list');
23
+ return fallback;
24
+ }
25
+
26
+ export function inspectRepository(directory, ctx, now = Date.now()) {
27
+ const local = { ...ctx, git: (args, options) => ctx.git(['-C', directory, ...args], options) };
28
+ const errors = [];
29
+ const report = { path: directory, dirty: null, unpushed: null, behind: null, diverged: null,
30
+ staleStashes: null, abandonedWorktrees: null, upstream: null, errors };
31
+ const get = args => {
32
+ try { return gitText(local, args); } catch { errors.push(args[0]); return null; }
33
+ };
34
+ if (get(['rev-parse', '--is-inside-work-tree'])?.trim() !== 'true') return { ...report, error: 'Repository is unavailable' };
35
+ const status = get(['status', '--porcelain=v1', '-z']);
36
+ if (status !== null) report.dirty = status.length > 0;
37
+ const upstream = local.git(['rev-parse', '--abbrev-ref', '--symbolic-full-name', '@{upstream}']);
38
+ if (upstream.status === 0) {
39
+ report.upstream = upstream.stdout.trim();
40
+ const counts = get(['rev-list', '--left-right', '--count', '@{upstream}...HEAD'])?.trim().match(/^(\d+)\s+(\d+)$/);
41
+ if (counts) { report.behind = Number(counts[1]); report.unpushed = Number(counts[2]); report.diverged = report.behind > 0 && report.unpushed > 0; }
42
+ } else {
43
+ // No upstream is unknown sync state, not zero unpushed commits.
44
+ report.upstreamStatus = 'unavailable';
45
+ if (!/no upstream configured|no such branch|does not point to a branch|unknown revision/i.test(upstream.stderr || '')) errors.push('upstream');
46
+ }
47
+ const stashes = get(['stash', 'list', '--format=%H%x00%ct%x00%gs']);
48
+ if (stashes !== null) report.staleStashes = stashes.split('\n').filter(Boolean).flatMap(row => {
49
+ const [commit, timestamp, message] = row.split('\0');
50
+ return Number(timestamp) * 1000 < now - STALE_MS ? [{ commit, timestamp: Number(timestamp), message }] : [];
51
+ });
52
+ const trees = get(['worktree', 'list', '--porcelain', '-z']);
53
+ if (trees !== null) report.abandonedWorktrees = trees.split('\0\0').filter(Boolean).slice(1).flatMap(record => {
54
+ const fields = Object.fromEntries(record.split('\0').filter(Boolean).map(row => {
55
+ const i = row.indexOf(' '); return i < 0 ? [row, true] : [row.slice(0, i), row.slice(i + 1)];
56
+ }));
57
+ if (fields.locked) return [];
58
+ if (fields.prunable || !existsSync(fields.worktree)) return [{ path: fields.worktree, reason: 'missing-or-prunable' }];
59
+ const treeCtx = { ...ctx, git: (a, options) => ctx.git(['-C', fields.worktree, ...a], options) };
60
+ const state = treeCtx.git(['status', '--porcelain=v1', '-z']);
61
+ const last = treeCtx.git(['log', '-1', '--format=%ct']);
62
+ if (state.status !== 0 || last.status !== 0) { errors.push('worktree-inspection'); return []; }
63
+ return !state.stdout && Number(last.stdout.trim()) * 1000 < now - STALE_MS
64
+ ? [{ path: fields.worktree, reason: 'clean-with-old-head', candidate: true }] : [];
65
+ });
66
+ return report;
67
+ }
68
+
69
+ function containingCheckouts(directory, commit, ctx) {
70
+ const local = { ...ctx, git: (args, options) => ctx.git(['-C', directory, ...args], options) };
71
+ const oid = gitText(local, ['rev-parse', '--verify', '--end-of-options', `${commit}^{commit}`]).trim();
72
+ const refs = gitText(local, ['for-each-ref', `--contains=${oid}`, '--format=%(refname)', 'refs/heads', 'refs/remotes', 'refs/tags']).trim().split('\n').filter(Boolean);
73
+ const records = gitText(local, ['worktree', 'list', '--porcelain', '-z']).split('\0\0').filter(Boolean);
74
+ const checkouts = [];
75
+ for (const record of records) {
76
+ const file = record.split('\0').find(row => row.startsWith('worktree '))?.slice(9);
77
+ if (!file) continue;
78
+ const contains = ctx.git(['-C', file, 'merge-base', '--is-ancestor', oid, 'HEAD']);
79
+ if (contains.status === 0) checkouts.push(file);
80
+ else if (contains.status !== 1) throw new Error('Cannot inspect a registered checkout');
81
+ }
82
+ return { path: directory, commit: oid, objectPresent: true, refs, checkouts };
83
+ }
84
+
85
+ function localReport(ctx, commit) {
86
+ const errors = [];
87
+ const repositories = [];
88
+ for (const directory of registeredRepositories(ctx)) {
89
+ try {
90
+ if (commit) {
91
+ const contains = ctx.git(['-C', directory, 'cat-file', '-e', `${commit}^{commit}`]);
92
+ if (contains.status !== 0) {
93
+ if (ctx.git(['-C', directory, 'rev-parse', '--git-dir']).status !== 0) errors.push({ path: directory, error: 'Repository is unavailable' });
94
+ continue;
95
+ }
96
+ repositories.push(containingCheckouts(directory, commit, ctx));
97
+ } else {
98
+ const report = inspectRepository(directory, ctx);
99
+ repositories.push(report);
100
+ if (report.error || report.errors.length) errors.push({ path: directory, error: report.error || report.errors.join(', ') });
101
+ }
102
+ } catch { errors.push({ path: directory, error: 'Repository inspection failed' }); }
103
+ }
104
+ return { node: os.hostname(), repositories, errors, observedAt: new Date().toISOString(), syncSource: 'local refs; no fetch' };
105
+ }
106
+
107
+ function remoteReports(ctx, commit) {
108
+ const inventory = trJson(ctx, ['mesh', 'nodes', '--json']);
109
+ const rows = Array.isArray(inventory) ? inventory : inventory.nodes;
110
+ if (!Array.isArray(rows)) throw new Error('Mesh returned no node list');
111
+ const reports = [];
112
+ const seen = new Set();
113
+ for (const node of rows) {
114
+ const name = typeof node === 'string' ? node : node.hostname || node.name || node.id;
115
+ if (!name || seen.has(name) || name === os.hostname() || node.self || node.isLocal || node.isCurrentNode) continue;
116
+ seen.add(name);
117
+ try {
118
+ if (node.online === false || node.status === 'offline') throw new Error('Node is offline');
119
+ // Mesh joins words for remote execution. Quote the whole fixed command once.
120
+ const quote = word => `'${word.replaceAll("'", "'\\''")}'`;
121
+ const namespace = 'git';
122
+ const command = ['total-recall', namespace, commit ? 'where' : 'fleet', ...(commit ? [commit, '--local'] : []), '--json'].map(quote).join(' ');
123
+ const response = trJson(ctx, ['mesh', 'exec', name, command, '--json']);
124
+ if (response.exitCode !== 0 || typeof response.stdout !== 'string') throw new Error('Remote command failed');
125
+ const parsed = JSON.parse(response.stdout);
126
+ if (!Array.isArray(parsed.nodes) || typeof parsed.ok !== 'boolean') throw new Error('Remote report is invalid');
127
+ reports.push(...parsed.nodes);
128
+ } catch { reports.push({ node: name, repositories: [], errors: [{ error: 'Node is unavailable or returned no valid report' }] }); }
129
+ }
130
+ return reports;
131
+ }
132
+
133
+ async function run(args, ctx, where = false) {
134
+ try {
135
+ const flags = new Set(where ? ['--local', '--all-nodes'] : ['--all-nodes']);
136
+ const words = args.filter(a => !a.startsWith('-'));
137
+ if (args.some(a => a.startsWith('-') && !flags.has(a)) || words.length !== (where ? 1 : 0)) throw new Error(where ? 'Use where <commit> [--local]' : 'Use fleet [--all-nodes]');
138
+ const commit = words[0];
139
+ if (where && !/^[a-f0-9]{4,64}$/i.test(commit)) throw new Error('Supply a commit object id (4 to 64 hexadecimal characters)');
140
+ const nodes = [localReport(ctx, commit)];
141
+ if (args.includes('--all-nodes') || where && !args.includes('--local')) nodes.push(...remoteReports(ctx, commit));
142
+ const ok = nodes.every(node => !node.errors?.length);
143
+ const result = { ok, ...(where ? { commit } : {}), nodes };
144
+ const lines = nodes.flatMap(node => [node.node, ...node.repositories.map(repo => where
145
+ ? ` ${repo.path}: ${repo.checkouts.length} checkouts; ${repo.refs.length} refs`
146
+ : ` ${repo.path}: dirty=${repo.dirty ?? 'unknown'} unpushed=${repo.unpushed ?? 'unknown'} behind=${repo.behind ?? 'unknown'} diverged=${repo.diverged ?? 'unknown'} stale-stashes=${repo.staleStashes?.length ?? 'unknown'} abandoned-worktrees=${repo.abandonedWorktrees?.length ?? 'unknown'}`),
147
+ ...(node.errors || []).map(e => ` unavailable: ${e.path || node.node}`)]);
148
+ output(ctx, result, lines.join('\n'));
149
+ return ok ? 0 : 1;
150
+ } catch (error) { return failure(ctx, error); }
151
+ }
152
+
153
+ export const operations = {
154
+ fleet: { summary: 'Report repository state on this node or every mesh node', usage: 'fleet [--all-nodes] [--json]', run: (args, ctx) => run(args, ctx) },
155
+ where: { summary: 'Find machines and checkouts that contain a commit', usage: 'where <commit> [--local] [--json]', run: (args, ctx) => run(args, ctx, true) },
156
+ };
@@ -0,0 +1,58 @@
1
+ import { randomUUID } from 'node:crypto';
2
+ import { helpOnly, invocation, repoId, stateStore } from './_support.mjs';
3
+
4
+ export async function guardPush(args, ctx) {
5
+ const call = invocation(args, ctx);
6
+ if (call.command !== 'push' || helpOnly(args)) return;
7
+ const rewritten = [];
8
+ let reason;
9
+ let deletion = false;
10
+ let force = false;
11
+ let options = true;
12
+ let remoteSeen = false;
13
+ for (let i = 0; i < call.args.length; i += 1) {
14
+ const a = call.args[i];
15
+ if (options && ['--repo', '--receive-pack', '--exec', '--push-option', '-o'].includes(a)) {
16
+ if (a === '--repo') remoteSeen = true;
17
+ rewritten.push(a);
18
+ if (call.args[i + 1] !== undefined) rewritten.push(call.args[++i]);
19
+ continue;
20
+ }
21
+ if (options && a === '--reason') {
22
+ if (reason !== undefined || !call.args[i + 1] || call.args[i + 1].startsWith('-')) return { refuse: 'Supply one --reason <text>' };
23
+ reason = call.args[++i]; continue;
24
+ }
25
+ if (options && a.startsWith('--reason=')) {
26
+ if (reason !== undefined) return { refuse: 'Supply one --reason <text>' };
27
+ reason = a.slice(9); continue;
28
+ }
29
+ if (a === '--') options = false;
30
+ if (a.startsWith('--repo=')) remoteSeen = true;
31
+ if (options && ['--delete', '-d'].includes(a)) deletion = true;
32
+ if (remoteSeen && /^\+?:[^:]+$/.test(a)) deletion = true;
33
+ if (options && ['--force', '-f'].includes(a)) { force = true; continue; }
34
+ if (options && /^-[fvdqnu]+$/.test(a)) {
35
+ if (a.includes('f')) force = true;
36
+ if (a.includes('d')) deletion = true;
37
+ const remaining = a.slice(1).replaceAll('f', '');
38
+ if (remaining) rewritten.push(`-${remaining}`);
39
+ continue;
40
+ }
41
+ if (options && a === '--mirror') return { refuse: '--mirror can delete remote refs without a lease. Use explicit refs.' };
42
+ if (remoteSeen && a.startsWith('+') && a.length > 1) { force = true; rewritten.push(a.slice(1)); }
43
+ else rewritten.push(a);
44
+ if (!a.startsWith('-') && !remoteSeen) remoteSeen = true;
45
+ }
46
+ if (deletion) {
47
+ if (!reason?.trim()) return { refuse: 'Remote branch deletion requires --reason <text>' };
48
+ try {
49
+ const store = await stateStore(call.ctx);
50
+ store.append({ id: randomUUID(), type: 'git.remote_delete', timestamp: new Date().toISOString(),
51
+ repository: repoId(call.ctx), reason: reason.trim(), status: 'requested' });
52
+ } catch { return { refuse: 'Cannot record the remote deletion reason' }; }
53
+ }
54
+ if (force && !rewritten.some(a => a === '--force-with-lease' || a.startsWith('--force-with-lease='))) rewritten.unshift('--force-with-lease');
55
+ return { args: [...call.prefix, 'push', ...rewritten] };
56
+ }
57
+
58
+ export const guards = [{ name: 'force-push', before: guardPush }];
@@ -0,0 +1,119 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { git, tr, operation, output } from '../lib/context.mjs';
6
+
7
+ function changeRows(ctx, head) {
8
+ let rows;
9
+ if (ctx.env.GIT_PLUGIN_CHANGE_LOG) {
10
+ const text = fs.readFileSync(ctx.env.GIT_PLUGIN_CHANGE_LOG, 'utf8');
11
+ rows = text.trim().startsWith('[') ? JSON.parse(text) : text.split('\n').filter(Boolean).map(row => JSON.parse(row));
12
+ } else {
13
+ const result = tr(ctx, ['recall', 'file-change agent session records', '--project', '--local', '--format', 'json']);
14
+ if (result.status !== 0 || result.error) return [];
15
+ try { rows = JSON.parse(result.stdout); } catch { return []; }
16
+ }
17
+ if (!Array.isArray(rows)) throw new Error('File-change records must be an array or JSONL');
18
+ const root = git(ctx, ['rev-parse', '--show-toplevel']).trim();
19
+ const since = Number(git(ctx, ['show', '-s', '--format=%ct', head]).trim()) * 1000;
20
+ return rows.flatMap(row => {
21
+ let data = row;
22
+ if (row.type === 'session' && typeof row.body === 'string') {
23
+ try { data = { ...row, ...JSON.parse(row.body) }; } catch { return []; }
24
+ }
25
+ return (data.changes || [data]).map(change => ({ ...data, ...change, session: change.session || data.session || data.session_id, file: change.file || change.path }));
26
+ }).filter(row => row.file && row.session &&
27
+ (row.repository || row.cwd) && fs.existsSync(row.repository || row.cwd) && fs.realpathSync(row.repository || row.cwd) === fs.realpathSync(root) &&
28
+ (row.base === head || (!row.base && Date.parse(row.timestamp) >= since)))
29
+ .map(row => ({ ...row, file: path.relative(root, path.resolve(root, row.file)), start: Number(row.startLine ?? row.start_line), end: Number(row.endLine ?? row.end_line ?? row.startLine ?? row.start_line) }));
30
+ }
31
+ function dirtyFiles(ctx) {
32
+ return [...new Set([...git(ctx, ['diff', 'HEAD', '--name-only', '-z', '--no-renames']).split('\0'), ...git(ctx, ['ls-files', '--others', '--exclude-standard', '-z']).split('\0')].filter(Boolean))];
33
+ }
34
+ const overlap = (a, b) => Number.isInteger(a.start) && Number.isInteger(a.end) && Number.isInteger(b.start) && Number.isInteger(b.end) && a.start <= b.end && b.start <= a.end;
35
+ function fingerprint(root, file) {
36
+ const target = path.join(root, file);
37
+ const stat = fs.lstatSync(target, { throwIfNoEntry: false });
38
+ if (!stat) return 'deleted';
39
+ if (!stat.isFile() && !stat.isSymbolicLink()) throw new Error('Submodule and directory changes need manual grouping');
40
+ return `${stat.mode}:${crypto.createHash('sha256').update(stat.isSymbolicLink() ? fs.readlinkSync(target) : fs.readFileSync(target)).digest('hex')}`;
41
+ }
42
+
43
+ export const operations = {
44
+ group: operation('Group dirty changes by the session that wrote them', 'group [--apply] [--json]', async (args, ctx) => {
45
+ if (args.some(arg => arg !== '--apply') || args.length > 1) throw new Error('Use group [--apply]');
46
+ if (git(ctx, ['diff', '--name-only', '--diff-filter=U', '-z'])) throw new Error('Resolve conflicts before grouping');
47
+ const head = git(ctx, ['rev-parse', '--verify', 'HEAD']).trim();
48
+ const root = git(ctx, ['rev-parse', '--show-toplevel']).trim();
49
+ const sourceGit = ctx.git;
50
+ ctx = { ...ctx, cwd: root, git: (args, options) => sourceGit(args, { ...options, cwd: root }) };
51
+ const files = dirtyFiles(ctx);
52
+ const fingerprints = new Map(files.map(file => [file, fingerprint(root, file)]));
53
+ const records = changeRows(ctx, head);
54
+ const warnings = [];
55
+ for (let i = 0; i < records.length; i++) for (const other of records.slice(i + 1)) {
56
+ const row = records[i];
57
+ if (row.file === other.file && row.session !== other.session && overlap(row, other)) warnings.push(`${row.file}:${Math.max(row.start, other.start)}-${Math.min(row.end, other.end)}: sessions ${row.session} and ${other.session} touch the same lines`);
58
+ }
59
+ const groups = new Map();
60
+ const add = (session, unit, reason = null) => {
61
+ const group = groups.get(session) || { session, files: [], units: [], reasons: [] };
62
+ if (!group.files.includes(unit.file)) group.files.push(unit.file);
63
+ if (reason && !group.reasons.includes(reason)) group.reasons.push(reason);
64
+ group.units.push(unit); groups.set(session, group);
65
+ };
66
+ for (const file of files) {
67
+ const changes = records.filter(row => row.file === file);
68
+ const sessions = [...new Set(changes.map(row => row.session))];
69
+ const patch = git(ctx, ['diff', 'HEAD', '--no-ext-diff', '--no-textconv', '--no-renames', '--binary', '--unified=0', '--', file]);
70
+ if (sessions.length <= 1) { add(sessions[0] || 'unknown', { file, whole: true }, sessions.length ? null : 'No current session record identifies this change'); continue; }
71
+ const first = patch.indexOf('@@ ');
72
+ if (first < 0 || patch.includes('GIT binary patch')) { add('unknown', { file, whole: true }, 'Multiple sessions wrote this file. The changes have no separate text hunks'); continue; }
73
+ const header = patch.slice(0, first);
74
+ const chunks = patch.slice(first).split(/(?=^@@ )/m).filter(Boolean);
75
+ for (const chunk of chunks) {
76
+ const match = /^@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))? @@([^\n]*)\n/.exec(chunk);
77
+ if (!match) throw new Error('Cannot parse a changed hunk');
78
+ const oldStart = Number(match[1]), oldCount = Number(match[2] ?? 1), start = Number(match[3]), count = Number(match[4] ?? 1);
79
+ const owners = [...new Set(changes.filter(row => overlap(row, { start, end: start + Math.max(count, 1) - 1 })).map(row => row.session))];
80
+ add(owners.length === 1 ? owners[0] : 'unknown', { file, header, oldStart, oldCount, start, count, suffix: match[5], body: chunk.slice(match[0].length) }, owners.length === 1 ? null : 'A hunk has missing or shared session attribution');
81
+ }
82
+ }
83
+ const publicGroups = [...groups.values()].map(({ units, ...row }) => ({ ...row, hunks: units.length }));
84
+ if (args.includes('--apply')) {
85
+ if (warnings.length || [...groups.values()].some(row => row.session === 'unknown' && row.reasons.some(reason => !reason.startsWith('No current')))) throw new Error(`Shared changes need manual grouping. ${warnings.join('; ')}`);
86
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git-group-'));
87
+ const applied = [];
88
+ const completed = [];
89
+ const initialIndex = git(ctx, ['write-tree']).trim();
90
+ try {
91
+ for (const group of groups.values()) {
92
+ const env = { ...ctx.env, GIT_INDEX_FILE: path.join(tmp, 'index'), CLAUDE_SESSION_ID: group.session, CODEX_SESSION_ID: group.session };
93
+ git(ctx, ['read-tree', 'HEAD'], { env });
94
+ for (const unit of group.units) {
95
+ if (fingerprint(root, unit.file) !== fingerprints.get(unit.file)) throw new Error(`File ${unit.file} changed during grouping. Completed groups: ${completed.join(', ') || 'none'}`);
96
+ if (unit.whole) git(ctx, ['add', '--all', '--', unit.file], { env });
97
+ else {
98
+ const before = applied.filter(row => row.file === unit.file && row.oldStart < unit.oldStart).reduce((sum, row) => sum + row.count - row.oldCount, 0);
99
+ const patch = `${unit.header}@@ -${unit.oldStart + before},${unit.oldCount} +${unit.oldStart + before + (unit.oldCount === 0 ? 1 : 0) - (unit.count === 0 ? 1 : 0)},${unit.count} @@${unit.suffix}\n${unit.body}`;
100
+ git(ctx, ['apply', '--cached', '--unidiff-zero', '--recount', '-'], { env, input: patch });
101
+ applied.push(unit);
102
+ }
103
+ }
104
+ const message = `Update ${group.files.join(', ')}\n\nAgent-Session: ${group.session}\n`;
105
+ const result = tr({ ...ctx, env }, ['git', 'commit', '--file=-'], { input: message });
106
+ if (result.status !== 0 || result.error) throw new Error(`Session ${group.session} could not commit. Completed groups: ${completed.join(', ') || 'none'}`);
107
+ completed.push(group.session);
108
+ }
109
+ } finally {
110
+ // Synchronize the real index with completed commits. Keep worktree content.
111
+ if (completed.length && git(ctx, ['write-tree']).trim() === initialIndex) git(ctx, ['read-tree', 'HEAD']);
112
+ else if (completed.length) warnings.push('The index changed during grouping. The index was left intact.');
113
+ fs.rmSync(tmp, { recursive: true, force: true });
114
+ }
115
+ }
116
+ output(ctx, { ok: true, groups: publicGroups, warnings, applied: args.includes('--apply') }, [...publicGroups.map(row => `${row.session}: ${row.files.join(', ')}${row.reasons.length ? ` (${row.reasons.join('; ')})` : ''}`), ...warnings.map(row => `Warning: ${row}`)].join('\n') || 'The tree is clean.');
117
+ return 0;
118
+ }),
119
+ };
@@ -0,0 +1,59 @@
1
+ import fs from 'node:fs';
2
+ import os from 'node:os';
3
+ import path from 'node:path';
4
+ import crypto from 'node:crypto';
5
+ import { exportBundle, validateBundle } from '@gregiteen/ssss-cli/bundle';
6
+ import { serializeDocument } from '@gregiteen/ssss-cli/frontmatter';
7
+ import { git, trJSON, activePlan, ledger, operation, output } from '../lib/context.mjs';
8
+
9
+ export const operations = {
10
+ handoff: operation('Pack a branch, project tracker and related memory in an SSSS bundle', 'handoff <branch> [--out <file>] [--json]', async (args, ctx) => {
11
+ if (['create', 'verify', 'list-heads', 'unbundle'].includes(args[0])) {
12
+ const result = ctx.git(['bundle', ...args]);
13
+ if (result.stdout) process.stdout.write(result.stdout);
14
+ if (result.stderr) process.stderr.write(result.stderr);
15
+ return result.status ?? result.code ?? 1;
16
+ }
17
+ const [branch, flag, name] = args;
18
+ if (!branch || branch.startsWith('-') || (args.length !== 1 && !(args.length === 3 && flag === '--out' && name))) throw new Error('Use bundle <branch> [--out <file>]');
19
+ git(ctx, ['check-ref-format', '--branch', branch]);
20
+ const ref = `refs/heads/${branch}`;
21
+ const sha = git(ctx, ['rev-parse', '--verify', '--end-of-options', `${ref}^{commit}`]).trim();
22
+ let plan;
23
+ if (git(ctx, ['branch', '--show-current']).trim() === branch) plan = activePlan(ctx);
24
+ else {
25
+ const paths = git(ctx, ['ls-tree', '-r', '--name-only', '-z', ref, '--', 'docs/projects/in-progress']).split('\0').filter(file => file.endsWith('_PROJECT_TRACKER.md'));
26
+ if (paths.length !== 1) throw new Error('The branch must contain one active tracker');
27
+ plan = { tracker: paths[0], body: git(ctx, ['show', `${ref}:${paths[0]}`]) };
28
+ }
29
+ if (!plan.tracker) throw new Error('A hand-off bundle requires an active tracker');
30
+ const commits = new Set(git(ctx, ['rev-list', ref]).trim().split('\n'));
31
+ const store = await ledger(ctx);
32
+ const records = store?.read([]).filter(row => commits.has(row.sha)) || [];
33
+ const memory = trJSON(ctx, ['recall', `${branch} ${records.flatMap(row => row.trackerItems || []).join(' ')}`, '--local', '--project', '--format', 'json']);
34
+ if (!Array.isArray(memory)) throw new Error('Related memory must be a result array');
35
+ const out = path.resolve(ctx.cwd, name || `${branch.replace(/[^\w.-]/g, '-')}.ucw.json`);
36
+ if (fs.existsSync(out)) throw new Error('The output file already exists');
37
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'git-handoff-'));
38
+ try {
39
+ const pack = path.join(tmp, 'branch.bundle');
40
+ git(ctx, ['bundle', 'create', pack, ref]);
41
+ git(ctx, ['bundle', 'verify', pack]);
42
+ const vault = path.join(tmp, 'vault');
43
+ fs.mkdirSync(vault);
44
+ const asset = (file, title, content, encoding = 'utf-8', mime = 'text/plain') => {
45
+ const bytes = encoding === 'base64' ? Buffer.from(content, 'base64') : Buffer.from(content);
46
+ fs.writeFileSync(path.join(vault, file), serializeDocument({ type: 'asset', name: title, title, description: `Git hand-off: ${title}`, timestamp: new Date().toISOString(), mime_type: mime, encoding, x_portability: 'tenant_private', hash: `sha256:${crypto.createHash('sha256').update(bytes).digest('hex')}`, size_bytes: bytes.length }, content));
47
+ };
48
+ asset('branch.md', `${branch}.bundle`, fs.readFileSync(pack).toString('base64'), 'base64', 'application/x-git-bundle');
49
+ asset('tracker.md', path.basename(plan.tracker), plan.body, 'utf-8', 'text/markdown');
50
+ asset('memory.md', 'Related repository memory', JSON.stringify({ branch, sha, records, memory }, null, 2), 'utf-8', 'application/json');
51
+ const bundle = exportBundle(vault, { name: `Git hand-off: ${branch}`, description: 'Branch, active tracker and related repository memory', profile: 'backup', exportedAt: new Date().toISOString() });
52
+ const check = validateBundle(bundle);
53
+ if (!check.valid) throw new Error(`Invalid SSSS hand-off bundle: ${check.errors.join('; ')}`);
54
+ fs.writeFileSync(out, JSON.stringify(bundle, null, 2), { flag: 'wx', mode: 0o600 });
55
+ output(ctx, { ok: true, file: out, branch, sha, manifest: bundle.manifest }, `Saved ${out}`);
56
+ return 0;
57
+ } finally { fs.rmSync(tmp, { recursive: true, force: true }); }
58
+ }),
59
+ };