@goodbones/campaigns 0.1.0 → 0.1.1-beta.2

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 (60) hide show
  1. package/build/dts/core/campaign-state.d.ts +3 -1
  2. package/build/dts/core/campaign-state.d.ts.map +1 -1
  3. package/build/dts/core/campaigns.d.ts +46 -2
  4. package/build/dts/core/campaigns.d.ts.map +1 -1
  5. package/build/dts/core/ledger.d.ts +58 -2
  6. package/build/dts/core/ledger.d.ts.map +1 -1
  7. package/build/dts/domain/config.d.ts +204 -2
  8. package/build/dts/domain/config.d.ts.map +1 -1
  9. package/build/dts/host/campaigns.d.ts +34 -0
  10. package/build/dts/host/campaigns.d.ts.map +1 -1
  11. package/build/dts/host/diff.d.ts +1 -0
  12. package/build/dts/host/diff.d.ts.map +1 -1
  13. package/build/dts/host/measure-command.d.ts +3 -0
  14. package/build/dts/host/measure-command.d.ts.map +1 -0
  15. package/build/dts/index.d.ts +5 -4
  16. package/build/dts/index.d.ts.map +1 -1
  17. package/build/dts/load/extension.d.ts +2 -1
  18. package/build/dts/load/extension.d.ts.map +1 -1
  19. package/build/dts/manifest/lower.d.ts.map +1 -1
  20. package/build/dts/manifest/spec.d.ts +209 -3
  21. package/build/dts/manifest/spec.d.ts.map +1 -1
  22. package/build/dts/ports/campaign-measure.d.ts +3 -0
  23. package/build/dts/ports/campaign-measure.d.ts.map +1 -0
  24. package/build/esm/core/campaign-state.js +35 -2
  25. package/build/esm/core/campaign-state.js.map +1 -1
  26. package/build/esm/core/campaigns.js +177 -2
  27. package/build/esm/core/campaigns.js.map +1 -1
  28. package/build/esm/core/ledger.js +215 -0
  29. package/build/esm/core/ledger.js.map +1 -1
  30. package/build/esm/domain/config.js +40 -4
  31. package/build/esm/domain/config.js.map +1 -1
  32. package/build/esm/host/campaigns.js +423 -29
  33. package/build/esm/host/campaigns.js.map +1 -1
  34. package/build/esm/host/diff.js +24 -0
  35. package/build/esm/host/diff.js.map +1 -1
  36. package/build/esm/host/measure-command.js +35 -0
  37. package/build/esm/host/measure-command.js.map +1 -0
  38. package/build/esm/index.js +2 -2
  39. package/build/esm/index.js.map +1 -1
  40. package/build/esm/load/extension.js +57 -13
  41. package/build/esm/load/extension.js.map +1 -1
  42. package/build/esm/manifest/lower.js +68 -1
  43. package/build/esm/manifest/lower.js.map +1 -1
  44. package/build/esm/manifest/spec.js +85 -5
  45. package/build/esm/manifest/spec.js.map +1 -1
  46. package/build/esm/ports/campaign-measure.js +2 -0
  47. package/build/esm/ports/campaign-measure.js.map +1 -0
  48. package/package.json +2 -2
  49. package/src/core/campaign-state.ts +49 -2
  50. package/src/core/campaigns.ts +232 -3
  51. package/src/core/ledger.ts +264 -2
  52. package/src/domain/config.ts +46 -4
  53. package/src/host/campaigns.ts +573 -33
  54. package/src/host/diff.ts +29 -0
  55. package/src/host/measure-command.ts +36 -0
  56. package/src/index.ts +31 -0
  57. package/src/load/extension.ts +70 -6
  58. package/src/manifest/lower.ts +80 -1
  59. package/src/manifest/spec.ts +100 -5
  60. package/src/ports/campaign-measure.ts +11 -0
@@ -3,6 +3,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node
3
3
  import * as path from "node:path";
4
4
 
5
5
  import {
6
+ breaches,
6
7
  compileExportRules,
7
8
  compileImportRules,
8
9
  compileMemberRules,
@@ -39,20 +40,30 @@ import {
39
40
  type CompiledCampaign,
40
41
  type CompiledObjective,
41
42
  detectorOf,
43
+ distanceToTarget,
44
+ measureDetectorsOf,
42
45
  needsSyntax,
43
46
  } from "../core/campaigns.js";
44
47
  import {
45
48
  attestedRecord,
49
+ clearedMeasure,
46
50
  clearedSector,
51
+ concededMeasure,
47
52
  concededSector,
48
53
  EMPTY_LEDGER,
54
+ EMPTY_MEASURE_LEDGER,
49
55
  EMPTY_SECTOR_RECORD,
50
56
  isComplete,
51
57
  isStalled,
52
58
  lastClearedOf,
59
+ lastImprovedOf,
53
60
  type Ledger,
54
61
  ledgerArithmeticHolds,
55
62
  ledgerPathOf,
63
+ type MeasureLedger,
64
+ measureLedgerArithmeticHolds,
65
+ measureProgressOf,
66
+ measureStandingOf,
56
67
  notedRecord,
57
68
  planDiffOf,
58
69
  planOf,
@@ -61,11 +72,13 @@ import {
61
72
  reachedRecord,
62
73
  rebaselinedSector,
63
74
  reconcileSector,
75
+ recordedOf,
64
76
  sectorArithmeticHolds,
65
77
  sectorClockOf,
66
78
  type SectorRecord,
67
79
  sectorRecordPathOf,
68
80
  serializeLedger,
81
+ serializeMeasureLedger,
69
82
  serializePlanRecord,
70
83
  serializeSectorRecord,
71
84
  } from "../core/ledger.js";
@@ -88,10 +101,12 @@ import {
88
101
  commitsTouching,
89
102
  type Diff,
90
103
  distanceToHunks,
104
+ gitEnv,
91
105
  materializeTree,
92
106
  readDiff,
93
107
  textAt,
94
108
  } from "./diff.js";
109
+ import { commandValues } from "./measure-command.js";
95
110
 
96
111
  // The campaigns family, as the CLI runs it: every campaign evaluated over
97
112
  // the files it sees, each objective's hits judged against its ledger per
@@ -247,11 +262,12 @@ export const evaluateCampaigns = (
247
262
  const projects = campaignsOf(policy).campaignRules.some((rule) => rule.perimeter?.kind === "nx")
248
263
  ? listWorkspaceProjects(policy.repoRoot, roots)
249
264
  : [];
265
+ const commandValueOf = commandValues(policy.repoRoot);
250
266
  return campaignsOf(policy).campaignRules.map((rule) => {
251
267
  const detectors = [
252
268
  ...rule.objectives.flatMap((one) => {
253
269
  const detect = detectorOf(one);
254
- return detect === null ? [] : [detect];
270
+ return [...(detect === null ? [] : [detect]), ...measureDetectorsOf(one)];
255
271
  }),
256
272
  ...(rule.perimeter?.kind === "match" ? [rule.perimeter.detect] : []),
257
273
  ];
@@ -276,6 +292,7 @@ export const evaluateCampaigns = (
276
292
  globToRegExp,
277
293
  projects,
278
294
  recordOf: (sector: string) => campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector)),
295
+ commandValueOf,
279
296
  };
280
297
  if (!rule.objectives.some((one) => one.endState !== null)) return evaluateCampaign(rule, input);
281
298
  // An end state's `{ sector, via }` entries need every sector's root, so
@@ -303,6 +320,17 @@ export const evaluateCampaigns = (
303
320
  const ledgerOf = (policy: LoadedPolicy, rule: CompiledCampaign, objective: CompiledObjective) =>
304
321
  campaignsOf(policy).ledgers.get(ledgerKeyOf(rule.id, objective.id));
305
322
 
323
+ const measureLedgerOf = (
324
+ policy: LoadedPolicy,
325
+ rule: CompiledCampaign,
326
+ objective: CompiledObjective,
327
+ ): MeasureLedger | undefined =>
328
+ campaignsOf(policy).measureLedgers.get(ledgerKeyOf(rule.id, objective.id));
329
+
330
+ // How a scalar reads in a line: its value, and against what.
331
+ const describeValue = (value: number): string =>
332
+ Number.isNaN(value) ? "no number" : String(value);
333
+
306
334
  const recordOf = (policy: LoadedPolicy, rule: CompiledCampaign, sector: string) =>
307
335
  campaignsOf(policy).sectorRecords.get(ledgerKeyOf(rule.id, sector));
308
336
 
@@ -323,6 +351,14 @@ export type SectorObjectiveReport = {
323
351
  // The sector is in the objective's window and the ledger has not seen it.
324
352
  readonly unrecorded: boolean;
325
353
  readonly arithmetic: boolean;
354
+ // For a scalar objective: the value measured, the value the ledger holds
355
+ // the sector to (`null` before `clear` records it), and where the one
356
+ // stands against the other.
357
+ readonly measure?: {
358
+ readonly value: number | null;
359
+ readonly recorded: number | null;
360
+ readonly standing: "within" | "breached" | "surpassed" | "unrecorded" | "unmeasured";
361
+ };
326
362
  };
327
363
 
328
364
  export type ObjectiveReport = {
@@ -330,6 +366,14 @@ export type ObjectiveReport = {
330
366
  readonly count: number;
331
367
  readonly ledgered: boolean;
332
368
  readonly sectors: ReadonlyArray<SectorObjectiveReport>;
369
+ // For a scalar objective: its number across the sectors in window.
370
+ readonly measure?: {
371
+ readonly direction: "down" | "up";
372
+ readonly value: number | null;
373
+ readonly recorded: number | null;
374
+ readonly target: number | null;
375
+ readonly tolerance: number;
376
+ };
333
377
  };
334
378
 
335
379
  export type SectorReport = {
@@ -350,6 +394,9 @@ export type CampaignReport = {
350
394
  // No ledger for an objective with hits, or a sector in window the ledger
351
395
  // has not seen: `objectives clear` has not been run.
352
396
  readonly missingLedger: boolean;
397
+ // Scalar objectives that measured no number in a sector in window: a
398
+ // function that answered something else, a command that did not run.
399
+ readonly unmeasured: ReadonlyArray<{ objective: string; sector: string }>;
353
400
  readonly arithmetic: boolean;
354
401
  readonly complete: boolean;
355
402
  readonly stalled: boolean;
@@ -386,7 +433,108 @@ export const campaignReportsOf = (
386
433
  const { rule } = evaluation;
387
434
  const counted = hitsInWindow(evaluation);
388
435
  let missingLedger = false;
436
+ const unmeasured: Array<{ objective: string; sector: string }> = [];
437
+ const scalarNew: Array<{ objective: string; sector: string; entry: string }> = [];
438
+ const scalarStale: Array<{ objective: string; sector: string; entry: string }> = [];
439
+ // A scalar objective, reconciled per sector against its ledger. A value
440
+ // worse than the record past the tolerance is growth `concede` records;
441
+ // better past it, progress `clear` records — each a line of the same
442
+ // `new` and `stale` lists a holdout lands in, so `check` fails on them
443
+ // for the same reasons and says the same next step.
444
+ const scalarReport = (objective: CompiledObjective): ObjectiveReport => {
445
+ const ledger = measureLedgerOf(policy, rule, objective);
446
+ const sectors: Array<SectorObjectiveReport> = [];
447
+ let value = 0;
448
+ let recorded = 0;
449
+ let measuredAll = true;
450
+ let recordedAll = true;
451
+ for (const name of sectorNames(evaluation)) {
452
+ const state = evaluation.sectors.get(name);
453
+ if (state === undefined) continue;
454
+ const measured = state.values[objective.id] ?? Number.NaN;
455
+ const own = ledger?.sectors[name];
456
+ if (!state.inWindow.some((one) => one.id === objective.id)) {
457
+ // Past the window: a record still open is closed by `clear`.
458
+ if (own?.closed === null) {
459
+ scalarStale.push({
460
+ objective: objective.id,
461
+ sector: name,
462
+ entry: `left the window at ${describeValue(measured)}`,
463
+ });
464
+ }
465
+ continue;
466
+ }
467
+ if (Number.isNaN(measured)) {
468
+ measuredAll = false;
469
+ unmeasured.push({ objective: objective.id, sector: name });
470
+ } else value += measured;
471
+ if (ledger === undefined || own === undefined || own.closed !== null) {
472
+ missingLedger = true;
473
+ recordedAll = false;
474
+ sectors.push({
475
+ sector: name,
476
+ count: 0,
477
+ new: [],
478
+ stale: [],
479
+ drifted: 0,
480
+ unrecorded: true,
481
+ arithmetic: true,
482
+ measure: {
483
+ value: Number.isNaN(measured) ? null : measured,
484
+ recorded: null,
485
+ standing: Number.isNaN(measured) ? "unmeasured" : "unrecorded",
486
+ },
487
+ });
488
+ continue;
489
+ }
490
+ recorded += own.recorded;
491
+ const standing = Number.isNaN(measured)
492
+ ? "unmeasured"
493
+ : measureStandingOf(ledger, name, measured, objective.tolerance);
494
+ const line = `measured ${describeValue(measured)}, recorded ${String(own.recorded)}`;
495
+ if (standing === "breached") {
496
+ scalarNew.push({ objective: objective.id, sector: name, entry: line });
497
+ }
498
+ if (standing === "surpassed") {
499
+ scalarStale.push({ objective: objective.id, sector: name, entry: line });
500
+ }
501
+ sectors.push({
502
+ sector: name,
503
+ count: 0,
504
+ new: standing === "breached" ? [line] : [],
505
+ stale: standing === "surpassed" ? [line] : [],
506
+ drifted: 0,
507
+ unrecorded: false,
508
+ arithmetic: measureLedgerArithmeticHolds(ledger),
509
+ measure: {
510
+ value: Number.isNaN(measured) ? null : measured,
511
+ recorded: own.recorded,
512
+ standing,
513
+ },
514
+ });
515
+ }
516
+ // Sectors the ledger records that the code no longer births: closed
517
+ // on the next `clear`, stale until then.
518
+ for (const [name, own] of Object.entries(ledger?.sectors ?? {})) {
519
+ if (evaluation.sectors.has(name) || own.closed !== null) continue;
520
+ scalarStale.push({ objective: objective.id, sector: name, entry: "no longer a sector" });
521
+ }
522
+ return {
523
+ id: objective.id,
524
+ count: 0,
525
+ ledgered: ledger !== undefined,
526
+ sectors,
527
+ measure: {
528
+ direction: objective.direction,
529
+ value: measuredAll ? value : null,
530
+ recorded: recordedAll ? recorded : null,
531
+ target: objective.target,
532
+ tolerance: objective.tolerance,
533
+ },
534
+ };
535
+ };
389
536
  const objectives: Array<ObjectiveReport> = rule.objectives.map((objective) => {
537
+ if (objective.measure !== null) return scalarReport(objective);
390
538
  const ledger = ledgerOf(policy, rule, objective);
391
539
  const sectors: Array<SectorObjectiveReport> = [];
392
540
  for (const name of sectorNames(evaluation)) {
@@ -469,6 +617,30 @@ export const campaignReportsOf = (
469
617
  const ledger = ledgerOf(policy, rule, objective);
470
618
  return ledger === undefined ? [] : [ledger];
471
619
  });
620
+ const measureLedgers = rule.objectives.flatMap((objective) => {
621
+ const ledger = measureLedgerOf(policy, rule, objective);
622
+ return ledger === undefined ? [] : [{ objective, ledger }];
623
+ });
624
+ // A targeted scalar is met in every sector in window when its distance
625
+ // is 0 there; a standing one never holds a campaign open.
626
+ const scalarsMet = rule.objectives
627
+ .filter((objective) => objective.measure !== null && objective.target !== null)
628
+ .every((objective) =>
629
+ [...evaluation.sectors.values()].every(
630
+ (state) =>
631
+ !state.inWindow.some((one) => one.id === objective.id) ||
632
+ (state.counts[objective.id] ?? 0) === 0,
633
+ ),
634
+ );
635
+ const scalarsOpen = measureLedgers.filter(
636
+ ({ objective }) =>
637
+ objective.target !== null &&
638
+ [...evaluation.sectors.values()].some(
639
+ (state) =>
640
+ state.inWindow.some((one) => one.id === objective.id) &&
641
+ (state.counts[objective.id] ?? 0) > 0,
642
+ ),
643
+ );
472
644
  const sectorClock = [...evaluation.sectors.keys()]
473
645
  .map((name) => recordOf(policy, rule, name))
474
646
  .filter((one): one is SectorRecord => one !== undefined)
@@ -478,18 +650,30 @@ export const campaignReportsOf = (
478
650
  return {
479
651
  id: rule.id,
480
652
  count,
481
- new: flat((one) => one.new),
482
- stale: flat((one) => one.stale),
653
+ new: [
654
+ ...flat((one) => (one.measure === undefined ? one.new : [])),
655
+ ...scalarNew,
656
+ ],
657
+ stale: [
658
+ ...flat((one) => (one.measure === undefined ? one.stale : [])),
659
+ ...scalarStale,
660
+ ],
483
661
  drifted: objectives.reduce(
484
662
  (sum, one) => sum + one.sectors.reduce((inner, two) => inner + two.drifted, 0),
485
663
  0,
486
664
  ),
487
665
  missingLedger,
488
- arithmetic: ledgers.every(ledgerArithmeticHolds),
489
- complete: count === 0 && ledgers.every(isComplete),
666
+ unmeasured,
667
+ arithmetic:
668
+ ledgers.every(ledgerArithmeticHolds) &&
669
+ measureLedgers.every(({ ledger }) => measureLedgerArithmeticHolds(ledger)),
670
+ complete: count === 0 && ledgers.every(isComplete) && scalarsMet && unmeasured.length === 0,
490
671
  stalled:
491
- ledgers.length > 0 &&
492
- ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock)),
672
+ (ledgers.length > 0 &&
673
+ ledgers.some((ledger) => isStalled(rule, ledger, policy.now, sectorClock))) ||
674
+ scalarsOpen.some(({ ledger }) =>
675
+ isMeasureStalled(rule, ledger, policy.now, sectorClock),
676
+ ),
493
677
  onComplete: rule.onComplete,
494
678
  objectives,
495
679
  sectors: [...evaluation.sectors.values()].map((state) => ({
@@ -504,6 +688,22 @@ export const campaignReportsOf = (
504
688
  };
505
689
  });
506
690
 
691
+ // A scalar short of its target is stalled when its record has not improved,
692
+ // and no sector has been attested or noted, within the campaign's
693
+ // `staleAfter`.
694
+ const isMeasureStalled = (
695
+ rule: CompiledCampaign,
696
+ ledger: MeasureLedger,
697
+ now: number,
698
+ sectorClock: string | null,
699
+ ): boolean => {
700
+ if (rule.staleAfter === null) return false;
701
+ const last = [lastImprovedOf(ledger), sectorClock]
702
+ .filter((one): one is string => one !== null)
703
+ .reduce((a, b) => (a > b ? a : b), ledger.created);
704
+ return now - Date.parse(last) > rule.staleAfter;
705
+ };
706
+
507
707
  // Which hits in window are carried by a ledger, exactly or by anchor.
508
708
  export const ledgeredFilter = (
509
709
  policy: LoadedPolicy,
@@ -543,6 +743,9 @@ export const campaignFailuresOf = (
543
743
  ...campaigns
544
744
  .filter((one) => one.plan.unreceipted.length > 0)
545
745
  .map((one) => `campaign ${one.id}: a defined phase changed without a concession`),
746
+ ...campaigns
747
+ .filter((one) => one.unmeasured.length > 0)
748
+ .map((one) => `campaign ${one.id}: a scalar objective measured no number`),
546
749
  ...campaigns.filter((one) => one.stale.length > 0).map(() => "stale ledger entries"),
547
750
  ...campaigns.filter((one) => !one.arithmetic).map(() => "ledger arithmetic does not hold"),
548
751
  ...campaigns.filter((one) => one.missingLedger).map((one) => `campaign ${one.id} has no ledger`),
@@ -592,8 +795,19 @@ export const renderCampaignReports = (
592
795
  if (campaign.missingLedger) {
593
796
  const unrecorded = campaign.objectives.flatMap((objective) =>
594
797
  objective.sectors
595
- .filter((one) => one.unrecorded && (one.count > 0 || objective.ledgered))
596
- .map((one) => ` ${objective.id} · ${one.sector} (${count(one.count, "hit")})`),
798
+ .filter(
799
+ (one) =>
800
+ one.unrecorded &&
801
+ (one.count > 0 || objective.ledgered || one.measure !== undefined),
802
+ )
803
+ .map(
804
+ (one) =>
805
+ ` ${objective.id} · ${one.sector} (${
806
+ one.measure === undefined
807
+ ? count(one.count, "hit")
808
+ : `measures ${one.measure.value === null ? "no number" : String(one.measure.value)}`
809
+ })`,
810
+ ),
597
811
  );
598
812
  say(
599
813
  "",
@@ -603,6 +817,13 @@ export const renderCampaignReports = (
603
817
  ` architecture objectives clear ${campaign.id}`,
604
818
  );
605
819
  }
820
+ if (campaign.unmeasured.length > 0) {
821
+ say(
822
+ "",
823
+ `campaign ${campaign.id}: ${count(campaign.unmeasured.length, "sector")} where a scalar objective measured no number — a function that answered something other than a number of zero or more, or a command that did not run or printed none:`,
824
+ ...campaign.unmeasured.map((one) => ` ${one.objective} · ${one.sector}`),
825
+ );
826
+ }
606
827
  if (campaign.new.length > 0 && !campaign.missingLedger) {
607
828
  say(
608
829
  "",
@@ -635,7 +856,7 @@ export const renderCampaignReports = (
635
856
  if (!campaign.arithmetic) {
636
857
  say(
637
858
  "",
638
- `campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed). A holdout was added by hand; remove it, or record it with \`objectives concede\`.`,
859
+ `campaign ${campaign.id}: a ledger does not add up (holdouts ≠ initial + conceded − cleared − closed; for a scalar, recorded ≠ initial + risen − improved). The ledger was edited by hand; restore it, or record the change with \`objectives concede\`.`,
639
860
  );
640
861
  }
641
862
  const complete = campaign.complete && !campaign.missingLedger;
@@ -681,10 +902,46 @@ export const snapshotCampaignsOf = (
681
902
  if (evaluation === undefined) throw new Error("report without evaluation");
682
903
  const { rule } = evaluation;
683
904
  const ledgers = rule.objectives.map((objective) => ledgerOf(policy, rule, objective));
905
+ const scalar = new Set(
906
+ rule.objectives.filter((one) => one.measure !== null).map((one) => one.id),
907
+ );
684
908
  const objectives = rule.objectives.map((objective, j) => {
685
909
  const ledger = ledgers[j];
686
910
  const own = report.objectives[j];
687
911
  const phase = rule.phases.find((one) => one.objectives.includes(objective.id))?.id ?? null;
912
+ if (objective.measure !== null) {
913
+ // A scalar: the holdout counts are all 0, and the number is under
914
+ // `measure`. Met when every sector in window is at its target.
915
+ const measured = measureLedgerOf(policy, rule, objective);
916
+ const met =
917
+ objective.target !== null &&
918
+ (own?.sectors ?? []).every(
919
+ (one) => (evaluation.sectors.get(one.sector)?.counts[objective.id] ?? 1) === 0,
920
+ );
921
+ return {
922
+ id: objective.id,
923
+ phase,
924
+ initial: 0,
925
+ allowed: 0,
926
+ count: 0,
927
+ cleared: 0,
928
+ closed: 0,
929
+ progress:
930
+ measured === undefined ? 0 : measureProgressOf(measured, objective.target),
931
+ lastCleared: measured === undefined ? null : lastImprovedOf(measured),
932
+ concessions: measured?.concessions.length ?? 0,
933
+ complete: met,
934
+ ledgered: measured !== undefined,
935
+ measure: {
936
+ direction: objective.direction,
937
+ value: own?.measure?.value ?? 0,
938
+ recorded:
939
+ own?.measure?.recorded ?? (measured === undefined ? 0 : recordedOf(measured)),
940
+ target: objective.target,
941
+ tolerance: objective.tolerance,
942
+ },
943
+ };
944
+ }
688
945
  if (ledger === undefined) {
689
946
  return {
690
947
  id: objective.id,
@@ -765,13 +1022,21 @@ export const snapshotCampaignsOf = (
765
1022
  legacy: {
766
1023
  files: evaluation.index.legacy.length,
767
1024
  holdouts:
768
- legacy === undefined ? 0 : Object.values(legacy.residue).reduce((a, b) => a + b, 0),
1025
+ legacy === undefined
1026
+ ? 0
1027
+ : Object.entries(legacy.residue)
1028
+ .filter(([id]) => !scalar.has(id))
1029
+ .reduce((a, [, n]) => a + n, 0),
769
1030
  },
770
1031
  plan: report.plan,
771
1032
  stalled: report.stalled,
772
1033
  complete: report.complete,
773
1034
  onComplete: rule.onComplete,
774
- ledgered: ledgers.every((one) => one !== undefined),
1035
+ ledgered: rule.objectives.every((objective, j) =>
1036
+ objective.measure === null
1037
+ ? ledgers[j] !== undefined
1038
+ : measureLedgerOf(policy, rule, objective) !== undefined,
1039
+ ),
775
1040
  };
776
1041
  });
777
1042
 
@@ -807,12 +1072,17 @@ export const renderCampaignRows = (
807
1072
  .join(" → ")}` +
808
1073
  (one.legacy.files > 0 ? ` · legacy ${count(one.legacy.files, "file")}` : ""),
809
1074
  ]),
810
- ...one.objectives.map(
811
- (objective) =>
812
- ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
813
- ` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
814
- (objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
815
- (objective.ledgered ? "" : " no ledger"),
1075
+ ...one.objectives.map((objective) =>
1076
+ objective.measure !== undefined
1077
+ ? ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} measures ${String(objective.measure.value)}, held to ${String(objective.measure.recorded)}` +
1078
+ (objective.measure.target === null
1079
+ ? ""
1080
+ : `, target ${String(objective.measure.target)}`) +
1081
+ (objective.ledgered ? "" : " no ledger")
1082
+ : ` ${objective.id.padEnd(width)} ${percent(objective.progress).padStart(4)} ${String(objective.count).padStart(5)} left` +
1083
+ ` ${String(objective.cleared)} cleared ${String(objective.allowed)} conceded` +
1084
+ (objective.closed > 0 ? ` ${String(objective.closed)} closed` : "") +
1085
+ (objective.ledgered ? "" : " no ledger"),
816
1086
  ),
817
1087
  ];
818
1088
  });
@@ -834,6 +1104,7 @@ export const authorOf = (given: string | undefined): string | null => {
834
1104
  if (given !== undefined && given !== "") return given;
835
1105
  try {
836
1106
  const email = execFileSync("git", ["config", "user.email"], {
1107
+ env: gitEnv(),
837
1108
  encoding: "utf8",
838
1109
  stdio: ["ignore", "pipe", "ignore"],
839
1110
  }).trim();
@@ -854,6 +1125,103 @@ export type ClearOutcome = {
854
1125
  readonly entered: ReadonlyArray<string>;
855
1126
  readonly rebaselined: ReadonlyArray<string>;
856
1127
  readonly left: number;
1128
+ // For a scalar objective: the sectors whose record improved, and what the
1129
+ // open sectors are held to now, summed. `cleared`, `rewritten` and `left`
1130
+ // are 0; `closed` counts sectors.
1131
+ readonly measure?: {
1132
+ readonly improved: ReadonlyArray<{ sector: string; from: number; to: number }>;
1133
+ readonly recorded: number;
1134
+ };
1135
+ };
1136
+
1137
+ // `clear` for a scalar objective: a sector entering the window is recorded
1138
+ // at its value; one inside it whose value improved past the tolerance is
1139
+ // held to that value from now; one past it, or no longer born, is closed;
1140
+ // and a receipted phase change re-baselines the sectors in window to what
1141
+ // they measure, with a concession. A rise is left where it is.
1142
+ const clearMeasure = (
1143
+ policy: LoadedPolicy,
1144
+ evaluation: CampaignEvaluation,
1145
+ objective: CompiledObjective,
1146
+ receipted: PhaseRule | null,
1147
+ by: string,
1148
+ ): ClearOutcome => {
1149
+ const { rule } = evaluation;
1150
+ const existing = measureLedgerOf(policy, rule, objective);
1151
+ const before =
1152
+ existing ?? EMPTY_MEASURE_LEDGER(rule.id, objective.id, objective.direction, policy.now);
1153
+ let ledger = before;
1154
+ const entered: Array<string> = [];
1155
+ const rebaselined: Array<string> = [];
1156
+ const improved: Array<{ sector: string; from: number; to: number }> = [];
1157
+ let closed = 0;
1158
+ for (const [name, state] of evaluation.sectors) {
1159
+ const value = state.values[objective.id] ?? Number.NaN;
1160
+ const own = ledger.sectors[name];
1161
+ if (!state.inWindow.some((one) => one.id === objective.id)) {
1162
+ const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "outside");
1163
+ if (next !== ledger) closed += 1;
1164
+ ledger = next;
1165
+ continue;
1166
+ }
1167
+ if (own === undefined || own.closed !== null) {
1168
+ const next = clearedMeasure(
1169
+ ledger,
1170
+ name,
1171
+ value,
1172
+ objective.tolerance,
1173
+ policy.now,
1174
+ "inside",
1175
+ by,
1176
+ );
1177
+ if (next !== ledger) entered.push(name);
1178
+ ledger = next;
1179
+ continue;
1180
+ }
1181
+ if (receipted !== null) {
1182
+ const next = concededMeasure(ledger, name, value, {
1183
+ at: policy.now,
1184
+ by,
1185
+ reason: `phase ${receipted.id} changed: ${receipted.concessions.at(-1)?.reason ?? ""}`,
1186
+ });
1187
+ if (next !== ledger) rebaselined.push(name);
1188
+ ledger = next;
1189
+ continue;
1190
+ }
1191
+ const next = clearedMeasure(ledger, name, value, objective.tolerance, policy.now, "inside");
1192
+ if (next !== ledger) {
1193
+ improved.push({
1194
+ sector: name,
1195
+ from: own.recorded,
1196
+ to: next.sectors[name]?.recorded ?? own.recorded,
1197
+ });
1198
+ }
1199
+ ledger = next;
1200
+ }
1201
+ // Sectors the code no longer births are past every window.
1202
+ for (const [name, own] of Object.entries(ledger.sectors)) {
1203
+ if (evaluation.sectors.has(name) || own.closed !== null) continue;
1204
+ ledger = clearedMeasure(ledger, name, own.recorded, objective.tolerance, policy.now, "outside");
1205
+ closed += 1;
1206
+ }
1207
+ if (ledger !== before || existing === undefined) {
1208
+ writeJson(
1209
+ policy.repoRoot,
1210
+ ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
1211
+ serializeMeasureLedger(ledger),
1212
+ );
1213
+ }
1214
+ return {
1215
+ campaign: rule.id,
1216
+ objective: objective.id,
1217
+ cleared: 0,
1218
+ rewritten: 0,
1219
+ closed,
1220
+ entered,
1221
+ rebaselined,
1222
+ left: 0,
1223
+ measure: { improved, recorded: recordedOf(ledger) },
1224
+ };
857
1225
  };
858
1226
 
859
1227
  // `clear`: the ledger reconciled with the code wherever that is not a
@@ -875,17 +1243,21 @@ export const clear = (
875
1243
  const outcomes: Array<ClearOutcome> = [];
876
1244
  for (const objective of rule.objectives) {
877
1245
  if (only !== null && objective.id !== only) continue;
878
- const before =
879
- ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
880
- let ledger = before;
881
- const entered: Array<string> = [];
882
- const rebaselined: Array<string> = [];
883
1246
  const phase = rule.phases.find((one) => one.objectives.includes(objective.id));
884
1247
  // The phase naming the objective, when it changed with a receipt.
885
1248
  const receipted =
886
1249
  phase !== undefined && plan.changed.includes(phase.id) && !plan.unreceipted.includes(phase.id)
887
1250
  ? phase
888
1251
  : null;
1252
+ if (objective.measure !== null) {
1253
+ outcomes.push(clearMeasure(policy, evaluation, objective, receipted, by));
1254
+ continue;
1255
+ }
1256
+ const before =
1257
+ ledgerOf(policy, rule, objective) ?? EMPTY_LEDGER(rule.id, objective.id, policy.now);
1258
+ let ledger = before;
1259
+ const entered: Array<string> = [];
1260
+ const rebaselined: Array<string> = [];
889
1261
  let rewritten = 0;
890
1262
  for (const [name, state] of evaluation.sectors) {
891
1263
  const inWindow = state.inWindow.some((one) => one.id === objective.id);
@@ -979,6 +1351,14 @@ export const concede = (
979
1351
  const objective = rule.objectives.find((one) => one.id === objectiveId);
980
1352
  if (objective === undefined)
981
1353
  return Result.fail(`no objective of ${rule.id} is named "${objectiveId}"`);
1354
+ if (objective.measure !== null) {
1355
+ if (chosen !== null) {
1356
+ return Result.fail(
1357
+ `${rule.id}/${objectiveId} is a scalar objective: it has no holdouts to choose among. Narrow with --sector.`,
1358
+ );
1359
+ }
1360
+ return concedeMeasure(policy, evaluation, objective, sector, record);
1361
+ }
982
1362
  let ledger = ledgerOf(policy, rule, objective);
983
1363
  if (ledger === undefined) {
984
1364
  return Result.fail(
@@ -1029,6 +1409,52 @@ export const concede = (
1029
1409
  });
1030
1410
  };
1031
1411
 
1412
+ // `concede` for a scalar objective: each sector in window whose value is
1413
+ // worse than its record past the tolerance is held to its value from now,
1414
+ // with a concession naming the reason and the author. A sector the ledger
1415
+ // has not recorded is `clear`'s to enter first.
1416
+ const concedeMeasure = (
1417
+ policy: LoadedPolicy,
1418
+ evaluation: CampaignEvaluation,
1419
+ objective: CompiledObjective,
1420
+ sector: string | null,
1421
+ record: { at: number; by: string; reason: string },
1422
+ ): Result.Result<ConcedeOutcome, string> => {
1423
+ const { rule } = evaluation;
1424
+ let ledger = measureLedgerOf(policy, rule, objective);
1425
+ if (ledger === undefined) {
1426
+ return Result.fail(
1427
+ `objective ${rule.id}/${objective.id} has no ledger yet; run \`objectives clear ${rule.id}\` first.`,
1428
+ );
1429
+ }
1430
+ const conceded: Array<{ sector: string; entry: string }> = [];
1431
+ const left: Array<{ sector: string; entry: string }> = [];
1432
+ for (const [name, state] of evaluation.sectors) {
1433
+ if (!state.inWindow.some((one) => one.id === objective.id)) continue;
1434
+ const value = state.values[objective.id] ?? Number.NaN;
1435
+ const standing = Number.isNaN(value)
1436
+ ? "unmeasured"
1437
+ : measureStandingOf(ledger, name, value, objective.tolerance);
1438
+ if (standing !== "breached") continue;
1439
+ const from = ledger.sectors[name]?.recorded ?? value;
1440
+ const entry = `${String(from)} → ${String(value)}`;
1441
+ if (sector !== null && name !== sector) {
1442
+ left.push({ sector: name, entry });
1443
+ continue;
1444
+ }
1445
+ ledger = concededMeasure(ledger, name, value, record);
1446
+ conceded.push({ sector: name, entry });
1447
+ }
1448
+ if (conceded.length > 0) {
1449
+ writeJson(
1450
+ policy.repoRoot,
1451
+ ledgerPathOf(campaignsOf(policy).ledgerDir, rule.id, objective.id),
1452
+ serializeMeasureLedger(ledger),
1453
+ );
1454
+ }
1455
+ return Result.succeed({ objective: objective.id, conceded, left });
1456
+ };
1457
+
1032
1458
  // `attest`: a step no detector sees is recorded done for a sector — with a
1033
1459
  // reason and evidence — and only while the sector stands at that phase, so
1034
1460
  // it cannot be recorded ahead and passed through on arrival.
@@ -1135,6 +1561,19 @@ export type SectorNudge = {
1135
1561
  readonly holdouts: { total: number; cap: number; shown: ReadonlyArray<NudgeHoldout> };
1136
1562
  readonly added: ReadonlyArray<string>;
1137
1563
  readonly removed: ReadonlyArray<string>;
1564
+ // The scalar objectives in window: each one's value before and after,
1565
+ // and whether it went back past its tolerance. `before` is the ledger's
1566
+ // record, or the base tree's value in the exact mode; `null` where
1567
+ // neither has one.
1568
+ readonly measures: ReadonlyArray<{
1569
+ readonly objective: string;
1570
+ readonly direction: "down" | "up";
1571
+ readonly before: number | null;
1572
+ readonly after: number | null;
1573
+ readonly target: number | null;
1574
+ readonly tolerance: number;
1575
+ readonly back: boolean;
1576
+ }>;
1138
1577
  // Files this diff added inside the scope that no sector claims.
1139
1578
  readonly belongsInSector: ReadonlyArray<string>;
1140
1579
  };
@@ -1160,6 +1599,9 @@ export type BaseSide = ReadonlyArray<{
1160
1599
  readonly id: string;
1161
1600
  readonly sectors: Readonly<Record<string, Readonly<Record<string, number>>>>;
1162
1601
  readonly hits: ReadonlyArray<{ sector: string; objective: string; entry: string }>;
1602
+ // Each scalar objective's value per sector. Absent from a base side cached
1603
+ // before scalars existed, and then the ledger stands in for it.
1604
+ readonly values?: Readonly<Record<string, Readonly<Record<string, number | null>>>>;
1163
1605
  }>;
1164
1606
 
1165
1607
  export const baseSideOf = (evaluations: ReadonlyArray<CampaignEvaluation>): BaseSide =>
@@ -1173,6 +1615,15 @@ export const baseSideOf = (evaluations: ReadonlyArray<CampaignEvaluation>): Base
1173
1615
  objective: hit.objective,
1174
1616
  entry: hit.entry,
1175
1617
  })),
1618
+ // `NaN` does not survive the JSON cache; `null` is "no number".
1619
+ values: Object.fromEntries(
1620
+ [...evaluation.sectors.values()].map((state) => [
1621
+ state.name,
1622
+ Object.fromEntries(
1623
+ Object.entries(state.values).map(([id, value]) => [id, Number.isNaN(value) ? null : value]),
1624
+ ),
1625
+ ]),
1626
+ ),
1176
1627
  }));
1177
1628
 
1178
1629
  // The nudge for one diff. The "before" side is the ledger, or the base tree
@@ -1223,8 +1674,44 @@ export const nudgeOf = (
1223
1674
  const open = phase !== undefined && isOpenPhase(phase);
1224
1675
  const onTouch = onTouchOf(rule, state.phase);
1225
1676
  // Before: the ledger's count per objective in window, or the base tree's.
1677
+ // A scalar's is its distance to target from the value before: the
1678
+ // base tree's, or the ledger's record.
1226
1679
  const before: Record<string, number> = {};
1680
+ const measures: Array<SectorNudge["measures"][number]> = [];
1227
1681
  for (const objective of state.inWindow) {
1682
+ if (objective.measure !== null) {
1683
+ const recorded = measureLedgerOf(policy, rule, objective)?.sectors[name];
1684
+ const baseValue =
1685
+ baseEvaluation?.values === undefined
1686
+ ? undefined
1687
+ : baseEvaluation.values[name]?.[objective.id];
1688
+ const was =
1689
+ baseValue !== undefined
1690
+ ? baseValue
1691
+ : recorded === undefined || recorded.closed !== null
1692
+ ? null
1693
+ : recorded.recorded;
1694
+ const value = state.values[objective.id] ?? Number.NaN;
1695
+ const now_ = Number.isNaN(value) ? null : value;
1696
+ before[objective.id] =
1697
+ was === null ? (state.counts[objective.id] ?? 0) : distanceToTarget(objective, was);
1698
+ measures.push({
1699
+ objective: objective.id,
1700
+ direction: objective.direction,
1701
+ before: was,
1702
+ after: now_,
1703
+ target: objective.target,
1704
+ tolerance: objective.tolerance,
1705
+ back:
1706
+ was !== null &&
1707
+ (now_ === null ||
1708
+ breaches(
1709
+ { direction: objective.direction, limit: was, tolerance: objective.tolerance },
1710
+ now_,
1711
+ )),
1712
+ });
1713
+ continue;
1714
+ }
1228
1715
  if (baseEvaluation !== null) {
1229
1716
  before[objective.id] = baseEvaluation.sectors[name]?.[objective.id] ?? 0;
1230
1717
  } else {
@@ -1309,9 +1796,20 @@ export const nudgeOf = (
1309
1796
  }
1310
1797
  if (state_.stale.length > 0) editsHoldout = true;
1311
1798
  }
1312
- const back = worsened(before, after);
1313
- const totalBefore = Object.values(before).reduce((a, b) => a + b, 0);
1314
- const totalAfter = Object.values(after).reduce((a, b) => a + b, 0);
1799
+ // A scalar goes back by its tolerance, not by any rise of its
1800
+ // distance; the holdout dimensions go back by any rise at all. Only
1801
+ // the holdouts are paid down: a scalar has none to touch.
1802
+ const scalar = new Set(measures.map((one) => one.objective));
1803
+ const back = [
1804
+ ...worsened(before, after).filter((id) => !scalar.has(id)),
1805
+ ...measures.filter((one) => one.back).map((one) => one.objective),
1806
+ ].sort();
1807
+ const holdoutTotal = (vector: ResidueVector): number =>
1808
+ Object.entries(vector)
1809
+ .filter(([id]) => !scalar.has(id))
1810
+ .reduce((sum, [, n]) => sum + n, 0);
1811
+ const totalBefore = holdoutTotal(before);
1812
+ const totalAfter = holdoutTotal(after);
1315
1813
  let ask: Ask;
1316
1814
  let verdict: Verdict = "ok";
1317
1815
  if (open) ask = "note";
@@ -1327,6 +1825,16 @@ export const nudgeOf = (
1327
1825
  if (verdict !== "ok" && hotfix !== null && by !== null) {
1328
1826
  // The escape: the growth is conceded, with a reason naming the hotfix.
1329
1827
  for (const objective of state.inWindow) {
1828
+ if (objective.measure !== null) {
1829
+ if (measures.some((one) => one.objective === objective.id && one.back)) {
1830
+ concedeMeasure(policy, evaluation, objective, name, {
1831
+ at: policy.now,
1832
+ by,
1833
+ reason: `hotfix: ${hotfix}`,
1834
+ });
1835
+ }
1836
+ continue;
1837
+ }
1330
1838
  const ledger = ledgerOf(policy, rule, objective);
1331
1839
  if (ledger === undefined) continue;
1332
1840
  const entries = own
@@ -1372,6 +1880,7 @@ export const nudgeOf = (
1372
1880
  holdouts: { total: own.length, cap: HOLDOUT_CAP, shown },
1373
1881
  added: added.sort(),
1374
1882
  removed: removed.sort(),
1883
+ measures,
1375
1884
  belongsInSector: name === LEGACY_SECTOR ? belongs.sort() : [],
1376
1885
  });
1377
1886
  }
@@ -1444,6 +1953,7 @@ export const renderNudge = (nudge: Nudge, now: number): ReadonlyArray<string> =>
1444
1953
  : `phase ${one.phase.id ?? "done"} (${String(one.phase.index + 1)} of ${String(one.phase.of)}${one.phase.open ? ", open" : ""})`;
1445
1954
  const quiet =
1446
1955
  one.holdouts.total === 0 &&
1956
+ one.measures.every((measure) => measure.before === measure.after) &&
1447
1957
  one.direction === "neutral" &&
1448
1958
  !one.phase.open &&
1449
1959
  one.verdict === "ok" &&
@@ -1487,6 +1997,18 @@ export const renderNudge = (nudge: Nudge, now: number): ReadonlyArray<string> =>
1487
1997
  ` this diff: ${residueOf(one.residue.before)} → ${residueOf(one.residue.after)} ${one.direction}`,
1488
1998
  );
1489
1999
  }
2000
+ for (const measure of one.measures) {
2001
+ if (measure.before === measure.after && !measure.back) continue;
2002
+ const bounds = [
2003
+ ...(measure.target === null ? [] : [`target ${String(measure.target)}`]),
2004
+ ...(measure.tolerance === 0 ? [] : [`tolerance ${String(measure.tolerance)}`]),
2005
+ ];
2006
+ say(
2007
+ ` ${measure.objective}: ${measure.before === null ? "unrecorded" : String(measure.before)} → ${measure.after === null ? "no number" : String(measure.after)}` +
2008
+ (bounds.length === 0 ? "" : ` (${bounds.join(", ")})`) +
2009
+ (measure.back ? " back" : ""),
2010
+ );
2011
+ }
1490
2012
  }
1491
2013
  for (const file of one.belongsInSector)
1492
2014
  say(` ${file} landed in the legacy inside the scope: this belongs in a sector`);
@@ -1504,7 +2026,8 @@ export type HistoryRow = {
1504
2026
  readonly sha: string;
1505
2027
  readonly at: string;
1506
2028
  readonly subject: string;
1507
- // Holdouts per objective, as the ledgers stood after the commit.
2029
+ // Holdouts per objective, as the ledgers stood after the commit; for a
2030
+ // scalar, the value its open sectors were held to.
1508
2031
  readonly counts: Readonly<Record<string, number>>;
1509
2032
  readonly planChanged: boolean;
1510
2033
  };
@@ -1533,15 +2056,26 @@ export const historyOf = (
1533
2056
  if (text === null) continue;
1534
2057
  try {
1535
2058
  const raw = JSON.parse(text) as {
1536
- sectors?: Record<string, { holdouts?: Array<unknown> }>;
2059
+ kind?: string;
2060
+ sectors?: Record<
2061
+ string,
2062
+ { holdouts?: Array<unknown>; recorded?: number; closed?: number | null }
2063
+ >;
1537
2064
  entries?: Array<unknown>;
1538
2065
  };
2066
+ // A scalar's column is what its open sectors were held to.
1539
2067
  counts[objective.id] =
1540
- raw.entries?.length ??
1541
- Object.values(raw.sectors ?? {}).reduce(
1542
- (sum, one) => sum + (one.holdouts?.length ?? 0),
1543
- 0,
1544
- );
2068
+ raw.kind === "measure"
2069
+ ? Math.round(
2070
+ Object.values(raw.sectors ?? {})
2071
+ .filter((one) => one.closed === null || one.closed === undefined)
2072
+ .reduce((sum, one) => sum + (one.recorded ?? 0), 0) * 1e6,
2073
+ ) / 1e6
2074
+ : (raw.entries?.length ??
2075
+ Object.values(raw.sectors ?? {}).reduce(
2076
+ (sum, one) => sum + (one.holdouts?.length ?? 0),
2077
+ 0,
2078
+ ));
1545
2079
  } catch {
1546
2080
  // an unreadable ledger at that commit contributes nothing
1547
2081
  }
@@ -1602,6 +2136,12 @@ export const explainCampaignLines = (
1602
2136
  return [
1603
2137
  ` ${rule.name}: sector ${sector}${at}${record?.reached === undefined || record.reached === null ? "" : `, reached ${record.reached}`}`,
1604
2138
  ` in window: ${state.inWindow.length === 0 ? "(nothing)" : state.inWindow.map((one) => `${one.id}${firing.has(one.id) ? " ✗" : ""}`).join(", ")}`,
2139
+ ...state.inWindow
2140
+ .filter((one) => one.measure !== null)
2141
+ .map((one) => {
2142
+ const recorded = measureLedgerOf(policy, rule, one)?.sectors[sector];
2143
+ return ` ${one.id}: the sector measures ${describeValue(state.values[one.id] ?? Number.NaN)}${recorded === undefined || recorded.closed !== null ? ", unrecorded" : `, held to ${String(recorded.recorded)}`}${one.target === null ? "" : `, target ${String(one.target)}`}`;
2144
+ }),
1605
2145
  ...(own.length === 0
1606
2146
  ? []
1607
2147
  : [