@goodbones/campaigns 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 (102) hide show
  1. package/LICENSE +21 -0
  2. package/build/dts/core/campaign-state.d.ts +38 -0
  3. package/build/dts/core/campaign-state.d.ts.map +1 -0
  4. package/build/dts/core/campaigns.d.ts +196 -0
  5. package/build/dts/core/campaigns.d.ts.map +1 -0
  6. package/build/dts/core/ledger.d.ts +173 -0
  7. package/build/dts/core/ledger.d.ts.map +1 -0
  8. package/build/dts/core/phases.d.ts +26 -0
  9. package/build/dts/core/phases.d.ts.map +1 -0
  10. package/build/dts/core/sectors.d.ts +52 -0
  11. package/build/dts/core/sectors.d.ts.map +1 -0
  12. package/build/dts/domain/config.d.ts +985 -0
  13. package/build/dts/domain/config.d.ts.map +1 -0
  14. package/build/dts/domain/report.d.ts +16 -0
  15. package/build/dts/domain/report.d.ts.map +1 -0
  16. package/build/dts/host/campaigns.d.ts +195 -0
  17. package/build/dts/host/campaigns.d.ts.map +1 -0
  18. package/build/dts/host/diff.d.ts +31 -0
  19. package/build/dts/host/diff.d.ts.map +1 -0
  20. package/build/dts/index.d.ts +17 -0
  21. package/build/dts/index.d.ts.map +1 -0
  22. package/build/dts/infrastructure/campaign-functions.d.ts +7 -0
  23. package/build/dts/infrastructure/campaign-functions.d.ts.map +1 -0
  24. package/build/dts/infrastructure/report-source-fake.d.ts +4 -0
  25. package/build/dts/infrastructure/report-source-fake.d.ts.map +1 -0
  26. package/build/dts/infrastructure/report-source-live.d.ts +3 -0
  27. package/build/dts/infrastructure/report-source-live.d.ts.map +1 -0
  28. package/build/dts/load/decode.d.ts +7 -0
  29. package/build/dts/load/decode.d.ts.map +1 -0
  30. package/build/dts/load/extension.d.ts +25 -0
  31. package/build/dts/load/extension.d.ts.map +1 -0
  32. package/build/dts/manifest/lower.d.ts +14 -0
  33. package/build/dts/manifest/lower.d.ts.map +1 -0
  34. package/build/dts/manifest/spec.d.ts +515 -0
  35. package/build/dts/manifest/spec.d.ts.map +1 -0
  36. package/build/dts/ports/campaign-predicate.d.ts +23 -0
  37. package/build/dts/ports/campaign-predicate.d.ts.map +1 -0
  38. package/build/dts/ports/report-source.d.ts +14 -0
  39. package/build/dts/ports/report-source.d.ts.map +1 -0
  40. package/build/dts/testing.d.ts +2 -0
  41. package/build/dts/testing.d.ts.map +1 -0
  42. package/build/esm/core/campaign-state.js +140 -0
  43. package/build/esm/core/campaign-state.js.map +1 -0
  44. package/build/esm/core/campaigns.js +954 -0
  45. package/build/esm/core/campaigns.js.map +1 -0
  46. package/build/esm/core/ledger.js +519 -0
  47. package/build/esm/core/ledger.js.map +1 -0
  48. package/build/esm/core/phases.js +87 -0
  49. package/build/esm/core/phases.js.map +1 -0
  50. package/build/esm/core/sectors.js +313 -0
  51. package/build/esm/core/sectors.js.map +1 -0
  52. package/build/esm/domain/config.js +245 -0
  53. package/build/esm/domain/config.js.map +1 -0
  54. package/build/esm/domain/report.js +168 -0
  55. package/build/esm/domain/report.js.map +1 -0
  56. package/build/esm/host/campaigns.js +1159 -0
  57. package/build/esm/host/campaigns.js.map +1 -0
  58. package/build/esm/host/diff.js +156 -0
  59. package/build/esm/host/diff.js.map +1 -0
  60. package/build/esm/index.js +27 -0
  61. package/build/esm/index.js.map +1 -0
  62. package/build/esm/infrastructure/campaign-functions.js +67 -0
  63. package/build/esm/infrastructure/campaign-functions.js.map +1 -0
  64. package/build/esm/infrastructure/report-source-fake.js +6 -0
  65. package/build/esm/infrastructure/report-source-fake.js.map +1 -0
  66. package/build/esm/infrastructure/report-source-live.js +165 -0
  67. package/build/esm/infrastructure/report-source-live.js.map +1 -0
  68. package/build/esm/load/decode.js +60 -0
  69. package/build/esm/load/decode.js.map +1 -0
  70. package/build/esm/load/extension.js +326 -0
  71. package/build/esm/load/extension.js.map +1 -0
  72. package/build/esm/manifest/lower.js +488 -0
  73. package/build/esm/manifest/lower.js.map +1 -0
  74. package/build/esm/manifest/spec.js +296 -0
  75. package/build/esm/manifest/spec.js.map +1 -0
  76. package/build/esm/ports/campaign-predicate.js +2 -0
  77. package/build/esm/ports/campaign-predicate.js.map +1 -0
  78. package/build/esm/ports/report-source.js +7 -0
  79. package/build/esm/ports/report-source.js.map +1 -0
  80. package/build/esm/testing.js +5 -0
  81. package/build/esm/testing.js.map +1 -0
  82. package/package.json +65 -0
  83. package/src/core/campaign-state.ts +227 -0
  84. package/src/core/campaigns.ts +1319 -0
  85. package/src/core/ledger.ts +675 -0
  86. package/src/core/phases.ts +138 -0
  87. package/src/core/sectors.ts +399 -0
  88. package/src/domain/config.ts +314 -0
  89. package/src/domain/report.ts +203 -0
  90. package/src/host/campaigns.ts +1664 -0
  91. package/src/host/diff.ts +203 -0
  92. package/src/index.ts +256 -0
  93. package/src/infrastructure/campaign-functions.ts +99 -0
  94. package/src/infrastructure/report-source-fake.ts +10 -0
  95. package/src/infrastructure/report-source-live.ts +193 -0
  96. package/src/load/decode.ts +81 -0
  97. package/src/load/extension.ts +466 -0
  98. package/src/manifest/lower.ts +615 -0
  99. package/src/manifest/spec.ts +364 -0
  100. package/src/ports/campaign-predicate.ts +29 -0
  101. package/src/ports/report-source.ts +35 -0
  102. package/src/testing.ts +4 -0
@@ -0,0 +1,675 @@
1
+ import * as Result from "effect/Result";
2
+ import * as Schema from "effect/Schema";
3
+
4
+ import type { CampaignUnit } from "../domain/config.js";
5
+ import type { CompiledCampaign } from "./campaigns.js";
6
+ import { isDefinedPhase, type SectorPosition } from "./phases.js";
7
+ import { IMPLICIT_SECTOR } from "./sectors.js";
8
+
9
+ // An objective's ledger: every place the pattern still occurs, per sector,
10
+ // and the record of every time a count was allowed to go up. It is the
11
+ // baseline's cousin and not the baseline — same fingerprint, different
12
+ // file, different semantics.
13
+ //
14
+ // - One file per objective, `<ledger>/<campaign>/<objective>.json`, with
15
+ // every counter and the holdout list per sector inside, so two pull
16
+ // requests paying down the same objective in two sectors edit different
17
+ // regions of the file and merge cleanly.
18
+ // - Growth is only ever recorded, never silent. `clear` reconciles the
19
+ // ledger with the code wherever that is not a regression; `concede` is
20
+ // the one way a holdout is added by hand, and it always appends a
21
+ // concession with a reason, a timestamp and an author.
22
+ // - The arithmetic is checked, per sector:
23
+ // `holdouts.length === initial + Σ delta − cleared − closed`. A
24
+ // hand-added holdout with no concession fails the build, with no git
25
+ // history in the loop.
26
+ // - A holdout that no longer fires is stale, and `check` fails on it as it
27
+ // fails on a stale baseline entry, so progress lands as a visible diff.
28
+ // - A sector leaving an objective's window has its holdouts `closed` —
29
+ // not cleared, so a window shutting is never counted as progress.
30
+
31
+ export const SectorLedger = Schema.Struct({
32
+ // When the sector entered the objective's window.
33
+ entered: Schema.String,
34
+ // The count the day it did.
35
+ initial: Schema.Finite,
36
+ // Holdouts removed by `clear` since, because they stopped firing.
37
+ cleared: Schema.Finite,
38
+ // Holdouts still firing when the sector left the window.
39
+ closed: Schema.Finite,
40
+ // When a holdout last left the ledger — what the stall clock reads.
41
+ lastCleared: Schema.String,
42
+ // `file` or `file#subject`, relative to the sector's root; `~` for the
43
+ // sector itself. Deduplicated and sorted.
44
+ holdouts: Schema.Array(Schema.String),
45
+ });
46
+
47
+ // Growth conceded with a reason, or a re-baseline authorized by a phase
48
+ // concession: the holdouts as of the plan change, `from` the count before
49
+ // and `to` the count after.
50
+ const Growth = Schema.Struct({
51
+ sector: Schema.String,
52
+ at: Schema.String,
53
+ by: Schema.String,
54
+ delta: Schema.Finite,
55
+ reason: Schema.String,
56
+ holdouts: Schema.Array(Schema.String),
57
+ });
58
+
59
+ const Rebaseline = Schema.Struct({
60
+ sector: Schema.String,
61
+ at: Schema.String,
62
+ by: Schema.String,
63
+ from: Schema.Finite,
64
+ to: Schema.Finite,
65
+ reason: Schema.String,
66
+ });
67
+
68
+ export const Concession = Schema.Union([Growth, Rebaseline]);
69
+
70
+ export const Ledger = Schema.Struct({
71
+ version: Schema.Literal(2),
72
+ campaign: Schema.String,
73
+ objective: Schema.String,
74
+ created: Schema.String,
75
+ sectors: Schema.Record(Schema.String, SectorLedger),
76
+ concessions: Schema.Array(Concession),
77
+ });
78
+
79
+ export type Ledger = typeof Ledger.Type;
80
+ export type SectorLedger = typeof SectorLedger.Type;
81
+ export type Concession = typeof Concession.Type;
82
+
83
+ // The family's first ledger: one campaign, one detector, one flat list.
84
+ // Read as one sector — the implicit one, named for the scope — and
85
+ // rewritten in the new layout on the next `clear`.
86
+ const LedgerV1 = Schema.Struct({
87
+ version: Schema.Literal(1),
88
+ id: Schema.String,
89
+ created: Schema.String,
90
+ initial: Schema.Finite,
91
+ fixed: Schema.Finite,
92
+ lastProgress: Schema.String,
93
+ regressions: Schema.Array(
94
+ Schema.Struct({
95
+ at: Schema.String,
96
+ by: Schema.String,
97
+ delta: Schema.Finite,
98
+ reason: Schema.String,
99
+ entries: Schema.Array(Schema.String),
100
+ }),
101
+ ),
102
+ entries: Schema.Array(Schema.String),
103
+ });
104
+
105
+ const decode = Schema.decodeUnknownResult(Ledger, { errors: "all", onExcessProperty: "error" });
106
+ const decodeV1 = Schema.decodeUnknownResult(LedgerV1, {
107
+ errors: "all",
108
+ onExcessProperty: "error",
109
+ });
110
+
111
+ export const EMPTY_LEDGER = (campaign: string, objective: string, now: number): Ledger => ({
112
+ version: 2,
113
+ campaign,
114
+ objective,
115
+ created: new Date(now).toISOString(),
116
+ sectors: {},
117
+ concessions: [],
118
+ });
119
+
120
+ export const isLegacyLedger = (raw: unknown): boolean =>
121
+ typeof raw === "object" && raw !== null && (raw as { readonly version?: unknown }).version === 1;
122
+
123
+ // A malformed ledger is refused, unlike a malformed baseline, which reads as
124
+ // empty: an empty baseline reports every violation, the safe direction, while
125
+ // an empty ledger would report every hit as unrecorded growth and demand a
126
+ // concession for debt that was already counted.
127
+ export const decodeLedger = (raw: unknown): Result.Result<Ledger, string> => {
128
+ if (isLegacyLedger(raw)) {
129
+ const decoded = decodeV1(raw);
130
+ if (Result.isFailure(decoded)) return Result.fail(String(decoded.failure.issue));
131
+ const old = decoded.success;
132
+ return Result.succeed({
133
+ version: 2,
134
+ campaign: old.id,
135
+ objective: old.id,
136
+ created: old.created,
137
+ sectors: {
138
+ [IMPLICIT_SECTOR]: {
139
+ entered: old.created,
140
+ initial: old.initial,
141
+ cleared: old.fixed,
142
+ closed: 0,
143
+ lastCleared: old.lastProgress,
144
+ holdouts: old.entries,
145
+ },
146
+ },
147
+ concessions: old.regressions.map((one) => ({
148
+ sector: IMPLICIT_SECTOR,
149
+ at: one.at,
150
+ by: one.by,
151
+ delta: one.delta,
152
+ reason: one.reason,
153
+ holdouts: one.entries,
154
+ })),
155
+ });
156
+ }
157
+ const decoded = decode(raw);
158
+ return Result.isFailure(decoded)
159
+ ? Result.fail(String(decoded.failure.issue))
160
+ : Result.succeed(decoded.success);
161
+ };
162
+
163
+ export const serializeLedger = (ledger: Ledger): string => `${JSON.stringify(ledger, null, 2)}\n`;
164
+
165
+ const sorted = (entries: Iterable<string>): ReadonlyArray<string> => [...new Set(entries)].sort();
166
+
167
+ export const deltaOf = (concession: Concession): number =>
168
+ "delta" in concession ? concession.delta : concession.to - concession.from;
169
+
170
+ const concededTotal = (ledger: Ledger, sector: string): number =>
171
+ ledger.concessions
172
+ .filter((one) => one.sector === sector)
173
+ .reduce((sum, one) => sum + deltaOf(one), 0);
174
+
175
+ export const sectorArithmeticHolds = (ledger: Ledger, sector: string): boolean => {
176
+ const own = ledger.sectors[sector];
177
+ if (own === undefined) return concededTotal(ledger, sector) === 0;
178
+ return (
179
+ own.holdouts.length === own.initial + concededTotal(ledger, sector) - own.cleared - own.closed
180
+ );
181
+ };
182
+
183
+ export const ledgerArithmeticHolds = (ledger: Ledger): boolean =>
184
+ [
185
+ ...new Set([...Object.keys(ledger.sectors), ...ledger.concessions.map((one) => one.sector)]),
186
+ ].every((sector) => sectorArithmeticHolds(ledger, sector));
187
+
188
+ // A `match` entry is `file#anchor#hash`; an edit inside the anchored
189
+ // declaration changes the hash and nothing else, and is the same entry.
190
+ const anchorOf = (entry: string): string => entry.slice(0, entry.lastIndexOf("#"));
191
+
192
+ export type Reconciliation = {
193
+ // Entries the ledger already carries, exactly or by anchor.
194
+ readonly ledgered: ReadonlyArray<string>;
195
+ // Entries the ledger does not carry: unrecorded growth.
196
+ readonly unrecorded: ReadonlyArray<string>;
197
+ // Holdouts no entry produces: cleared, and waiting for `clear`.
198
+ readonly stale: ReadonlyArray<string>;
199
+ // Holdouts whose hash moved under a still-present anchor, with what they
200
+ // read now. `clear` rewrites them; they count as neither cleared nor new.
201
+ readonly drifted: ReadonlyArray<{ readonly from: string; readonly to: string }>;
202
+ };
203
+
204
+ // One sector's holdouts against the entries the code produces for it now.
205
+ // An absent sector carries nothing, so every entry is unrecorded.
206
+ export const reconcileSector = (
207
+ ledger: Ledger,
208
+ sector: string,
209
+ entries: Iterable<string>,
210
+ unit: CampaignUnit,
211
+ ): Reconciliation => {
212
+ const holdouts = ledger.sectors[sector]?.holdouts ?? [];
213
+ const known = new Set(holdouts);
214
+ const ledgered: Array<string> = [];
215
+ const unmatched: Array<string> = [];
216
+ const current = new Set<string>();
217
+ for (const entry of entries) {
218
+ current.add(entry);
219
+ if (known.has(entry)) ledgered.push(entry);
220
+ else unmatched.push(entry);
221
+ }
222
+ let stale = holdouts.filter((entry) => !current.has(entry));
223
+ const drifted: Array<{ from: string; to: string }> = [];
224
+ const unrecorded: Array<string> = [];
225
+
226
+ if (unit === "match") {
227
+ // Pair each stale holdout with an unmatched entry under the same anchor,
228
+ // in order, so a declaration with two edited matches keeps two entries.
229
+ const byAnchor = new Map<string, Array<string>>();
230
+ for (const entry of stale) {
231
+ const anchor = anchorOf(entry);
232
+ byAnchor.set(anchor, [...(byAnchor.get(anchor) ?? []), entry]);
233
+ }
234
+ const paired = new Set<string>();
235
+ for (const entry of unmatched) {
236
+ const candidates = byAnchor.get(anchorOf(entry));
237
+ const from = candidates?.shift();
238
+ if (from === undefined) {
239
+ unrecorded.push(entry);
240
+ continue;
241
+ }
242
+ paired.add(from);
243
+ drifted.push({ from, to: entry });
244
+ ledgered.push(entry);
245
+ }
246
+ stale = stale.filter((entry) => !paired.has(entry));
247
+ } else {
248
+ for (const entry of unmatched) unrecorded.push(entry);
249
+ }
250
+
251
+ return { ledgered, unrecorded: sorted(unrecorded), stale, drifted };
252
+ };
253
+
254
+ // `clear` for one sector, in one of three positions: entering the window
255
+ // (recorded with its initial), inside it (stale holdouts leave, drifted
256
+ // ones are rewritten, and `lastCleared` moves only when something left),
257
+ // or past it (what still fires is `closed`, and the clock does not move).
258
+ export const clearedSector = (
259
+ ledger: Ledger,
260
+ sector: string,
261
+ entries: Iterable<string>,
262
+ unit: CampaignUnit,
263
+ now: number,
264
+ window: "inside" | "outside",
265
+ ): Ledger => {
266
+ const at = new Date(now).toISOString();
267
+ const own = ledger.sectors[sector];
268
+ if (window === "outside") {
269
+ if (own === undefined || own.holdouts.length === 0) return ledger;
270
+ return {
271
+ ...ledger,
272
+ sectors: {
273
+ ...ledger.sectors,
274
+ [sector]: { ...own, closed: own.closed + own.holdouts.length, holdouts: [] },
275
+ },
276
+ };
277
+ }
278
+ if (own === undefined) {
279
+ const holdouts = sorted(entries);
280
+ return {
281
+ ...ledger,
282
+ sectors: {
283
+ ...ledger.sectors,
284
+ [sector]: {
285
+ entered: at,
286
+ initial: holdouts.length,
287
+ cleared: 0,
288
+ closed: 0,
289
+ lastCleared: at,
290
+ holdouts,
291
+ },
292
+ },
293
+ };
294
+ }
295
+ const { drifted, stale } = reconcileSector(ledger, sector, entries, unit);
296
+ const rewritten = new Map(drifted.map((one) => [one.from, one.to]));
297
+ const gone = new Set(stale);
298
+ const holdouts = sorted(
299
+ own.holdouts.filter((entry) => !gone.has(entry)).map((entry) => rewritten.get(entry) ?? entry),
300
+ );
301
+ return {
302
+ ...ledger,
303
+ sectors: {
304
+ ...ledger.sectors,
305
+ [sector]: {
306
+ ...own,
307
+ cleared: own.cleared + stale.length,
308
+ lastCleared: stale.length > 0 ? at : own.lastCleared,
309
+ holdouts,
310
+ },
311
+ },
312
+ };
313
+ };
314
+
315
+ export type ConcessionRecord = {
316
+ readonly at: number;
317
+ readonly by: string;
318
+ readonly reason: string;
319
+ };
320
+
321
+ // `concede`: the entries join the sector's holdouts, and a concession
322
+ // records that they did. The delta is what was actually added — an entry
323
+ // already present is not growth.
324
+ export const concededSector = (
325
+ ledger: Ledger,
326
+ sector: string,
327
+ entries: Iterable<string>,
328
+ record: ConcessionRecord,
329
+ ): Ledger => {
330
+ const own = ledger.sectors[sector];
331
+ const present = new Set(own?.holdouts ?? []);
332
+ const added = sorted([...entries].filter((entry) => !present.has(entry)));
333
+ if (added.length === 0) return ledger;
334
+ const at = new Date(record.at).toISOString();
335
+ const base: SectorLedger = own ?? {
336
+ entered: at,
337
+ initial: 0,
338
+ cleared: 0,
339
+ closed: 0,
340
+ lastCleared: at,
341
+ holdouts: [],
342
+ };
343
+ return {
344
+ ...ledger,
345
+ sectors: {
346
+ ...ledger.sectors,
347
+ [sector]: { ...base, holdouts: sorted([...base.holdouts, ...added]) },
348
+ },
349
+ concessions: [
350
+ ...ledger.concessions,
351
+ { sector, at, by: record.by, delta: added.length, reason: record.reason, holdouts: added },
352
+ ],
353
+ };
354
+ };
355
+
356
+ // A re-baseline, authorized by a phase concession: the sector's holdouts
357
+ // become what the code produces now, and the concession records the count
358
+ // before and after with the phase concession's reason.
359
+ export const rebaselinedSector = (
360
+ ledger: Ledger,
361
+ sector: string,
362
+ entries: Iterable<string>,
363
+ record: ConcessionRecord,
364
+ ): Ledger => {
365
+ const own = ledger.sectors[sector];
366
+ const holdouts = sorted(entries);
367
+ const at = new Date(record.at).toISOString();
368
+ const base: SectorLedger = own ?? {
369
+ entered: at,
370
+ initial: 0,
371
+ cleared: 0,
372
+ closed: 0,
373
+ lastCleared: at,
374
+ holdouts: [],
375
+ };
376
+ if (
377
+ base.holdouts.length === holdouts.length &&
378
+ base.holdouts.every((one, i) => one === holdouts[i])
379
+ ) {
380
+ return ledger;
381
+ }
382
+ return {
383
+ ...ledger,
384
+ sectors: { ...ledger.sectors, [sector]: { ...base, holdouts } },
385
+ concessions: [
386
+ ...ledger.concessions,
387
+ {
388
+ sector,
389
+ at,
390
+ by: record.by,
391
+ from: base.holdouts.length,
392
+ to: holdouts.length,
393
+ reason: record.reason,
394
+ },
395
+ ],
396
+ };
397
+ };
398
+
399
+ export const holdoutsOf = (ledger: Ledger): number =>
400
+ Object.values(ledger.sectors).reduce((sum, one) => sum + one.holdouts.length, 0);
401
+
402
+ export const initialOf = (ledger: Ledger): number =>
403
+ Object.values(ledger.sectors).reduce((sum, one) => sum + one.initial, 0);
404
+
405
+ export const clearedOf = (ledger: Ledger): number =>
406
+ Object.values(ledger.sectors).reduce((sum, one) => sum + one.cleared, 0);
407
+
408
+ export const closedOf = (ledger: Ledger): number =>
409
+ Object.values(ledger.sectors).reduce((sum, one) => sum + one.closed, 0);
410
+
411
+ // Growth conceded: every positive delta, the re-baselines included.
412
+ export const allowedOf = (ledger: Ledger): number =>
413
+ ledger.concessions.reduce((sum, one) => sum + Math.max(0, deltaOf(one)), 0);
414
+
415
+ // `cleared / (initial + Σ delta − closed)`: how much of everything the
416
+ // objective was asked to pay down, and was not closed by a window shutting,
417
+ // has been paid.
418
+ export const progressOf = (ledger: Ledger): number => {
419
+ const total =
420
+ initialOf(ledger) +
421
+ ledger.concessions.reduce((sum, one) => sum + deltaOf(one), 0) -
422
+ closedOf(ledger);
423
+ return total <= 0 ? 1 : 1 - holdoutsOf(ledger) / total;
424
+ };
425
+
426
+ export const isComplete = (ledger: Ledger): boolean => holdoutsOf(ledger) === 0;
427
+
428
+ export const lastClearedOf = (ledger: Ledger): string | null => {
429
+ const stamps = Object.values(ledger.sectors).map((one) => one.lastCleared);
430
+ return stamps.length === 0 ? null : stamps.reduce((a, b) => (a > b ? a : b));
431
+ };
432
+
433
+ // Stalled: holdouts remain, and nothing has left the ledger — nor, for a
434
+ // sector standing in an attested or open phase, been said about it —
435
+ // within the campaign's `staleAfter`. A campaign with none never stalls.
436
+ export const isStalled = (
437
+ rule: { readonly staleAfter: number | null },
438
+ ledger: Ledger,
439
+ now: number,
440
+ // The latest per-sector clock reading, when the host has the records.
441
+ sectorClock: string | null = null,
442
+ ): boolean => {
443
+ if (rule.staleAfter === null || isComplete(ledger)) return false;
444
+ const last = [lastClearedOf(ledger), sectorClock]
445
+ .filter((one): one is string => one !== null)
446
+ .reduce((a, b) => (a > b ? a : b), ledger.created);
447
+ return now - Date.parse(last) > rule.staleAfter;
448
+ };
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // The per-sector record: what derivation cannot say about a sector — the
452
+ // furthest phase it has reached, the phases attested for it, and the notes
453
+ // left for the next person or agent to touch it.
454
+
455
+ const Attestation = Schema.Struct({
456
+ phase: Schema.String,
457
+ reason: Schema.String,
458
+ evidence: Schema.optionalKey(Schema.String),
459
+ at: Schema.String,
460
+ by: Schema.String,
461
+ });
462
+
463
+ const Note = Schema.Struct({
464
+ at: Schema.String,
465
+ by: Schema.String,
466
+ // The phase the sector stood at when the note was left; a note under an
467
+ // earlier phase leaves the nudge and stays in the file.
468
+ phase: Schema.NullOr(Schema.String),
469
+ text: Schema.String,
470
+ });
471
+
472
+ export const SectorRecord = Schema.Struct({
473
+ version: Schema.Literal(1),
474
+ campaign: Schema.String,
475
+ sector: Schema.String,
476
+ // The furthest phase ever derived for the sector, by id; `null` before
477
+ // the first `clear` placed it.
478
+ reached: Schema.NullOr(Schema.String),
479
+ // When `reached` last advanced.
480
+ since: Schema.String,
481
+ attested: Schema.Array(Attestation),
482
+ notes: Schema.Array(Note),
483
+ });
484
+
485
+ export type SectorRecord = typeof SectorRecord.Type;
486
+ export type Attestation = typeof Attestation.Type;
487
+ export type Note = typeof Note.Type;
488
+
489
+ const decodeRecord = Schema.decodeUnknownResult(SectorRecord, {
490
+ errors: "all",
491
+ onExcessProperty: "error",
492
+ });
493
+
494
+ export const decodeSectorRecord = (raw: unknown): Result.Result<SectorRecord, string> => {
495
+ const decoded = decodeRecord(raw);
496
+ return Result.isFailure(decoded)
497
+ ? Result.fail(String(decoded.failure.issue))
498
+ : Result.succeed(decoded.success);
499
+ };
500
+
501
+ export const serializeSectorRecord = (record: SectorRecord): string =>
502
+ `${JSON.stringify(record, null, 2)}\n`;
503
+
504
+ export const EMPTY_SECTOR_RECORD = (
505
+ campaign: string,
506
+ sector: string,
507
+ now: number,
508
+ ): SectorRecord => ({
509
+ version: 1,
510
+ campaign,
511
+ sector,
512
+ reached: null,
513
+ since: new Date(now).toISOString(),
514
+ attested: [],
515
+ notes: [],
516
+ });
517
+
518
+ // The record's position, as the phase logic reads it: `reached` as an index
519
+ // (`-1` when unplaced, or when the phase it names is gone).
520
+ export const positionOf = (
521
+ rule: CompiledCampaign,
522
+ record: SectorRecord | undefined,
523
+ ): SectorPosition => ({
524
+ reached:
525
+ record?.reached === null || record === undefined
526
+ ? -1
527
+ : rule.phases.findIndex((phase) => phase.id === record.reached),
528
+ attested: new Set(record?.attested.map((one) => one.phase) ?? []),
529
+ });
530
+
531
+ // `reached` only advances.
532
+ export const reachedRecord = (
533
+ record: SectorRecord,
534
+ rule: CompiledCampaign,
535
+ phase: number,
536
+ now: number,
537
+ ): SectorRecord => {
538
+ const current = positionOf(rule, record).reached;
539
+ const id = rule.phases[phase]?.id ?? null;
540
+ if (phase <= current || id === null) return record;
541
+ return { ...record, reached: id, since: new Date(now).toISOString() };
542
+ };
543
+
544
+ export const attestedRecord = (
545
+ record: SectorRecord,
546
+ entry: Omit<Attestation, "at"> & { readonly at: number },
547
+ ): SectorRecord => ({
548
+ ...record,
549
+ attested: [...record.attested, { ...entry, at: new Date(entry.at).toISOString() }],
550
+ });
551
+
552
+ // Notes are capped — the last `NOTE_CAP`, each at most `NOTE_LENGTH`
553
+ // characters — since the file is reviewed like any other and the nudge
554
+ // hands them to an agent.
555
+ export const NOTE_CAP = 20;
556
+ export const NOTE_LENGTH = 500;
557
+
558
+ export const notedRecord = (
559
+ record: SectorRecord,
560
+ note: Omit<Note, "at"> & { readonly at: number },
561
+ ): SectorRecord => ({
562
+ ...record,
563
+ notes: [
564
+ ...record.notes,
565
+ { ...note, text: note.text.slice(0, NOTE_LENGTH), at: new Date(note.at).toISOString() },
566
+ ].slice(-NOTE_CAP),
567
+ });
568
+
569
+ // The sector's own clock: when it last advanced, or was attested or noted.
570
+ export const sectorClockOf = (record: SectorRecord): string =>
571
+ [
572
+ record.since,
573
+ ...record.attested.map((one) => one.at),
574
+ ...record.notes.map((one) => one.at),
575
+ ].reduce((a, b) => (a > b ? a : b));
576
+
577
+ // ---------------------------------------------------------------------------
578
+ // The plan record: what `clear` last saw of the campaign's phases, so that
579
+ // `check` can tell a change to a defined phase (which needs a concession)
580
+ // from a refinement of an open one (which is free).
581
+
582
+ const PlanPhase = Schema.Struct({
583
+ id: Schema.String,
584
+ hash: Schema.String,
585
+ defined: Schema.Boolean,
586
+ concessions: Schema.Finite,
587
+ });
588
+
589
+ export const PlanRecord = Schema.Struct({
590
+ version: Schema.Literal(1),
591
+ campaign: Schema.String,
592
+ phases: Schema.Array(PlanPhase),
593
+ });
594
+
595
+ export type PlanRecord = typeof PlanRecord.Type;
596
+
597
+ const decodePlan = Schema.decodeUnknownResult(PlanRecord, {
598
+ errors: "all",
599
+ onExcessProperty: "error",
600
+ });
601
+
602
+ export const decodePlanRecord = (raw: unknown): Result.Result<PlanRecord, string> => {
603
+ const decoded = decodePlan(raw);
604
+ return Result.isFailure(decoded)
605
+ ? Result.fail(String(decoded.failure.issue))
606
+ : Result.succeed(decoded.success);
607
+ };
608
+
609
+ export const serializePlanRecord = (record: PlanRecord): string =>
610
+ `${JSON.stringify(record, null, 2)}\n`;
611
+
612
+ export const planOf = (rule: CompiledCampaign): PlanRecord => ({
613
+ version: 1,
614
+ campaign: rule.id,
615
+ phases: rule.phases.map((phase) => ({
616
+ id: phase.id,
617
+ hash: phase.hash,
618
+ defined: isDefinedPhase(phase),
619
+ concessions: phase.concessions.length,
620
+ })),
621
+ });
622
+
623
+ export type PlanDiff = {
624
+ // Open phases that gained criteria, and phases that are new: free.
625
+ readonly refined: ReadonlyArray<string>;
626
+ // Defined phases whose definition changed, or that were removed.
627
+ readonly changed: ReadonlyArray<string>;
628
+ // Changed phases with no new concession — what fails `check`.
629
+ readonly unreceipted: ReadonlyArray<string>;
630
+ };
631
+
632
+ export const planDiffOf = (rule: CompiledCampaign, recorded: PlanRecord | undefined): PlanDiff => {
633
+ if (recorded === undefined) return { refined: [], changed: [], unreceipted: [] };
634
+ const refined: Array<string> = [];
635
+ const changed: Array<string> = [];
636
+ const unreceipted: Array<string> = [];
637
+ for (const phase of rule.phases) {
638
+ const before = recorded.phases.find((one) => one.id === phase.id);
639
+ if (before === undefined) {
640
+ refined.push(phase.id);
641
+ continue;
642
+ }
643
+ if (before.hash === phase.hash) continue;
644
+ if (!before.defined) {
645
+ refined.push(phase.id);
646
+ continue;
647
+ }
648
+ changed.push(phase.id);
649
+ if (phase.concessions.length <= before.concessions) unreceipted.push(phase.id);
650
+ }
651
+ for (const before of recorded.phases) {
652
+ if (before.defined && !rule.phases.some((phase) => phase.id === before.id)) {
653
+ changed.push(before.id);
654
+ unreceipted.push(before.id);
655
+ }
656
+ }
657
+ return { refined, changed, unreceipted };
658
+ };
659
+
660
+ // ---------------------------------------------------------------------------
661
+ // Where the files are, so both hosts agree.
662
+
663
+ export const ledgerPathOf = (dir: string, campaign: string, objective: string): string =>
664
+ `${dir}/${campaign}/${objective}.json`;
665
+
666
+ // The family's first layout: one file per campaign.
667
+ export const legacyLedgerPathOf = (dir: string, campaign: string): string =>
668
+ `${dir}/${campaign}.json`;
669
+
670
+ export const encodeSectorName = (sector: string): string => encodeURIComponent(sector);
671
+
672
+ export const sectorRecordPathOf = (dir: string, campaign: string, sector: string): string =>
673
+ `${dir}/${campaign}/sectors/${encodeSectorName(sector)}.json`;
674
+
675
+ export const planPathOf = (dir: string, campaign: string): string => `${dir}/${campaign}/plan.json`;