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.
- package/CHANGELOG.md +509 -0
- package/README.md +272 -136
- package/docs/adapter-contract.md +1 -1
- package/docs/host-contract.md +7 -1
- package/docs/mutation-contract.md +354 -82
- package/docs/work-claim-contract.md +466 -88
- package/package.json +2 -2
- package/schemas/core-capabilities-response.json +1 -1
- package/schemas/core-envelope.json +4 -3
- package/schemas/index.json +18 -0
- package/schemas/ledger-repair-proposal.json +170 -0
- package/schemas/ledger-repair-request.json +61 -0
- package/schemas/ledger-repair-response.json +90 -0
- package/schemas/report-config-v1.json +4 -0
- package/schemas/report-config-v2.json +5 -0
- package/skills/wowbagger/SKILL.md +242 -59
- package/src/adapter/core-probe.js +3 -4
- package/src/adapter/process-outcome.js +8 -1
- package/src/claim-capabilities.js +3 -3
- package/src/claim-coordinator.js +61 -12
- package/src/claim-journal.js +212 -7
- package/src/claim-prospective.js +1 -28
- package/src/claim-publication.js +412 -78
- package/src/claim-request.js +9 -0
- package/src/claim-store.js +9 -4
- package/src/cli.js +302 -56
- package/src/extensions.js +1 -0
- package/src/git-autocommit.js +106 -43
- package/src/git-reconciliation.js +74 -19
- package/src/git-worktrees.js +73 -0
- package/src/instrumentation.js +1 -0
- package/src/launch.js +2 -2
- package/src/ledger-repair.js +1170 -0
- package/src/mutation.js +73 -15
- package/src/reconciliation-classifier.js +117 -0
- package/src/report-evidence.js +158 -41
- package/src/report-graph.js +201 -73
- package/src/report-html.js +358 -155
- package/src/report-impact.js +106 -0
- package/src/report-selection.js +97 -0
- package/src/report-sequencing.js +4 -4
- package/src/report-svg.js +74 -20
- package/src/report-view.js +14 -1
- package/src/report.js +109 -23
- package/src/version-drift.js +98 -0
- 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(
|
|
240
|
+
(authorize, ledgerSnapshot) => createItemUnfenced(
|
|
241
|
+
ledgerDirectory, request, scenario, authorize, ledgerSnapshot,
|
|
242
|
+
),
|
|
233
243
|
);
|
|
234
244
|
}
|
|
235
245
|
|
|
236
|
-
async function createItemUnfenced(
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
-
|
|
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:
|
|
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 (
|
|
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 (
|
|
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 (
|
|
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
|
|
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
|
+
}
|
package/src/report-evidence.js
CHANGED
|
@@ -71,59 +71,120 @@ function shiftDays(date, days) {
|
|
|
71
71
|
.slice(0, 10);
|
|
72
72
|
}
|
|
73
73
|
|
|
74
|
-
// Arrivals against
|
|
75
|
-
//
|
|
76
|
-
//
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
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
|
-
|
|
100
|
-
return attachRollingMean(series);
|
|
155
|
+
return attachRollingMean(counts, window);
|
|
101
156
|
}
|
|
102
157
|
|
|
103
|
-
// A four-week trailing mean over
|
|
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
|
-
//
|
|
106
|
-
//
|
|
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(
|
|
110
|
-
return
|
|
111
|
-
|
|
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
|
|
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
|
-
|
|
126
|
-
|
|
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 <=
|
|
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
|
|
209
|
-
// the ledger actually recorded rather than extrapolating an average,
|
|
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.
|
|
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
|
|
288
|
-
const
|
|
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:
|
|
296
|
-
|
|
297
|
-
|
|
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
|
+
}
|