wowbagger 0.1.0-alpha.9 → 0.5.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 (46) hide show
  1. package/CHANGELOG.md +509 -0
  2. package/README.md +272 -136
  3. package/docs/adapter-contract.md +1 -1
  4. package/docs/host-contract.md +7 -1
  5. package/docs/mutation-contract.md +354 -82
  6. package/docs/work-claim-contract.md +466 -88
  7. package/package.json +2 -2
  8. package/schemas/core-capabilities-response.json +1 -1
  9. package/schemas/core-envelope.json +4 -3
  10. package/schemas/index.json +18 -0
  11. package/schemas/ledger-repair-proposal.json +170 -0
  12. package/schemas/ledger-repair-request.json +61 -0
  13. package/schemas/ledger-repair-response.json +90 -0
  14. package/schemas/report-config-v1.json +4 -0
  15. package/schemas/report-config-v2.json +5 -0
  16. package/skills/wowbagger/SKILL.md +242 -59
  17. package/src/adapter/core-probe.js +3 -4
  18. package/src/adapter/process-outcome.js +8 -1
  19. package/src/claim-capabilities.js +3 -3
  20. package/src/claim-coordinator.js +61 -12
  21. package/src/claim-journal.js +212 -7
  22. package/src/claim-prospective.js +1 -28
  23. package/src/claim-publication.js +412 -78
  24. package/src/claim-request.js +9 -0
  25. package/src/claim-store.js +9 -4
  26. package/src/cli.js +302 -56
  27. package/src/extensions.js +1 -0
  28. package/src/git-autocommit.js +106 -43
  29. package/src/git-reconciliation.js +74 -19
  30. package/src/git-worktrees.js +73 -0
  31. package/src/instrumentation.js +1 -0
  32. package/src/launch.js +2 -2
  33. package/src/ledger-repair.js +1170 -0
  34. package/src/mutation.js +73 -15
  35. package/src/reconciliation-classifier.js +117 -0
  36. package/src/report-evidence.js +158 -41
  37. package/src/report-graph.js +201 -73
  38. package/src/report-html.js +358 -155
  39. package/src/report-impact.js +106 -0
  40. package/src/report-selection.js +97 -0
  41. package/src/report-sequencing.js +4 -4
  42. package/src/report-svg.js +74 -20
  43. package/src/report-view.js +14 -1
  44. package/src/report.js +109 -23
  45. package/src/version-drift.js +98 -0
  46. package/src/worktree-identity.js +165 -0
package/src/mutation.js CHANGED
@@ -26,6 +26,13 @@ import { MAX_ITEM_SOURCE_BYTES } from './limits.js';
26
26
  import { JsonNumber, parseJsonRequest, pointer, sortIssues } from './request.js';
27
27
  import { isCalendarDate, isRfc3339Utc, validateLedger } from './validate.js';
28
28
 
29
+ const LOCK_OWNER_OPERATIONS = new Set([
30
+ 'create',
31
+ 'transition',
32
+ 'parent-migrate',
33
+ 'snooze',
34
+ 'patch',
35
+ ]);
29
36
  const REQUIRED_CORE_FIELDS = [
30
37
  'schema_version',
31
38
  'id',
@@ -57,6 +64,7 @@ const CONTROLLED_ITEM_FIELDS = new Set([
57
64
  'related',
58
65
  'decisions',
59
66
  'body',
67
+ 'extensions',
60
68
  ]);
61
69
  // Everything the core view owns. Extension-node identity preserves only
62
70
  // fields outside this set; core-owned values are compared through coreView.
@@ -167,7 +175,7 @@ export function validateCreateRequest(request, parseIssues = []) {
167
175
  }
168
176
  const controlled = new Set([
169
177
  'schema_version', 'id', 'status', 'created', 'updated', 'completed',
170
- 'killed', 'archived', 'deferred', 'decisions', 'body', 'number',
178
+ 'killed', 'archived', 'deferred', 'decisions', 'body', 'number', 'extensions',
171
179
  ]);
172
180
  for (const field of Object.keys(item)) {
173
181
  if (controlled.has(field)) {
@@ -229,11 +237,15 @@ export async function createItem(ledgerDirectory, request, scenario) {
229
237
  ledgerDirectory,
230
238
  request.id,
231
239
  'create-v1',
232
- (authorize, ledgerSnapshot) => createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnapshot),
240
+ (authorize, ledgerSnapshot) => createItemUnfenced(
241
+ ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
242
+ ),
233
243
  );
234
244
  }
235
245
 
236
- async function createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnapshot) {
246
+ async function createItemUnfenced(
247
+ ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
248
+ ) {
237
249
  const root = path.resolve(ledgerDirectory);
238
250
  const id = request.id;
239
251
  const readPreLockLedger = snapshotReader(root, ledgerSnapshot);
@@ -361,6 +373,15 @@ async function createItemUnfenced(ledgerDirectory, request, scenario, ledgerSnap
361
373
  }));
362
374
  }
363
375
 
376
+ // The allocation this create proposes becomes journal-visible before any
377
+ // byte reaches the ledger, so a sibling worktree that cannot see this
378
+ // item's publication cannot hand the same number out again. The intent is
379
+ // appended only once the candidate is known publishable, so a refusal
380
+ // this command would have returned anyway records no attempt.
381
+ if (authorize) {
382
+ await authorize(null, revisionFor(bytes), relativeFinalPath);
383
+ }
384
+
364
385
  temporaryPath = path.join(finalDirectory, `.wowbagger-tmp-${id}-${randomSuffix()}`);
365
386
  const temporaryFailure = await prepareTemporary(temporaryPath, bytes, null, scenario);
366
387
  if (temporaryFailure) {
@@ -518,7 +539,7 @@ export async function migrateParentItem(ledgerDirectory, request, scenario) {
518
539
  build: buildParentMigration,
519
540
  authorize,
520
541
  }, ledgerSnapshot)
521
- ));
542
+ ), { responseCommand: 'parent-migrate-v1' });
522
543
  }
523
544
 
524
545
  export async function snoozeItem(ledgerDirectory, request, scenario) {
@@ -529,7 +550,7 @@ export async function snoozeItem(ledgerDirectory, request, scenario) {
529
550
  build: buildSnooze,
530
551
  authorize,
531
552
  }, ledgerSnapshot)
532
- ));
553
+ ), { responseCommand: 'snooze-v1' });
533
554
  }
534
555
 
535
556
  // The namespace-lock-held mutation strategy. A claimed publication runs inside
@@ -936,18 +957,23 @@ export function validateParentMigrationRequest(request, parseIssues = []) {
936
957
  for (const member of ['id', 'expected_revision', 'expected_parent', 'parent', 'date']) {
937
958
  if (!hasOwn(request, member)) issues.push(issue(`/${member}`, 'missing-member', `Required member ${member} is missing.`));
938
959
  }
939
- if (typeof request.id !== 'string' || !ULID_PATTERN.test(request.id)) {
960
+ if (hasOwn(request, 'id')
961
+ && (typeof request.id !== 'string' || !ULID_PATTERN.test(request.id))) {
940
962
  issues.push(issue('/id', 'invalid-value', 'Member id must be a canonical Wowbagger item ID.'));
941
963
  }
942
- if (typeof request.expected_revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(request.expected_revision)) {
964
+ if (hasOwn(request, 'expected_revision')
965
+ && (typeof request.expected_revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(request.expected_revision))) {
943
966
  issues.push(issue('/expected_revision', 'invalid-value', 'Member expected_revision must be a SHA-256 revision.'));
944
967
  }
945
968
  for (const member of ['expected_parent', 'parent']) {
946
- if (request[member] !== null && (typeof request[member] !== 'string' || !ULID_PATTERN.test(request[member]))) {
969
+ if (hasOwn(request, member)
970
+ && request[member] !== null
971
+ && (typeof request[member] !== 'string' || !ULID_PATTERN.test(request[member]))) {
947
972
  issues.push(issue(`/${member}`, 'invalid-value', `Member ${member} must be null or a canonical Wowbagger item ID.`));
948
973
  }
949
974
  }
950
- if (typeof request.date !== 'string' || !isCalendarDate(request.date)) {
975
+ if (hasOwn(request, 'date')
976
+ && (typeof request.date !== 'string' || !isCalendarDate(request.date))) {
951
977
  issues.push(issue('/date', 'invalid-value', 'Member date must be an ISO calendar date.'));
952
978
  }
953
979
  return sortIssues(issues);
@@ -961,9 +987,24 @@ async function buildParentMigration(lockedTarget, ledger, request, scenario, roo
961
987
  if (request.date < lockedTarget.data.updated) {
962
988
  issues.push(dateIssue('date-before-updated', 'Migration date must not be earlier than updated.', lockedTarget.data));
963
989
  }
990
+ if (issues.length > 0) {
991
+ return { outcome: parentMigrationRefusal(2, { id: lockedTarget.data.id, issues }) };
992
+ }
964
993
  const currentParent = lockedTarget.data.parent ?? null;
965
994
  if (request.expected_parent !== currentParent) {
966
- issues.push({ code: 'parent-revision-conflict', field: 'expected_parent', message: 'The current parent does not match expected_parent.', related_ids: [] });
995
+ return {
996
+ outcome: parentMigrationRefusal(4, {
997
+ id: lockedTarget.data.id,
998
+ expected_parent: request.expected_parent,
999
+ actual_parent: currentParent,
1000
+ issues: [{
1001
+ code: 'parent-revision-conflict',
1002
+ field: 'expected_parent',
1003
+ message: 'The current parent does not match expected_parent.',
1004
+ related_ids: [],
1005
+ }],
1006
+ }),
1007
+ };
967
1008
  }
968
1009
  if (request.parent === lockedTarget.data.id) {
969
1010
  issues.push({ code: 'invalid-parent', field: 'parent', message: 'An item cannot parent itself.', related_ids: [] });
@@ -975,7 +1016,7 @@ async function buildParentMigration(lockedTarget, ledger, request, scenario, roo
975
1016
  }
976
1017
  }
977
1018
  if (issues.length > 0) {
978
- return { outcome: mutationError('parent-migration-precondition-failed', 'The parent migration failed its preconditions.', 'unchanged', 2, { id: lockedTarget.data.id, issues }) };
1019
+ return { outcome: parentMigrationRefusal(2, { id: lockedTarget.data.id, issues }) };
979
1020
  }
980
1021
  const successor = { ...lockedTarget.data, updated: request.date };
981
1022
  if (request.parent === null) delete successor.parent;
@@ -993,6 +1034,19 @@ async function buildParentMigration(lockedTarget, ledger, request, scenario, roo
993
1034
  return { outcome: operationFailed(request.id, 'serialize-candidate', 'serialization-failed') };
994
1035
  }
995
1036
  }
1037
+
1038
+ // One refusal answers every parent-migration precondition. The exit separates a
1039
+ // stated conflict with the observed parent from the other precondition failures.
1040
+ function parentMigrationRefusal(exit, details) {
1041
+ return mutationError(
1042
+ 'parent-migration-precondition-failed',
1043
+ 'The parent migration failed its preconditions.',
1044
+ 'unchanged',
1045
+ exit,
1046
+ details,
1047
+ );
1048
+ }
1049
+
996
1050
  export function validateSnoozeRequest(request, parseIssues = []) {
997
1051
  const issues = [...parseIssues];
998
1052
  if (request === null || typeof request !== 'object' || Array.isArray(request)) {
@@ -1001,14 +1055,18 @@ export function validateSnoozeRequest(request, parseIssues = []) {
1001
1055
  for (const member of ['id', 'expected_revision', 'snoozed_until', 'date']) {
1002
1056
  if (!hasOwn(request, member)) issues.push(issue(`/${member}`, 'missing-member', `Required member ${member} is missing.`));
1003
1057
  }
1004
- if (typeof request.id !== 'string' || !ULID_PATTERN.test(request.id)) {
1058
+ if (hasOwn(request, 'id')
1059
+ && (typeof request.id !== 'string' || !ULID_PATTERN.test(request.id))) {
1005
1060
  issues.push(issue('/id', 'invalid-value', 'Member id must be a canonical Wowbagger item ID.'));
1006
1061
  }
1007
- if (typeof request.expected_revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(request.expected_revision)) {
1062
+ if (hasOwn(request, 'expected_revision')
1063
+ && (typeof request.expected_revision !== 'string' || !/^sha256:[0-9a-f]{64}$/.test(request.expected_revision))) {
1008
1064
  issues.push(issue('/expected_revision', 'invalid-value', 'Member expected_revision must be a SHA-256 revision.'));
1009
1065
  }
1010
1066
  for (const member of ['snoozed_until', 'date']) {
1011
- if (request[member] !== null && (typeof request[member] !== 'string' || !isCalendarDate(request[member]))) {
1067
+ if (hasOwn(request, member)
1068
+ && request[member] !== null
1069
+ && (typeof request[member] !== 'string' || !isCalendarDate(request[member]))) {
1012
1070
  issues.push(issue(`/${member}`, 'invalid-value', `Member ${member} must be null or an ISO calendar date.`));
1013
1071
  }
1014
1072
  }
@@ -2015,7 +2073,7 @@ function validLockOwner(owner, file) {
2015
2073
  }
2016
2074
  return isJsonInteger(owner.lock_version, 1)
2017
2075
  && owner.item_id === expectedId
2018
- && (owner.operation === 'create' || owner.operation === 'transition' || owner.operation === 'patch')
2076
+ && LOCK_OWNER_OPERATIONS.has(owner.operation)
2019
2077
  && typeof owner.writer_id === 'string'
2020
2078
  && /^[\x21-\x7e]{1,128}$/.test(owner.writer_id)
2021
2079
  && isRfc3339Utc(owner.started_at);
@@ -0,0 +1,117 @@
1
+ // The reconciliation topology, decided once, from evidence alone.
2
+ //
3
+ // Every command that reconciles a claim journal has to answer the same
4
+ // question about a drifted item: which of the recognized topologies is this,
5
+ // and does it block the write in front of us? Answering it inside each command
6
+ // is how the answers drifted apart, so the decision lives here, pure: no Git,
7
+ // no filesystem, no prose. Callers gather the evidence, render the sentences,
8
+ // and keep the scope to themselves.
9
+ //
10
+ // The vocabulary:
11
+ //
12
+ // revision state where a surface's bytes stand against the journal —
13
+ // `expected` the authorized revision itself, `authorized`
14
+ // some other revision the journal once ruled legitimate,
15
+ // `unknown` bytes no ruling covers, `absent` no bytes at all.
16
+ // owner evidence `{ kind, ref?, commit? }` from `findRevisionOwner`: which
17
+ // live worktree, if any, carries the expected revision.
18
+ // expected writer `current` when the journal names this worktree as the
19
+ // writer of the expected revision, `other` when it names
20
+ // another, `unknown` when nothing can be attributed.
21
+ // scope who a finding blocks: `global` every write, `target` only
22
+ // a write against the item it names, `none` nobody.
23
+ // remediation which remedy the topology prescribes; the caller renders
24
+ // the sentence, so the kinds carry no wording.
25
+
26
+ // Where one surface's bytes stand against the journal. `expected` is a
27
+ // refinement of `authorized`, so it is tested first.
28
+ export function normalizeRevision(revision, expectedRevision, authorizedRevisions) {
29
+ if (revision === null) return 'absent';
30
+ if (revision === expectedRevision) return 'expected';
31
+ return authorizedRevisions.has(revision) ? 'authorized' : 'unknown';
32
+ }
33
+
34
+ // Owner evidence costs a walk of every live worktree's history, so the two
35
+ // topologies that never consult it must not pay for it. The predicate answers
36
+ // from the same states the classifier judges, through the same helper, so
37
+ // neither can drift from the other.
38
+ export function requiresOwnerEvidence({ workingTree, head }) {
39
+ return !isUnattributed(workingTree, head) && workingTree !== 'expected';
40
+ }
41
+
42
+ // Bytes no ruling covers, on either surface, and a working tree that is gone
43
+ // while another surface still holds bytes. Nothing here is attributable to a
44
+ // writer or an owner: the local state is simply out of protocol.
45
+ function isUnattributed(workingTree, head) {
46
+ return workingTree === 'unknown'
47
+ || head === 'unknown'
48
+ || (workingTree === 'absent' && head !== 'absent');
49
+ }
50
+
51
+ // One topology, one member. `expectedOwner` is required exactly when
52
+ // `requiresOwnerEvidence` says so, and is never read otherwise.
53
+ export function classifyReconciliation({ workingTree, head, expectedOwner, expectedWriter }) {
54
+ if (isUnattributed(workingTree, head)) return UNAUTHORIZED_REVISION;
55
+ // The authorized bytes are here and Git has yet to record them. Nothing is
56
+ // in doubt but the commit.
57
+ if (workingTree === 'expected') {
58
+ return { scope: 'global', reason: 'git-finalization-required', remediation: 'commit-in-git' };
59
+ }
60
+ // An item absent from both local surfaces has never existed in this
61
+ // checkout. A sibling may carry the expected revision, but that does not
62
+ // establish ownership for a checkout with no local history or item path.
63
+ if (workingTree === 'absent') {
64
+ return {
65
+ scope: 'target',
66
+ reason: 'worktree-synchronization-required',
67
+ remediation: 'establish-ownership',
68
+ };
69
+ }
70
+ // A live named worktree carries the expected revision, so there is a ref to
71
+ // wait on and a commit to name. This outranks the remaining synchronization
72
+ // answers, because it is the only one that names an owner.
73
+ if (expectedOwner.kind === 'named-sibling') {
74
+ return {
75
+ scope: 'target',
76
+ reason: 'worktree-synchronization-required',
77
+ owner: expectedOwner,
78
+ remediation: 'wait-for-named-owner',
79
+ };
80
+ }
81
+ // Advice to wait for an owning worktree needs an owner that could still
82
+ // appear. When the journal names this worktree as the writer of the expected
83
+ // revision, or this worktree's own history reaches it, the successor exists
84
+ // nowhere but in the journal: there is nothing to synchronize from, and the
85
+ // authorized bytes on disk are simply the wrong ones.
86
+ if (expectedWriter !== 'current' && expectedOwner.kind !== 'current') {
87
+ return {
88
+ scope: 'target',
89
+ reason: 'worktree-synchronization-required',
90
+ // Waiting is only truthful while the commit is still missing. Git already
91
+ // reaches a `reachable-unowned` revision through a tag, a remote-tracking
92
+ // ref, an unchecked branch, or a detached HEAD, so telling a reader to
93
+ // wait for a commit names a wait that can never end: the bytes are there
94
+ // to inspect, and no named worktree will publish them.
95
+ remediation: expectedOwner.kind === 'reachable-unowned'
96
+ ? 'inspect-reachable-history'
97
+ : 'await-owner-commit',
98
+ };
99
+ }
100
+ return UNAUTHORIZED_REVISION;
101
+ }
102
+
103
+ const UNAUTHORIZED_REVISION = Object.freeze({
104
+ scope: 'global',
105
+ reason: 'unauthorized-revision',
106
+ remediation: 'restore-or-adopt',
107
+ });
108
+
109
+ // Scope, never reason text, decides what a finding refuses. A mutation names
110
+ // the item it targets, and a synchronization another item waits on is a wait
111
+ // this mutation does not touch. A caller that names no target, such as the
112
+ // `claim-verify` command, keeps every finding blocking.
113
+ export function blocksTarget(scope, itemId, targetItemId) {
114
+ if (scope === 'none') return false;
115
+ if (scope === 'global') return true;
116
+ return targetItemId === null || itemId === targetItemId;
117
+ }
@@ -71,59 +71,120 @@ function shiftDays(date, days) {
71
71
  .slice(0, 10);
72
72
  }
73
73
 
74
- // Arrivals against completions, week by week. An arrival is `created`; a
75
- // completion is the item reaching any terminal status, because every one of the
76
- // four terminal statuses removes work from the backlog.
77
- export function buildWeeklyFlow(allItems, asOf) {
78
- const lastWeek = weekStart(asOf);
79
- const counts = new Map();
74
+ // Arrivals against closures, week by week. An arrival is `created`; a closure
75
+ // is the item reaching any terminal status, because every one of the four
76
+ // terminal statuses removes work from the backlog. `done` counts the closures
77
+ // that delivered: only a `done` departure is finished work, so only `done`
78
+ // may be read as completed work.
79
+ function isValidDateString(value) {
80
+ if (typeof value !== 'string' || !/^\d{4}-\d{2}-\d{2}$/.test(value)) {
81
+ return false;
82
+ }
83
+ const parsed = Date.parse(`${value}T00:00:00Z`);
84
+ return !Number.isNaN(parsed)
85
+ && new Date(parsed).toISOString().slice(0, 10) === value;
86
+ }
87
+
88
+ // The weeks an evidence window covers. The default is the trailing twelve
89
+ // calendar weeks ending in the week of `asOf`. An explicit range uses the
90
+ // inclusive UTC calendar dates `from` through `to` (`to <= asOf`); a week
91
+ // bucket the range cuts short is marked partial, and the rolling mean only
92
+ // covers complete weeks. A range never filters items out: anything created
93
+ // before `from` still contributes to cumulative inventory.
94
+ function resolveWindow(asOf, range) {
95
+ if (range === null || range === undefined) {
96
+ const lastWeek = weekStart(asOf);
97
+ const weekStarts = [];
98
+ for (let index = WINDOW_WEEKS - 1; index >= 0; index -= 1) {
99
+ weekStarts.push(shiftDays(lastWeek, -index * 7));
100
+ }
101
+ return {
102
+ weekStarts,
103
+ start: weekStarts[0],
104
+ end: asOf,
105
+ partial: new Set(),
106
+ windowWeeks: WINDOW_WEEKS,
107
+ range: null,
108
+ };
109
+ }
110
+ const from = range?.from ?? null;
111
+ const to = range?.to ?? null;
112
+ if (!isValidDateString(from) || !isValidDateString(to) || from > to || to > asOf) {
113
+ throw new Error(`invalid report range ${JSON.stringify(range)}`);
114
+ }
80
115
  const weekStarts = [];
81
- for (let index = WINDOW_WEEKS - 1; index >= 0; index -= 1) {
82
- const start = shiftDays(lastWeek, -index * 7);
116
+ for (let start = weekStart(from); start <= weekStart(to); start = shiftDays(start, 7)) {
83
117
  weekStarts.push(start);
84
- counts.set(start, { weekStart: start, arrivals: 0, completions: 0 });
118
+ }
119
+ const partial = new Set(
120
+ weekStarts.filter((start) => start < from || shiftDays(start, 6) > to),
121
+ );
122
+ const windowDays = (daysBetween(from, to) ?? 0) + 1;
123
+ const windowWeeks = windowDays / 7;
124
+ return {
125
+ weekStarts, start: from, end: to, partial, windowWeeks, range: { from, to },
126
+ };
127
+ }
128
+
129
+ export function buildWeeklyFlow(allItems, asOf, range = null) {
130
+ const window = resolveWindow(asOf, range);
131
+ const counts = new Map();
132
+ for (const start of window.weekStarts) {
133
+ counts.set(start, {
134
+ weekStart: start, arrivals: 0, closures: 0, done: 0, partial: window.partial.has(start),
135
+ });
85
136
  }
86
137
 
87
138
  for (const item of allItems) {
88
139
  const arrivalWeek = counts.get(weekStart(item.created));
89
- if (arrivalWeek !== undefined) {
140
+ if (arrivalWeek !== undefined && item.created >= window.start && item.created <= window.end) {
90
141
  arrivalWeek.arrivals += 1;
91
142
  }
92
- const departureWeek = item.terminalDate === null
93
- ? undefined
94
- : counts.get(weekStart(item.terminalDate));
95
- if (departureWeek !== undefined) {
96
- departureWeek.completions += 1;
143
+ if (item.terminalDate === null) {
144
+ continue;
145
+ }
146
+ const departureWeek = counts.get(weekStart(item.terminalDate));
147
+ if (departureWeek !== undefined
148
+ && item.terminalDate >= window.start && item.terminalDate <= window.end) {
149
+ departureWeek.closures += 1;
150
+ if (item.status === 'done') {
151
+ departureWeek.done += 1;
152
+ }
97
153
  }
98
154
  }
99
- const series = weekStarts.map((start) => counts.get(start));
100
- return attachRollingMean(series);
155
+ return attachRollingMean(counts, window);
101
156
  }
102
157
 
103
- // A four-week trailing mean over completions. Weekly throughput is noisy
158
+ // A four-week trailing mean over closures. Weekly throughput is noisy
104
159
  // enough that the bar heights alone mislead; the mean is the trend line.
105
- // The first three weeks stay null because a four-week mean needs four weeks,
106
- // and averaging over fewer would flatter the start of the window.
160
+ // A trailing window shorter than four complete weeks stays null: averaging
161
+ // over fewer weeks, or over a week the selected range cut short, would
162
+ // flatter or understate the start of the window.
107
163
  const ROLLING_WEEKS = 4;
108
164
 
109
- function attachRollingMean(series) {
110
- return series.map((week, index) => {
111
- if (index < ROLLING_WEEKS - 1) {
165
+ function attachRollingMean(counts, window) {
166
+ return window.weekStarts.map((start, index) => {
167
+ const week = counts.get(start);
168
+ const trailing = window.weekStarts.slice(Math.max(0, index - ROLLING_WEEKS + 1), index + 1);
169
+ if (trailing.length < ROLLING_WEEKS || trailing.some((member) => window.partial.has(member))) {
112
170
  return { ...week, rolling: null };
113
171
  }
114
- const window = series.slice(index - ROLLING_WEEKS + 1, index + 1);
115
- const total = window.reduce((sum, member) => sum + member.completions, 0);
172
+ const total = trailing.reduce((sum, member) => sum + counts.get(member).closures, 0);
116
173
  return { ...week, rolling: Math.round((total / ROLLING_WEEKS) * 100) / 100 };
117
174
  });
118
175
  }
119
-
120
176
  // Cumulative flow over the same window the weekly series covers. Each item
121
177
  // carries three timestamps - `created`, the accept decision, and its terminal
122
178
  // date - so every day in the window can be replayed from the current bytes.
123
179
  // Only three bands are honest here: `backlog -> in-progress` records no
124
- // decision, so the ledger cannot say when work actually started.
125
- export function buildCumulativeFlow(allItems, asOf) {
126
- const start = shiftDays(weekStart(asOf), -(WINDOW_WEEKS - 1) * 7);
180
+ // decision, so the ledger cannot say when work actually started. An explicit
181
+ // range moves the window edges but never drops early items: anything created
182
+ // before the range still stands in inventory on every day it spans.
183
+ // A band named Untriaged holds items with no recorded accept decision, which
184
+ // is reconstruction uncertainty - not proof the item sat untriaged. Deleted
185
+ // items and unrecorded transitions cannot be recovered from the snapshot.
186
+ export function buildCumulativeFlow(allItems, asOf, range = null) {
187
+ const window = resolveWindow(asOf, range);
127
188
  const states = allItems.map((item) => ({
128
189
  created: item.created,
129
190
  accepted: item.decisions.find((decision) => decision.action === 'accept')?.date ?? null,
@@ -131,7 +192,7 @@ export function buildCumulativeFlow(allItems, asOf) {
131
192
  }));
132
193
 
133
194
  const points = [];
134
- for (let date = start; date <= asOf; date = shiftDays(date, 1)) {
195
+ for (let date = window.start; date <= window.end; date = shiftDays(date, 1)) {
135
196
  const point = {
136
197
  date, triage: 0, accepted: 0, terminal: 0,
137
198
  };
@@ -205,11 +266,13 @@ const FORECAST_WEEK_CEILING = 520;
205
266
  // the same --as-of, so the sampler may not read the clock or Math.random.
206
267
  const FORECAST_SEED = 0x9e3779b9;
207
268
 
208
- // Monte Carlo over observed weekly throughput, per Vacanti: resample the weeks
209
- // the ledger actually recorded rather than extrapolating an average, and report
210
- // a band instead of one false-precision date.
269
+ // Monte Carlo over the observed weekly closure rate, per Vacanti: resample the
270
+ // weeks the ledger actually recorded rather than extrapolating an average,
271
+ // and report a band instead of one false-precision date. This is a
272
+ // closure-based estimate of when open work clears, not a feature-delivery
273
+ // commitment: closures include kills, deferrals, and archives alongside done.
211
274
  export function buildForecast(weeks, remaining, asOf) {
212
- const samples = weeks.map((week) => week.completions);
275
+ const samples = weeks.map((week) => week.closures);
213
276
  if (samples.reduce((total, value) => total + value, 0) === 0) {
214
277
  return null;
215
278
  }
@@ -283,21 +346,75 @@ function seededRandom(seed) {
283
346
  };
284
347
  }
285
348
 
286
- export function buildEvidence(openItems, terminalItems, asOf) {
287
- const weeks = buildWeeklyFlow([...openItems, ...terminalItems], asOf);
288
- const completionTotal = weeks.reduce((total, week) => total + week.completions, 0);
349
+ export function buildEvidence(openItems, terminalItems, asOf, range = null) {
350
+ const window = resolveWindow(asOf, range);
351
+ const retained = [...openItems, ...terminalItems];
352
+ const weeks = buildWeeklyFlow(retained, asOf, range);
353
+ const closureTotal = weeks.reduce((total, week) => total + week.closures, 0);
354
+ const doneTotal = weeks.reduce((total, week) => total + week.done, 0);
289
355
 
290
356
  return {
357
+ range: window.range,
291
358
  agingBuckets: buildAgingBuckets(openItems, asOf),
292
359
  agingMatrix: buildAgingMatrix(openItems, asOf),
293
360
  weeks,
294
361
  throughput: {
295
- total: completionTotal,
296
- windowWeeks: WINDOW_WEEKS,
297
- perWeek: Math.round((completionTotal / WINDOW_WEEKS) * 100) / 100,
362
+ total: closureTotal,
363
+ done: doneTotal,
364
+ windowWeeks: window.windowWeeks,
365
+ perWeek: window.windowWeeks === 0
366
+ ? 0
367
+ : Math.round((closureTotal / window.windowWeeks) * 100) / 100,
368
+ },
369
+ cumulativeFlow: buildCumulativeFlow(retained, asOf, range),
370
+ coverageGaps: {
371
+ // Retained items past triage with no recorded accept decision. Their
372
+ // band history reads as untriaged until departure, which is
373
+ // reconstruction uncertainty, not proof they sat untriaged. An item
374
+ // killed straight from triage records a kill instead of an accept;
375
+ // its history is complete, so it is not a gap.
376
+ missingAcceptance: retained.filter((item) => item.status !== 'triage'
377
+ && !item.decisions.some((decision) => decision.action === 'accept')
378
+ && !(item.status === 'killed'
379
+ && item.decisions.some((decision) => decision.action === 'kill'))).length,
298
380
  },
299
- cumulativeFlow: buildCumulativeFlow([...openItems, ...terminalItems], asOf),
300
381
  cycleTime: buildCycleTime(terminalItems),
301
382
  forecast: buildForecast(weeks, openItems.length, asOf),
302
383
  };
303
384
  }
385
+
386
+ // The calculators the browser re-runs, serialized from the same functions
387
+ // Node executes above. Tested for runtime parity against the direct functions
388
+ // in a VM: the browser never carries a second formula.
389
+ function browserFunctionSource(fn) {
390
+ return fn.toString().replace(/^export\s+/, '');
391
+ }
392
+
393
+ export function reportEvidenceBrowserSource() {
394
+ return [
395
+ `const AGE_BUCKETS = ${JSON.stringify(AGE_BUCKETS)};`,
396
+ `const OPEN_STATUSES = ${JSON.stringify(OPEN_STATUSES)};`,
397
+ `const WINDOW_WEEKS = ${WINDOW_WEEKS};`,
398
+ `const MILLISECONDS_PER_DAY = ${MILLISECONDS_PER_DAY};`,
399
+ `const ROLLING_WEEKS = ${ROLLING_WEEKS};`,
400
+ `const FORECAST_TRIALS = ${FORECAST_TRIALS};`,
401
+ `const FORECAST_WEEK_CEILING = ${FORECAST_WEEK_CEILING};`,
402
+ `const FORECAST_SEED = ${FORECAST_SEED};`,
403
+ browserFunctionSource(daysBetween),
404
+ browserFunctionSource(buildAgingBuckets),
405
+ browserFunctionSource(buildAgingMatrix),
406
+ browserFunctionSource(weekStart),
407
+ browserFunctionSource(shiftDays),
408
+ browserFunctionSource(isValidDateString),
409
+ browserFunctionSource(resolveWindow),
410
+ browserFunctionSource(buildWeeklyFlow),
411
+ browserFunctionSource(attachRollingMean),
412
+ browserFunctionSource(buildCumulativeFlow),
413
+ browserFunctionSource(buildCycleTime),
414
+ browserFunctionSource(percentile),
415
+ browserFunctionSource(buildForecast),
416
+ browserFunctionSource(cumulativeShare),
417
+ browserFunctionSource(seededRandom),
418
+ browserFunctionSource(buildEvidence),
419
+ ].join('\n');
420
+ }