@midhunxavier/code-flow 0.0.0-stage → 0.1.0-preview.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (284) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +122 -2
  3. package/bin/codeflow.mjs +8 -0
  4. package/bin/launcher.mjs +127 -0
  5. package/dist/remote-host.json +1 -0
  6. package/licenses/inter.txt +92 -0
  7. package/licenses/preact.txt +21 -0
  8. package/licenses/signals-core.txt +21 -0
  9. package/licenses/signals.txt +21 -0
  10. package/licenses/typescript-notices.txt +193 -0
  11. package/licenses/typescript.txt +55 -0
  12. package/licenses/uqr.txt +22 -0
  13. package/licenses/xterm-fit.txt +19 -0
  14. package/licenses/xterm.txt +21 -0
  15. package/package.json +34 -4
  16. package/public/assets/browser-7XI52A6V.js +8 -0
  17. package/public/assets/changes-EYFPASEI.js +1 -0
  18. package/public/assets/chunk-7H3ZLTKA.js +2 -0
  19. package/public/assets/chunk-L7TUCIYF.js +1 -0
  20. package/public/assets/chunk-LLMQG23J.js +163 -0
  21. package/public/assets/main-KIRM2OEK.js +1 -0
  22. package/public/assets/settings-QJZVW22Y.js +9 -0
  23. package/public/assets/styles-615EBB20.css +32 -0
  24. package/public/assets/terminal-A2FI2NAX.js +62 -0
  25. package/public/assets.json +1 -0
  26. package/public/bundle-meta.json +1 -0
  27. package/public/docs/.nojekyll +0 -0
  28. package/public/docs/accessibility.html +19 -0
  29. package/public/docs/acp-agents.html +117 -0
  30. package/public/docs/agent-containers.html +79 -0
  31. package/public/docs/api.html +1130 -0
  32. package/public/docs/app.js +342 -0
  33. package/public/docs/application-improvements.html +275 -0
  34. package/public/docs/architecture-explorer.html +19 -0
  35. package/public/docs/architecture.html +149 -0
  36. package/public/docs/auto-team.html +142 -0
  37. package/public/docs/automations.html +109 -0
  38. package/public/docs/checkpoint-review.html +37 -0
  39. package/public/docs/code-hosts.html +35 -0
  40. package/public/docs/code-intelligence.html +35 -0
  41. package/public/docs/configuration.html +346 -0
  42. package/public/docs/context-inspection.html +23 -0
  43. package/public/docs/coordinated-changes.html +28 -0
  44. package/public/docs/deferred-runs.html +25 -0
  45. package/public/docs/dependency-discovery.html +19 -0
  46. package/public/docs/documentation.html +84 -0
  47. package/public/docs/dynamic-teams-evaluation.html +174 -0
  48. package/public/docs/dynamic-teams-implementation-plan.html +882 -0
  49. package/public/docs/dynamic-teams-implementation-review-1.html +141 -0
  50. package/public/docs/dynamic-teams-plan-review-0.html +204 -0
  51. package/public/docs/extensions.html +42 -0
  52. package/public/docs/external-sources.html +96 -0
  53. package/public/docs/filesystem-policy.html +26 -0
  54. package/public/docs/filtered-tasks.html +44 -0
  55. package/public/docs/font-license.txt +92 -0
  56. package/public/docs/getting-started.html +118 -0
  57. package/public/docs/github-review-threads.html +26 -0
  58. package/public/docs/github.html +157 -0
  59. package/public/docs/help.html +69 -0
  60. package/public/docs/index.html +77 -0
  61. package/public/docs/installation.html +74 -0
  62. package/public/docs/launch-profiles.html +48 -0
  63. package/public/docs/maintenance.html +60 -0
  64. package/public/docs/memory-claims.html +31 -0
  65. package/public/docs/memory-storage.html +58 -0
  66. package/public/docs/notifications.html +39 -0
  67. package/public/docs/patterns.html +195 -0
  68. package/public/docs/performance-results.html +210 -0
  69. package/public/docs/performance.html +166 -0
  70. package/public/docs/project-conventions.html +22 -0
  71. package/public/docs/project-first-navigation-plan.html +587 -0
  72. package/public/docs/project-teams-implementation-plan.html +772 -0
  73. package/public/docs/project-teams.html +99 -0
  74. package/public/docs/provider-compatibility.html +20 -0
  75. package/public/docs/releasing.html +61 -0
  76. package/public/docs/remote-workers.html +64 -0
  77. package/public/docs/review-inbox.html +26 -0
  78. package/public/docs/run-storage.html +23 -0
  79. package/public/docs/runs.html +141 -0
  80. package/public/docs/search.json +1 -0
  81. package/public/docs/security.html +133 -0
  82. package/public/docs/shared-projects.html +31 -0
  83. package/public/docs/ssh-projects.html +60 -0
  84. package/public/docs/style.css +768 -0
  85. package/public/docs/task-reruns.html +30 -0
  86. package/public/docs/team-collaboration.html +379 -0
  87. package/public/docs/verification.html +22 -0
  88. package/public/docs/vscode.html +53 -0
  89. package/public/docs/worker-permission-verification.html +122 -0
  90. package/public/index.html +17 -0
  91. package/scripts/check-node.mjs +7 -0
  92. package/scripts/codeflow.mjs +55 -0
  93. package/scripts/pty.py +273 -0
  94. package/src/acp-connections.js +110 -0
  95. package/src/adapters/acp-presets.js +37 -0
  96. package/src/adapters/acp-types.js +0 -0
  97. package/src/adapters/acp.js +328 -0
  98. package/src/adapters/agy-auth.js +67 -0
  99. package/src/adapters/agy-isolation.js +223 -0
  100. package/src/adapters/agy.js +297 -0
  101. package/src/adapters/claude.js +220 -0
  102. package/src/adapters/codex-app-server.js +518 -0
  103. package/src/adapters/codex.js +299 -0
  104. package/src/adapters/errors.js +57 -0
  105. package/src/adapters/index.js +91 -0
  106. package/src/adapters/managed-permissions.js +37 -0
  107. package/src/adapters/model-discovery.js +100 -0
  108. package/src/adapters/process.js +418 -0
  109. package/src/adapters/team-tools.js +102 -0
  110. package/src/adapters/types.js +0 -0
  111. package/src/agent-container.js +155 -0
  112. package/src/approvals.js +121 -0
  113. package/src/architecture.js +456 -0
  114. package/src/assets.js +44 -0
  115. package/src/automation.js +901 -0
  116. package/src/certificate-renewal.js +75 -0
  117. package/src/chat-handoff.js +55 -0
  118. package/src/checkpoint-review.js +72 -0
  119. package/src/checks.js +303 -0
  120. package/src/code-host-git.js +102 -0
  121. package/src/code-hosts-api.js +99 -0
  122. package/src/code-hosts.js +482 -0
  123. package/src/code-intelligence-worker.mjs +452 -0
  124. package/src/code-intelligence.js +141 -0
  125. package/src/collab/api.js +365 -0
  126. package/src/collab/checkpoints.js +198 -0
  127. package/src/collab/connections.js +139 -0
  128. package/src/collab/host-execution.js +347 -0
  129. package/src/collab/hub-client.js +210 -0
  130. package/src/collab/link-api.js +229 -0
  131. package/src/collab/link.js +595 -0
  132. package/src/collab/model.js +496 -0
  133. package/src/collab/runner.js +1014 -0
  134. package/src/collab/store.js +1452 -0
  135. package/src/collab/terminals.js +361 -0
  136. package/src/collaboration-reviews.js +116 -0
  137. package/src/config.js +48 -0
  138. package/src/context-budget.js +14 -0
  139. package/src/context-inspection.js +204 -0
  140. package/src/context.js +154 -0
  141. package/src/conventions.js +283 -0
  142. package/src/coordinated-api.js +247 -0
  143. package/src/deferred-launch.js +82 -0
  144. package/src/deferred-runs.js +515 -0
  145. package/src/dependency-health.js +35 -0
  146. package/src/diff.js +43 -0
  147. package/src/docs-help.js +316 -0
  148. package/src/doctor.js +232 -0
  149. package/src/engine.js +4366 -0
  150. package/src/env.js +44 -0
  151. package/src/experience.js +50 -0
  152. package/src/extensions.js +144 -0
  153. package/src/filesystem-access.js +107 -0
  154. package/src/filesystem-policy.js +238 -0
  155. package/src/filtered-task-limits.js +93 -0
  156. package/src/filtered-tasks-api.js +53 -0
  157. package/src/filtered-tasks.js +576 -0
  158. package/src/fs.js +57 -0
  159. package/src/git.js +626 -0
  160. package/src/github-review-threads.js +181 -0
  161. package/src/github.js +538 -0
  162. package/src/http.js +69 -0
  163. package/src/improve-settings.js +38 -0
  164. package/src/improve.js +187 -0
  165. package/src/integrations.js +373 -0
  166. package/src/integrity.js +259 -0
  167. package/src/journey-api.js +77 -0
  168. package/src/journey-replay.js +65 -0
  169. package/src/journey-store.js +97 -0
  170. package/src/journeys.js +60 -0
  171. package/src/json.js +81 -0
  172. package/src/launch-profiles.js +292 -0
  173. package/src/learnings.js +80 -0
  174. package/src/log.js +39 -0
  175. package/src/maintenance.js +335 -0
  176. package/src/memory-analysis.js +182 -0
  177. package/src/memory-claims.js +153 -0
  178. package/src/memory-sqlite.js +236 -0
  179. package/src/memory.js +552 -0
  180. package/src/merge-validation.js +58 -0
  181. package/src/model-catalog.js +238 -0
  182. package/src/model-research.js +91 -0
  183. package/src/notes.js +176 -0
  184. package/src/notifications.js +413 -0
  185. package/src/patterns/auto-team-model.js +212 -0
  186. package/src/patterns/auto-team.js +659 -0
  187. package/src/patterns/council.js +199 -0
  188. package/src/patterns/debate.js +188 -0
  189. package/src/patterns/findings.js +141 -0
  190. package/src/patterns/index.js +19 -0
  191. package/src/patterns/loop.js +158 -0
  192. package/src/patterns/pipeline.js +125 -0
  193. package/src/patterns/prompts.js +181 -0
  194. package/src/patterns/race.js +147 -0
  195. package/src/patterns/schemas.js +40 -0
  196. package/src/patterns/solo.js +49 -0
  197. package/src/patterns/supervisor.js +235 -0
  198. package/src/patterns/task-draft.js +29 -0
  199. package/src/patterns/tdd.js +231 -0
  200. package/src/patterns/team-strategies.js +67 -0
  201. package/src/performance.js +43 -0
  202. package/src/persistence-status.js +35 -0
  203. package/src/phone-sessions.js +96 -0
  204. package/src/preview-overlay.js +383 -0
  205. package/src/preview.js +219 -0
  206. package/src/privacy.js +23 -0
  207. package/src/project-report.js +22 -0
  208. package/src/project-team-model.js +178 -0
  209. package/src/project-team-policy.js +42 -0
  210. package/src/project-team-store.js +213 -0
  211. package/src/project-teams-api.js +36 -0
  212. package/src/project-teams.js +551 -0
  213. package/src/projects.js +286 -0
  214. package/src/provider-eligibility.js +9 -0
  215. package/src/readiness.js +98 -0
  216. package/src/remote-tls.js +66 -0
  217. package/src/remote-workers/bundles.js +198 -0
  218. package/src/remote-workers/client.js +332 -0
  219. package/src/remote-workers/durability.js +37 -0
  220. package/src/remote-workers/protocol.js +68 -0
  221. package/src/remote-workers/stdio.js +138 -0
  222. package/src/remote-workers/worker.js +304 -0
  223. package/src/remote.js +204 -0
  224. package/src/repo-config.js +201 -0
  225. package/src/repo-coordination.js +732 -0
  226. package/src/repo-tree.js +106 -0
  227. package/src/retention-policy.js +466 -0
  228. package/src/review-inbox.js +38 -0
  229. package/src/routing.js +49 -0
  230. package/src/run-analysis.js +111 -0
  231. package/src/run-cold-store.js +231 -0
  232. package/src/run-environment.js +15 -0
  233. package/src/run-history.js +91 -0
  234. package/src/run-log.js +160 -0
  235. package/src/run-permissions.js +110 -0
  236. package/src/run-projection.js +75 -0
  237. package/src/run-recovery.js +53 -0
  238. package/src/run-status.js +54 -0
  239. package/src/serve.js +138 -0
  240. package/src/server.js +2738 -0
  241. package/src/shared-access.js +103 -0
  242. package/src/shared-accounts.js +301 -0
  243. package/src/shared-api.js +239 -0
  244. package/src/shell.js +346 -0
  245. package/src/ship.js +344 -0
  246. package/src/slots.js +74 -0
  247. package/src/snapshot.js +388 -0
  248. package/src/source-context.js +17 -0
  249. package/src/sse-writer.js +70 -0
  250. package/src/ssh-projects/bootstrap.mjs +95 -0
  251. package/src/ssh-projects/config.js +45 -0
  252. package/src/ssh-projects/gateway.js +143 -0
  253. package/src/ssh-projects/manager.js +320 -0
  254. package/src/ssh-projects/team-autostart.mjs +93 -0
  255. package/src/ssh-projects/team-remove.mjs +69 -0
  256. package/src/ssh-projects/team-status.mjs +39 -0
  257. package/src/ssh-projects/transport.js +116 -0
  258. package/src/stats.js +139 -0
  259. package/src/store.js +544 -0
  260. package/src/stream.js +91 -0
  261. package/src/sweeps.js +241 -0
  262. package/src/tailscale.js +101 -0
  263. package/src/task-draft.js +23 -0
  264. package/src/team-evaluation.js +242 -0
  265. package/src/team-mailbox.js +160 -0
  266. package/src/team-memory.js +84 -0
  267. package/src/team-rerun-graph.js +15 -0
  268. package/src/team-reruns.js +130 -0
  269. package/src/team-scheduler.js +121 -0
  270. package/src/team-template-store.js +98 -0
  271. package/src/team-templates.js +117 -0
  272. package/src/team-tool-client.mjs +79 -0
  273. package/src/team-tools.js +176 -0
  274. package/src/teams.js +0 -0
  275. package/src/templates.js +62 -0
  276. package/src/transcript.js +134 -0
  277. package/src/verification-integrity.js +27 -0
  278. package/src/verification.js +183 -0
  279. package/src/watch.js +195 -0
  280. package/src/work-queue.js +47 -0
  281. package/src/workflow-control.js +83 -0
  282. package/src/workspace-api.js +207 -0
  283. package/src/workspace-changes.js +61 -0
  284. package/src/workspace.js +146 -0
@@ -0,0 +1,204 @@
1
+ import { execFile } from "node:child_process";
2
+ import { createHash } from "node:crypto";
3
+ import { constants } from "node:fs";
4
+ import { lstat, open, realpath } from "node:fs/promises";
5
+ import { basename, isAbsolute, join } from "node:path";
6
+ import { git, repoRoot, resolveCommit } from "./git.js";
7
+ import { normalizeRepoPath } from "./repo-tree.js";
8
+ import { childEnv } from "./env.js";
9
+ import { redactText } from "./privacy.js";
10
+ import { validateContextBudget } from "./context-budget.js";
11
+ import { validateContextBudget as validateContextBudget2 } from "./context-budget.js";
12
+ const CONTEXT_INSPECTION_LIMITS = { selections: 50, files: 200, scanned: 5e3, fileBytes: 512 * 1024, totalBytes: 8 * 1024 * 1024, previewCharacters: 3e3 };
13
+ const hash = (text) => createHash("sha256").update(text).digest("hex");
14
+ class ContextInspectionError extends Error {
15
+ constructor(message, status = 400) {
16
+ super(message);
17
+ this.status = status;
18
+ }
19
+ }
20
+ function sensitiveContextPath(path) {
21
+ const parts = path.replace(/\\/g, "/").toLowerCase().split("/");
22
+ return parts.some((part) => [".git", ".ssh", ".aws", ".gnupg", ".azure", ".kube"].includes(part)) || /^(?:\.env(?:\..*)?|\.npmrc|\.netrc|\.pypirc|credentials(?:\.json)?|secrets?(?:\.json|\.ya?ml)?|id_(?:rsa|dsa|ecdsa|ed25519)(?:\.pub)?|.*\.(?:pem|key|p12|pfx|kdbx))$/.test(parts.at(-1) ?? "");
23
+ }
24
+ function assertContextPathAllowed(raw) {
25
+ if (typeof raw !== "string" || isAbsolute(raw) || /^[a-z]:/i.test(raw) || /[\x00-\x1f]/.test(raw) || raw.length > 1e3) throw new ContextInspectionError("Context paths must stay inside the repository.");
26
+ const path = normalizeRepoPath(raw);
27
+ if (!path) throw new ContextInspectionError("Select a repository file or folder.");
28
+ if (sensitiveContextPath(path)) throw new ContextInspectionError(`Potential credential or Git-internal path is not allowed as explicit context: ${path}`);
29
+ return path;
30
+ }
31
+ async function assertNoContextSymlinks(repo, path, baseRef) {
32
+ const root = await realpath(repo);
33
+ let current = root;
34
+ for (const part of path.split("/")) {
35
+ current = join(current, part);
36
+ const info = await lstat(current).catch((error) => {
37
+ if (error.code === "ENOENT") return void 0;
38
+ throw error;
39
+ });
40
+ if (!info) break;
41
+ if (info.isSymbolicLink()) throw new ContextInspectionError(`Symbolic context paths are refused: ${path}`);
42
+ }
43
+ if (baseRef) {
44
+ const mode = await git(["--literal-pathspecs", "ls-tree", "--format=%(objectmode)", baseRef, "--", path], root, { timeoutMs: 1e4 });
45
+ if (mode.code !== 0) throw new ContextInspectionError("Cannot validate the selected base context path.");
46
+ if (/^(?:120000|160000)$/m.test(mode.stdout.trim())) throw new ContextInspectionError(`Symbolic links and submodules are refused as explicit context: ${path}`);
47
+ }
48
+ }
49
+ const treeObjects = (text) => text.split("\0").filter(Boolean).map((record) => {
50
+ const tab = record.indexOf(" ");
51
+ const [mode, type, object, size] = record.slice(0, tab).split(/\s+/);
52
+ return { path: record.slice(tab + 1), mode, type, object, bytes: Number(size) };
53
+ });
54
+ function gitBlob(repo, object, signal) {
55
+ if (!/^[a-f0-9]{40,64}$/.test(object)) return Promise.reject(new ContextInspectionError("Invalid context object."));
56
+ return new Promise((resolve, reject) => execFile("git", ["cat-file", "blob", object], { cwd: repo, env: childEnv({ GIT_TERMINAL_PROMPT: "0", GIT_OPTIONAL_LOCKS: "0" }), timeout: 1e4, maxBuffer: CONTEXT_INSPECTION_LIMITS.fileBytes + 1, encoding: "buffer", signal }, (error, stdout) => error ? reject(error) : resolve(stdout)));
57
+ }
58
+ async function workingFile(repo, path) {
59
+ await assertNoContextSymlinks(repo, path);
60
+ const handle = await open(join(repo, path), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch((error) => {
61
+ if (error.code === "ENOENT") return void 0;
62
+ throw error;
63
+ });
64
+ if (!handle) return void 0;
65
+ try {
66
+ const before = await handle.stat();
67
+ if (!before.isFile()) throw new ContextInspectionError("Not a regular text file.");
68
+ if (before.size > CONTEXT_INSPECTION_LIMITS.fileBytes) throw new ContextInspectionError("File exceeds the 512 KiB inspection limit.");
69
+ const buffer = Buffer.alloc(before.size);
70
+ let offset = 0;
71
+ while (offset < buffer.length) {
72
+ const read = await handle.read(buffer, offset, buffer.length - offset, offset);
73
+ if (!read.bytesRead) break;
74
+ offset += read.bytesRead;
75
+ }
76
+ const after = await handle.stat();
77
+ if (offset !== before.size || after.size !== before.size || after.mtimeMs !== before.mtimeMs || after.ctimeMs !== before.ctimeMs) throw new ContextInspectionError("File changed while being inspected. Inspect again.", 409);
78
+ return buffer;
79
+ } finally {
80
+ await handle.close();
81
+ }
82
+ }
83
+ async function inspectContext(input) {
84
+ if (!Array.isArray(input.paths) || input.paths.length > CONTEXT_INSPECTION_LIMITS.selections || input.paths.some((path) => typeof path !== "string")) throw new ContextInspectionError("Choose at most 50 repository context selections.");
85
+ const repo = await realpath(await repoRoot(input.repo));
86
+ const baseRef = await resolveCommit(repo, input.baseRef || "HEAD");
87
+ const files = [];
88
+ const seen = /* @__PURE__ */ new Set();
89
+ let fileBytes = 0;
90
+ let scanned = 0;
91
+ let truncated = false;
92
+ const add = async (path, selectedBy, source, object) => {
93
+ if (seen.has(path)) return;
94
+ seen.add(path);
95
+ scanned++;
96
+ if (files.length >= CONTEXT_INSPECTION_LIMITS.files || scanned > CONTEXT_INSPECTION_LIMITS.scanned) {
97
+ truncated = true;
98
+ return;
99
+ }
100
+ const result = { path, selectedBy, source };
101
+ if (sensitiveContextPath(path)) {
102
+ files.push({ ...result, sensitive: true, omitted: "Potential credential or Git-internal file; not included in the preview." });
103
+ return;
104
+ }
105
+ try {
106
+ assertContextPathAllowed(path);
107
+ await assertNoContextSymlinks(repo, path);
108
+ if (object && Number.isFinite(object.bytes) && object.bytes >= 0) result.bytes = object.bytes;
109
+ if (source === "working-copy") {
110
+ const info = await lstat(join(repo, path)).catch(() => void 0);
111
+ if (info?.isFile()) result.bytes = info.size;
112
+ }
113
+ if (object && (object.type !== "blob" || object.mode === "120000")) throw new ContextInspectionError("Symbolic links and submodules are not inspected.");
114
+ if (object && object.bytes > CONTEXT_INSPECTION_LIMITS.fileBytes) throw new ContextInspectionError("File exceeds the 512 KiB inspection limit.");
115
+ const local = source === "working-copy" ? await workingFile(repo, path) : void 0;
116
+ const buffer = local ?? (object ? await gitBlob(repo, object.object, input.signal) : void 0);
117
+ if (!local && object) result.source = "base-ref";
118
+ if (!buffer) throw new ContextInspectionError("File was not found in the working copy or selected base.");
119
+ if (fileBytes + buffer.length > CONTEXT_INSPECTION_LIMITS.totalBytes) {
120
+ truncated = true;
121
+ throw new ContextInspectionError("Aggregate file inspection exceeds 8 MiB.");
122
+ }
123
+ const text = new TextDecoder("utf-8", { fatal: true }).decode(buffer);
124
+ if (text.includes("\0")) throw new ContextInspectionError("Binary file; token and text previews are unavailable.");
125
+ if (text.startsWith("version https://git-lfs.github.com/spec/v1")) throw new ContextInspectionError("Git LFS pointer; materialized file contents are not included in this estimate.");
126
+ const preview = redactText(text.slice(0, CONTEXT_INSPECTION_LIMITS.previewCharacters));
127
+ fileBytes += buffer.length;
128
+ files.push({ ...result, bytes: buffer.length, approximateTokens: Math.ceil(buffer.length / 4), hash: hash(buffer), preview, redacted: preview !== text.slice(0, CONTEXT_INSPECTION_LIMITS.previewCharacters), truncated: text.length > CONTEXT_INSPECTION_LIMITS.previewCharacters });
129
+ } catch (error) {
130
+ files.push({ ...result, omitted: error instanceof TypeError ? "Binary or invalid UTF-8 file; no text preview." : error.message });
131
+ }
132
+ };
133
+ for (const raw of input.paths) {
134
+ if (input.signal?.aborted) throw new ContextInspectionError("Context inspection cancelled.", 409);
135
+ let path;
136
+ try {
137
+ path = assertContextPathAllowed(raw);
138
+ await assertNoContextSymlinks(repo, path);
139
+ } catch (error) {
140
+ if (files.length < CONTEXT_INSPECTION_LIMITS.files) files.push({ path: raw, selectedBy: raw, source: "working-copy", sensitive: sensitiveContextPath(raw), omitted: error.message });
141
+ continue;
142
+ }
143
+ const exact = await git(["--literal-pathspecs", "ls-tree", "-l", "-z", baseRef, "--", path], repo, { timeoutMs: 1e4, signal: input.signal });
144
+ if (exact.code !== 0) throw new ContextInspectionError("Cannot inspect the selected base tree.");
145
+ const entry = treeObjects(exact.stdout).find((entry2) => entry2.path === path);
146
+ if (entry?.type === "tree") {
147
+ const listing = await git(["--literal-pathspecs", "ls-tree", "-r", "-l", "-z", baseRef, "--", path], repo, { timeoutMs: 1e4, signal: input.signal });
148
+ if (listing.code !== 0) throw new ContextInspectionError("Cannot expand the selected context directory.");
149
+ const entries = treeObjects(listing.stdout);
150
+ for (const file of entries.slice(0, CONTEXT_INSPECTION_LIMITS.scanned)) {
151
+ if (files.length >= CONTEXT_INSPECTION_LIMITS.files) {
152
+ truncated = true;
153
+ break;
154
+ }
155
+ await add(file.path, path, "base-ref", file);
156
+ }
157
+ if (entries.length > CONTEXT_INSPECTION_LIMITS.scanned) truncated = true;
158
+ } else await add(path, path, "working-copy", entry);
159
+ }
160
+ const rawAttachments = input.attachments ?? [];
161
+ if (!Array.isArray(rawAttachments) || rawAttachments.length > 20 || rawAttachments.some((entry) => !entry || typeof entry.name !== "string" || entry.name.length > 500 || /[\x00-\x1f]/.test(entry.name) || !Number.isSafeInteger(entry.size) || entry.size < 0 || entry.size > 10 * 1024 * 1024)) throw new ContextInspectionError("Attachment metadata must describe at most 20 files of up to 10 MiB each.");
162
+ const attachments = rawAttachments.map((entry) => ({ name: entry.name, bytes: entry.size, ...sensitiveContextPath(basename(entry.name)) ? { omitted: "Potential credential attachment; actual dispatch rejects this selection." } : /\.(?:txt|md|json|csv|ya?ml|[cm]?[jt]sx?|py|go|rs|html|css|xml|toml)$/i.test(entry.name) ? { approximateTokens: Math.ceil(entry.size / 4) } : {} }));
163
+ const attachmentBytes = attachments.reduce((sum, entry) => sum + entry.bytes, 0);
164
+ if (attachmentBytes > 25 * 1024 * 1024) throw new ContextInspectionError("Attachment metadata exceeds the 25 MiB context limit.");
165
+ const omittedBytes = files.reduce((sum, file) => sum + (file.omitted ? file.bytes ?? 0 : 0), 0);
166
+ return { inspectedAt: Date.now(), baseRef, files, attachments, fileBytes, omittedBytes, attachmentBytes, bytes: fileBytes + omittedBytes + attachmentBytes, approximateTokens: files.reduce((sum, file) => sum + (file.approximateTokens ?? 0), 0) + attachments.reduce((sum, entry) => sum + (entry.approximateTokens ?? 0), 0), unknownTokenAttachments: attachments.filter((entry) => entry.approximateTokens === void 0).length, truncated, scanned, note: "Folders expand committed base files only. Explicit files show current copies; local files missing from the base need individual selection. Reference contents are not pasted into prompts. Byte totals include known omitted-file sizes; unscanned files remain unknown. Token estimates cover known text only; hidden CLI history/system prompts, later file reads and image tokens are excluded." };
167
+ }
168
+ class ContextBudgetError extends Error {
169
+ constructor(evidence) {
170
+ super(`Explicit invocation context exceeds its configured budget: ${evidence.violations.map((entry) => `${entry.limit} ${entry.actual.toLocaleString()} > ${entry.ceiling.toLocaleString()}`).join("; ")}. Hidden provider context is not included in this measurement.`);
171
+ this.evidence = evidence;
172
+ this.name = "ContextBudgetError";
173
+ }
174
+ }
175
+ function measurePromptContext(input, rawBudget) {
176
+ if (typeof input.prompt !== "string") throw new ContextInspectionError("Measure the assembled invocation prompt text.");
177
+ const budget = validateContextBudget(rawBudget);
178
+ const schema = input.jsonSchema === void 0 ? "" : JSON.stringify(input.jsonSchema);
179
+ const promptBytes = Buffer.byteLength(input.prompt);
180
+ const schemaBytes = Buffer.byteLength(schema);
181
+ const bytes = promptBytes + schemaBytes;
182
+ const approximateTokens = Math.ceil(bytes / 4);
183
+ const violations = [];
184
+ if (budget?.maxPromptBytes !== void 0 && bytes > budget.maxPromptBytes) violations.push({ limit: "maxPromptBytes", actual: bytes, ceiling: budget.maxPromptBytes });
185
+ if (budget?.maxApproximateTokens !== void 0 && approximateTokens > budget.maxApproximateTokens) violations.push({ limit: "maxApproximateTokens", actual: approximateTokens, ceiling: budget.maxApproximateTokens });
186
+ return { scope: "assembled-invocation-input", measuredAt: Date.now(), promptBytes, schemaBytes, bytes, approximateTokens, hash: hash(JSON.stringify([input.prompt, schema])), resumed: !!input.resumeSessionId, excluded: ["CLI system instructions", "Earlier conversation history", "Tool output and later file reads", "Image/audio tokenization", "Provider transport wrappers"], ...budget ? { policy: budget.policy } : {}, exceeded: !!violations.length, violations };
187
+ }
188
+ function enforceContextBudget(input, budget) {
189
+ const evidence = measurePromptContext(input, budget);
190
+ if (evidence.exceeded && evidence.policy === "block") throw new ContextBudgetError(evidence);
191
+ return evidence;
192
+ }
193
+ export {
194
+ CONTEXT_INSPECTION_LIMITS,
195
+ ContextBudgetError,
196
+ ContextInspectionError,
197
+ assertContextPathAllowed,
198
+ assertNoContextSymlinks,
199
+ enforceContextBudget,
200
+ inspectContext,
201
+ measurePromptContext,
202
+ sensitiveContextPath,
203
+ validateContextBudget2 as validateContextBudget
204
+ };
package/src/context.js ADDED
@@ -0,0 +1,154 @@
1
+ import { copyFile, mkdir, readdir, stat, writeFile } from "node:fs/promises";
2
+ import { basename, dirname, extname, join } from "node:path";
3
+ import { CONTEXT_DIR, objectTypeAt, workingCopyDiffers } from "./git.js";
4
+ import { copyFileInside } from "./repo-config.js";
5
+ import { normalizeRepoPath, resolveInsideRepo } from "./repo-tree.js";
6
+ import { assertContextPathAllowed, assertNoContextSymlinks, sensitiveContextPath } from "./context-inspection.js";
7
+ const LIMITS = {
8
+ paths: 50,
9
+ attachments: 20,
10
+ fileBytes: 10 * 1024 * 1024,
11
+ totalBytes: 25 * 1024 * 1024
12
+ };
13
+ const EMPTY_CONTEXT = { paths: [], attachments: [] };
14
+ function hasStagedFiles(ctx) {
15
+ return ctx.attachments.length > 0 || ctx.paths.some((p) => p.workingCopy);
16
+ }
17
+ async function prepareContext(repo, baseRef, input, stageDir) {
18
+ const ctx = { paths: [], attachments: [] };
19
+ const rawPaths = input?.paths ?? [];
20
+ const rawAttachments = input?.attachments ?? [];
21
+ if (rawPaths.length > LIMITS.paths) throw new Error(`Too many context paths (max ${LIMITS.paths})`);
22
+ if (rawAttachments.length > LIMITS.attachments) throw new Error(`Too many attached files (max ${LIMITS.attachments})`);
23
+ let total = 0;
24
+ const seen = /* @__PURE__ */ new Set();
25
+ for (const { path: raw } of rawPaths) {
26
+ const path = normalizeRepoPath(String(raw ?? ""));
27
+ if (!path || seen.has(path)) continue;
28
+ assertContextPathAllowed(String(raw));
29
+ await assertNoContextSymlinks(repo, path, baseRef);
30
+ seen.add(path);
31
+ const inBase = await objectTypeAt(repo, baseRef, path);
32
+ const local = await resolveInsideRepo(repo, path).then((abs) => stat(abs).then((s) => ({ abs, s }))).catch((err) => {
33
+ if (err.code === "ENOENT") return null;
34
+ throw err;
35
+ });
36
+ if (!inBase && !local) throw new Error(`"${path}" was not found in the repository`);
37
+ if (inBase === "tree" || local?.s.isDirectory()) {
38
+ if (!inBase) throw new Error(`Folder "${path}" isn't in the base branch; commit it, or drop the individual files you need`);
39
+ ctx.paths.push({ path, type: "dir" });
40
+ continue;
41
+ }
42
+ if (!local || inBase && !await workingCopyDiffers(repo, baseRef, path)) {
43
+ ctx.paths.push({ path, type: "file" });
44
+ continue;
45
+ }
46
+ if (local.s.size > LIMITS.fileBytes) throw new Error(`"${path}" is larger than ${mb(LIMITS.fileBytes)}`);
47
+ total += local.s.size;
48
+ if (total > LIMITS.totalBytes) throw new Error(`Context files exceed ${mb(LIMITS.totalBytes)} in total`);
49
+ const staged = join(stageDir, "repo", ...path.split("/"));
50
+ await mkdir(dirname(staged), { recursive: true });
51
+ await copyFile(local.abs, staged);
52
+ ctx.paths.push({ path, type: "file", workingCopy: `${CONTEXT_DIR}/repo/${path}`, note: inBase ? "modified" : "uncommitted" });
53
+ }
54
+ const names = /* @__PURE__ */ new Set();
55
+ for (const att of rawAttachments) {
56
+ if (sensitiveContextPath(String(att.name ?? "file").replace(/\\/g, "/").split("/").at(-1))) throw new Error("Potential credential attachments are not allowed as explicit context");
57
+ const data = Buffer.from(String(att.data ?? ""), "base64");
58
+ if (data.length > LIMITS.fileBytes) throw new Error(`"${att.name}" is larger than ${mb(LIMITS.fileBytes)}`);
59
+ total += data.length;
60
+ if (total > LIMITS.totalBytes) throw new Error(`Context files exceed ${mb(LIMITS.totalBytes)} in total`);
61
+ const name = uniqueName(safeFileName(String(att.name ?? "file")), names);
62
+ await mkdir(join(stageDir, "attachments"), { recursive: true });
63
+ await writeFile(join(stageDir, "attachments", name), data);
64
+ ctx.attachments.push({ name, size: data.length, file: `${CONTEXT_DIR}/attachments/${name}` });
65
+ }
66
+ return ctx;
67
+ }
68
+ async function stageMessageFiles(stageDir, prefix, files) {
69
+ if (files.length > LIMITS.attachments) throw new Error(`Too many attached files (max ${LIMITS.attachments})`);
70
+ const names = /* @__PURE__ */ new Set();
71
+ const paths = [];
72
+ let total = 0;
73
+ for (const f of files) {
74
+ if (sensitiveContextPath(String(f.name ?? "file").replace(/\\/g, "/").split("/").at(-1))) throw new Error("Potential credential attachments are not allowed as message context");
75
+ const data = Buffer.from(String(f.data ?? ""), "base64");
76
+ if (data.length > LIMITS.fileBytes) throw new Error(`"${f.name}" is larger than ${mb(LIMITS.fileBytes)}`);
77
+ total += data.length;
78
+ if (total > LIMITS.totalBytes) throw new Error(`Attached files exceed ${mb(LIMITS.totalBytes)} in total`);
79
+ const name = uniqueName(`${prefix}-${safeFileName(String(f.name ?? "file"))}`, names);
80
+ const dir = join(stageDir, "messages");
81
+ await mkdir(dir, { recursive: true });
82
+ await writeFile(join(dir, name), data);
83
+ paths.push(`${CONTEXT_DIR}/messages/${name}`);
84
+ }
85
+ return paths;
86
+ }
87
+ async function copyMessageFiles(stageDir, worktree, files) {
88
+ for (const file of files) {
89
+ const rel = `${CONTEXT_DIR}/messages/${basename(file)}`;
90
+ const done = await copyFileInside(worktree, rel, join(stageDir, "messages", basename(file)));
91
+ if (!done.copied) throw new Error(`Couldn't put ${basename(file)} in the worktree: ${done.why ?? "it's in the way"}`);
92
+ }
93
+ }
94
+ async function copyStagedContext(stageDir, worktree) {
95
+ const refused = [];
96
+ const walk = async (rel) => {
97
+ for (const entry of await readdir(join(stageDir, rel), { withFileTypes: true })) {
98
+ const path = rel ? `${rel}/${entry.name}` : entry.name;
99
+ if (entry.isDirectory()) await walk(path);
100
+ else if (entry.isFile()) {
101
+ const done = await copyFileInside(worktree, `${CONTEXT_DIR}/${path}`, join(stageDir, path));
102
+ if (!done.copied) refused.push({ path: `${CONTEXT_DIR}/${path}`, why: done.why ?? "it's in the way" });
103
+ }
104
+ }
105
+ };
106
+ await walk("");
107
+ return refused;
108
+ }
109
+ function contextPrompt(ctx) {
110
+ if (!ctx.paths.length && !ctx.attachments.length) return "";
111
+ const lines = ["", "## Context from the user"];
112
+ if (ctx.paths.length) {
113
+ lines.push("The user pointed you to these. Read them before you start (paths are relative to the repository root):");
114
+ for (const p of ctx.paths) {
115
+ if (p.type === "dir") lines.push(`- \`${p.path}/\` (folder)`);
116
+ else if (p.note === "uncommitted") lines.push(`- \`${p.path}\` \u2014 not committed yet; the user's copy is at \`${p.workingCopy}\``);
117
+ else if (p.note === "modified") lines.push(`- \`${p.path}\` \u2014 the user has local changes; their version is at \`${p.workingCopy}\``);
118
+ else lines.push(`- \`${p.path}\``);
119
+ }
120
+ }
121
+ if (ctx.attachments.length) {
122
+ lines.push("Files the user attached:");
123
+ for (const a of ctx.attachments) lines.push(`- \`${a.file}\` (${kb(a.size)})`);
124
+ }
125
+ if (hasStagedFiles(ctx)) {
126
+ lines.push(`\`${CONTEXT_DIR}/\` is reference material: read it, but don't edit it or copy it into the codebase unless the task asks for that.`);
127
+ }
128
+ return lines.join("\n") + "\n";
129
+ }
130
+ function safeFileName(raw) {
131
+ const base = raw.split(/[\\/]/).pop() || "file";
132
+ const cleaned = base.replace(/[^\w.\- ()]+/g, "_").replace(/^\.+/, "_").slice(0, 120);
133
+ return cleaned || "file";
134
+ }
135
+ function uniqueName(name, taken) {
136
+ const ext = extname(name);
137
+ const stem = name.slice(0, name.length - ext.length);
138
+ let candidate = name;
139
+ for (let i = 2; taken.has(candidate.toLowerCase()); i++) candidate = `${stem} (${i})${ext}`;
140
+ taken.add(candidate.toLowerCase());
141
+ return candidate;
142
+ }
143
+ const kb = (bytes) => bytes < 1024 ? `${bytes} B` : `${Math.round(bytes / 1024)} KB`;
144
+ const mb = (bytes) => `${Math.round(bytes / 1024 / 1024)} MB`;
145
+ export {
146
+ EMPTY_CONTEXT,
147
+ LIMITS,
148
+ contextPrompt,
149
+ copyMessageFiles,
150
+ copyStagedContext,
151
+ hasStagedFiles,
152
+ prepareContext,
153
+ stageMessageFiles
154
+ };
@@ -0,0 +1,283 @@
1
+ import { createHash, randomBytes } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { lstat, mkdir, open, realpath, rename, rm, rmdir } from "node:fs/promises";
4
+ import { basename, join, resolve } from "node:path";
5
+ import { CODEFLOW_HOME } from "./config.js";
6
+ import { git, repoRoot, resolveCommit, withRepoLock } from "./git.js";
7
+ const CONVENTIONS_PATH = ".codeflow/conventions.md";
8
+ const MAX_CONVENTION_BYTES = 64 * 1024;
9
+ const MAX_CONVENTION_REVISIONS = 32;
10
+ const MAX_DOCUMENT_BYTES = MAX_CONVENTION_BYTES * MAX_CONVENTION_REVISIONS * 6 + 128 * 1024;
11
+ const digest = (text) => createHash("sha256").update(text).digest("hex");
12
+ const validRevision = (value) => Number.isSafeInteger(value) && Number(value) >= 0;
13
+ const validHash = (value) => typeof value === "string" && /^[a-f0-9]{64}$/.test(value);
14
+ class ConventionsError extends Error {
15
+ constructor(message, status = 400, current) {
16
+ super(message);
17
+ this.status = status;
18
+ this.current = current;
19
+ this.name = "ConventionsError";
20
+ }
21
+ }
22
+ const queues = /* @__PURE__ */ new Map();
23
+ function serialized(key, fn) {
24
+ const next = (queues.get(key) ?? Promise.resolve()).catch(() => void 0).then(fn);
25
+ const tail = next.catch(() => void 0);
26
+ queues.set(key, tail);
27
+ void tail.then(() => {
28
+ if (queues.get(key) === tail) queues.delete(key);
29
+ });
30
+ return next;
31
+ }
32
+ function projectKey(id) {
33
+ if (typeof id !== "string" || !id.trim() || id.length > 200 || /[\x00-\x1f]/.test(id)) throw new ConventionsError("Choose an existing project for conventions.");
34
+ return createHash("sha256").update(id).digest("hex");
35
+ }
36
+ function normalizedText(value) {
37
+ if (typeof value !== "string" || value.includes("\0") || Buffer.from(value).toString("utf8") !== value) throw new ConventionsError("Conventions must be valid UTF-8 plain text without binary NUL characters.");
38
+ const text = value.replace(/\r\n/g, "\n");
39
+ const normalized = text && !text.endsWith("\n") ? `${text}
40
+ ` : text;
41
+ if (Buffer.byteLength(normalized) > MAX_CONVENTION_BYTES) throw new ConventionsError("Conventions are limited to 64 KiB including the final newline.");
42
+ return normalized;
43
+ }
44
+ function projection(document) {
45
+ const current = document.revisions.at(-1);
46
+ return { projectId: document.projectId, revision: current?.revision ?? 0, text: current?.text ?? "", hash: current?.hash ?? digest(""), updatedAt: current?.at ?? 0, history: document.revisions.slice().reverse().map(({ text: _text, ...version }) => ({ ...version })) };
47
+ }
48
+ async function syncDirectory(path) {
49
+ const handle = await open(path, "r");
50
+ try {
51
+ await handle.sync();
52
+ } finally {
53
+ await handle.close();
54
+ }
55
+ }
56
+ class ConventionsStore {
57
+ directory;
58
+ constructor(home = CODEFLOW_HOME) {
59
+ this.directory = join(resolve(home), "conventions");
60
+ }
61
+ file(projectId) {
62
+ return join(this.directory, `${projectKey(projectId)}.json`);
63
+ }
64
+ async safeDirectory(create = false) {
65
+ if (create) await mkdir(this.directory, { recursive: true, mode: 448 });
66
+ const info = await lstat(this.directory).catch((error) => {
67
+ if (!create && error.code === "ENOENT") return void 0;
68
+ throw error;
69
+ });
70
+ if (info && (!info.isDirectory() || info.isSymbolicLink())) throw new ConventionsError("Conventions storage must be a regular private directory.");
71
+ return !!info;
72
+ }
73
+ async read(projectId) {
74
+ const file = this.file(projectId);
75
+ if (!await this.safeDirectory()) return { version: 1, projectId, revisions: [] };
76
+ const handle = await open(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0)).catch((error) => {
77
+ if (error.code === "ENOENT") return void 0;
78
+ throw error;
79
+ });
80
+ if (!handle) return { version: 1, projectId, revisions: [] };
81
+ try {
82
+ const stat = await handle.stat();
83
+ if (!stat.isFile() || stat.size > MAX_DOCUMENT_BYTES) throw new ConventionsError("Conventions history is not a bounded regular file.");
84
+ const document = JSON.parse(await handle.readFile("utf8"));
85
+ if (!document || document.version !== 1 || document.projectId !== projectId || !Array.isArray(document.revisions) || document.revisions.length < 1 || document.revisions.length > MAX_CONVENTION_REVISIONS) throw new ConventionsError("Conventions history is invalid; it has been left untouched.");
86
+ let previous = 0;
87
+ for (const version of document.revisions) {
88
+ if (!version || !validRevision(version.revision) || version.revision <= previous || !Number.isSafeInteger(version.at) || version.at < 0 || typeof version.note !== "string" || version.note.length > 500 || typeof version.text !== "string" || normalizedText(version.text) !== version.text || !validHash(version.hash) || digest(version.text) !== version.hash || version.restoredFrom !== void 0 && (!validRevision(version.restoredFrom) || version.restoredFrom < 1 || version.restoredFrom >= version.revision)) throw new ConventionsError("Conventions history is invalid; it has been left untouched.");
89
+ previous = version.revision;
90
+ }
91
+ return document;
92
+ } finally {
93
+ await handle.close();
94
+ }
95
+ }
96
+ async write(document) {
97
+ await this.safeDirectory(true);
98
+ const file = this.file(document.projectId);
99
+ const temporary = `${file}.${randomBytes(8).toString("hex")}.tmp`;
100
+ try {
101
+ const handle = await open(temporary, "wx", 384);
102
+ try {
103
+ await handle.writeFile(JSON.stringify(document));
104
+ await handle.sync();
105
+ } finally {
106
+ await handle.close();
107
+ }
108
+ await this.safeDirectory();
109
+ await rename(temporary, file);
110
+ await syncDirectory(this.directory);
111
+ } finally {
112
+ await rm(temporary, { force: true });
113
+ }
114
+ }
115
+ expect(document, revision) {
116
+ if (!validRevision(revision)) throw new ConventionsError("Send the exact current conventions revision.");
117
+ const current = projection(document);
118
+ if (current.revision !== revision) throw new ConventionsError(`Conventions changed to revision ${current.revision}. Your draft was not saved.`, 409, current);
119
+ if (revision >= Number.MAX_SAFE_INTEGER) throw new ConventionsError("Conventions revision limit reached.");
120
+ }
121
+ async append(document, text, note, restoredFrom) {
122
+ const version = { revision: (document.revisions.at(-1)?.revision ?? 0) + 1, text, hash: digest(text), at: Date.now(), note, ...restoredFrom ? { restoredFrom } : {} };
123
+ const next = { ...document, revisions: [...document.revisions, version].slice(-MAX_CONVENTION_REVISIONS) };
124
+ await this.write(next);
125
+ return projection(next);
126
+ }
127
+ get(projectId) {
128
+ return serialized(this.file(projectId), async () => projection(await this.read(projectId)));
129
+ }
130
+ save(projectId, input) {
131
+ return serialized(this.file(projectId), async () => {
132
+ const text = normalizedText(input.text);
133
+ const note = input.note ?? "";
134
+ if (typeof note !== "string" || note.length > 500 || note.includes("\0")) throw new ConventionsError("Revision notes are limited to 500 characters.");
135
+ const document = await this.read(projectId);
136
+ this.expect(document, input.revision);
137
+ return this.append(document, text, note.trim());
138
+ });
139
+ }
140
+ previewRestore(projectId, version) {
141
+ return serialized(this.file(projectId), async () => {
142
+ const document = await this.read(projectId);
143
+ const found = document.revisions.find((entry) => entry.revision === version);
144
+ if (!validRevision(version) || version < 1 || !found) throw new ConventionsError("That conventions revision is no longer retained.", 404);
145
+ return { revision: projection(document).revision, version, hash: found.hash, text: found.text };
146
+ });
147
+ }
148
+ restore(projectId, input) {
149
+ return serialized(this.file(projectId), async () => {
150
+ const document = await this.read(projectId);
151
+ this.expect(document, input.revision);
152
+ const found = document.revisions.find((entry) => entry.revision === input.version);
153
+ if (!found || !validHash(input.hash) || found.hash !== input.hash) throw new ConventionsError("Preview the retained revision again before restoring it.", 409);
154
+ return this.append(document, found.text, `Restored revision ${found.revision}`, found.revision);
155
+ });
156
+ }
157
+ async inspectRepository(repo) {
158
+ const root = await regularRepository(repo);
159
+ return withRepoLock(root, () => inspectAt(root));
160
+ }
161
+ publish(projectId, input, guard) {
162
+ return serialized(this.file(projectId), async () => {
163
+ if (!guard || guard.actor !== "user" || !Array.isArray(guard.repos) || typeof guard.isIdle !== "function") throw new ConventionsError("Only an explicit user action can publish project guidance.", 403);
164
+ const document = await this.read(projectId);
165
+ this.expect(document, input.revision);
166
+ const current = projection(document);
167
+ if (!current.revision) throw new ConventionsError("Save a conventions revision before publishing it.");
168
+ if (input.sourceHash !== null && !validHash(input.sourceHash) || typeof input.head !== "string" || !/^[a-f0-9]{40,64}$/.test(input.head)) throw new ConventionsError("Inspect the repository file before publishing.");
169
+ const root = await regularRepository(input.repo);
170
+ const owned = await Promise.all(guard.repos.map((repo) => realpath(repo).catch(() => "")));
171
+ if (!owned.includes(root)) throw new ConventionsError("Publish only to a repository owned by this project.", 403);
172
+ return withRepoLock(root, async () => {
173
+ if (!await guard.isIdle()) throw new ConventionsError("Wait for repository activity to finish before publishing conventions.", 409);
174
+ const before = await inspectAt(root);
175
+ if (!before.clean) throw new ConventionsError("Commit or save existing repository changes before publishing conventions.", 409);
176
+ if (before.sourceHash !== input.sourceHash || before.head !== input.head) throw new ConventionsError("The repository guidance or HEAD changed. Inspect it again before publishing.", 409);
177
+ const directory = join(root, ".codeflow");
178
+ const existed = !!await lstat(directory).catch(() => void 0);
179
+ await mkdir(directory, { recursive: false }).catch((error) => {
180
+ if (error.code !== "EEXIST") throw error;
181
+ });
182
+ const temporary = join(directory, `.conventions-${randomBytes(8).toString("hex")}.tmp`);
183
+ let wrote = false;
184
+ let staged = false;
185
+ try {
186
+ await safeTarget(root);
187
+ const handle = await open(temporary, "wx", 420);
188
+ try {
189
+ await handle.writeFile(current.text);
190
+ await handle.sync();
191
+ } finally {
192
+ await handle.close();
193
+ }
194
+ const latest = await inspectAt(root);
195
+ const status = await git(["status", "--porcelain=v1", "-z", "--untracked-files=all", "--", ".", `:(exclude).codeflow/${basename(temporary)}`], root);
196
+ if (status.code !== 0 || status.stdout || latest.sourceHash !== before.sourceHash || latest.head !== before.head || !await guard.isIdle()) throw new ConventionsError("Repository activity changed during publication. Inspect and retry.", 409);
197
+ await safeTarget(root);
198
+ await rename(temporary, join(root, CONVENTIONS_PATH));
199
+ wrote = true;
200
+ await syncDirectory(directory);
201
+ if (!await guard.isIdle() || await resolveCommit(root) !== before.head) throw new ConventionsError("Repository activity changed during publication.", 409);
202
+ const add = await git(["add", "-f", "--", CONVENTIONS_PATH], root);
203
+ if (add.code !== 0) throw new ConventionsError("Could not stage conventions. Resolve the Git index error and retry.", 409);
204
+ staged = true;
205
+ const index = await git(["show", `:${CONVENTIONS_PATH}`], root);
206
+ if (index.code !== 0 || index.stdout !== current.text) throw new ConventionsError("A Git filter changed the guidance while staging. Review the repository filters before publishing.", 409);
207
+ return { repo: root, path: CONVENTIONS_PATH, revision: current.revision, hash: current.hash, staged: true, head: before.head, requiresCommit: true };
208
+ } catch (error) {
209
+ if (wrote) {
210
+ const latest = await inspectAt(root).catch(() => void 0);
211
+ if (latest?.head === before.head && latest.sourceHash === current.hash) {
212
+ const reset = staged ? await git(["reset", "-q", before.head, "--", CONVENTIONS_PATH], root) : { code: 0 };
213
+ if (reset.code === 0) {
214
+ if (before.exists) {
215
+ const rollback = await open(temporary, "wx", 420);
216
+ try {
217
+ await rollback.writeFile(before.text);
218
+ await rollback.sync();
219
+ } finally {
220
+ await rollback.close();
221
+ }
222
+ await rename(temporary, join(root, CONVENTIONS_PATH));
223
+ } else await rm(join(root, CONVENTIONS_PATH));
224
+ await syncDirectory(directory);
225
+ wrote = false;
226
+ } else throw new ConventionsError(`${error.message} The staged file needs manual review.`, 409);
227
+ } else throw new ConventionsError(`${error.message} Concurrent changes were preserved; review the repository file before retrying.`, 409);
228
+ }
229
+ throw error;
230
+ } finally {
231
+ await rm(temporary, { force: true });
232
+ if (!existed && !wrote) await rmdir(directory).catch(() => void 0);
233
+ }
234
+ });
235
+ });
236
+ }
237
+ }
238
+ async function regularRepository(repo) {
239
+ if (typeof repo !== "string" || !(await lstat(repo)).isDirectory()) throw new ConventionsError("Choose a regular project repository directory.");
240
+ const root = await realpath(await repoRoot(repo));
241
+ if ((await lstat(join(root, ".git"))).isSymbolicLink()) throw new ConventionsError("Publishing through a symbolic .git marker is refused.");
242
+ return root;
243
+ }
244
+ async function safeTarget(root) {
245
+ const directory = join(root, ".codeflow");
246
+ const target = join(root, CONVENTIONS_PATH);
247
+ const dir = await lstat(directory).catch((error) => {
248
+ if (error.code === "ENOENT") return void 0;
249
+ throw error;
250
+ });
251
+ if (dir && (!dir.isDirectory() || dir.isSymbolicLink() || await realpath(directory) !== directory)) throw new ConventionsError("Publishing refuses symbolic links or non-directory .codeflow paths.");
252
+ const info = dir ? await lstat(target).catch((error) => {
253
+ if (error.code === "ENOENT") return void 0;
254
+ throw error;
255
+ }) : void 0;
256
+ if (info && (!info.isFile() || info.isSymbolicLink() || info.size > MAX_CONVENTION_BYTES)) throw new ConventionsError("Publish only to a regular conventions file under 64 KiB.");
257
+ return info;
258
+ }
259
+ async function inspectAt(root) {
260
+ const info = await safeTarget(root);
261
+ let text = "";
262
+ if (info) {
263
+ const handle = await open(join(root, CONVENTIONS_PATH), constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
264
+ try {
265
+ const stat = await handle.stat();
266
+ if (!stat.isFile() || stat.size > MAX_CONVENTION_BYTES) throw new ConventionsError("The conventions file changed during inspection.", 409);
267
+ text = new TextDecoder("utf-8", { fatal: true }).decode(await handle.readFile());
268
+ if (text.includes("\0")) throw new ConventionsError("The repository conventions file is binary.");
269
+ } finally {
270
+ await handle.close();
271
+ }
272
+ }
273
+ const [head, branch, status, tracked] = await Promise.all([resolveCommit(root), git(["symbolic-ref", "--quiet", "--short", "HEAD"], root), git(["status", "--porcelain=v1", "-z", "--untracked-files=all"], root), git(["ls-files", "--error-unmatch", "--", CONVENTIONS_PATH], root)]);
274
+ if (status.code !== 0) throw new ConventionsError("Cannot inspect repository status.");
275
+ return { repo: root, path: CONVENTIONS_PATH, exists: !!info, tracked: tracked.code === 0, sourceHash: info ? digest(text) : null, text, head, branch: branch.code === 0 ? branch.stdout.trim() : null, clean: !status.stdout };
276
+ }
277
+ export {
278
+ CONVENTIONS_PATH,
279
+ ConventionsError,
280
+ ConventionsStore,
281
+ MAX_CONVENTION_BYTES,
282
+ MAX_CONVENTION_REVISIONS
283
+ };