forge-workflow 0.0.8 → 0.0.10

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 (135) hide show
  1. package/.claude/commands/dev.md +2 -2
  2. package/.claude/commands/plan.md +2 -2
  3. package/.claude/commands/premerge.md +2 -2
  4. package/.claude/commands/review.md +5 -2
  5. package/.claude/commands/ship.md +6 -5
  6. package/.claude/commands/status.md +4 -4
  7. package/.claude/rules/greptile-review-process.md +4 -4
  8. package/.cline/workflows/dev.md +2 -2
  9. package/.cline/workflows/plan.md +2 -2
  10. package/.cline/workflows/premerge.md +2 -2
  11. package/.cline/workflows/review.md +5 -2
  12. package/.cline/workflows/ship.md +6 -5
  13. package/.cline/workflows/status.md +4 -4
  14. package/.codex/skills/dev/SKILL.md +2 -2
  15. package/.codex/skills/plan/SKILL.md +2 -2
  16. package/.codex/skills/premerge/SKILL.md +2 -2
  17. package/.codex/skills/review/SKILL.md +5 -2
  18. package/.codex/skills/ship/SKILL.md +6 -5
  19. package/.codex/skills/status/SKILL.md +4 -4
  20. package/.cursor/commands/dev.md +2 -2
  21. package/.cursor/commands/plan.md +2 -2
  22. package/.cursor/commands/premerge.md +2 -2
  23. package/.cursor/commands/review.md +5 -2
  24. package/.cursor/commands/ship.md +6 -5
  25. package/.cursor/commands/status.md +4 -4
  26. package/.github/prompts/dev.prompt.md +2 -2
  27. package/.github/prompts/plan.prompt.md +2 -2
  28. package/.github/prompts/premerge.prompt.md +2 -2
  29. package/.github/prompts/review.prompt.md +5 -2
  30. package/.github/prompts/ship.prompt.md +6 -5
  31. package/.github/prompts/status.prompt.md +4 -4
  32. package/.github/workflows/beads-to-github.yml +44 -11
  33. package/.github/workflows/github-to-beads.yml +11 -8
  34. package/.kilocode/workflows/dev.md +2 -2
  35. package/.kilocode/workflows/plan.md +2 -2
  36. package/.kilocode/workflows/premerge.md +2 -2
  37. package/.kilocode/workflows/review.md +5 -2
  38. package/.kilocode/workflows/ship.md +6 -5
  39. package/.kilocode/workflows/status.md +4 -4
  40. package/.opencode/commands/dev.md +2 -2
  41. package/.opencode/commands/plan.md +2 -2
  42. package/.opencode/commands/premerge.md +2 -2
  43. package/.opencode/commands/review.md +5 -2
  44. package/.opencode/commands/ship.md +6 -5
  45. package/.opencode/commands/status.md +4 -4
  46. package/.roo/commands/dev.md +2 -2
  47. package/.roo/commands/plan.md +2 -2
  48. package/.roo/commands/premerge.md +2 -2
  49. package/.roo/commands/review.md +5 -2
  50. package/.roo/commands/ship.md +6 -5
  51. package/.roo/commands/status.md +4 -4
  52. package/AGENTS.md +10 -9
  53. package/CLAUDE.md +12 -0
  54. package/README.md +12 -6
  55. package/bin/forge.js +24 -8
  56. package/docs/BEADS_GITHUB_SYNC.md +32 -2
  57. package/docs/EXAMPLES.md +22 -22
  58. package/docs/ROADMAP.md +3 -3
  59. package/docs/TOOLCHAIN.md +150 -160
  60. package/lib/agents/codex.plugin.json +3 -0
  61. package/lib/agents-config.js +18 -12
  62. package/lib/beads-bootstrap.js +225 -0
  63. package/lib/beads-health-check.js +55 -10
  64. package/lib/beads-setup.js +104 -28
  65. package/lib/beads-sync-scaffold.js +11 -6
  66. package/lib/codex-skills.js +54 -1
  67. package/lib/commands/_issue.js +11 -1
  68. package/lib/commands/issues.js +49 -0
  69. package/lib/commands/plan.js +5 -2
  70. package/lib/commands/recommend.js +22 -1
  71. package/lib/commands/setup.js +247 -27
  72. package/lib/commands/ship.js +188 -5
  73. package/lib/commands/status.js +187 -19
  74. package/lib/commands/team.js +11 -1
  75. package/lib/commands/test.js +125 -25
  76. package/lib/commands/validate.js +232 -9
  77. package/lib/commands/worktree.js +27 -54
  78. package/lib/dep-guard/keyword-ripple.js +184 -0
  79. package/lib/detect-worktree.js +9 -10
  80. package/lib/forge-issues.js +326 -0
  81. package/lib/issue-sync/authority.js +100 -0
  82. package/lib/issue-sync/github-pull.js +184 -0
  83. package/lib/issue-sync/import-primitives.js +98 -0
  84. package/lib/issue-sync/legacy-link-bridge.js +436 -0
  85. package/lib/issue-sync/link-store.js +292 -0
  86. package/lib/issue-sync/project-github.js +123 -0
  87. package/lib/issue-sync/reconcile.js +195 -0
  88. package/lib/issue-sync/schema.js +126 -0
  89. package/lib/lefthook-check.js +5 -2
  90. package/lib/project-memory.js +564 -0
  91. package/lib/runtime-health.js +100 -12
  92. package/lib/setup-action-log.js +2 -0
  93. package/lib/setup-summary-renderer.js +15 -11
  94. package/lib/smart-status/conflicts.js +205 -0
  95. package/lib/smart-status/scoring.js +177 -0
  96. package/lib/status/beads-snapshot.js +102 -0
  97. package/lib/status/presenter.js +65 -0
  98. package/lib/workflow/enforce-stage.js +14 -8
  99. package/lib/workflow/state-manager.js +349 -0
  100. package/package.json +12 -4
  101. package/scripts/beads-context.sh +124 -5
  102. package/scripts/beads-context.test.js +21 -4
  103. package/scripts/beads-migrate-to-dolt.sh +7 -0
  104. package/scripts/beads-upgrade-smoke.sh +263 -0
  105. package/scripts/behavioral-judge.sh +115 -11
  106. package/scripts/benchmark.js +349 -63
  107. package/scripts/dep-guard-analyze.js +52 -17
  108. package/scripts/dep-guard-keyword-ripple.js +29 -0
  109. package/scripts/dep-guard-render-review.js +86 -0
  110. package/scripts/dep-guard.sh +45 -222
  111. package/scripts/forge-team/lib/hooks.sh +1 -1
  112. package/scripts/forge-team/lib/sync-github.sh +160 -28
  113. package/scripts/forge-team/lib/verify.sh +1 -1
  114. package/scripts/forge-team/lib/workload.sh +56 -27
  115. package/scripts/forge-team/tests/sync-github.test.sh +195 -58
  116. package/scripts/forge-team/tests/workload.test.sh +35 -4
  117. package/scripts/github-beads-sync/index.mjs +122 -98
  118. package/scripts/github-beads-sync/mapping.mjs +54 -0
  119. package/scripts/github-beads-sync/reverse-sync-cli.mjs +2 -2
  120. package/scripts/github-beads-sync/reverse-sync.mjs +31 -7
  121. package/scripts/github-beads-sync/run-bd.mjs +4 -2
  122. package/scripts/lib/beads-migrate-to-dolt.mjs +503 -0
  123. package/scripts/preflight.sh +181 -0
  124. package/scripts/smart-status-score.js +31 -0
  125. package/scripts/smart-status-sessions.js +51 -0
  126. package/scripts/smart-status.sh +84 -330
  127. package/scripts/sync-agentic-workflow.js +48 -0
  128. package/scripts/sync-utils.sh +39 -0
  129. package/scripts/test-ci-shard.js +244 -0
  130. package/scripts/test-dashboard.js +188 -52
  131. package/scripts/test-full-suite.js +186 -0
  132. package/scripts/test-profile.js +278 -0
  133. package/scripts/test.js +335 -38
  134. package/scripts/validate.js +143 -0
  135. package/scripts/validate.sh +18 -1
@@ -0,0 +1,126 @@
1
+ function createDefaultGitHubSection() {
2
+ return {
3
+ number: null,
4
+ nodeId: null,
5
+ url: null,
6
+ };
7
+ }
8
+
9
+ function createDefaultSharedSection() {
10
+ return {
11
+ title: '',
12
+ body: '',
13
+ state: 'open',
14
+ assignees: [],
15
+ labels: [],
16
+ milestone: null,
17
+ };
18
+ }
19
+
20
+ function createDefaultForgeSection() {
21
+ return {
22
+ issueId: null,
23
+ dependencies: [],
24
+ parentId: null,
25
+ childIds: [],
26
+ workflowStage: null,
27
+ acceptanceCriteria: [],
28
+ progressNotes: [],
29
+ stageTransitions: [],
30
+ decisions: [],
31
+ memory: [],
32
+ };
33
+ }
34
+
35
+ function createDefaultCacheSection() {
36
+ return {
37
+ githubSnapshot: null,
38
+ materializedIssue: null,
39
+ legacyLinkHints: {
40
+ mapping: null,
41
+ githubIssue: null,
42
+ syncComments: [],
43
+ externalRef: null,
44
+ descriptionUrl: null,
45
+ },
46
+ };
47
+ }
48
+
49
+ function createDefaultSyncSection() {
50
+ return {
51
+ remoteUpdatedAt: null,
52
+ lastPulledAt: null,
53
+ lastPushedAt: null,
54
+ pendingOutbound: [],
55
+ drift: [],
56
+ };
57
+ }
58
+
59
+ function createDefaultSharedIssueRecord() {
60
+ return {
61
+ github: createDefaultGitHubSection(),
62
+ shared: createDefaultSharedSection(),
63
+ forge: createDefaultForgeSection(),
64
+ cache: createDefaultCacheSection(),
65
+ sync: createDefaultSyncSection(),
66
+ };
67
+ }
68
+
69
+ function isPlainObject(value) {
70
+ return Boolean(value) && typeof value === 'object' && !Array.isArray(value);
71
+ }
72
+
73
+ function cloneValue(value) {
74
+ if (Array.isArray(value)) {
75
+ return value.map(cloneValue);
76
+ }
77
+
78
+ if (isPlainObject(value)) {
79
+ const result = {};
80
+
81
+ for (const [key, nestedValue] of Object.entries(value)) {
82
+ result[key] = cloneValue(nestedValue);
83
+ }
84
+
85
+ return result;
86
+ }
87
+
88
+ return value;
89
+ }
90
+
91
+ function mergeRecordSections(baseValue, overrideValue) {
92
+ if (overrideValue === undefined) {
93
+ return cloneValue(baseValue);
94
+ }
95
+
96
+ if (Array.isArray(overrideValue)) {
97
+ return overrideValue.map(cloneValue);
98
+ }
99
+
100
+ if (!isPlainObject(baseValue) || !isPlainObject(overrideValue)) {
101
+ return cloneValue(overrideValue);
102
+ }
103
+
104
+ const merged = {};
105
+ const keys = new Set([...Object.keys(baseValue), ...Object.keys(overrideValue)]);
106
+
107
+ for (const key of keys) {
108
+ merged[key] = mergeRecordSections(baseValue[key], overrideValue[key]);
109
+ }
110
+
111
+ return merged;
112
+ }
113
+
114
+ function buildSharedIssueRecord(overrides = {}) {
115
+ return mergeRecordSections(createDefaultSharedIssueRecord(), overrides);
116
+ }
117
+
118
+ module.exports = {
119
+ buildSharedIssueRecord,
120
+ createDefaultCacheSection,
121
+ createDefaultForgeSection,
122
+ createDefaultGitHubSection,
123
+ createDefaultSharedIssueRecord,
124
+ createDefaultSharedSection,
125
+ createDefaultSyncSection,
126
+ };
@@ -59,11 +59,14 @@ function checkLefthookStatus(projectRoot) {
59
59
  };
60
60
  }
61
61
 
62
- // Check for the binary in node_modules/.bin
62
+ // Check for the binary in node_modules/.bin. Bun on Windows creates
63
+ // lefthook.exe and lefthook.bunx instead of the npm-style .cmd shim.
63
64
  const binDir = path.join(root, 'node_modules', '.bin');
64
65
  const binaryAvailable =
65
66
  fs.existsSync(path.join(binDir, 'lefthook')) ||
66
- fs.existsSync(path.join(binDir, 'lefthook.cmd'));
67
+ fs.existsSync(path.join(binDir, 'lefthook.cmd')) ||
68
+ fs.existsSync(path.join(binDir, 'lefthook.exe')) ||
69
+ fs.existsSync(path.join(binDir, 'lefthook.bunx'));
67
70
 
68
71
  if (!binaryAvailable) {
69
72
  return {
@@ -0,0 +1,564 @@
1
+ const fs = require('node:fs');
2
+ const path = require('node:path');
3
+ const crypto = require('node:crypto');
4
+
5
+ const DEFAULT_MEMORY_FILE = path.join('.forge', 'memory', 'entries.jsonl');
6
+ const DEFAULT_LOCK_TIMEOUT_MS = 5000;
7
+ const DEFAULT_LOCK_RETRY_MS = 10;
8
+ const DEFAULT_DEAD_LOCK_GRACE_MS = 1000;
9
+ const INVALID_LOCK_INITIALIZATION_BUFFER_MS = 1000;
10
+
11
+ function assertInsideProjectRoot(root, targetPath) {
12
+ const relative = path.relative(root, targetPath);
13
+ if (relative === '' || relative.startsWith('..') || path.isAbsolute(relative)) {
14
+ throw new Error('project memory filePath must stay within projectRoot');
15
+ }
16
+ }
17
+
18
+ function nearestExistingPath(targetPath) {
19
+ let current = targetPath;
20
+ while (!fs.existsSync(current)) {
21
+ const next = path.dirname(current);
22
+ if (next === current) break;
23
+ current = next;
24
+ }
25
+
26
+ return current;
27
+ }
28
+
29
+ function assertRealPathInsideProjectRoot(root, targetPath) {
30
+ const realRoot = fs.realpathSync.native(root);
31
+ const existingPath = fs.existsSync(targetPath)
32
+ ? targetPath
33
+ : nearestExistingPath(path.dirname(targetPath));
34
+ const realExistingPath = fs.realpathSync.native(existingPath);
35
+
36
+ if (realExistingPath === realRoot) {
37
+ return;
38
+ }
39
+
40
+ assertInsideProjectRoot(realRoot, realExistingPath);
41
+ }
42
+
43
+ function memoryPath(projectRoot, options = {}) {
44
+ const root = path.resolve(projectRoot);
45
+ if (!options.filePath) {
46
+ const defaultPath = path.join(root, DEFAULT_MEMORY_FILE);
47
+ assertRealPathInsideProjectRoot(root, defaultPath);
48
+ return defaultPath;
49
+ }
50
+
51
+ const candidate = path.isAbsolute(options.filePath)
52
+ ? options.filePath
53
+ : path.join(root, options.filePath);
54
+ const resolved = path.resolve(candidate);
55
+ assertInsideProjectRoot(root, resolved);
56
+ assertRealPathInsideProjectRoot(root, resolved);
57
+
58
+ return resolved;
59
+ }
60
+
61
+ function sleep(ms) {
62
+ Atomics.wait(new Int32Array(new SharedArrayBuffer(4)), 0, 0, ms);
63
+ }
64
+
65
+ function isProcessAlive(pid) {
66
+ if (!Number.isInteger(pid) || pid <= 0) return false;
67
+
68
+ try {
69
+ process.kill(pid, 0);
70
+ return true;
71
+ } catch (err) {
72
+ return err.code === 'EPERM';
73
+ }
74
+ }
75
+
76
+ function readLock(lockPath) {
77
+ try {
78
+ const lockStat = fs.statSync(lockPath);
79
+ const metadataPath = lockStat.isDirectory()
80
+ ? path.join(lockPath, 'owner.json')
81
+ : lockPath;
82
+ return JSON.parse(fs.readFileSync(metadataPath, 'utf8'));
83
+ } catch (err) {
84
+ return {
85
+ pid: null,
86
+ createdAt: null,
87
+ invalid: true,
88
+ readError: err.message,
89
+ };
90
+ }
91
+ }
92
+
93
+ function isLockDirectory(lockPath) {
94
+ try {
95
+ return fs.statSync(lockPath).isDirectory();
96
+ } catch (err) {
97
+ if (err.code !== 'ENOENT') {
98
+ throw err;
99
+ }
100
+ return false;
101
+ }
102
+ }
103
+
104
+ function lockPathAgeMs(lockPath) {
105
+ try {
106
+ return Date.now() - fs.statSync(lockPath).mtimeMs;
107
+ } catch (err) {
108
+ if (err.code !== 'ENOENT') {
109
+ throw err;
110
+ }
111
+ return null;
112
+ }
113
+ }
114
+
115
+ function lockAgeMs(lock) {
116
+ const createdAt = Date.parse(lock?.createdAt);
117
+ if (Number.isNaN(createdAt)) return null;
118
+ return Date.now() - createdAt;
119
+ }
120
+
121
+ function isWithinGraceWindow(ageMs, graceMs) {
122
+ return ageMs !== null && Math.abs(ageMs) < graceMs;
123
+ }
124
+
125
+ function debugSuppressedLockError(message, err) {
126
+ if (process.env.FORGE_DEBUG_LOCKS) {
127
+ console.debug(`${message}: ${err.message}`);
128
+ }
129
+ }
130
+
131
+ function shouldKeepInvalidLock(lockPath, lock, options) {
132
+ if (!lock.invalid) {
133
+ return false;
134
+ }
135
+
136
+ const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
137
+ const lockRetryMs = options.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
138
+ const invalidLockGraceMs = lockTimeoutMs + Math.max(lockRetryMs, INVALID_LOCK_INITIALIZATION_BUFFER_MS);
139
+ const lockPathAge = lockPathAgeMs(lockPath);
140
+ return isWithinGraceWindow(lockPathAge, invalidLockGraceMs);
141
+ }
142
+
143
+ function shouldKeepDeadOwnerLock(lock, options) {
144
+ if (lock.invalid) {
145
+ return false;
146
+ }
147
+
148
+ const configuredGraceMs = options.deadLockGraceMs ?? DEFAULT_DEAD_LOCK_GRACE_MS;
149
+ const lockTimeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
150
+ const deadLockGraceMs = lockTimeoutMs <= configuredGraceMs ? 0 : configuredGraceMs;
151
+ const age = lockAgeMs(lock);
152
+ const tokenizedLock = typeof lock?.token === 'string' && lock.token !== '';
153
+ const tokenRecoveryGraceMs = Math.max(deadLockGraceMs, Math.floor(lockTimeoutMs * 0.9));
154
+
155
+ if (tokenizedLock && age !== null && age >= 0 && age < tokenRecoveryGraceMs) {
156
+ return true;
157
+ }
158
+
159
+ return age !== null && age >= 0 && age < deadLockGraceMs;
160
+ }
161
+
162
+ function removeLockPath(lockPath) {
163
+ try {
164
+ fs.rmSync(lockPath, { recursive: true, force: true });
165
+ return true;
166
+ } catch (err) {
167
+ debugSuppressedLockError(`project memory lock cleanup skipped for ${lockPath}`, err);
168
+ return false;
169
+ }
170
+ }
171
+
172
+ function removeStaleLock(lockPath, options = {}) {
173
+ const lock = readLock(lockPath);
174
+ if (shouldKeepInvalidLock(lockPath, lock, options)) {
175
+ return false;
176
+ }
177
+
178
+ if (isProcessAlive(lock?.pid)) {
179
+ return false;
180
+ }
181
+
182
+ if (shouldKeepDeadOwnerLock(lock, options)) {
183
+ return false;
184
+ }
185
+
186
+ return removeLockPath(lockPath);
187
+ }
188
+
189
+ function isExistingLockError(err, lockPath) {
190
+ if (err.code === 'EEXIST' || err.code === 'EISDIR') {
191
+ return true;
192
+ }
193
+
194
+ return err.code === 'EPERM' && isLockDirectory(lockPath);
195
+ }
196
+
197
+ function cleanupCreatedLockFile(lockPath) {
198
+ try {
199
+ fs.unlinkSync(lockPath);
200
+ } catch (err) {
201
+ if (err.code !== 'ENOENT') {
202
+ debugSuppressedLockError(`project memory lock init cleanup skipped for ${lockPath}`, err);
203
+ }
204
+ }
205
+ }
206
+
207
+ function closeLockFd(lockFd, lockPath) {
208
+ try {
209
+ fs.closeSync(lockFd);
210
+ } catch (err) {
211
+ debugSuppressedLockError(`project memory lock fd close failed for ${lockPath}`, err);
212
+ throw err;
213
+ }
214
+ }
215
+
216
+ function closeLockFdAfterFailure(lockFd, lockPath) {
217
+ try {
218
+ closeLockFd(lockFd, lockPath);
219
+ } catch (err) {
220
+ debugSuppressedLockError(`project memory lock fd close skipped after failure for ${lockPath}`, err);
221
+ }
222
+ }
223
+
224
+ function writeLockMetadata(lockFd, token) {
225
+ fs.writeFileSync(lockFd, JSON.stringify({
226
+ pid: process.pid,
227
+ createdAt: new Date().toISOString(),
228
+ token,
229
+ }), 'utf8');
230
+ fs.fsyncSync(lockFd);
231
+ }
232
+
233
+ function initializeLockFile(lockPath, token) {
234
+ const lockFd = fs.openSync(lockPath, 'wx');
235
+ try {
236
+ writeLockMetadata(lockFd, token);
237
+ closeLockFd(lockFd, lockPath);
238
+ } catch (err) {
239
+ closeLockFdAfterFailure(lockFd, lockPath);
240
+ cleanupCreatedLockFile(lockPath);
241
+ throw err;
242
+ }
243
+ }
244
+
245
+ function releaseLockFile(lockPath, token) {
246
+ const lock = readLock(lockPath);
247
+ if (!lock.invalid && lock.pid === process.pid && lock.token === token) {
248
+ fs.rmSync(lockPath, { force: true });
249
+ }
250
+ }
251
+
252
+ function acquireLock(filePath, options = {}) {
253
+ const lockPath = `${filePath}.lock`;
254
+ const timeoutMs = options.lockTimeoutMs ?? DEFAULT_LOCK_TIMEOUT_MS;
255
+ const retryMs = options.lockRetryMs ?? DEFAULT_LOCK_RETRY_MS;
256
+ const startedAt = Date.now();
257
+ const token = `${process.pid}-${Date.now()}-${crypto.randomUUID()}`;
258
+
259
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
260
+
261
+ while (true) {
262
+ try {
263
+ initializeLockFile(lockPath, token);
264
+ return () => releaseLockFile(lockPath, token);
265
+ } catch (err) {
266
+ if (!isExistingLockError(err, lockPath)) {
267
+ throw err;
268
+ }
269
+ if (removeStaleLock(lockPath, options)) {
270
+ continue;
271
+ }
272
+ if (Date.now() - startedAt >= timeoutMs) {
273
+ throw err;
274
+ }
275
+ sleep(retryMs);
276
+ }
277
+ }
278
+ }
279
+
280
+ function toApiEntry(entry) {
281
+ const apiEntry = {
282
+ key: entry.key,
283
+ value: entry.value,
284
+ sourceAgent: entry['source-agent'],
285
+ timestamp: entry.timestamp,
286
+ tags: Array.isArray(entry.tags) ? [...entry.tags] : [],
287
+ };
288
+
289
+ if (entry.scope !== undefined) apiEntry.scope = entry.scope;
290
+ if (entry.confidence !== undefined) apiEntry.confidence = entry.confidence;
291
+ if (entry.supersedes !== undefined) apiEntry.supersedes = [...entry.supersedes];
292
+ if (entry['beads-refs'] !== undefined) apiEntry.beadsRefs = [...entry['beads-refs']];
293
+
294
+ return apiEntry;
295
+ }
296
+
297
+ function toDiskEntry(entry) {
298
+ const diskEntry = {
299
+ key: entry.key,
300
+ value: entry.value,
301
+ 'source-agent': entry.sourceAgent || entry['source-agent'],
302
+ timestamp: entry.timestamp || new Date().toISOString(),
303
+ tags: Array.isArray(entry.tags) ? [...entry.tags] : [],
304
+ };
305
+
306
+ if (entry.scope !== undefined) diskEntry.scope = entry.scope;
307
+ if (entry.confidence !== undefined) diskEntry.confidence = entry.confidence;
308
+ if (entry.supersedes !== undefined) diskEntry.supersedes = [...entry.supersedes];
309
+ if (entry.beadsRefs !== undefined || entry['beads-refs'] !== undefined) {
310
+ diskEntry['beads-refs'] = [...(entry.beadsRefs || entry['beads-refs'])];
311
+ }
312
+
313
+ return diskEntry;
314
+ }
315
+
316
+ function assertEntryObject(entry) {
317
+ if (!entry || typeof entry !== 'object') {
318
+ throw new TypeError('project memory entry must be an object');
319
+ }
320
+ }
321
+
322
+ function assertRequiredString(value, fieldName) {
323
+ if (typeof value !== 'string' || value.trim() === '') {
324
+ throw new TypeError(`project memory entry ${fieldName} is required`);
325
+ }
326
+ }
327
+
328
+ function assertStringArray(value, fieldName) {
329
+ if (!Array.isArray(value) || value.some((item) => typeof item !== 'string')) {
330
+ throw new TypeError(`project memory entry ${fieldName} must be an array of strings`);
331
+ }
332
+ }
333
+
334
+ function validateRequiredFields(entry) {
335
+ if (typeof entry.key !== 'string' || entry.key.trim() === '') {
336
+ throw new TypeError('project memory entry key is required');
337
+ }
338
+ if (!Object.hasOwn(entry, 'value') || entry.value === undefined) {
339
+ throw new TypeError('project memory entry value is required');
340
+ }
341
+
342
+ const sourceAgent = entry.sourceAgent || entry['source-agent'];
343
+ assertRequiredString(sourceAgent, 'sourceAgent');
344
+ }
345
+
346
+ function validateTimestamp(entry) {
347
+ if (entry.timestamp !== undefined) {
348
+ const parsed = Date.parse(entry.timestamp);
349
+ if (typeof entry.timestamp !== 'string' || Number.isNaN(parsed)) {
350
+ throw new TypeError('project memory entry timestamp must be an ISO-compatible string');
351
+ }
352
+ }
353
+ }
354
+
355
+ function validateScope(entry) {
356
+ if (entry.scope !== undefined && (typeof entry.scope !== 'string' || entry.scope.trim() === '')) {
357
+ throw new TypeError('project memory entry scope must be a non-empty string');
358
+ }
359
+ }
360
+
361
+ function validateConfidence(entry) {
362
+ if (entry.confidence !== undefined) {
363
+ if (typeof entry.confidence !== 'number' || entry.confidence < 0 || entry.confidence > 1) {
364
+ throw new TypeError('project memory entry confidence must be a number from 0 to 1');
365
+ }
366
+ }
367
+ }
368
+
369
+ function validateOptionalArrays(entry) {
370
+ if (entry.tags !== undefined) assertStringArray(entry.tags, 'tags');
371
+ if (entry.supersedes !== undefined) assertStringArray(entry.supersedes, 'supersedes');
372
+ const beadsRefs = entry.beadsRefs || entry['beads-refs'];
373
+ if (beadsRefs !== undefined) assertStringArray(beadsRefs, 'beadsRefs');
374
+ }
375
+
376
+ function validateEntry(entry) {
377
+ assertEntryObject(entry);
378
+ validateRequiredFields(entry);
379
+ validateTimestamp(entry);
380
+ validateScope(entry);
381
+ validateConfidence(entry);
382
+ validateOptionalArrays(entry);
383
+ }
384
+
385
+ function validateStoredEntry(entry) {
386
+ validateEntry(entry);
387
+ assertRequiredString(entry['source-agent'], 'source-agent');
388
+ assertStringArray(entry.tags, 'tags');
389
+ }
390
+
391
+ function readEntries(projectRoot, options = {}) {
392
+ const filePath = memoryPath(projectRoot, options);
393
+ if (!fs.existsSync(filePath)) {
394
+ return [];
395
+ }
396
+
397
+ const content = fs.readFileSync(filePath, 'utf8');
398
+ if (content.trim() === '') return [];
399
+
400
+ const hasTerminatingNewline = /\r?\n$/.test(content);
401
+ const lines = content.split(/\r?\n/);
402
+ if (hasTerminatingNewline && lines.at(-1) === '') {
403
+ lines.pop();
404
+ }
405
+
406
+ const entries = [];
407
+ lines.forEach((line, index) => {
408
+ const trailingIncompleteLine = !hasTerminatingNewline && index === lines.length - 1;
409
+ if (trailingIncompleteLine) return;
410
+
411
+ let parsed;
412
+ try {
413
+ parsed = JSON.parse(line);
414
+ } catch (err) {
415
+ if (trailingIncompleteLine) return;
416
+ throw new Error(`invalid project memory JSONL at line ${index + 1}: ${err.message}`);
417
+ }
418
+ try {
419
+ validateStoredEntry(parsed);
420
+ } catch (err) {
421
+ if (trailingIncompleteLine) return;
422
+ throw new Error(`invalid project memory entry at line ${index + 1}: ${err.message}`);
423
+ }
424
+ entries.push(parsed);
425
+ });
426
+
427
+ return dedupeEntries(entries);
428
+ }
429
+
430
+ function dedupeEntries(entries) {
431
+ const positions = new Map();
432
+ const deduped = [];
433
+
434
+ for (const entry of entries) {
435
+ const existingIndex = positions.get(entry.key);
436
+ if (existingIndex === undefined) {
437
+ positions.set(entry.key, deduped.length);
438
+ deduped.push(entry);
439
+ } else {
440
+ deduped[existingIndex] = entry;
441
+ }
442
+ }
443
+
444
+ return deduped;
445
+ }
446
+
447
+ function writeAllSync(fd, buffer) {
448
+ let offset = 0;
449
+ while (offset < buffer.length) {
450
+ offset += fs.writeSync(fd, buffer, offset, buffer.length - offset);
451
+ }
452
+ }
453
+
454
+ function normalizeJsonlTail(filePath) {
455
+ if (!fs.existsSync(filePath)) {
456
+ return false;
457
+ }
458
+
459
+ const content = fs.readFileSync(filePath, 'utf8');
460
+ if (content === '' || /\r?\n$/.test(content)) {
461
+ return false;
462
+ }
463
+
464
+ const lastLineStart = Math.max(content.lastIndexOf('\n'), content.lastIndexOf('\r')) + 1;
465
+ fs.truncateSync(filePath, Buffer.byteLength(content.slice(0, lastLineStart), 'utf8'));
466
+ return false;
467
+ }
468
+
469
+ function appendEntry(projectRoot, entry, options = {}) {
470
+ const filePath = memoryPath(projectRoot, options);
471
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
472
+
473
+ const needsLineBreak = normalizeJsonlTail(filePath);
474
+ const record = Buffer.from(`${needsLineBreak ? '\n' : ''}${JSON.stringify(entry)}\n`, 'utf8');
475
+ const rollbackSize = fs.existsSync(filePath) ? fs.statSync(filePath).size : 0;
476
+ const fd = fs.openSync(filePath, 'a');
477
+ let fdClosed = false;
478
+ try {
479
+ writeAllSync(fd, record);
480
+ fs.fsyncSync(fd);
481
+ } catch (err) {
482
+ try {
483
+ fs.closeSync(fd);
484
+ fdClosed = true;
485
+ } catch (_rollbackCloseErr) {
486
+ // Preserve the original append/fsync error.
487
+ }
488
+ try {
489
+ fs.truncateSync(filePath, rollbackSize);
490
+ } catch (_rollbackErr) {
491
+ // Preserve the original append/fsync error.
492
+ }
493
+ throw err;
494
+ } finally {
495
+ if (!fdClosed) {
496
+ fs.closeSync(fd);
497
+ }
498
+ }
499
+ }
500
+
501
+ function list(projectRoot, options = {}) {
502
+ return readEntries(projectRoot, options).map(toApiEntry);
503
+ }
504
+
505
+ function read(projectRoot, key, options = {}) {
506
+ if (typeof key !== 'string' || key.trim() === '') {
507
+ throw new Error('project memory read key is required');
508
+ }
509
+ const normalizedKey = key.trim();
510
+
511
+ const entry = readEntries(projectRoot, options).find((candidate) => candidate.key === normalizedKey);
512
+ return entry ? toApiEntry(entry) : null;
513
+ }
514
+
515
+ function write(projectRoot, entry, options = {}) {
516
+ validateEntry(entry);
517
+ const filePath = memoryPath(projectRoot, options);
518
+ const releaseLock = acquireLock(filePath, options);
519
+
520
+ try {
521
+ const normalized = toDiskEntry({
522
+ ...entry,
523
+ key: entry.key.trim(),
524
+ sourceAgent: (entry.sourceAgent || entry['source-agent']).trim(),
525
+ tags: entry.tags || [],
526
+ });
527
+
528
+ appendEntry(projectRoot, normalized, options);
529
+ return toApiEntry(normalized);
530
+ } finally {
531
+ releaseLock();
532
+ }
533
+ }
534
+
535
+ function searchableText(entry) {
536
+ return [
537
+ entry.key,
538
+ JSON.stringify(entry.value),
539
+ entry['source-agent'],
540
+ entry.scope,
541
+ String(entry.confidence ?? ''),
542
+ ...(Array.isArray(entry.tags) ? entry.tags : []),
543
+ ...(Array.isArray(entry.supersedes) ? entry.supersedes : []),
544
+ ...(Array.isArray(entry['beads-refs']) ? entry['beads-refs'] : []),
545
+ ].join('\n').toLowerCase();
546
+ }
547
+
548
+ function search(projectRoot, query, options = {}) {
549
+ if (typeof query !== 'string' || query.trim() === '') {
550
+ return [];
551
+ }
552
+
553
+ const needle = query.trim().toLowerCase();
554
+ return readEntries(projectRoot, options)
555
+ .filter((entry) => searchableText(entry).includes(needle))
556
+ .map(toApiEntry);
557
+ }
558
+
559
+ module.exports = {
560
+ read,
561
+ write,
562
+ search,
563
+ list,
564
+ };