wowbagger 0.1.0-alpha.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 (46) hide show
  1. package/CHANGELOG.md +94 -0
  2. package/LICENSE +201 -0
  3. package/README.md +464 -0
  4. package/adapters/claude-code/entrypoint.js +19 -0
  5. package/adapters/claude-code/wowbagger-adapter.json +25 -0
  6. package/adapters/codex/entrypoint.js +11 -0
  7. package/adapters/codex/wowbagger-adapter.json +25 -0
  8. package/adapters/opencode/entrypoint.js +11 -0
  9. package/adapters/opencode/wowbagger-adapter.json +25 -0
  10. package/bin/wowbagger.js +7 -0
  11. package/package.json +51 -0
  12. package/skills/wowbagger/SKILL.md +136 -0
  13. package/src/adapter/approval.js +135 -0
  14. package/src/adapter/bootstrap.js +43 -0
  15. package/src/adapter/context.js +34 -0
  16. package/src/adapter/core-probe.js +231 -0
  17. package/src/adapter/describe.js +383 -0
  18. package/src/adapter/entrypoint-main.js +335 -0
  19. package/src/adapter/entrypoint-path.js +103 -0
  20. package/src/adapter/handoff.js +124 -0
  21. package/src/adapter/instructions.js +106 -0
  22. package/src/adapter/invoke.js +294 -0
  23. package/src/adapter/limits.js +26 -0
  24. package/src/adapter/manifest.js +93 -0
  25. package/src/adapter/messages.js +15 -0
  26. package/src/adapter/paths.js +88 -0
  27. package/src/adapter/process-outcome.js +1116 -0
  28. package/src/adapter/schema-helpers.js +60 -0
  29. package/src/claim-capabilities.js +54 -0
  30. package/src/claim-coordinator.js +85 -0
  31. package/src/claim-journal.js +236 -0
  32. package/src/claim-operations.js +138 -0
  33. package/src/claim-publication.js +739 -0
  34. package/src/claim-request.js +140 -0
  35. package/src/claim-store.js +198 -0
  36. package/src/cli.js +1130 -0
  37. package/src/dependencies.js +3 -0
  38. package/src/git-reconciliation.js +62 -0
  39. package/src/ledger.js +296 -0
  40. package/src/mint.js +32 -0
  41. package/src/mutation.js +1979 -0
  42. package/src/namespace.js +35 -0
  43. package/src/ready.js +85 -0
  44. package/src/request.js +246 -0
  45. package/src/schema-migration.js +300 -0
  46. package/src/validate.js +1208 -0
@@ -0,0 +1,1208 @@
1
+ import { isDependencySatisfied } from './dependencies.js';
2
+
3
+ const KINDS = new Set(['task', 'epic']);
4
+ const STATUSES = new Set([
5
+ 'triage',
6
+ 'backlog',
7
+ 'in-progress',
8
+ 'done',
9
+ 'killed',
10
+ 'archived',
11
+ 'deferred',
12
+ ]);
13
+ const TERMINAL_STATES = new Map([
14
+ ['done', { field: 'completed', action: 'complete' }],
15
+ ['killed', { field: 'killed', action: 'kill' }],
16
+ ['archived', { field: 'archived', action: 'archive' }],
17
+ ['deferred', { field: 'deferred', action: 'defer' }],
18
+ ]);
19
+ const TERMINAL_DATE_FIELDS = ['completed', 'killed', 'archived', 'deferred'];
20
+ const DECISION_ACTIONS = new Set([
21
+ 'accept',
22
+ 'complete',
23
+ 'resolve',
24
+ 'kill',
25
+ 'archive',
26
+ 'restore',
27
+ 'defer',
28
+ 'undefer',
29
+ 'replace-dependency',
30
+ 'waive-dependency',
31
+ 'reparent',
32
+ 'record',
33
+ ]);
34
+ const ULID_PATTERN = /^wb_([0-7][0-9A-HJKMNP-TV-Z]{25})$/;
35
+ const ULID_ALPHABET = '0123456789ABCDEFGHJKMNPQRSTVWXYZ';
36
+ const NON_TERMINAL_STATUSES = new Set(['triage', 'backlog', 'in-progress']);
37
+
38
+ export function validateLedger(ledger) {
39
+ const context = {
40
+ errors: [...ledger.errors],
41
+ errorKeys: new Set(),
42
+ };
43
+ const facts = ledger.items.map((item) => inspectItem(item, context));
44
+ const index = buildIdentityIndex(facts);
45
+
46
+ validateUniformSchemaVersion(facts, context);
47
+ validateDuplicateIds(index, context);
48
+ validateDuplicateNumbers(facts, context);
49
+
50
+ for (const fact of facts) {
51
+ validateRelationLists(fact, context);
52
+ validateTerminalDates(fact, context);
53
+ validateDoneDependencies(fact, index, context);
54
+ validateDecisionRecords(fact, context);
55
+ }
56
+
57
+ for (const fact of facts) {
58
+ validateRelations(fact, index, context);
59
+ }
60
+
61
+ validateCycles(facts, index, 'dependency', context);
62
+ validateCycles(facts, index, 'containment', context);
63
+
64
+ const childrenByParent = indexChildren(facts);
65
+ for (const fact of facts) {
66
+ validateTerminalDecisions(fact, context);
67
+ validateRollupPlacement(fact, context);
68
+ validateEpicRules(fact, childrenByParent, context);
69
+ validateTerminalParent(fact, index, context);
70
+ }
71
+
72
+ context.errors.sort(compareErrors);
73
+ return {
74
+ valid: context.errors.length === 0,
75
+ errors: context.errors,
76
+ };
77
+ }
78
+
79
+ function inspectItem(item, context) {
80
+ const { data } = item;
81
+ const fact = {
82
+ item,
83
+ data,
84
+ schemaVersion: null,
85
+ id: null,
86
+ kind: null,
87
+ status: null,
88
+ created: null,
89
+ updated: null,
90
+ dependsOn: [],
91
+ related: [],
92
+ parent: null,
93
+ decisions: [],
94
+ terminalDate: null,
95
+ matchingTerminalDecision: null,
96
+ };
97
+
98
+ fact.schemaVersion = validateSchemaVersion(fact, context);
99
+ fact.id = inspectId(fact, context);
100
+ validateTitle(fact, context);
101
+ fact.kind = inspectKind(fact, context);
102
+ fact.status = inspectStatus(fact, context);
103
+ fact.created = inspectRequiredDate(fact, 'created', context);
104
+ fact.updated = inspectRequiredDate(fact, 'updated', context);
105
+ validateProvenance(fact, context);
106
+ fact.dependsOn = inspectReferenceList(fact, 'depends_on', true, context);
107
+ fact.related = inspectReferenceList(fact, 'related', false, context);
108
+ fact.parent = inspectParent(fact, context);
109
+ inspectOptionalDate(fact, 'snoozed_until', context);
110
+ inspectOptionalPriority(fact, context);
111
+ fact.number = inspectOptionalNumber(fact, context);
112
+
113
+ for (const field of TERMINAL_DATE_FIELDS) {
114
+ inspectOptionalDate(fact, field, context);
115
+ }
116
+
117
+ inspectDecisions(fact, context);
118
+ validateDateOrdering(fact, context);
119
+ validateIdCreatedAgreement(fact, context);
120
+ return fact;
121
+ }
122
+
123
+ function validateSchemaVersion(fact, context) {
124
+ const { data } = fact;
125
+ if (!hasOwn(data, 'schema_version')) {
126
+ addError(fact, 'schema_version', 'missing-required-field', 'Field schema_version is required.', context);
127
+ return null;
128
+ }
129
+
130
+ if (!Number.isInteger(data.schema_version)) {
131
+ addError(fact, 'schema_version', 'invalid-schema-version', 'schema_version must be the integer 1 or 2.', context);
132
+ return null;
133
+ }
134
+
135
+ if (data.schema_version !== 1 && data.schema_version !== 2) {
136
+ addError(fact, 'schema_version', 'unsupported-schema-version', 'schema_version must be 1 or 2.', context);
137
+ return null;
138
+ }
139
+
140
+ return data.schema_version;
141
+ }
142
+
143
+ function validateUniformSchemaVersion(facts, context) {
144
+ if (new Set(facts.map((fact) => fact.schemaVersion).filter(Boolean)).size < 2) {
145
+ return;
146
+ }
147
+
148
+ for (const fact of facts) {
149
+ if (fact.schemaVersion !== null) {
150
+ addError(
151
+ fact,
152
+ 'schema_version',
153
+ 'mixed-schema-versions',
154
+ 'Schema versions 1 and 2 must not be mixed in one ledger.',
155
+ context,
156
+ );
157
+ }
158
+ }
159
+ }
160
+
161
+ function inspectId(fact, context) {
162
+ const { data } = fact;
163
+ if (!hasOwn(data, 'id')) {
164
+ addError(fact, 'id', 'missing-required-field', 'Field id is required.', context);
165
+ return null;
166
+ }
167
+
168
+ if (typeof data.id !== 'string' || !ULID_PATTERN.test(data.id)) {
169
+ addError(
170
+ fact,
171
+ 'id',
172
+ 'invalid-id',
173
+ 'ID must use the canonical wb_ ULID form.',
174
+ context,
175
+ );
176
+ return null;
177
+ }
178
+
179
+ return data.id;
180
+ }
181
+
182
+ function validateTitle(fact, context) {
183
+ const { data } = fact;
184
+ if (!hasOwn(data, 'title')) {
185
+ addError(fact, 'title', 'missing-required-field', 'Field title is required.', context);
186
+ } else if (!isNonEmptyString(data.title)) {
187
+ addError(fact, 'title', 'invalid-field-type', 'Field title must be a non-empty string.', context);
188
+ }
189
+ }
190
+
191
+ function inspectKind(fact, context) {
192
+ const { data } = fact;
193
+ if (!hasOwn(data, 'kind')) {
194
+ addError(fact, 'kind', 'missing-required-field', 'Field kind is required.', context);
195
+ return null;
196
+ }
197
+
198
+ if (typeof data.kind !== 'string') {
199
+ addError(fact, 'kind', 'invalid-field-type', 'Field kind must be a string.', context);
200
+ return null;
201
+ }
202
+
203
+ if (!KINDS.has(data.kind)) {
204
+ addError(fact, 'kind', 'unknown-kind', `Kind ${data.kind} is not one of the schema version ${fact.schemaVersion ?? '1 or 2'} kinds.`, context);
205
+ return null;
206
+ }
207
+
208
+ return data.kind;
209
+ }
210
+
211
+ function inspectStatus(fact, context) {
212
+ const { data } = fact;
213
+ if (!hasOwn(data, 'status')) {
214
+ addError(fact, 'status', 'missing-required-field', 'Field status is required.', context);
215
+ return null;
216
+ }
217
+
218
+ if (typeof data.status !== 'string') {
219
+ addError(fact, 'status', 'invalid-field-type', 'Field status must be a string.', context);
220
+ return null;
221
+ }
222
+
223
+ if (!STATUSES.has(data.status)) {
224
+ addError(
225
+ fact,
226
+ 'status',
227
+ 'unknown-status',
228
+ `Status ${data.status} is not one of the schema version ${fact.schemaVersion ?? '1 or 2'} statuses.`,
229
+ context,
230
+ );
231
+ return null;
232
+ }
233
+
234
+ return data.status;
235
+ }
236
+
237
+ function inspectRequiredDate(fact, field, context) {
238
+ if (!hasOwn(fact.data, field)) {
239
+ addError(fact, field, 'missing-required-field', `Field ${field} is required.`, context);
240
+ return null;
241
+ }
242
+
243
+ return inspectDate(fact, field, context);
244
+ }
245
+
246
+ // number is a short human handle. The immutable ULID remains the identity used
247
+ // for publication, references, and the filename; number exists so a person can
248
+ // say "item 12" instead of reading out twenty-six characters.
249
+ function inspectOptionalNumber(fact, context) {
250
+ if (!hasOwn(fact.data, 'number')) {
251
+ return null;
252
+ }
253
+
254
+ const value = fact.data.number;
255
+ if (!Number.isSafeInteger(value) || value < 1) {
256
+ addError(
257
+ fact,
258
+ 'number',
259
+ 'invalid-number',
260
+ 'Field number must be a positive integer.',
261
+ context,
262
+ );
263
+ return null;
264
+ }
265
+ return value;
266
+ }
267
+
268
+ // priority is supplied by a consumer policy. The core validates its form and
269
+ // reports it; it never invents, recalculates, or persists one.
270
+ function inspectOptionalPriority(fact, context) {
271
+ if (!hasOwn(fact.data, 'priority')) {
272
+ return;
273
+ }
274
+
275
+ const value = fact.data.priority;
276
+ if (!Number.isSafeInteger(value) || value < 0) {
277
+ addError(
278
+ fact,
279
+ 'priority',
280
+ 'invalid-priority',
281
+ 'Field priority must be a non-negative integer.',
282
+ context,
283
+ );
284
+ }
285
+ }
286
+
287
+ function inspectOptionalDate(fact, field, context) {
288
+ if (!hasOwn(fact.data, field)) {
289
+ return null;
290
+ }
291
+
292
+ return inspectDate(fact, field, context);
293
+ }
294
+
295
+ function inspectDate(fact, field, context) {
296
+ const value = fact.data[field];
297
+ if (!isCalendarDate(value)) {
298
+ addError(fact, field, 'invalid-date', `Field ${field} must be an ISO calendar date.`, context);
299
+ return null;
300
+ }
301
+
302
+ return value;
303
+ }
304
+
305
+ function validateProvenance(fact, context) {
306
+ const { data } = fact;
307
+ if (!hasOwn(data, 'provenance')) {
308
+ addError(fact, 'provenance', 'missing-required-field', 'Field provenance is required.', context);
309
+ return;
310
+ }
311
+
312
+ if (!isMapping(data.provenance)) {
313
+ addError(fact, 'provenance', 'invalid-field-type', 'Field provenance must be a mapping.', context);
314
+ return;
315
+ }
316
+
317
+ if (!hasOwn(data.provenance, 'source')) {
318
+ addError(fact, 'provenance.source', 'missing-required-field', 'Field provenance.source is required.', context);
319
+ } else if (!isNonEmptyString(data.provenance.source)) {
320
+ addError(fact, 'provenance.source', 'invalid-field-type', 'Field provenance.source must be a non-empty string.', context);
321
+ }
322
+
323
+ if (!hasOwn(data.provenance, 'recorded_at')) {
324
+ addError(fact, 'provenance.recorded_at', 'missing-required-field', 'Field provenance.recorded_at is required.', context);
325
+ } else if (!isRfc3339Utc(data.provenance.recorded_at)) {
326
+ addError(
327
+ fact,
328
+ 'provenance.recorded_at',
329
+ 'invalid-rfc3339-utc',
330
+ 'Field provenance.recorded_at must be an RFC 3339 UTC instant.',
331
+ context,
332
+ );
333
+ }
334
+ }
335
+
336
+ function inspectReferenceList(fact, field, required, context) {
337
+ if (!hasOwn(fact.data, field)) {
338
+ if (required) {
339
+ addError(fact, field, 'missing-required-field', `Field ${field} is required.`, context);
340
+ }
341
+ return [];
342
+ }
343
+
344
+ const value = fact.data[field];
345
+ if (!Array.isArray(value)) {
346
+ addError(fact, field, 'invalid-field-type', `Field ${field} must be a YAML sequence.`, context);
347
+ return [];
348
+ }
349
+
350
+ const references = [];
351
+ for (let index = 0; index < value.length; index += 1) {
352
+ const reference = value[index];
353
+ if (typeof reference !== 'string' || !ULID_PATTERN.test(reference)) {
354
+ addError(
355
+ fact,
356
+ `${field}[${index}]`,
357
+ 'invalid-relation-reference',
358
+ `Field ${field} entries must be canonical item IDs.`,
359
+ context,
360
+ );
361
+ continue;
362
+ }
363
+ references.push(reference);
364
+ }
365
+
366
+ return references;
367
+ }
368
+
369
+ function inspectParent(fact, context) {
370
+ if (!hasOwn(fact.data, 'parent')) {
371
+ return null;
372
+ }
373
+
374
+ const parent = fact.data.parent;
375
+ if (typeof parent !== 'string' || !ULID_PATTERN.test(parent)) {
376
+ addError(fact, 'parent', 'invalid-parent-reference', 'Field parent must be a canonical item ID.', context);
377
+ return null;
378
+ }
379
+
380
+ return parent;
381
+ }
382
+
383
+ function inspectDecisions(fact, context) {
384
+ if (!hasOwn(fact.data, 'decisions')) {
385
+ return;
386
+ }
387
+
388
+ const value = fact.data.decisions;
389
+ if (!Array.isArray(value)) {
390
+ addError(fact, 'decisions', 'invalid-field-type', 'Field decisions must be a YAML sequence.', context);
391
+ return;
392
+ }
393
+
394
+ for (let index = 0; index < value.length; index += 1) {
395
+ const decision = value[index];
396
+ const field = `decisions[${index}]`;
397
+ if (!isMapping(decision)) {
398
+ addError(fact, field, 'invalid-decision', 'Each decision must be a mapping.', context);
399
+ continue;
400
+ }
401
+
402
+ const inspected = {
403
+ index,
404
+ valid: true,
405
+ action: null,
406
+ date: null,
407
+ hasRollup: hasOwn(decision, 'rollup'),
408
+ rollup: decision.rollup,
409
+ };
410
+
411
+ if (!hasOwn(decision, 'action')) {
412
+ inspected.valid = false;
413
+ addError(fact, `${field}.action`, 'missing-decision-field', 'Each decision requires action.', context);
414
+ } else if (typeof decision.action !== 'string' || !DECISION_ACTIONS.has(decision.action)) {
415
+ inspected.valid = false;
416
+ addError(fact, `${field}.action`, 'invalid-decision-action', `Decision action is not recognized by schema version ${fact.schemaVersion ?? '1 or 2'}.`, context);
417
+ } else {
418
+ inspected.action = decision.action;
419
+ }
420
+
421
+ if (!hasOwn(decision, 'date')) {
422
+ inspected.valid = false;
423
+ addError(fact, `${field}.date`, 'missing-decision-field', 'Each decision requires date.', context);
424
+ } else if (!isCalendarDate(decision.date)) {
425
+ inspected.valid = false;
426
+ addError(fact, `${field}.date`, 'invalid-date', 'Decision date must be an ISO calendar date.', context);
427
+ } else {
428
+ inspected.date = decision.date;
429
+ }
430
+
431
+ for (const requiredField of ['summary', 'rationale']) {
432
+ if (!hasOwn(decision, requiredField)) {
433
+ inspected.valid = false;
434
+ addError(
435
+ fact,
436
+ `${field}.${requiredField}`,
437
+ 'missing-decision-field',
438
+ `Each decision requires ${requiredField}.`,
439
+ context,
440
+ );
441
+ } else if (!isNonEmptyString(decision[requiredField])) {
442
+ inspected.valid = false;
443
+ addError(
444
+ fact,
445
+ `${field}.${requiredField}`,
446
+ 'invalid-field-type',
447
+ `Decision ${requiredField} must be a non-empty string.`,
448
+ context,
449
+ );
450
+ }
451
+ }
452
+
453
+ fact.decisions.push(inspected);
454
+ }
455
+ }
456
+
457
+ function validateDateOrdering(fact, context) {
458
+ if (fact.created && fact.updated && fact.updated < fact.created) {
459
+ addError(
460
+ fact,
461
+ 'updated',
462
+ 'updated-before-created',
463
+ 'Field updated must not be earlier than created.',
464
+ context,
465
+ );
466
+ }
467
+ }
468
+
469
+ function validateIdCreatedAgreement(fact, context) {
470
+ if (!fact.id || !fact.created) {
471
+ return;
472
+ }
473
+
474
+ const idDate = dateFromId(fact.id);
475
+ if (idDate !== fact.created) {
476
+ addError(
477
+ fact,
478
+ 'created',
479
+ 'id-created-date-mismatch',
480
+ `Field created must equal the UTC calendar date encoded by ID ${fact.id}.`,
481
+ context,
482
+ );
483
+ }
484
+ }
485
+
486
+ function buildIdentityIndex(facts) {
487
+ const byId = new Map();
488
+ for (const fact of facts) {
489
+ if (!fact.id) {
490
+ continue;
491
+ }
492
+ const members = byId.get(fact.id) ?? [];
493
+ members.push(fact);
494
+ byId.set(fact.id, members);
495
+ }
496
+
497
+ const uniqueById = new Map();
498
+ for (const [id, members] of byId) {
499
+ if (members.length === 1) {
500
+ uniqueById.set(id, members[0]);
501
+ }
502
+ }
503
+
504
+ return { byId, uniqueById };
505
+ }
506
+
507
+ function validateDuplicateIds(index, context) {
508
+ for (const [id, members] of index.byId) {
509
+ if (members.length < 2) {
510
+ continue;
511
+ }
512
+
513
+ for (const fact of members) {
514
+ addError(
515
+ fact,
516
+ 'id',
517
+ 'duplicate-id',
518
+ `ID ${id} is used by more than one ledger item.`,
519
+ context,
520
+ );
521
+ }
522
+ }
523
+ }
524
+
525
+ // A duplicate number is recoverable — the ULID still distinguishes the items —
526
+ // but it must be surfaced so a merge resolves it rather than a reader guessing.
527
+ function validateDuplicateNumbers(facts, context) {
528
+ const byNumber = new Map();
529
+ for (const fact of facts) {
530
+ if (fact.number === null || fact.number === undefined) {
531
+ continue;
532
+ }
533
+ const members = byNumber.get(fact.number) ?? [];
534
+ members.push(fact);
535
+ byNumber.set(fact.number, members);
536
+ }
537
+
538
+ for (const [number, members] of byNumber) {
539
+ if (members.length < 2) {
540
+ continue;
541
+ }
542
+ for (const fact of members) {
543
+ addError(
544
+ fact,
545
+ 'number',
546
+ 'duplicate-number',
547
+ `Number ${number} is used by more than one ledger item.`,
548
+ context,
549
+ );
550
+ }
551
+ }
552
+ }
553
+
554
+ function validateRelationLists(fact, context) {
555
+ validateDuplicateReferences(fact, 'depends_on', fact.dependsOn, context);
556
+ validateDuplicateReferences(fact, 'related', fact.related, context);
557
+
558
+ const related = new Set(fact.related);
559
+ for (const dependency of new Set(fact.dependsOn)) {
560
+ if (related.has(dependency)) {
561
+ addError(
562
+ fact,
563
+ 'depends_on',
564
+ 'relation-overlap',
565
+ `Reference ${dependency} must not appear in both depends_on and related.`,
566
+ context,
567
+ );
568
+ }
569
+ }
570
+ }
571
+
572
+ function validateDuplicateReferences(fact, field, references, context) {
573
+ const seen = new Set();
574
+ for (const reference of references) {
575
+ if (seen.has(reference)) {
576
+ addError(
577
+ fact,
578
+ field,
579
+ 'duplicate-reference',
580
+ `Field ${field} must not repeat reference ${reference}.`,
581
+ context,
582
+ );
583
+ }
584
+ seen.add(reference);
585
+ }
586
+ }
587
+
588
+ function validateRelations(fact, index, context) {
589
+ validateDependencies(fact, index, context);
590
+ validateRelated(fact, index, context);
591
+ validateParent(fact, index, context);
592
+ }
593
+
594
+ function validateDependencies(fact, index, context) {
595
+ for (const dependency of new Set(fact.dependsOn)) {
596
+ if (fact.id && dependency === fact.id) {
597
+ addError(
598
+ fact,
599
+ 'depends_on',
600
+ 'self-dependency',
601
+ `Dependency ${dependency} must not reference the item itself.`,
602
+ context,
603
+ );
604
+ continue;
605
+ }
606
+
607
+ const target = resolveReference(dependency, index);
608
+ if (target === null) {
609
+ addError(
610
+ fact,
611
+ 'depends_on',
612
+ 'unresolved-dependency',
613
+ `Dependency ${dependency} does not resolve to an item in the configured ledger.`,
614
+ context,
615
+ );
616
+ continue;
617
+ }
618
+
619
+ if (target === 'ambiguous') {
620
+ addError(
621
+ fact,
622
+ 'depends_on',
623
+ 'ambiguous-dependency',
624
+ `Dependency ${dependency} resolves to more than one ledger item.`,
625
+ context,
626
+ );
627
+ continue;
628
+ }
629
+
630
+ if (target.status === 'archived') {
631
+ addError(
632
+ fact,
633
+ 'depends_on',
634
+ 'archived-dependency-must-be-dispositioned',
635
+ `Dependency ${dependency} is archived and cannot remain a live blocker; restore it or explicitly disposition the dependent.`,
636
+ context,
637
+ );
638
+ } else if (target.status === 'killed'
639
+ || (isDependencySatisfied(target.status) && fact.schemaVersion === 1)) {
640
+ addError(
641
+ fact,
642
+ 'depends_on',
643
+ 'terminal-dependency-invalid',
644
+ `Dependency ${dependency} is terminal (${target.status}) and cannot remain live; replace it with a valid live blocker, waive it with a durable decision, or terminalize the dependent.`,
645
+ context,
646
+ );
647
+ }
648
+ }
649
+ }
650
+
651
+ function validateRelated(fact, index, context) {
652
+ for (const related of new Set(fact.related)) {
653
+ if (fact.id && related === fact.id) {
654
+ addError(
655
+ fact,
656
+ 'related',
657
+ 'self-related',
658
+ `Related item ${related} must not reference the item itself.`,
659
+ context,
660
+ );
661
+ continue;
662
+ }
663
+
664
+ const target = resolveReference(related, index);
665
+ if (target === null) {
666
+ addError(
667
+ fact,
668
+ 'related',
669
+ 'unresolved-related',
670
+ `Related item ${related} does not resolve to an item in the configured ledger.`,
671
+ context,
672
+ );
673
+ } else if (target === 'ambiguous') {
674
+ addError(
675
+ fact,
676
+ 'related',
677
+ 'ambiguous-related',
678
+ `Related item ${related} resolves to more than one ledger item.`,
679
+ context,
680
+ );
681
+ }
682
+ }
683
+ }
684
+
685
+ function validateParent(fact, index, context) {
686
+ if (!fact.parent) {
687
+ return;
688
+ }
689
+
690
+ if (fact.id && fact.parent === fact.id) {
691
+ addError(
692
+ fact,
693
+ 'parent',
694
+ 'self-parent',
695
+ `Parent ${fact.parent} must not reference the item itself.`,
696
+ context,
697
+ );
698
+ return;
699
+ }
700
+
701
+ const target = resolveReference(fact.parent, index);
702
+ if (target === null) {
703
+ addError(
704
+ fact,
705
+ 'parent',
706
+ 'unresolved-parent',
707
+ `Parent ${fact.parent} does not resolve to an item in the configured ledger.`,
708
+ context,
709
+ );
710
+ return;
711
+ }
712
+
713
+ if (target === 'ambiguous') {
714
+ addError(
715
+ fact,
716
+ 'parent',
717
+ 'ambiguous-parent',
718
+ `Parent ${fact.parent} resolves to more than one ledger item.`,
719
+ context,
720
+ );
721
+ return;
722
+ }
723
+
724
+ if (target.kind !== 'epic') {
725
+ addError(
726
+ fact,
727
+ 'parent',
728
+ 'parent-must-be-epic',
729
+ `Parent ${fact.parent} must resolve to an epic item.`,
730
+ context,
731
+ );
732
+ }
733
+ }
734
+
735
+ function resolveReference(id, index) {
736
+ const members = index.byId.get(id);
737
+ if (!members) {
738
+ return null;
739
+ }
740
+ return members.length === 1 ? members[0] : 'ambiguous';
741
+ }
742
+
743
+ function validateCycles(facts, index, relation, context) {
744
+ const graph = new Map();
745
+ for (const fact of facts) {
746
+ if (!fact.id || !index.uniqueById.has(fact.id)) {
747
+ continue;
748
+ }
749
+
750
+ const references = relation === 'dependency' ? fact.dependsOn : [fact.parent].filter(Boolean);
751
+ const edges = [...new Set(references)]
752
+ .filter((reference) => reference !== fact.id && index.uniqueById.has(reference))
753
+ .sort(compareText);
754
+ graph.set(fact.id, edges);
755
+ }
756
+
757
+ for (const component of stronglyConnectedComponents(graph)) {
758
+ if (component.length < 2) {
759
+ continue;
760
+ }
761
+
762
+ for (const id of [...component].sort(compareText)) {
763
+ const fact = index.uniqueById.get(id);
764
+ const field = relation === 'dependency' ? 'depends_on' : 'parent';
765
+ const code = relation === 'dependency' ? 'dependency-cycle' : 'containment-cycle';
766
+ const label = relation === 'dependency' ? 'Dependency' : 'Containment';
767
+ addError(
768
+ fact,
769
+ field,
770
+ code,
771
+ `${label} cycle detected in a component of ${component.length} items; member ${id}.`,
772
+ context,
773
+ );
774
+ }
775
+ }
776
+ }
777
+
778
+ function stronglyConnectedComponents(graph) {
779
+ const nodes = [...graph.keys()].sort(compareText);
780
+ const reverseGraph = new Map(nodes.map((id) => [id, []]));
781
+ for (const [id, neighbors] of graph) {
782
+ for (const neighbor of neighbors) {
783
+ reverseGraph.get(neighbor).push(id);
784
+ }
785
+ }
786
+ for (const neighbors of reverseGraph.values()) {
787
+ neighbors.sort(compareText);
788
+ }
789
+
790
+ const visited = new Set();
791
+ const finished = [];
792
+ for (const start of nodes) {
793
+ if (visited.has(start)) {
794
+ continue;
795
+ }
796
+
797
+ visited.add(start);
798
+ const stack = [{ id: start, nextNeighbor: 0 }];
799
+ while (stack.length > 0) {
800
+ const frame = stack.at(-1);
801
+ const neighbors = graph.get(frame.id) ?? [];
802
+ if (frame.nextNeighbor < neighbors.length) {
803
+ const neighbor = neighbors[frame.nextNeighbor];
804
+ frame.nextNeighbor += 1;
805
+ if (!visited.has(neighbor)) {
806
+ visited.add(neighbor);
807
+ stack.push({ id: neighbor, nextNeighbor: 0 });
808
+ }
809
+ continue;
810
+ }
811
+
812
+ finished.push(frame.id);
813
+ stack.pop();
814
+ }
815
+ }
816
+
817
+ const components = [];
818
+ visited.clear();
819
+ for (let index = finished.length - 1; index >= 0; index -= 1) {
820
+ const start = finished[index];
821
+ if (visited.has(start)) {
822
+ continue;
823
+ }
824
+
825
+ const component = [];
826
+ const stack = [start];
827
+ visited.add(start);
828
+ while (stack.length > 0) {
829
+ const member = stack.pop();
830
+ component.push(member);
831
+ for (const neighbor of reverseGraph.get(member) ?? []) {
832
+ if (!visited.has(neighbor)) {
833
+ visited.add(neighbor);
834
+ stack.push(neighbor);
835
+ }
836
+ }
837
+ }
838
+ components.push(component);
839
+ }
840
+
841
+ return components;
842
+ }
843
+
844
+ function indexChildren(facts) {
845
+ const children = new Map();
846
+ for (const fact of facts) {
847
+ if (!fact.parent) {
848
+ continue;
849
+ }
850
+ const members = children.get(fact.parent) ?? [];
851
+ members.push(fact);
852
+ children.set(fact.parent, members);
853
+ }
854
+ return children;
855
+ }
856
+
857
+ function validateTerminalDates(fact, context) {
858
+ if (!fact.status) {
859
+ return;
860
+ }
861
+
862
+ const terminal = TERMINAL_STATES.get(fact.status);
863
+ if (!terminal) {
864
+ for (const field of TERMINAL_DATE_FIELDS) {
865
+ if (hasOwn(fact.data, field)) {
866
+ addError(
867
+ fact,
868
+ field,
869
+ 'terminal-date-not-allowed',
870
+ `Status ${fact.status} forbids completed, killed, archived, and deferred.`,
871
+ context,
872
+ );
873
+ }
874
+ }
875
+ return;
876
+ }
877
+
878
+ const terminalDate = inspectOptionalDate(fact, terminal.field, context);
879
+ if (!terminalDate) {
880
+ if (!hasOwn(fact.data, terminal.field)) {
881
+ addError(
882
+ fact,
883
+ terminal.field,
884
+ 'missing-terminal-date',
885
+ `Status ${fact.status} requires ${terminal.field} and forbids ${forbiddenTerminalFields(terminal.field).join(' and ')}.`,
886
+ context,
887
+ );
888
+ }
889
+ validateForbiddenTerminalDates(fact, terminal.field, context);
890
+ return;
891
+ }
892
+
893
+ fact.terminalDate = terminalDate;
894
+ if (fact.updated && terminalDate !== fact.updated) {
895
+ addError(
896
+ fact,
897
+ terminal.field,
898
+ 'terminal-date-must-match-updated',
899
+ `Field ${terminal.field} must equal updated for status ${fact.status}.`,
900
+ context,
901
+ );
902
+ }
903
+
904
+ validateForbiddenTerminalDates(fact, terminal.field, context);
905
+ }
906
+
907
+ function validateForbiddenTerminalDates(fact, activeField, context) {
908
+ for (const field of forbiddenTerminalFields(activeField)) {
909
+ if (hasOwn(fact.data, field)) {
910
+ addError(
911
+ fact,
912
+ field,
913
+ 'terminal-date-conflict',
914
+ `Status ${fact.status} forbids ${field}.`,
915
+ context,
916
+ );
917
+ }
918
+ }
919
+ }
920
+
921
+ function validateDoneDependencies(fact, index, context) {
922
+ if (fact.status !== 'done' || !Array.isArray(fact.data.depends_on)
923
+ || fact.data.depends_on.length === 0) {
924
+ return;
925
+ }
926
+
927
+ if (fact.schemaVersion === 2) {
928
+ const hasUnsatisfiedDependency = fact.dependsOn.some((dependency) => {
929
+ const target = resolveReference(dependency, index);
930
+ return target !== null && target !== 'ambiguous' && !isDependencySatisfied(target.status);
931
+ });
932
+ if (!hasUnsatisfiedDependency) {
933
+ return;
934
+ }
935
+ addError(
936
+ fact,
937
+ 'depends_on',
938
+ 'done-item-has-dependencies',
939
+ `Done item ${fact.id ?? 'without a valid ID'} requires every depends_on target to be done.`,
940
+ context,
941
+ );
942
+ return;
943
+ }
944
+
945
+ addError(
946
+ fact,
947
+ 'depends_on',
948
+ 'done-item-has-dependencies',
949
+ `Done item ${fact.id ?? 'without a valid ID'} must have an empty depends_on list; resolve or explicitly disposition every dependency before completion.`,
950
+ context,
951
+ );
952
+ }
953
+
954
+ function forbiddenTerminalFields(activeField) {
955
+ return TERMINAL_DATE_FIELDS.filter((field) => field !== activeField);
956
+ }
957
+
958
+ function validateDecisionRecords(fact, context) {
959
+ if (fact.kind === 'epic' && fact.status === 'in-progress') {
960
+ addError(
961
+ fact,
962
+ 'status',
963
+ 'epic-in-progress-not-allowed',
964
+ 'An epic must not use the in-progress status.',
965
+ context,
966
+ );
967
+ }
968
+ }
969
+
970
+ function validateTerminalDecisions(fact, context) {
971
+ const terminal = fact.status ? TERMINAL_STATES.get(fact.status) : null;
972
+ if (!terminal || !fact.terminalDate) {
973
+ return;
974
+ }
975
+
976
+ const matching = fact.decisions.find(
977
+ (decision) => decision.valid
978
+ && decision.action === terminal.action
979
+ && decision.date === fact.terminalDate,
980
+ );
981
+
982
+ if (!matching) {
983
+ addError(
984
+ fact,
985
+ 'decisions',
986
+ 'missing-matching-terminal-decision',
987
+ `Status ${fact.status} requires an action ${terminal.action} decision dated ${fact.terminalDate}.`,
988
+ context,
989
+ );
990
+ return;
991
+ }
992
+
993
+ fact.matchingTerminalDecision = matching;
994
+ }
995
+
996
+ function validateRollupPlacement(fact, context) {
997
+ for (const decision of fact.decisions) {
998
+ if (!decision.hasRollup) {
999
+ continue;
1000
+ }
1001
+
1002
+ const completionDateIsUnavailable = fact.kind === 'epic'
1003
+ && fact.status === 'done'
1004
+ && !fact.terminalDate
1005
+ && decision.action === 'complete';
1006
+ if (completionDateIsUnavailable) {
1007
+ continue;
1008
+ }
1009
+
1010
+ const isMatchingEpicCompletion = fact.kind === 'epic'
1011
+ && fact.status === 'done'
1012
+ && decision === fact.matchingTerminalDecision;
1013
+ if (!isMatchingEpicCompletion) {
1014
+ addError(
1015
+ fact,
1016
+ `decisions[${decision.index}].rollup`,
1017
+ 'rollup-not-allowed',
1018
+ 'rollup is allowed only on the matching complete decision of a done epic.',
1019
+ context,
1020
+ );
1021
+ }
1022
+ }
1023
+ }
1024
+
1025
+ function validateEpicRules(fact, childrenByParent, context) {
1026
+ if (fact.kind !== 'epic') {
1027
+ return;
1028
+ }
1029
+
1030
+ const children = [...(fact.id ? childrenByParent.get(fact.id) ?? [] : [])]
1031
+ .sort((left, right) => compareText(left.id ?? '', right.id ?? ''));
1032
+
1033
+ if (fact.status === 'done') {
1034
+ if (children.some((child) => child.status !== 'done' && child.status !== 'killed')) {
1035
+ addError(
1036
+ fact,
1037
+ 'status',
1038
+ 'epic-has-nonterminal-child',
1039
+ `Done epic ${fact.id ?? 'without a valid ID'} has a direct child that is not done or killed.`,
1040
+ context,
1041
+ );
1042
+ }
1043
+
1044
+ validateEpicRollup(fact, children, context);
1045
+ }
1046
+ }
1047
+
1048
+ function validateEpicRollup(fact, children, context) {
1049
+ const decision = fact.matchingTerminalDecision;
1050
+ if (!decision) {
1051
+ return;
1052
+ }
1053
+
1054
+ if (!decision.hasRollup) {
1055
+ addError(
1056
+ fact,
1057
+ 'decisions',
1058
+ 'missing-epic-rollup',
1059
+ `Done epic ${fact.id ?? 'without a valid ID'} requires rollup evidence on its matching complete decision.`,
1060
+ context,
1061
+ );
1062
+ return;
1063
+ }
1064
+
1065
+ if (!Array.isArray(decision.rollup)) {
1066
+ addError(
1067
+ fact,
1068
+ `decisions[${decision.index}].rollup`,
1069
+ 'invalid-epic-rollup',
1070
+ 'Epic rollup must be a YAML sequence.',
1071
+ context,
1072
+ );
1073
+ return;
1074
+ }
1075
+
1076
+ const matches = decision.rollup.length === children.length
1077
+ && decision.rollup.every((entry, index) => isMatchingRollupEntry(entry, children[index]));
1078
+ if (!matches) {
1079
+ addError(
1080
+ fact,
1081
+ `decisions[${decision.index}].rollup`,
1082
+ 'invalid-epic-rollup',
1083
+ `Epic ${fact.id ?? 'without a valid ID'} rollup must list each direct child once in immutable ID order with its actual terminal status.`,
1084
+ context,
1085
+ );
1086
+ }
1087
+ }
1088
+
1089
+ function isMatchingRollupEntry(entry, child) {
1090
+ return isMapping(entry)
1091
+ && typeof entry.id === 'string'
1092
+ && typeof entry.status === 'string'
1093
+ && entry.id === child?.id
1094
+ && entry.status === child?.status
1095
+ && (entry.status === 'done' || entry.status === 'killed');
1096
+ }
1097
+
1098
+ function validateTerminalParent(fact, index, context) {
1099
+ if (!fact.parent || !fact.status || !NON_TERMINAL_STATUSES.has(fact.status)) {
1100
+ return;
1101
+ }
1102
+
1103
+ const parent = resolveReference(fact.parent, index);
1104
+ if (parent === null || parent === 'ambiguous' || parent.kind !== 'epic') {
1105
+ return;
1106
+ }
1107
+
1108
+ if (parent.status === 'killed' || parent.status === 'archived') {
1109
+ addError(
1110
+ fact,
1111
+ 'parent',
1112
+ 'nonterminal-child-of-terminal-epic',
1113
+ `${capitalize(fact.status)} child ${fact.id ?? 'without a valid ID'} cannot remain under ${parent.status} epic ${parent.id}; terminalize or reparent the child before the epic transition.`,
1114
+ context,
1115
+ );
1116
+ }
1117
+ }
1118
+
1119
+ export function isCalendarDate(value) {
1120
+ if (typeof value !== 'string') {
1121
+ return false;
1122
+ }
1123
+
1124
+ const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(value);
1125
+ if (!match) {
1126
+ return false;
1127
+ }
1128
+
1129
+ const year = Number(match[1]);
1130
+ const month = Number(match[2]);
1131
+ const day = Number(match[3]);
1132
+ if (month < 1 || month > 12 || day < 1) {
1133
+ return false;
1134
+ }
1135
+
1136
+ const monthLengths = [31, isLeapYear(year) ? 29 : 28, 31, 30, 31, 30, 31, 31, 30, 31, 30, 31];
1137
+ return day <= monthLengths[month - 1];
1138
+ }
1139
+
1140
+ export function isRfc3339Utc(value) {
1141
+ if (typeof value !== 'string') {
1142
+ return false;
1143
+ }
1144
+
1145
+ const match = /^(\d{4}-\d{2}-\d{2})T(\d{2}):(\d{2}):(\d{2})(?:\.\d+)?Z$/.exec(value);
1146
+ if (!match || !isCalendarDate(match[1])) {
1147
+ return false;
1148
+ }
1149
+
1150
+ const hour = Number(match[2]);
1151
+ const minute = Number(match[3]);
1152
+ const second = Number(match[4]);
1153
+ return hour <= 23 && minute <= 59 && second <= 59;
1154
+ }
1155
+
1156
+ function isLeapYear(year) {
1157
+ return year % 4 === 0 && (year % 100 !== 0 || year % 400 === 0);
1158
+ }
1159
+
1160
+ function dateFromId(id) {
1161
+ const ulid = ULID_PATTERN.exec(id)?.[1];
1162
+ let milliseconds = 0;
1163
+ for (const character of ulid.slice(0, 10)) {
1164
+ milliseconds = (milliseconds * 32) + ULID_ALPHABET.indexOf(character);
1165
+ }
1166
+ return new Date(milliseconds).toISOString().slice(0, 10);
1167
+ }
1168
+
1169
+ function isNonEmptyString(value) {
1170
+ return typeof value === 'string' && value.trim().length > 0;
1171
+ }
1172
+
1173
+ function isMapping(value) {
1174
+ return value !== null && typeof value === 'object' && !Array.isArray(value);
1175
+ }
1176
+
1177
+ function hasOwn(value, property) {
1178
+ return Object.prototype.hasOwnProperty.call(value, property);
1179
+ }
1180
+
1181
+ function addError(fact, field, code, message, context) {
1182
+ const error = {
1183
+ path: fact.item.path,
1184
+ field,
1185
+ code,
1186
+ message,
1187
+ };
1188
+ const key = JSON.stringify(error);
1189
+ if (!context.errorKeys.has(key)) {
1190
+ context.errorKeys.add(key);
1191
+ context.errors.push(error);
1192
+ }
1193
+ }
1194
+
1195
+ function compareErrors(left, right) {
1196
+ return compareText(left.path, right.path)
1197
+ || compareText(left.field, right.field)
1198
+ || compareText(left.code, right.code)
1199
+ || compareText(left.message, right.message);
1200
+ }
1201
+
1202
+ function compareText(left, right) {
1203
+ return left < right ? -1 : left > right ? 1 : 0;
1204
+ }
1205
+
1206
+ function capitalize(value) {
1207
+ return `${value[0].toUpperCase()}${value.slice(1)}`;
1208
+ }