@agjs/tsforge 0.1.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 (216) hide show
  1. package/bin/tsforge.js +2 -0
  2. package/package.json +35 -0
  3. package/src/agent/agent.constants.ts +382 -0
  4. package/src/agent/agent.types.ts +34 -0
  5. package/src/agent/index.ts +4 -0
  6. package/src/agent/model-agent.ts +297 -0
  7. package/src/agent/tool-repair.ts +194 -0
  8. package/src/agent/tools.ts +190 -0
  9. package/src/browser/checks.ts +96 -0
  10. package/src/browser/index.ts +8 -0
  11. package/src/browser/oracle.ts +303 -0
  12. package/src/classify.ts +48 -0
  13. package/src/cli.ts +1333 -0
  14. package/src/config/config.constants.ts +9 -0
  15. package/src/config/flags.ts +32 -0
  16. package/src/config/index.ts +8 -0
  17. package/src/config/tsforge-config.ts +301 -0
  18. package/src/constitution/baseline.ts +257 -0
  19. package/src/detect-gate.ts +498 -0
  20. package/src/eval/eval.types.ts +36 -0
  21. package/src/eval/index.ts +3 -0
  22. package/src/eval/judge.ts +62 -0
  23. package/src/eval/score.ts +39 -0
  24. package/src/files/create.ts +22 -0
  25. package/src/files/edit.ts +193 -0
  26. package/src/files/files.constants.ts +11 -0
  27. package/src/files/files.types.ts +81 -0
  28. package/src/files/hashline-format.ts +110 -0
  29. package/src/files/hashline.ts +689 -0
  30. package/src/files/index.ts +19 -0
  31. package/src/index.ts +8 -0
  32. package/src/inference/index.ts +6 -0
  33. package/src/inference/inference.constants.ts +34 -0
  34. package/src/inference/inference.types.ts +123 -0
  35. package/src/inference/openai-compatible.ts +113 -0
  36. package/src/inference/stream-guard.ts +161 -0
  37. package/src/inference/stream.ts +370 -0
  38. package/src/inference/transport.ts +78 -0
  39. package/src/inference/wire.ts +0 -0
  40. package/src/lib/fs/fs.ts +126 -0
  41. package/src/lib/fs/fs.types.ts +5 -0
  42. package/src/lib/fs/index.ts +3 -0
  43. package/src/lib/fs/process.ts +146 -0
  44. package/src/lib/guards/guards.ts +9 -0
  45. package/src/lib/guards/index.ts +1 -0
  46. package/src/lib/json/index.ts +1 -0
  47. package/src/lib/json/json.ts +12 -0
  48. package/src/lib/scope/index.ts +2 -0
  49. package/src/lib/scope/scope.constants.ts +3 -0
  50. package/src/lib/scope/scope.ts +40 -0
  51. package/src/loop/astgrep-fix.ts +228 -0
  52. package/src/loop/feedback/feedback.ts +138 -0
  53. package/src/loop/feedback/index.ts +8 -0
  54. package/src/loop/feedback/meta-rule-docs.ts +41 -0
  55. package/src/loop/feedback/meta-rule-feedback.ts +61 -0
  56. package/src/loop/feedback/rule-docs.generated.json +112 -0
  57. package/src/loop/feedback/rule-docs.ts +342 -0
  58. package/src/loop/index.ts +19 -0
  59. package/src/loop/loop.constants.ts +68 -0
  60. package/src/loop/loop.types.ts +99 -0
  61. package/src/loop/prompt/index.ts +2 -0
  62. package/src/loop/prompt/project-map.ts +69 -0
  63. package/src/loop/prompt/prompt.ts +107 -0
  64. package/src/loop/quality.ts +174 -0
  65. package/src/loop/rule-docs.generated.json +367 -0
  66. package/src/loop/run-spec.ts +88 -0
  67. package/src/loop/run.ts +400 -0
  68. package/src/loop/session.ts +1410 -0
  69. package/src/loop/tools/add-dependency.ts +71 -0
  70. package/src/loop/tools/condense.ts +498 -0
  71. package/src/loop/tools/edit-hashline.ts +80 -0
  72. package/src/loop/tools/execute-tool.ts +80 -0
  73. package/src/loop/tools/file-ops.ts +323 -0
  74. package/src/loop/tools/index.ts +2 -0
  75. package/src/loop/tools/lsp-ops.ts +222 -0
  76. package/src/loop/tools/scaffold-routes.ts +68 -0
  77. package/src/loop/tools/scaffold-ui.ts +62 -0
  78. package/src/loop/tools/scaffold-web.ts +35 -0
  79. package/src/loop/tools/tool-context.ts +126 -0
  80. package/src/loop/ttsr-defaults.ts +53 -0
  81. package/src/loop/ttsr.ts +322 -0
  82. package/src/loop/turn.ts +856 -0
  83. package/src/lsp/index.ts +2 -0
  84. package/src/lsp/lsp.types.ts +56 -0
  85. package/src/lsp/service.ts +500 -0
  86. package/src/meta-rules/context.ts +195 -0
  87. package/src/meta-rules/index.ts +9 -0
  88. package/src/meta-rules/meta-rules.types.ts +47 -0
  89. package/src/meta-rules/parsers/package-json-parser.ts +51 -0
  90. package/src/meta-rules/registry.ts +37 -0
  91. package/src/meta-rules/rules/ci/workflow-actions-pinned.ts +59 -0
  92. package/src/meta-rules/rules/ci/workflow-runner-pinned.ts +57 -0
  93. package/src/meta-rules/rules/ci/workflow-timeout-required.ts +114 -0
  94. package/src/meta-rules/rules/config/tsconfig-paths-exist.ts +117 -0
  95. package/src/meta-rules/rules/config/tsconfig-strict.ts +91 -0
  96. package/src/meta-rules/rules/source-text/no-eslint-disable-comments.ts +34 -0
  97. package/src/meta-rules/rules/source-text/no-ts-suppressions.ts +38 -0
  98. package/src/meta-rules/rules/supply-chain/no-overlapping-libs.ts +57 -0
  99. package/src/meta-rules/rules/supply-chain/package-exact-deps.ts +55 -0
  100. package/src/meta-rules/rules/testing/test-sibling-required.ts +110 -0
  101. package/src/meta-rules/runner.ts +64 -0
  102. package/src/models-config.ts +196 -0
  103. package/src/render/ansi.ts +289 -0
  104. package/src/render/banner.ts +113 -0
  105. package/src/render/box.ts +134 -0
  106. package/src/render/index.ts +7 -0
  107. package/src/render/markdown.ts +123 -0
  108. package/src/render/render.types.ts +21 -0
  109. package/src/render/stream-markdown.ts +128 -0
  110. package/src/render/style.ts +26 -0
  111. package/src/rule-packs/bullmq/index.ts +39 -0
  112. package/src/rule-packs/bullmq/rules/index.ts +7 -0
  113. package/src/rule-packs/bullmq/rules/job-name-must-be-constant.ts +141 -0
  114. package/src/rule-packs/bullmq/rules/job-options-must-set-attempts.ts +174 -0
  115. package/src/rule-packs/bullmq/rules/no-blocking-concurrency-zero.ts +103 -0
  116. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeoncomplete.ts +130 -0
  117. package/src/rule-packs/bullmq/rules/queue-options-must-set-removeonfail.ts +130 -0
  118. package/src/rule-packs/bullmq/rules/worker-must-implement-close.ts +182 -0
  119. package/src/rule-packs/bullmq/rules/worker-must-listen-failed.ts +140 -0
  120. package/src/rule-packs/bullmq/utils.ts +334 -0
  121. package/src/rule-packs/code-flow/index.ts +25 -0
  122. package/src/rule-packs/code-flow/rules/index.ts +3 -0
  123. package/src/rule-packs/code-flow/rules/no-bare-date-now.ts +138 -0
  124. package/src/rule-packs/code-flow/rules/no-template-trim-empty-ternary.ts +87 -0
  125. package/src/rule-packs/code-flow/rules/prefer-early-return.ts +80 -0
  126. package/src/rule-packs/code-flow/utils/prefer-early-return.ts +132 -0
  127. package/src/rule-packs/comment-hygiene/index.ts +25 -0
  128. package/src/rule-packs/comment-hygiene/rules/index.ts +3 -0
  129. package/src/rule-packs/comment-hygiene/rules/no-historical-comments.ts +102 -0
  130. package/src/rule-packs/comment-hygiene/rules/no-narration-comments.ts +83 -0
  131. package/src/rule-packs/comment-hygiene/rules/no-pr-reference-comments.ts +90 -0
  132. package/src/rule-packs/create-rule.ts +9 -0
  133. package/src/rule-packs/drizzle/index.ts +41 -0
  134. package/src/rule-packs/drizzle/rules/account-scoped-tables-require-where.ts +371 -0
  135. package/src/rule-packs/drizzle/rules/index.ts +8 -0
  136. package/src/rule-packs/drizzle/rules/no-nested-db-transaction.ts +127 -0
  137. package/src/rule-packs/drizzle/rules/no-raw-sql-outside-allowlist.ts +100 -0
  138. package/src/rule-packs/drizzle/rules/relations-must-cover-fks.ts +209 -0
  139. package/src/rule-packs/drizzle/rules/schema-files-must-not-import-driver.ts +127 -0
  140. package/src/rule-packs/drizzle/rules/schema-files-must-only-export-schema.ts +149 -0
  141. package/src/rule-packs/drizzle/rules/tables-must-have-timestamps.ts +312 -0
  142. package/src/rule-packs/drizzle/rules/timestamp-must-specify-mode.ts +166 -0
  143. package/src/rule-packs/drizzle/utils.ts +115 -0
  144. package/src/rule-packs/elysia/index.ts +43 -0
  145. package/src/rule-packs/elysia/rules/consistent-status-via-set.ts +69 -0
  146. package/src/rule-packs/elysia/rules/no-decorate-state-collision.ts +276 -0
  147. package/src/rule-packs/elysia/rules/no-separate-model-interfaces.ts +144 -0
  148. package/src/rule-packs/elysia/rules/prefer-destructured-context.ts +155 -0
  149. package/src/rule-packs/elysia/rules/prefer-direct-return.ts +176 -0
  150. package/src/rule-packs/elysia/rules/prefer-static-services.ts +159 -0
  151. package/src/rule-packs/elysia/rules/prefer-throw-status.ts +151 -0
  152. package/src/rule-packs/elysia/rules/require-hooks-before-routes.ts +209 -0
  153. package/src/rule-packs/elysia/rules/require-plugin-name.ts +107 -0
  154. package/src/rule-packs/elysia/utils/elysiaChain.ts +306 -0
  155. package/src/rule-packs/env-access/index.ts +23 -0
  156. package/src/rule-packs/env-access/rules/index.ts +2 -0
  157. package/src/rule-packs/env-access/rules/no-direct-process-env.ts +133 -0
  158. package/src/rule-packs/env-access/rules/no-process-exit.ts +95 -0
  159. package/src/rule-packs/i18n-keys/index.ts +19 -0
  160. package/src/rule-packs/i18n-keys/rules/static-translation-key-exists.ts +173 -0
  161. package/src/rule-packs/index.ts +139 -0
  162. package/src/rule-packs/jwt-cookies/index.ts +25 -0
  163. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-httponly.ts +150 -0
  164. package/src/rule-packs/jwt-cookies/rules/auth-cookie-must-be-secure-in-prod.ts +149 -0
  165. package/src/rule-packs/jwt-cookies/rules/bcrypt-rounds-min.ts +195 -0
  166. package/src/rule-packs/jwt-cookies/utils.ts +188 -0
  167. package/src/rule-packs/oauth-security/index.ts +25 -0
  168. package/src/rule-packs/oauth-security/rules/pkce-required-for-oidc.ts +296 -0
  169. package/src/rule-packs/oauth-security/rules/state-must-be-redis-backed.ts +193 -0
  170. package/src/rule-packs/oauth-security/rules/state-ttl-bounded.ts +219 -0
  171. package/src/rule-packs/oauth-security/utils.ts +127 -0
  172. package/src/rule-packs/react-component-architecture/index.ts +35 -0
  173. package/src/rule-packs/react-component-architecture/rules/component-folder-structure.ts +123 -0
  174. package/src/rule-packs/react-component-architecture/rules/forwardref-display-name.ts +93 -0
  175. package/src/rule-packs/react-component-architecture/rules/index-must-reexport-default.ts +123 -0
  176. package/src/rule-packs/react-component-architecture/rules/max-hooks-per-file.ts +122 -0
  177. package/src/rule-packs/react-component-architecture/rules/no-cross-feature-imports.ts +170 -0
  178. package/src/rule-packs/react-component-architecture/rules/no-inline-jsx-functions.ts +66 -0
  179. package/src/rule-packs/react-component-architecture/utils.ts +47 -0
  180. package/src/rule-packs/rule-packs.types.ts +18 -0
  181. package/src/rule-packs/structured-logging/index.ts +26 -0
  182. package/src/rule-packs/structured-logging/rules/mask-pii-fields.ts +221 -0
  183. package/src/rule-packs/structured-logging/rules/no-error-stringify.ts +217 -0
  184. package/src/rule-packs/structured-logging/rules/require-event-field.ts +136 -0
  185. package/src/rule-packs/structured-logging/utils/logger.ts +104 -0
  186. package/src/rule-packs/tanstack-query/index.ts +20 -0
  187. package/src/rule-packs/tanstack-query/rules/prefix-query-key-must-use-set-queries-data.ts +321 -0
  188. package/src/rule-packs/test-conventions/index.ts +23 -0
  189. package/src/rule-packs/test-conventions/rules/index.ts +2 -0
  190. package/src/rule-packs/test-conventions/rules/no-focused-tests.ts +170 -0
  191. package/src/rule-packs/test-conventions/rules/test-file-mirrors-source.ts +127 -0
  192. package/src/rule-packs/utils.ts +142 -0
  193. package/src/session-store.ts +359 -0
  194. package/src/spec/generate-tests.ts +213 -0
  195. package/src/spec/index.ts +5 -0
  196. package/src/spec/parse.ts +152 -0
  197. package/src/spec/review-tests.ts +162 -0
  198. package/src/spec/spec.constants.ts +13 -0
  199. package/src/spec/spec.types.ts +79 -0
  200. package/src/stack-detection/detect.ts +246 -0
  201. package/src/stack-detection/index.ts +3 -0
  202. package/src/stack-detection/packs.ts +174 -0
  203. package/src/stack-detection/stack-detection.types.ts +47 -0
  204. package/src/validate/accept.ts +49 -0
  205. package/src/validate/errors.ts +35 -0
  206. package/src/validate/index.ts +12 -0
  207. package/src/validate/parse.ts +148 -0
  208. package/src/validate/run-tests.ts +59 -0
  209. package/src/validate/validate.ts +40 -0
  210. package/src/validate/validate.types.ts +52 -0
  211. package/src/web-components.ts +638 -0
  212. package/src/web-coverage.ts +89 -0
  213. package/src/web-routes.ts +151 -0
  214. package/src/web-templates.ts +1011 -0
  215. package/strict.eslint.config.mjs +84 -0
  216. package/strict.web.eslint.config.mjs +185 -0
@@ -0,0 +1,689 @@
1
+ /**
2
+ * Hashline edit system: per-session snapshot store, parser, and 3-way merge recovery.
3
+ * Binds hashline section tags to the file content that minted them; allows edits
4
+ * against stale tags via snapshot-based recovery (3-way merge: snapshot=base,
5
+ * live=theirs, edited-snapshot=ours).
6
+ */
7
+
8
+ import { join } from "node:path";
9
+ import {
10
+ computeFileHash,
11
+ parseHashHeader,
12
+ normalizeHash,
13
+ } from "./hashline-format";
14
+
15
+ /**
16
+ * One full-file version observed at a point in time.
17
+ */
18
+ export interface ISnapshot {
19
+ readonly path: string;
20
+ readonly text: string;
21
+ readonly hash: string;
22
+ recordedAt: number;
23
+ }
24
+
25
+ /**
26
+ * Per-path LRU history: keep up to 4 snapshots per file.
27
+ */
28
+ export interface ISnapshotHistory {
29
+ path: string;
30
+ versions: ISnapshot[];
31
+ }
32
+
33
+ /**
34
+ * In-memory snapshot store: per-path LRU with up to 4 versions per path.
35
+ * Thread-safe for in-session use; session is single-threaded so no locks needed.
36
+ */
37
+ export class SessionSnapshotStore {
38
+ readonly #histories = new Map<string, ISnapshotHistory>();
39
+
40
+ readonly #pathLru: string[] = []; // LRU path ordering
41
+
42
+ readonly maxVersionsPerPath = 4;
43
+
44
+ readonly maxPaths = 30;
45
+
46
+ /**
47
+ * Record a file snapshot. Returns the computed hash.
48
+ * If the same content is recorded again, promotes it to head and reuses the tag.
49
+ */
50
+ record(filePath: string, fullText: string): string {
51
+ const hash = computeFileHash(fullText);
52
+
53
+ let history = this.#histories.get(filePath);
54
+
55
+ if (!history) {
56
+ history = { path: filePath, versions: [] };
57
+ this.#histories.set(filePath, history);
58
+ this.#pathLru.push(filePath);
59
+ } else {
60
+ // Promote to LRU head
61
+ const idx = this.#pathLru.indexOf(filePath);
62
+
63
+ if (idx !== -1) {
64
+ this.#pathLru.splice(idx, 1);
65
+ }
66
+
67
+ this.#pathLru.push(filePath);
68
+ }
69
+
70
+ // Check if we already have this exact content
71
+ const existing = history.versions.find((v) => v.hash === hash);
72
+
73
+ if (existing) {
74
+ existing.recordedAt = Date.now();
75
+ // Move to head
76
+ const idx = history.versions.indexOf(existing);
77
+
78
+ if (idx > 0) {
79
+ history.versions.splice(idx, 1);
80
+ history.versions.unshift(existing);
81
+ }
82
+
83
+ return hash;
84
+ }
85
+
86
+ // Record new version at head
87
+ const snapshot: ISnapshot = {
88
+ path: filePath,
89
+ text: fullText,
90
+ hash,
91
+ recordedAt: Date.now(),
92
+ };
93
+
94
+ history.versions.unshift(snapshot);
95
+
96
+ // Keep only latest maxVersionsPerPath
97
+ if (history.versions.length > this.maxVersionsPerPath) {
98
+ history.versions.pop();
99
+ }
100
+
101
+ // Evict LRU paths if we exceed maxPaths
102
+ while (this.#pathLru.length > this.maxPaths) {
103
+ const lruPath = this.#pathLru.shift();
104
+
105
+ if (lruPath !== undefined) {
106
+ this.#histories.delete(lruPath);
107
+ }
108
+ }
109
+
110
+ return hash;
111
+ }
112
+
113
+ /**
114
+ * Get the most recent snapshot for a path, or null.
115
+ */
116
+ head(filePath: string): ISnapshot | null {
117
+ return this.#histories.get(filePath)?.versions[0] ?? null;
118
+ }
119
+
120
+ /**
121
+ * Get a specific historical snapshot by path and hash, or null.
122
+ */
123
+ byHash(filePath: string, hash: string): ISnapshot | null {
124
+ const history = this.#histories.get(filePath);
125
+
126
+ if (!history) {
127
+ return null;
128
+ }
129
+
130
+ return history.versions.find((v) => v.hash === normalizeHash(hash)) ?? null;
131
+ }
132
+
133
+ /**
134
+ * Clear all snapshots.
135
+ */
136
+ clear(): void {
137
+ this.#histories.clear();
138
+ this.#pathLru.length = 0;
139
+ }
140
+ }
141
+
142
+ /**
143
+ * Parsed hashline edit operation.
144
+ */
145
+ export interface IEditOp {
146
+ kind: "replace" | "delete" | "insert";
147
+ startLine?: number;
148
+ endLine?: number;
149
+ insertPos?: "before" | "after";
150
+ insertAnchor?: number;
151
+ lines: string[]; // payload lines
152
+ lineNum: number; // source line in the edit text (for error reporting)
153
+ }
154
+
155
+ /**
156
+ * Result of applying a hashline edit.
157
+ */
158
+ export interface IHashlineResult {
159
+ ok: boolean;
160
+ file: string;
161
+ newHash?: string;
162
+ reason?: string; // error reason
163
+ suggestions?: string[]; // actionable feedback
164
+ }
165
+
166
+ const HL_PAYLOAD_PREFIX = "+";
167
+
168
+ /**
169
+ * Parse the header line (¶path#HASH). Returns {path, hash} or null.
170
+ */
171
+ function parseHeaderLine(
172
+ headerLine: string,
173
+ errors: string[]
174
+ ): { filePath: string; fileHash: string | undefined } {
175
+ const header = parseHashHeader(headerLine);
176
+
177
+ if (header) {
178
+ return { filePath: header.path, fileHash: header.hash };
179
+ }
180
+
181
+ if (headerLine.trim() !== "") {
182
+ errors.push(
183
+ `Expected header ¶path#HASH on first line, got: ${JSON.stringify(
184
+ headerLine.slice(0, 80)
185
+ )}`
186
+ );
187
+ }
188
+
189
+ return { filePath: "", fileHash: undefined };
190
+ }
191
+
192
+ /**
193
+ * Parse operation lines and collect ops.
194
+ */
195
+ function parseOperations(
196
+ lines: string[],
197
+ startIdx: number,
198
+ errors: string[]
199
+ ): IEditOp[] {
200
+ const ops: IEditOp[] = [];
201
+ let currentOp: IEditOp | null = null;
202
+
203
+ for (let i = startIdx; i < lines.length; i++) {
204
+ const line = lines[i] ?? "";
205
+ const trimmed = line.trimEnd();
206
+
207
+ if (trimmed === "") {
208
+ continue;
209
+ }
210
+
211
+ // Check if this is a payload line (starts with +)
212
+ if (trimmed.startsWith(HL_PAYLOAD_PREFIX)) {
213
+ if (currentOp) {
214
+ currentOp.lines.push(trimmed.slice(1)); // Strip the + prefix
215
+ } else {
216
+ errors.push(
217
+ `Payload line without operation: ${JSON.stringify(
218
+ trimmed.slice(0, 60)
219
+ )}`
220
+ );
221
+ }
222
+
223
+ continue;
224
+ }
225
+
226
+ // Try to parse as an operation header
227
+ const op = parseOpHeader(trimmed, i + 1);
228
+
229
+ if (op) {
230
+ if (currentOp) {
231
+ ops.push(currentOp);
232
+ }
233
+
234
+ currentOp = op;
235
+ } else if (trimmed.length > 0) {
236
+ errors.push(`Unrecognized line: ${JSON.stringify(trimmed.slice(0, 60))}`);
237
+ }
238
+ }
239
+
240
+ if (currentOp) {
241
+ ops.push(currentOp);
242
+ }
243
+
244
+ return ops;
245
+ }
246
+
247
+ /**
248
+ * Parse a single operation header line. Returns null if not recognized.
249
+ */
250
+ function parseOpHeader(line: string, lineNum: number): IEditOp | null {
251
+ // replace N..M: or replace N..M
252
+ let match = /^replace\s+(\d+)(?:\.\.(\d+))?\s*:?/i.exec(line);
253
+
254
+ if (match) {
255
+ const startLine = parseInt(match[1] ?? "0", 10);
256
+ const endLine = match[2] !== undefined ? parseInt(match[2], 10) : startLine;
257
+
258
+ return {
259
+ kind: "replace",
260
+ startLine,
261
+ endLine,
262
+ lines: [],
263
+ lineNum,
264
+ };
265
+ }
266
+
267
+ // delete N..M or delete N
268
+ match = /^delete\s+(\d+)(?:\.\.(\d+))?/i.exec(line);
269
+
270
+ if (match) {
271
+ const startLine = parseInt(match[1] ?? "0", 10);
272
+ const endLine = match[2] !== undefined ? parseInt(match[2], 10) : startLine;
273
+
274
+ return {
275
+ kind: "delete",
276
+ startLine,
277
+ endLine,
278
+ lines: [],
279
+ lineNum,
280
+ };
281
+ }
282
+
283
+ // insert before N: or insert after N:
284
+ match = /^insert\s+(before|after)\s+(\d+)\s*:?/i.exec(line);
285
+
286
+ if (match) {
287
+ const posText = (match[1] ?? "").toLowerCase();
288
+ const insertPos = posText === "before" ? "before" : "after";
289
+ const insertAnchor = parseInt(match[2] ?? "0", 10);
290
+
291
+ return {
292
+ kind: "insert",
293
+ insertPos,
294
+ insertAnchor,
295
+ lines: [],
296
+ lineNum,
297
+ };
298
+ }
299
+
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * Parse a hashline edit input. Lenient: accepts `replace N..M:` or `replace N..M` or
305
+ * `delete N..M`, `insert before N:` or `insert after N:`, with optional body lines
306
+ * prefixed `+`. Returns parsed operations and the file header (path, hash).
307
+ */
308
+ export function parseHashlineEdit(input: string): {
309
+ filePath: string;
310
+ fileHash: string | undefined;
311
+ ops: IEditOp[];
312
+ errors: string[];
313
+ } {
314
+ const lines = input.split("\n");
315
+ const errors: string[] = [];
316
+ let filePath = "";
317
+ let fileHash: string | undefined;
318
+
319
+ let i = 0;
320
+
321
+ // Parse file header
322
+ if (i < lines.length) {
323
+ const headerLine = lines[i] ?? "";
324
+ const parsed = parseHeaderLine(headerLine, errors);
325
+
326
+ filePath = parsed.filePath;
327
+ fileHash = parsed.fileHash;
328
+
329
+ if (filePath.length > 0) {
330
+ i++;
331
+ } else if (errors.length > 0) {
332
+ return { filePath: "", fileHash: undefined, ops: [], errors };
333
+ } else if (headerLine.trim() === "") {
334
+ i++;
335
+ }
336
+ }
337
+
338
+ // Parse operations
339
+ const ops = parseOperations(lines, i, errors);
340
+
341
+ return { filePath, fileHash, ops, errors };
342
+ }
343
+
344
+ /**
345
+ * Apply hashline edits to file content. Handles stale-tag recovery via 3-way merge.
346
+ */
347
+ export async function applyHashlineEdit(
348
+ store: SessionSnapshotStore,
349
+ cwd: string,
350
+ file: string,
351
+ fileHash: string | undefined,
352
+ ops: IEditOp[]
353
+ ): Promise<IHashlineResult> {
354
+ const path = join(cwd, file);
355
+ const f = Bun.file(path);
356
+
357
+ if (!(await f.exists())) {
358
+ return {
359
+ ok: false,
360
+ file,
361
+ reason: "missing-file",
362
+ suggestions: [
363
+ `File ${file} does not exist. Use \`create\` to create it.`,
364
+ ],
365
+ };
366
+ }
367
+
368
+ const liveContent = await f.text();
369
+ const liveHash = computeFileHash(liveContent);
370
+
371
+ // Case 1: Hash matches live file
372
+ if (
373
+ fileHash !== undefined &&
374
+ fileHash.length > 0 &&
375
+ normalizeHash(fileHash) === liveHash
376
+ ) {
377
+ const result = applyOpsToContent(liveContent, ops, file);
378
+
379
+ if (!result.ok || result.text === undefined) {
380
+ return { ...result, file };
381
+ }
382
+
383
+ const newHash = computeFileHash(result.text);
384
+
385
+ await Bun.write(path, result.text);
386
+ store.record(file, result.text);
387
+
388
+ return {
389
+ ok: true,
390
+ file,
391
+ newHash,
392
+ };
393
+ }
394
+
395
+ // Case 2: Hash is stale, try recovery via snapshot
396
+ if (fileHash !== undefined) {
397
+ const snapshot = store.byHash(file, fileHash);
398
+
399
+ if (snapshot) {
400
+ // Try 3-way merge: snapshot=base, live=theirs, edited-snapshot=ours
401
+ const mergedResult = threeWayMerge(snapshot.text, liveContent, ops, file);
402
+
403
+ if (
404
+ mergedResult.ok &&
405
+ mergedResult.cleanMerge &&
406
+ mergedResult.text !== undefined
407
+ ) {
408
+ const newHash = computeFileHash(mergedResult.text);
409
+
410
+ await Bun.write(path, mergedResult.text);
411
+ store.record(file, mergedResult.text);
412
+
413
+ return {
414
+ ok: true,
415
+ file,
416
+ newHash,
417
+ };
418
+ }
419
+
420
+ if (!mergedResult.ok || !mergedResult.cleanMerge) {
421
+ return {
422
+ ok: false,
423
+ file,
424
+ reason: "stale-anchor-conflict",
425
+ suggestions: [
426
+ `The file changed since you read it (was #${fileHash}, now #${liveHash}). ` +
427
+ `Your edits conflict with those changes. Please re-read the file and adjust your edits.`,
428
+ ],
429
+ };
430
+ }
431
+ }
432
+
433
+ return {
434
+ ok: false,
435
+ file,
436
+ reason: "stale-anchor",
437
+ suggestions: [
438
+ `The file changed since you read it (was #${fileHash}, now #${liveHash}). ` +
439
+ `Re-read the file to get a current hash, then edit again.`,
440
+ ],
441
+ };
442
+ }
443
+
444
+ // Case 3: No hash provided but file exists
445
+ return {
446
+ ok: false,
447
+ file,
448
+ reason: "no-anchor",
449
+ suggestions: [
450
+ `Edit needs a hashline anchor (¶${file}#HASH). Read the file first to get its current hash.`,
451
+ ],
452
+ };
453
+ }
454
+
455
+ /**
456
+ * Apply operations to content directly (hash already validated or not using hash).
457
+ * Operations are applied bottom-up so line numbers stay valid.
458
+ */
459
+ function applyOpsToContent(
460
+ content: string,
461
+ ops: IEditOp[],
462
+ _filePath: string
463
+ ): { ok: boolean; text?: string; reason?: string; suggestions?: string[] } {
464
+ const lines = content.split("\n");
465
+
466
+ // Sort ops by line number descending (bottom-up) to preserve line numbers
467
+ const sortedOps = [...ops].sort((a, b) => {
468
+ const aLine = a.startLine ?? a.insertAnchor ?? 0;
469
+ const bLine = b.startLine ?? b.insertAnchor ?? 0;
470
+
471
+ return bLine - aLine;
472
+ });
473
+
474
+ for (const op of sortedOps) {
475
+ const result = applyOp(lines, op, _filePath);
476
+
477
+ if (!result.ok) {
478
+ return result;
479
+ }
480
+ }
481
+
482
+ return { ok: true, text: lines.join("\n") };
483
+ }
484
+
485
+ /**
486
+ * Apply a single operation to the lines array (mutates it).
487
+ */
488
+ function applyOp(
489
+ lines: string[],
490
+ op: IEditOp,
491
+ _filePath: string
492
+ ): { ok: boolean; reason?: string; suggestions?: string[] } {
493
+ if (
494
+ op.kind === "replace" &&
495
+ op.startLine !== undefined &&
496
+ op.endLine !== undefined
497
+ ) {
498
+ // 1-indexed, inclusive range
499
+ const start = op.startLine - 1;
500
+ const end = op.endLine; // slice uses exclusive end
501
+
502
+ if (start < 0 || end > lines.length || start > end) {
503
+ return {
504
+ ok: false,
505
+ reason: "out-of-bounds",
506
+ suggestions: [
507
+ `replace ${op.startLine}..${op.endLine} is invalid for a ${lines.length}-line file. ` +
508
+ `Check your line numbers and try again.`,
509
+ ],
510
+ };
511
+ }
512
+
513
+ lines.splice(start, end - start, ...op.lines);
514
+
515
+ return { ok: true };
516
+ }
517
+
518
+ if (
519
+ op.kind === "delete" &&
520
+ op.startLine !== undefined &&
521
+ op.endLine !== undefined
522
+ ) {
523
+ const start = op.startLine - 1;
524
+ const end = op.endLine;
525
+
526
+ if (start < 0 || end > lines.length || start > end) {
527
+ return {
528
+ ok: false,
529
+ reason: "out-of-bounds",
530
+ suggestions: [
531
+ `delete ${op.startLine}..${op.endLine} is invalid for a ${lines.length}-line file.`,
532
+ ],
533
+ };
534
+ }
535
+
536
+ lines.splice(start, end - start);
537
+
538
+ return { ok: true };
539
+ }
540
+
541
+ if (
542
+ op.kind === "insert" &&
543
+ op.insertPos !== undefined &&
544
+ op.insertAnchor !== undefined
545
+ ) {
546
+ const anchorIdx = op.insertAnchor - 1;
547
+
548
+ if (anchorIdx < 0 || anchorIdx >= lines.length) {
549
+ return {
550
+ ok: false,
551
+ reason: "out-of-bounds",
552
+ suggestions: [
553
+ `insert ${op.insertPos ?? ""} ${op.insertAnchor ?? 0}: line does not exist ` +
554
+ `in the ${lines.length}-line file. Check your anchor line number.`,
555
+ ],
556
+ };
557
+ }
558
+
559
+ const pos = op.insertPos === "before" ? anchorIdx : anchorIdx + 1;
560
+
561
+ lines.splice(pos, 0, ...op.lines);
562
+
563
+ return { ok: true };
564
+ }
565
+
566
+ return {
567
+ ok: false,
568
+ reason: "invalid-operation",
569
+ suggestions: [`invalid operation: ${op.kind}`],
570
+ };
571
+ }
572
+
573
+ /**
574
+ * 3-way merge: snapshot=base, live=theirs, edited-snapshot=ours.
575
+ * Applies the ops against the snapshot and merges the result into live.
576
+ * Returns whether the merge was clean (no conflicts).
577
+ */
578
+ function threeWayMerge(
579
+ base: string,
580
+ theirs: string,
581
+ ops: IEditOp[],
582
+ _filePath: string
583
+ ): { ok: boolean; text?: string; cleanMerge: boolean } {
584
+ const baseLines = base.split("\n");
585
+ const theirLines = theirs.split("\n");
586
+ const located: { op: IEditOp; index: number; length: number }[] = [];
587
+
588
+ // Re-anchor each op: take the base lines it targets and find that exact
589
+ // run (uniquely) in the live file. Edits to lines the live file also
590
+ // changed fail re-anchoring and surface as conflicts.
591
+ for (const op of ops) {
592
+ const range = opAnchorRange(op, baseLines.length);
593
+
594
+ if (range === null) {
595
+ return { ok: false, cleanMerge: false };
596
+ }
597
+
598
+ const anchor = baseLines.slice(range.start - 1, range.end);
599
+ const index = findUniqueRun(theirLines, anchor);
600
+
601
+ if (index === null) {
602
+ return { ok: false, cleanMerge: false };
603
+ }
604
+
605
+ located.push({ op, index, length: anchor.length });
606
+ }
607
+
608
+ // Apply bottom-up in live coordinates; overlapping targets = conflict.
609
+ located.sort((a, b) => b.index - a.index);
610
+
611
+ for (let i = 1; i < located.length; i++) {
612
+ const above = located[i - 1];
613
+ const here = located[i];
614
+
615
+ if (
616
+ above !== undefined &&
617
+ here !== undefined &&
618
+ here.index + here.length > above.index
619
+ ) {
620
+ return { ok: false, cleanMerge: false };
621
+ }
622
+ }
623
+
624
+ for (const { op, index, length } of located) {
625
+ if (op.kind === "replace") {
626
+ theirLines.splice(index, length, ...op.lines);
627
+ } else if (op.kind === "delete") {
628
+ theirLines.splice(index, length);
629
+ } else if (op.insertPos === "before") {
630
+ theirLines.splice(index, 0, ...op.lines);
631
+ } else {
632
+ theirLines.splice(index + length, 0, ...op.lines);
633
+ }
634
+ }
635
+
636
+ return { ok: true, text: theirLines.join("\n"), cleanMerge: true };
637
+ }
638
+
639
+ /** The 1-based inclusive base-line range an op is anchored to. */
640
+ function opAnchorRange(
641
+ op: IEditOp,
642
+ lineCount: number
643
+ ): { start: number; end: number } | null {
644
+ if (op.kind === "insert") {
645
+ const anchor = op.insertAnchor ?? 0;
646
+
647
+ return anchor >= 1 && anchor <= lineCount
648
+ ? { start: anchor, end: anchor }
649
+ : null;
650
+ }
651
+
652
+ const start = op.startLine ?? 0;
653
+ const end = op.endLine ?? 0;
654
+
655
+ return start >= 1 && end >= start && end <= lineCount ? { start, end } : null;
656
+ }
657
+
658
+ /** Index of `needle` as a contiguous run in `haystack`, only if unique. */
659
+ function findUniqueRun(
660
+ haystack: readonly string[],
661
+ needle: readonly string[]
662
+ ): number | null {
663
+ if (needle.length === 0) {
664
+ return null;
665
+ }
666
+
667
+ let found = -1;
668
+
669
+ for (let i = 0; i + needle.length <= haystack.length; i++) {
670
+ let matches = true;
671
+
672
+ for (let j = 0; j < needle.length; j++) {
673
+ if (haystack[i + j] !== needle[j]) {
674
+ matches = false;
675
+ break;
676
+ }
677
+ }
678
+
679
+ if (matches) {
680
+ if (found !== -1) {
681
+ return null;
682
+ }
683
+
684
+ found = i;
685
+ }
686
+ }
687
+
688
+ return found === -1 ? null : found;
689
+ }
@@ -0,0 +1,19 @@
1
+ export * from "./files.types";
2
+ export * from "./files.constants";
3
+ export { applyEdit, applyEdits } from "./edit";
4
+ export { applyCreate } from "./create";
5
+ export {
6
+ SessionSnapshotStore,
7
+ parseHashlineEdit,
8
+ applyHashlineEdit,
9
+ type ISnapshot,
10
+ type IEditOp,
11
+ type IHashlineResult,
12
+ } from "./hashline";
13
+ export {
14
+ computeFileHash,
15
+ formatHashHeader,
16
+ parseHashHeader,
17
+ isValidHash,
18
+ normalizeHash,
19
+ } from "./hashline-format";
package/src/index.ts ADDED
@@ -0,0 +1,8 @@
1
+ export * from "./spec";
2
+ export * from "./validate";
3
+ export * from "./agent";
4
+ export * from "./files";
5
+ export * from "./inference";
6
+ export * from "./loop";
7
+ export * from "./render";
8
+ export * from "./eval";
@@ -0,0 +1,6 @@
1
+ export * from "./inference.types";
2
+ export * from "./inference.constants";
3
+ export {
4
+ OpenAICompatibleProvider,
5
+ salvageToolCalls,
6
+ } from "./openai-compatible";