clearctx 3.0.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.
@@ -0,0 +1,525 @@
1
+ // contract-store.js
2
+ // Layer 2: Contract lifecycle management with timeout and retry support
3
+ //
4
+ // A Contract is a formal agreement between sessions specifying:
5
+ // - What work needs to be done (title, description, inputs)
6
+ // - Who does the work (assignee)
7
+ // - What outputs are expected (expectedOutputs)
8
+ // - What must be satisfied first (dependencies)
9
+ // - How we know it's done (acceptanceCriteria)
10
+ // - Retry and timeout policies
11
+
12
+ const fs = require('fs');
13
+ const path = require('path');
14
+ const os = require('os');
15
+ const { atomicWriteJson, readJsonSafe } = require('./atomic-io');
16
+ const { acquireLock, releaseLock } = require('./file-lock');
17
+
18
+ /**
19
+ * ContractStore manages the lifecycle of contracts in a team
20
+ *
21
+ * A contract represents a unit of work with formal inputs, outputs,
22
+ * dependencies, and acceptance criteria. Contracts follow a strict
23
+ * lifecycle: pending → ready → in_progress → completed/failed
24
+ *
25
+ * Features:
26
+ * - Dependency tracking with cycle detection
27
+ * - Automatic status transitions when dependencies are satisfied
28
+ * - Retry support for failed/completed contracts
29
+ * - Timeout configuration
30
+ * - Priority levels
31
+ *
32
+ * @class
33
+ */
34
+ class ContractStore {
35
+ /**
36
+ * Creates a new ContractStore for a team
37
+ *
38
+ * @param {string} [teamName='default'] - The team name (used for directory isolation)
39
+ */
40
+ constructor(teamName = 'default') {
41
+ // Set up the base directory where all multi-session data lives
42
+ const baseDir = path.join(os.homedir(), '.clearctx');
43
+
44
+ // Each team gets its own directory to keep contracts separate
45
+ this.teamDir = path.join(baseDir, 'team', teamName);
46
+
47
+ // The contracts directory holds the index.json file
48
+ this.contractsDir = path.join(this.teamDir, 'contracts');
49
+
50
+ // Path to the main index file that stores all contracts
51
+ this.indexPath = path.join(this.contractsDir, 'index.json');
52
+
53
+ // Directory for lock files
54
+ this.locksDir = path.join(baseDir, 'locks');
55
+
56
+ // Create the directories if they don't exist yet
57
+ if (!fs.existsSync(this.contractsDir)) {
58
+ fs.mkdirSync(this.contractsDir, { recursive: true });
59
+ }
60
+ if (!fs.existsSync(this.locksDir)) {
61
+ fs.mkdirSync(this.locksDir, { recursive: true });
62
+ }
63
+ }
64
+
65
+ /**
66
+ * Creates a new contract
67
+ *
68
+ * This method:
69
+ * 1. Validates the contractId is unique
70
+ * 2. Checks for circular dependencies (cycles)
71
+ * 3. Determines initial status (pending if has dependencies, ready if not)
72
+ * 4. Saves the contract to the index
73
+ *
74
+ * @param {string} contractId - Unique identifier for this contract
75
+ * @param {Object} options - Contract configuration
76
+ * @param {string} options.title - Human-readable title
77
+ * @param {string} [options.description=''] - Longer description of the work
78
+ * @param {string} options.assignee - Session name who will do the work
79
+ * @param {string} options.assigner - Session name who created the contract
80
+ * @param {Object} [options.inputs={}] - Input artifacts and context
81
+ * @param {Array} [options.inputs.artifacts=[]] - Artifact IDs to read as inputs
82
+ * @param {string} [options.inputs.context=''] - Free-text instructions
83
+ * @param {Object} [options.inputs.parameters={}] - Key-value parameters
84
+ * @param {Array} [options.expectedOutputs=[]] - What the assignee should produce
85
+ * @param {Array} [options.dependencies=[]] - What must be satisfied before this can start
86
+ * @param {Array} [options.acceptanceCriteria=[]] - How we know the work is done
87
+ * @param {boolean} [options.autoComplete=true] - Auto-complete when criteria met
88
+ * @param {number} [options.timeoutMs=null] - Optional timeout in milliseconds
89
+ * @param {number} [options.maxRetries=3] - Maximum number of reopen attempts
90
+ * @param {string} [options.priority='normal'] - Priority level (low|normal|high|urgent)
91
+ * @returns {Object} The created contract object
92
+ * @throws {Error} If contractId already exists or circular dependency detected
93
+ */
94
+ create(contractId, {
95
+ title,
96
+ description = '',
97
+ assignee,
98
+ assigner,
99
+ inputs = {},
100
+ expectedOutputs = [],
101
+ dependencies = [],
102
+ acceptanceCriteria = [],
103
+ autoComplete = true,
104
+ timeoutMs = null,
105
+ maxRetries = 3,
106
+ priority = 'normal'
107
+ }) {
108
+ // Acquire exclusive lock on the contracts index
109
+ // This prevents race conditions when multiple sessions create contracts
110
+ acquireLock(this.locksDir, 'contracts-index');
111
+
112
+ try {
113
+ // Read the current index (or get an empty object if file doesn't exist)
114
+ const index = readJsonSafe(this.indexPath, {});
115
+
116
+ // Check if this contractId is already taken
117
+ if (index[contractId]) {
118
+ throw new Error(`Contract with ID "${contractId}" already exists`);
119
+ }
120
+
121
+ // Detect circular dependencies before creating the contract
122
+ // A circular dependency would create a deadlock where contracts wait on each other
123
+ if (this._detectCycle(contractId, dependencies, index)) {
124
+ throw new Error(`Circular dependency detected for contract "${contractId}"`);
125
+ }
126
+
127
+ // Normalize the inputs object with defaults
128
+ const normalizedInputs = {
129
+ artifacts: inputs.artifacts || [],
130
+ context: inputs.context || '',
131
+ parameters: inputs.parameters || {}
132
+ };
133
+
134
+ // Build the complete contract object
135
+ const now = new Date().toISOString();
136
+ const contract = {
137
+ contractId,
138
+ title,
139
+ description,
140
+ assignee,
141
+ assigner,
142
+ // Status starts as "pending" if there are dependencies, "ready" if not
143
+ status: dependencies.length > 0 ? 'pending' : 'ready',
144
+ priority,
145
+ createdAt: now,
146
+ updatedAt: now,
147
+ startedAt: null,
148
+ completedAt: null,
149
+ inputs: normalizedInputs,
150
+ expectedOutputs,
151
+ dependencies,
152
+ acceptanceCriteria,
153
+ autoComplete,
154
+ timeoutMs,
155
+ maxRetries,
156
+ retryCount: 0,
157
+ result: null
158
+ };
159
+
160
+ // Add the contract to the index
161
+ index[contractId] = contract;
162
+
163
+ // Save atomically (prevents corruption from partial writes)
164
+ atomicWriteJson(this.indexPath, index);
165
+
166
+ // Return a copy of the contract
167
+ return { ...contract };
168
+ } finally {
169
+ // Always release the lock, even if an error occurred
170
+ releaseLock(this.locksDir, 'contracts-index');
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Starts a contract (transitions from ready → in_progress)
176
+ *
177
+ * This is called by the assignee when they begin work.
178
+ * The contract must be in "ready" status.
179
+ *
180
+ * @param {string} contractId - The contract to start
181
+ * @returns {Object} The updated contract (including inputs for the assignee)
182
+ * @throws {Error} If contract doesn't exist or is not in "ready" status
183
+ */
184
+ start(contractId) {
185
+ acquireLock(this.locksDir, 'contracts-index');
186
+
187
+ try {
188
+ const index = readJsonSafe(this.indexPath, {});
189
+ const contract = index[contractId];
190
+
191
+ // Verify the contract exists
192
+ if (!contract) {
193
+ throw new Error(`Contract "${contractId}" not found`);
194
+ }
195
+
196
+ // Verify the contract is ready to start
197
+ if (contract.status !== 'ready') {
198
+ throw new Error(`Contract "${contractId}" is not ready (current status: ${contract.status})`);
199
+ }
200
+
201
+ // Transition to in_progress and record when we started
202
+ contract.status = 'in_progress';
203
+ contract.startedAt = new Date().toISOString();
204
+ contract.updatedAt = new Date().toISOString();
205
+
206
+ // Save the updated contract
207
+ atomicWriteJson(this.indexPath, index);
208
+
209
+ return { ...contract };
210
+ } finally {
211
+ releaseLock(this.locksDir, 'contracts-index');
212
+ }
213
+ }
214
+
215
+ /**
216
+ * Completes a contract (transitions from in_progress → completed)
217
+ *
218
+ * This is called when the work is done successfully.
219
+ *
220
+ * @param {string} contractId - The contract to complete
221
+ * @param {Object} [options={}] - Completion details
222
+ * @param {string} [options.summary=''] - Summary of what was done
223
+ * @param {Array} [options.publishedArtifacts=[]] - Artifact IDs that were published
224
+ * @returns {Object} The updated contract
225
+ * @throws {Error} If contract doesn't exist or is not in "in_progress" status
226
+ */
227
+ complete(contractId, { summary = '', publishedArtifacts = [] } = {}) {
228
+ acquireLock(this.locksDir, 'contracts-index');
229
+
230
+ try {
231
+ const index = readJsonSafe(this.indexPath, {});
232
+ const contract = index[contractId];
233
+
234
+ if (!contract) {
235
+ throw new Error(`Contract "${contractId}" not found`);
236
+ }
237
+
238
+ if (contract.status !== 'in_progress') {
239
+ throw new Error(`Contract "${contractId}" is not in progress (current status: ${contract.status})`);
240
+ }
241
+
242
+ // Transition to completed and record the result
243
+ const now = new Date().toISOString();
244
+ contract.status = 'completed';
245
+ contract.completedAt = now;
246
+ contract.updatedAt = now;
247
+ contract.result = { summary, publishedArtifacts };
248
+
249
+ atomicWriteJson(this.indexPath, index);
250
+
251
+ return { ...contract };
252
+ } finally {
253
+ releaseLock(this.locksDir, 'contracts-index');
254
+ }
255
+ }
256
+
257
+ /**
258
+ * Fails a contract (transitions from in_progress → failed)
259
+ *
260
+ * This is called when the work cannot be completed successfully.
261
+ *
262
+ * @param {string} contractId - The contract to fail
263
+ * @param {string} reason - Why the contract failed
264
+ * @returns {Object} The updated contract
265
+ * @throws {Error} If contract doesn't exist or is not in "in_progress" status
266
+ */
267
+ fail(contractId, reason) {
268
+ acquireLock(this.locksDir, 'contracts-index');
269
+
270
+ try {
271
+ const index = readJsonSafe(this.indexPath, {});
272
+ const contract = index[contractId];
273
+
274
+ if (!contract) {
275
+ throw new Error(`Contract "${contractId}" not found`);
276
+ }
277
+
278
+ if (contract.status !== 'in_progress') {
279
+ throw new Error(`Contract "${contractId}" is not in progress (current status: ${contract.status})`);
280
+ }
281
+
282
+ // Transition to failed and record the reason
283
+ const now = new Date().toISOString();
284
+ contract.status = 'failed';
285
+ contract.completedAt = now;
286
+ contract.updatedAt = now;
287
+ contract.result = { reason };
288
+
289
+ atomicWriteJson(this.indexPath, index);
290
+
291
+ return { ...contract };
292
+ } finally {
293
+ releaseLock(this.locksDir, 'contracts-index');
294
+ }
295
+ }
296
+
297
+ /**
298
+ * Reopens a failed or completed contract for retry or revision
299
+ *
300
+ * For failed contracts: transitions to "ready" for a retry
301
+ * For completed contracts: transitions to "in_progress" for a revision
302
+ *
303
+ * This increments the retryCount and will fail if maxRetries is exceeded.
304
+ *
305
+ * @param {string} contractId - The contract to reopen
306
+ * @param {Object} [options={}] - Reopen configuration
307
+ * @param {string} [options.reason=''] - Why we're reopening the contract
308
+ * @param {Object} [options.newInputs=null] - New inputs to merge in
309
+ * @returns {Object} The updated contract
310
+ * @throws {Error} If contract doesn't exist, wrong status, or max retries exceeded
311
+ */
312
+ reopen(contractId, { reason = '', newInputs = null } = {}) {
313
+ acquireLock(this.locksDir, 'contracts-index');
314
+
315
+ try {
316
+ const index = readJsonSafe(this.indexPath, {});
317
+ const contract = index[contractId];
318
+
319
+ if (!contract) {
320
+ throw new Error(`Contract "${contractId}" not found`);
321
+ }
322
+
323
+ // Can only reopen failed or completed contracts
324
+ if (contract.status !== 'failed' && contract.status !== 'completed') {
325
+ throw new Error(`Contract "${contractId}" cannot be reopened (current status: ${contract.status})`);
326
+ }
327
+
328
+ // Check if we've exceeded the retry limit
329
+ if (contract.retryCount >= contract.maxRetries) {
330
+ throw new Error(`Contract "${contractId}" has exceeded max retries (${contract.maxRetries})`);
331
+ }
332
+
333
+ // Increment the retry counter
334
+ contract.retryCount += 1;
335
+
336
+ // Determine new status based on current status
337
+ if (contract.status === 'failed') {
338
+ // Failed contracts go back to ready for a retry
339
+ contract.status = 'ready';
340
+ } else {
341
+ // Completed contracts go to in_progress for a revision
342
+ contract.status = 'in_progress';
343
+ }
344
+
345
+ // Merge in new inputs if provided
346
+ if (newInputs) {
347
+ contract.inputs = {
348
+ artifacts: newInputs.artifacts || contract.inputs.artifacts,
349
+ context: newInputs.context || contract.inputs.context,
350
+ parameters: { ...contract.inputs.parameters, ...(newInputs.parameters || {}) }
351
+ };
352
+ }
353
+
354
+ // Reset completion timestamp and update the updated timestamp
355
+ contract.completedAt = null;
356
+ contract.updatedAt = new Date().toISOString();
357
+
358
+ atomicWriteJson(this.indexPath, index);
359
+
360
+ return { ...contract };
361
+ } finally {
362
+ releaseLock(this.locksDir, 'contracts-index');
363
+ }
364
+ }
365
+
366
+ /**
367
+ * Gets a single contract by ID
368
+ *
369
+ * @param {string} contractId - The contract to retrieve
370
+ * @returns {Object|null} The contract object, or null if not found
371
+ */
372
+ get(contractId) {
373
+ const index = readJsonSafe(this.indexPath, {});
374
+ return index[contractId] ? { ...index[contractId] } : null;
375
+ }
376
+
377
+ /**
378
+ * Lists contracts with optional filtering
379
+ *
380
+ * @param {Object} [filters={}] - Filter criteria
381
+ * @param {string} [filters.status] - Filter by status
382
+ * @param {string} [filters.assignee] - Filter by assignee session name
383
+ * @param {string} [filters.assigner] - Filter by assigner session name
384
+ * @returns {Array} Array of contracts matching the filters
385
+ */
386
+ list({ status, assignee, assigner } = {}) {
387
+ const index = readJsonSafe(this.indexPath, {});
388
+
389
+ // Get all contracts as an array
390
+ let contracts = Object.values(index);
391
+
392
+ // Apply filters if provided
393
+ if (status) {
394
+ contracts = contracts.filter(c => c.status === status);
395
+ }
396
+ if (assignee) {
397
+ contracts = contracts.filter(c => c.assignee === assignee);
398
+ }
399
+ if (assigner) {
400
+ contracts = contracts.filter(c => c.assigner === assigner);
401
+ }
402
+
403
+ // Return copies to prevent mutation
404
+ return contracts.map(c => ({ ...c }));
405
+ }
406
+
407
+ /**
408
+ * Reassigns a contract to a different session
409
+ *
410
+ * @param {string} contractId - The contract to reassign
411
+ * @param {string} newAssignee - The new assignee session name
412
+ * @returns {Object} The updated contract
413
+ * @throws {Error} If contract doesn't exist
414
+ */
415
+ reassign(contractId, newAssignee) {
416
+ acquireLock(this.locksDir, 'contracts-index');
417
+
418
+ try {
419
+ const index = readJsonSafe(this.indexPath, {});
420
+ const contract = index[contractId];
421
+
422
+ if (!contract) {
423
+ throw new Error(`Contract "${contractId}" not found`);
424
+ }
425
+
426
+ // Update the assignee and timestamp
427
+ contract.assignee = newAssignee;
428
+ contract.updatedAt = new Date().toISOString();
429
+
430
+ atomicWriteJson(this.indexPath, index);
431
+
432
+ return { ...contract };
433
+ } finally {
434
+ releaseLock(this.locksDir, 'contracts-index');
435
+ }
436
+ }
437
+
438
+ /**
439
+ * Detects circular dependencies in the contract dependency graph
440
+ *
441
+ * Uses depth-first search (DFS) to traverse the dependency chain.
442
+ * If we encounter the starting contractId during traversal, there's a cycle.
443
+ *
444
+ * Example cycle: A depends on B, B depends on C, C depends on A
445
+ *
446
+ * @private
447
+ * @param {string} contractId - The contract we're creating
448
+ * @param {Array} dependencies - The dependencies for this contract
449
+ * @param {Object} allContracts - All existing contracts in the index
450
+ * @returns {boolean} True if a cycle is detected, false otherwise
451
+ */
452
+ _detectCycle(contractId, dependencies, allContracts) {
453
+ // Set to track which contracts we've visited in this DFS path
454
+ // This helps us detect when we've looped back to a contract we're already processing
455
+ const visited = new Set();
456
+
457
+ // Set to track the current path we're exploring
458
+ // If we see a contract in the current path again, that's a cycle
459
+ const path = new Set();
460
+
461
+ /**
462
+ * DFS helper function that explores one contract's dependencies
463
+ *
464
+ * @param {string} currentId - The contract we're currently exploring
465
+ * @returns {boolean} True if a cycle is found
466
+ */
467
+ const dfs = (currentId) => {
468
+ // If we've already fully explored this contract, skip it
469
+ if (visited.has(currentId)) {
470
+ return false;
471
+ }
472
+
473
+ // If this contract is in our current path, we found a cycle!
474
+ if (path.has(currentId)) {
475
+ return true;
476
+ }
477
+
478
+ // Add to the current path
479
+ path.add(currentId);
480
+
481
+ // Get this contract's dependencies
482
+ const contract = allContracts[currentId];
483
+ if (contract && contract.dependencies) {
484
+ // Check each dependency
485
+ for (const dep of contract.dependencies) {
486
+ // Only follow "contract" type dependencies
487
+ if (dep.type === 'contract' && dep.contractId) {
488
+ // Recursively check this dependency
489
+ if (dfs(dep.contractId)) {
490
+ return true; // Cycle found deeper in the graph
491
+ }
492
+ }
493
+ }
494
+ }
495
+
496
+ // Remove from current path (backtrack)
497
+ path.delete(currentId);
498
+
499
+ // Mark as fully visited
500
+ visited.add(currentId);
501
+
502
+ return false;
503
+ };
504
+
505
+ // Check each dependency of the new contract
506
+ for (const dep of dependencies) {
507
+ if (dep.type === 'contract' && dep.contractId) {
508
+ // Start DFS from this dependency
509
+ // If it eventually leads back to contractId, that's a cycle
510
+ if (dep.contractId === contractId) {
511
+ return true; // Direct self-dependency
512
+ }
513
+ if (dfs(dep.contractId)) {
514
+ return true; // Indirect cycle found
515
+ }
516
+ }
517
+ }
518
+
519
+ // No cycles detected
520
+ return false;
521
+ }
522
+ }
523
+
524
+ // Export the ContractStore class
525
+ module.exports = ContractStore;