wdi-method 0.5.13 → 0.6.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/README.md +436 -271
- package/bin/wdi-method.js +198 -5
- package/kit/.constitution/method/README.md +76 -75
- package/kit/.constitution/method/constitution.md +2 -1
- package/kit/.constitution/method/document/architecture-guide.md +10 -10
- package/kit/.constitution/method/document/bmad-guide.md +50 -16
- package/kit/.constitution/method/document/bmad-skill-register.md +74 -71
- package/kit/.constitution/method/document/brief-guide.md +42 -11
- package/kit/.constitution/method/document/corpus-guide.md +146 -11
- package/kit/.constitution/method/document/decision-guide.md +199 -134
- package/kit/.constitution/method/document/delivery-flow-guide.md +154 -83
- package/kit/.constitution/method/document/prd-guide.md +63 -20
- package/kit/.constitution/method/document/sdd-guide.md +5 -4
- package/kit/.constitution/method/document/srs-guide.md +150 -147
- package/kit/.constitution/method/document/templates/addendum.md +70 -71
- package/kit/.constitution/method/document/templates/architecture-spine.md +6 -4
- package/kit/.constitution/method/document/templates/brief.md +132 -110
- package/kit/.constitution/method/document/templates/c4.md +11 -11
- package/kit/.constitution/method/document/templates/contract.md +51 -52
- package/kit/.constitution/method/document/templates/cross-cutting.md +1 -2
- package/kit/.constitution/method/document/templates/design-system.md +0 -1
- package/kit/.constitution/method/document/templates/integration.md +68 -69
- package/kit/.constitution/method/document/templates/inventory.md +0 -1
- package/kit/.constitution/method/document/templates/lc.md +49 -47
- package/kit/.constitution/method/document/templates/lifecycle.md +51 -52
- package/kit/.constitution/method/document/templates/model.md +68 -55
- package/kit/.constitution/method/document/templates/oq.md +45 -45
- package/kit/.constitution/method/document/templates/prd.md +244 -226
- package/kit/.constitution/method/document/templates/questions.md +100 -76
- package/kit/.constitution/method/document/templates/rules.md +46 -47
- package/kit/.constitution/method/document/templates/sdd.md +26 -11
- package/kit/.constitution/method/document/templates/srs.md +46 -25
- package/kit/.constitution/method/document/templates/uc.md +1 -1
- package/kit/.constitution/method/document/templates/ux.md +76 -77
- package/kit/.constitution/method/document/ux-guide.md +115 -99
- package/kit/.constitution/method/language-guide.md +1 -1
- package/kit/.constitution/method/method-glossary.md +29 -16
- package/kit/.constitution/method/scripts/timeline.py +663 -665
- package/kit/.constitution/method/scripts/validate.py +1143 -362
- package/kit/.constitution/method/structure-guide.md +3 -3
- package/kit/.constitution/method/why/README.md +184 -169
- package/kit/.constitution/method/why/artifact-map.md +15 -13
- package/kit/.constitution/method/why/mode-risk-map.md +140 -0
- package/kit/.constitution/method/why/portability.md +1 -1
- package/kit/.constitution/method/why/rationale.md +12 -5
- package/kit/.constitution/project/README.md +2 -2
- package/kit/.constitution/project/codebase-brownfield-guide.md +18 -18
- package/kit/.constitution/project/codebase-conventions-guide.md +18 -18
- package/kit/.constitution/project/codebase-stack-guide.md +18 -18
- package/kit/assets/bmad-custom/bmad-architecture.toml +1 -1
- package/kit/assets/bmad-custom/bmad-correct-course.toml +5 -5
- package/kit/assets/bmad-custom/bmad-prd.toml +9 -3
- package/kit/assets/bmad-custom/bmad-product-brief.toml +6 -2
- package/kit/skills/wdi-blueprint/SKILL.md +71 -10
- package/kit/skills/wdi-build/SKILL.md +233 -122
- package/kit/skills/wdi-component/SKILL.md +8 -6
- package/kit/skills/wdi-decision/SKILL.md +187 -183
- package/kit/skills/wdi-help/SKILL.md +8 -7
- package/kit/skills/wdi-init/SKILL.md +20 -6
- package/kit/skills/wdi-problem/SKILL.md +31 -13
- package/kit/skills/wdi-product/SKILL.md +30 -17
- package/kit/skills/wdi-question/SKILL.md +170 -88
- package/kit/skills/wdi-reconcile/SKILL.md +33 -10
- package/kit/skills/wdi-report/SKILL.md +77 -25
- package/kit/skills/wdi-review/SKILL.md +201 -118
- package/kit/skills/wdi-systematic-debugging/SKILL.md +7 -7
- package/kit/skills/wdi-upgrade/SKILL.md +179 -0
- package/kit/skills/wdi-ux/SKILL.md +35 -11
- package/kit-overlay/AGENTS.md +2 -2
- package/kit-overlay/README.md +76 -75
- package/kit-overlay/constitution.md +2 -1
- package/kit-overlay/portability.md +1 -1
- package/package.json +43 -43
- package/scaffold/.control/product-glossary.md +1 -1
- package/scaffold/.control/registry/goals.yaml +25 -0
- package/scaffold/.control/registry/index.yaml +5 -5
- package/scaffold/.control/registry/specs.yaml +5 -0
- package/scaffold/.control/structure-codebase.md +19 -19
- package/scaffold/.control/structure-document.md +25 -25
- package/kit/.constitution/method/document/templates/rtr.md +0 -61
- package/kit/assets/bmad-custom/bmad-build-auto.toml +0 -10
- package/kit/assets/bmad-custom/bmad-build.toml +0 -52
- package/kit/assets/bmad-custom/bmad-code-review.toml +0 -13
- package/kit/assets/bmad-custom/bmad-retrospective.toml +0 -9
- package/kit/assets/bmad-custom/bmad-spec.toml +0 -16
- package/scaffold/.control/registry/requirements.yaml +0 -15
- package/scaffold/.control/registry/waves.yaml +0 -5
|
@@ -1,665 +1,663 @@
|
|
|
1
|
-
#!/usr/bin/env -S uv run --script
|
|
2
|
-
# /// script
|
|
3
|
-
# requires-python = ">=3.11"
|
|
4
|
-
# dependencies = ["pyyaml>=6"]
|
|
5
|
-
# ///
|
|
6
|
-
"""timeline — the time dimension: generated/timeline, generated/report, .control/reports/<period>.md.
|
|
7
|
-
|
|
8
|
-
The corpus can state what is true. It cannot state WHEN that became true, because not one
|
|
9
|
-
delivery date is stored — and indeed MUST NOT be stored. This script supplies that missing
|
|
10
|
-
dimension by reading git.
|
|
11
|
-
|
|
12
|
-
timeline.py --generate write .control/generated/timeline.* and report.*
|
|
13
|
-
timeline.py --publish weekly freeze .control/reports/2026-W34.md
|
|
14
|
-
timeline.py --publish monthly freeze .control/reports/2026-08.md
|
|
15
|
-
timeline.py --refresh --generate run validate --generate first
|
|
16
|
-
|
|
17
|
-
The division of labor stays as in 08-project-management.md: whatever can be computed from the
|
|
18
|
-
registry alone belongs to validate.py; only what needs git stays here. That is why Corpus, dump,
|
|
19
|
-
and friends are imported, not copied — one fact MUST NOT have two homes.
|
|
20
|
-
|
|
21
|
-
A delivery date MUST NOT be written back to any registry. It is re-derived on every run; a stored
|
|
22
|
-
copy is a copy that will go stale.
|
|
23
|
-
"""
|
|
24
|
-
|
|
25
|
-
from __future__ import annotations
|
|
26
|
-
|
|
27
|
-
import argparse
|
|
28
|
-
import datetime as dt
|
|
29
|
-
import sys
|
|
30
|
-
from pathlib import Path
|
|
31
|
-
|
|
32
|
-
import yaml
|
|
33
|
-
|
|
34
|
-
from validate import (FM, Corpus,
|
|
35
|
-
listy, load_yaml)
|
|
36
|
-
|
|
37
|
-
#
|
|
38
|
-
# actual_start, no matter how old the file is.
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
"
|
|
44
|
-
"
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
"""
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
return None
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
"
|
|
192
|
-
"
|
|
193
|
-
"
|
|
194
|
-
"
|
|
195
|
-
"
|
|
196
|
-
"
|
|
197
|
-
"
|
|
198
|
-
"
|
|
199
|
-
"
|
|
200
|
-
"
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
"
|
|
212
|
-
"
|
|
213
|
-
"
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
if
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
if
|
|
247
|
-
return False
|
|
248
|
-
if date >
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
285
|
-
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
|
|
289
|
-
|
|
290
|
-
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
|
|
296
|
-
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
|
|
306
|
-
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
310
|
-
|
|
311
|
-
|
|
312
|
-
|
|
313
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
#
|
|
322
|
-
|
|
323
|
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
|
|
337
|
-
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
"
|
|
341
|
-
"
|
|
342
|
-
"
|
|
343
|
-
|
|
344
|
-
"
|
|
345
|
-
#
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
"
|
|
349
|
-
"
|
|
350
|
-
"
|
|
351
|
-
"
|
|
352
|
-
"
|
|
353
|
-
"
|
|
354
|
-
"
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
"
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
out
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
421
|
-
|
|
422
|
-
|
|
423
|
-
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
"
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
out
|
|
434
|
-
out.append(
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
["
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
out.append("
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
out.append("
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
|
|
466
|
-
out
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
"
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
out.append(f"
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
f"
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
out.append("
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
out.append("
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
"
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
if kind == "
|
|
545
|
-
return asof.strftime("%
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
"
|
|
569
|
-
"
|
|
570
|
-
"
|
|
571
|
-
"
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
path
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
parser =
|
|
585
|
-
|
|
586
|
-
parser.add_argument("--
|
|
587
|
-
help="
|
|
588
|
-
parser.add_argument("--
|
|
589
|
-
help="
|
|
590
|
-
parser.add_argument("--
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
parser.
|
|
594
|
-
|
|
595
|
-
args
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
("
|
|
636
|
-
|
|
637
|
-
(
|
|
638
|
-
(
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
if __name__ == "__main__":
|
|
665
|
-
raise SystemExit(main())
|
|
1
|
+
#!/usr/bin/env -S uv run --script
|
|
2
|
+
# /// script
|
|
3
|
+
# requires-python = ">=3.11"
|
|
4
|
+
# dependencies = ["pyyaml>=6"]
|
|
5
|
+
# ///
|
|
6
|
+
"""timeline — the time dimension: generated/timeline, generated/report, .control/reports/<period>.md.
|
|
7
|
+
|
|
8
|
+
The corpus can state what is true. It cannot state WHEN that became true, because not one
|
|
9
|
+
delivery date is stored — and indeed MUST NOT be stored. This script supplies that missing
|
|
10
|
+
dimension by reading git.
|
|
11
|
+
|
|
12
|
+
timeline.py --generate write .control/generated/timeline.* and report.*
|
|
13
|
+
timeline.py --publish weekly freeze .control/reports/2026-W34.md
|
|
14
|
+
timeline.py --publish monthly freeze .control/reports/2026-08.md
|
|
15
|
+
timeline.py --refresh --generate run validate --generate first
|
|
16
|
+
|
|
17
|
+
The division of labor stays as in 08-project-management.md: whatever can be computed from the
|
|
18
|
+
registry alone belongs to validate.py; only what needs git stays here. That is why Corpus, dump,
|
|
19
|
+
and friends are imported, not copied — one fact MUST NOT have two homes.
|
|
20
|
+
|
|
21
|
+
A delivery date MUST NOT be written back to any registry. It is re-derived on every run; a stored
|
|
22
|
+
copy is a copy that will go stale.
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
from __future__ import annotations
|
|
26
|
+
|
|
27
|
+
import argparse
|
|
28
|
+
import datetime as dt
|
|
29
|
+
import sys
|
|
30
|
+
from pathlib import Path
|
|
31
|
+
|
|
32
|
+
import yaml
|
|
33
|
+
|
|
34
|
+
from validate import (FM, Corpus, _ticket_files, _ticket_status, cap_tickets, dump, git,
|
|
35
|
+
listy, load_yaml, status_in)
|
|
36
|
+
|
|
37
|
+
# Statuses that do not yet mean "in progress". A ticket still in this set has no
|
|
38
|
+
# actual_start, no matter how old the file is. `ready-for-agent` is the engine's own opening
|
|
39
|
+
# state — a ticket published and not yet picked up — so it belongs here too.
|
|
40
|
+
NOT_STARTED = {"", "unknown", "draft", "backlog", "todo", "planned", "ready-for-agent"}
|
|
41
|
+
|
|
42
|
+
TIMELINE_COLUMNS = [
|
|
43
|
+
"id", "text", "size", "priority", "owner", "target_release",
|
|
44
|
+
"planned_start", "planned_end", "estimate_mandays",
|
|
45
|
+
"actual_start", "actual_end", "delta_days", "state",
|
|
46
|
+
]
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
# ------------------------------------------------------------------ git history
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
_HIST: dict[tuple[str, str], list[tuple[str, str, str]]] = {}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def history(root: Path, rel: str) -> list[tuple[str, str, str]]:
|
|
56
|
+
"""[(sha, date, content)] in chronological order for one file. Empty if never committed."""
|
|
57
|
+
key = (str(root), rel)
|
|
58
|
+
if key in _HIST:
|
|
59
|
+
return _HIST[key]
|
|
60
|
+
out = git(root, "log", "--reverse", "--format=%H|%ad", "--date=short", "--", rel)
|
|
61
|
+
revs: list[tuple[str, str, str]] = []
|
|
62
|
+
for line in (out or "").splitlines():
|
|
63
|
+
sha, _, date = line.partition("|")
|
|
64
|
+
if not sha or not date:
|
|
65
|
+
continue
|
|
66
|
+
text = git(root, "show", f"{sha}:./{rel}")
|
|
67
|
+
if text is not None:
|
|
68
|
+
revs.append((sha, date, text))
|
|
69
|
+
_HIST[key] = revs
|
|
70
|
+
return revs
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
def fm_of(text: str) -> dict:
|
|
74
|
+
match = FM.match(text)
|
|
75
|
+
if not match:
|
|
76
|
+
return {}
|
|
77
|
+
data = yaml.safe_load(match.group(1))
|
|
78
|
+
return data if isinstance(data, dict) else {}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def yaml_of(text: str) -> dict:
|
|
82
|
+
try:
|
|
83
|
+
data = yaml.safe_load(text)
|
|
84
|
+
except yaml.YAMLError:
|
|
85
|
+
return {}
|
|
86
|
+
return data if isinstance(data, dict) else {}
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
# ------------------------------------------------------------- time derivation
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def ticket_path(c: Corpus, spec: dict, ticket: dict) -> str | None:
|
|
93
|
+
matches = _ticket_files(c, spec, ticket)
|
|
94
|
+
return matches[0].relative_to(c.root).as_posix() if matches else None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def ticket_span(c: Corpus, spec: dict, ticket: dict) -> dict:
|
|
98
|
+
"""When a ticket started being worked on and when it went `done`, read from its file history.
|
|
99
|
+
|
|
100
|
+
A file that exists on disk but has never been committed is marked `uncommitted` instead
|
|
101
|
+
of being given a made-up date. A finished ticket that has not been committed is a state that
|
|
102
|
+
MUST be visible, not one that gets patched over.
|
|
103
|
+
"""
|
|
104
|
+
rel = ticket_path(c, spec, ticket)
|
|
105
|
+
if rel is None:
|
|
106
|
+
return {"start": None, "end": None, "uncommitted": False, "path": None}
|
|
107
|
+
revs = history(c.root, rel)
|
|
108
|
+
if not revs:
|
|
109
|
+
return {"start": None, "end": None, "uncommitted": True, "path": rel}
|
|
110
|
+
start = end = None
|
|
111
|
+
for _sha, date, text in revs:
|
|
112
|
+
status = status_in(text).lower()
|
|
113
|
+
if start is None and status not in NOT_STARTED:
|
|
114
|
+
start = date
|
|
115
|
+
if status == "done":
|
|
116
|
+
end = date
|
|
117
|
+
break
|
|
118
|
+
return {"start": start, "end": end, "uncommitted": False, "path": rel}
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def fr_tickets(c: Corpus) -> dict[str, list[dict]]:
|
|
122
|
+
"""FR -> ticket, through its UCs. cap_tickets()'s twin, one level down."""
|
|
123
|
+
ucs_of: dict[str, list[str]] = {}
|
|
124
|
+
for uc in c.ucs:
|
|
125
|
+
for fid in listy(uc, "satisfies"):
|
|
126
|
+
ucs_of.setdefault(fid, []).append(str(uc.get("id")))
|
|
127
|
+
all_tickets = [t for _, t in c.tickets()]
|
|
128
|
+
out: dict[str, list[dict]] = {}
|
|
129
|
+
for fr in c.frs:
|
|
130
|
+
fid = str(fr.get("id"))
|
|
131
|
+
wanted = set(ucs_of.get(fid, []))
|
|
132
|
+
out[fid] = [t for t in all_tickets if wanted & set(listy(t, "satisfies"))]
|
|
133
|
+
return out
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def span_of(c: Corpus, items: list[dict], spans: dict[str, dict]) -> tuple[str | None, str | None, bool]:
|
|
137
|
+
"""(earliest start, latest end, closed). Closed only when ALL are finished."""
|
|
138
|
+
if not items:
|
|
139
|
+
return None, None, False
|
|
140
|
+
starts = [spans[str(t.get("id"))]["start"] for t in items]
|
|
141
|
+
ends = [spans[str(t.get("id"))]["end"] for t in items]
|
|
142
|
+
closed = all(e for e in ends)
|
|
143
|
+
return (min([x for x in starts if x], default=None),
|
|
144
|
+
max([x for x in ends if x], default=None) if closed else None,
|
|
145
|
+
closed)
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def days_between(a: str | None, b: str | None) -> int | None:
|
|
149
|
+
if not a or not b:
|
|
150
|
+
return None
|
|
151
|
+
try:
|
|
152
|
+
return (dt.date.fromisoformat(a) - dt.date.fromisoformat(b)).days
|
|
153
|
+
except ValueError:
|
|
154
|
+
return None
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def state_of(planned_end: str, actual_start: str | None, closed: bool, asof: dt.date) -> str:
|
|
158
|
+
if closed:
|
|
159
|
+
return "done"
|
|
160
|
+
if planned_end:
|
|
161
|
+
try:
|
|
162
|
+
if dt.date.fromisoformat(planned_end) < asof:
|
|
163
|
+
return "overdue"
|
|
164
|
+
except ValueError:
|
|
165
|
+
pass
|
|
166
|
+
return "in-progress" if actual_start else "not-started"
|
|
167
|
+
|
|
168
|
+
|
|
169
|
+
# ---------------------------------------------------------------- generated/timeline
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def gen_timeline(c: Corpus, asof: dt.date) -> dict:
|
|
173
|
+
spans = {str(t.get("id")): ticket_span(c, sp, t) for sp, t in c.tickets()}
|
|
174
|
+
spec_of = {str(t.get("id")): sp for sp, t in c.tickets()}
|
|
175
|
+
by_cap = cap_tickets(c)
|
|
176
|
+
by_fr = fr_tickets(c)
|
|
177
|
+
fr_of_cap: dict[str, list[dict]] = {}
|
|
178
|
+
for fr in c.frs:
|
|
179
|
+
fr_of_cap.setdefault(str(fr.get("capability", "")), []).append(fr)
|
|
180
|
+
|
|
181
|
+
out = []
|
|
182
|
+
for cap in c.caps:
|
|
183
|
+
cid = str(cap.get("id"))
|
|
184
|
+
items = by_cap.get(cid, [])
|
|
185
|
+
start, end, closed = span_of(c, items, spans)
|
|
186
|
+
planned_end = str(cap.get("planned_end") or "")
|
|
187
|
+
row = {
|
|
188
|
+
"id": cid,
|
|
189
|
+
"text": str(cap.get("title") or cap.get("text") or ""),
|
|
190
|
+
"size": str(cap.get("size") or ""),
|
|
191
|
+
"priority": str(cap.get("priority") or ""),
|
|
192
|
+
"owner": str(cap.get("owner") or ""),
|
|
193
|
+
"target_release": str(cap.get("target_release") or ""),
|
|
194
|
+
"planned_start": str(cap.get("planned_start") or ""),
|
|
195
|
+
"planned_end": planned_end,
|
|
196
|
+
"estimate_mandays": cap.get("estimate_mandays", 0),
|
|
197
|
+
"actual_start": start or "",
|
|
198
|
+
"actual_end": end or "",
|
|
199
|
+
"delta_days": days_between(end, planned_end),
|
|
200
|
+
"state": state_of(planned_end, start, closed, asof),
|
|
201
|
+
}
|
|
202
|
+
children = []
|
|
203
|
+
for fr in sorted(fr_of_cap.get(cid, []), key=lambda x: str(x.get("id"))):
|
|
204
|
+
fid = str(fr.get("id"))
|
|
205
|
+
f_items = by_fr.get(fid, [])
|
|
206
|
+
f_start, f_end, f_closed = span_of(c, f_items, spans)
|
|
207
|
+
children.append({
|
|
208
|
+
"id": fid,
|
|
209
|
+
"text": str(fr.get("title") or fr.get("text") or ""),
|
|
210
|
+
"tickets": sorted(str(t.get("id")) for t in f_items),
|
|
211
|
+
"actual_start": f_start or "",
|
|
212
|
+
"actual_end": f_end or "",
|
|
213
|
+
"state": state_of("", f_start, f_closed, asof),
|
|
214
|
+
})
|
|
215
|
+
row["children"] = children
|
|
216
|
+
row["waiting_on"] = sorted(
|
|
217
|
+
str(t.get("id")) for t in items
|
|
218
|
+
if _ticket_status(c, spec_of.get(str(t.get("id")), {}), t) != "done"
|
|
219
|
+
) if not closed else []
|
|
220
|
+
out.append(row)
|
|
221
|
+
|
|
222
|
+
stray = sorted(sid for sid, span in spans.items() if span["uncommitted"])
|
|
223
|
+
return {"asof": asof.isoformat(), "capabilities": out, "uncommitted_tickets": stray}
|
|
224
|
+
|
|
225
|
+
|
|
226
|
+
# ------------------------------------------------------------------ generated/report
|
|
227
|
+
|
|
228
|
+
|
|
229
|
+
def last_report(c: Corpus, asof: dt.date) -> tuple[str | None, str | None]:
|
|
230
|
+
"""(reference date of the last report, its name). Both None if no report exists yet."""
|
|
231
|
+
best: tuple[str, str] | None = None
|
|
232
|
+
for path in sorted((c.root / ".control/reports").glob("*.md")):
|
|
233
|
+
text = path.read_text(encoding="utf-8", errors="replace")
|
|
234
|
+
fm = fm_of(text)
|
|
235
|
+
stamp = str(fm.get("asof") or "")
|
|
236
|
+
if not stamp or stamp >= asof.isoformat():
|
|
237
|
+
continue
|
|
238
|
+
if best is None or stamp > best[0]:
|
|
239
|
+
best = (stamp, path.stem)
|
|
240
|
+
return best if best else (None, None)
|
|
241
|
+
|
|
242
|
+
|
|
243
|
+
def in_period(date: str | None, since: str | None, asof: dt.date) -> bool:
|
|
244
|
+
if not date:
|
|
245
|
+
return False
|
|
246
|
+
if date > asof.isoformat():
|
|
247
|
+
return False
|
|
248
|
+
return True if since is None else date > since
|
|
249
|
+
|
|
250
|
+
|
|
251
|
+
def first_seen(c: Corpus, rel: str, key: str, pick) -> dict[str, str]:
|
|
252
|
+
"""id -> date when `pick` first became true in a registry's history."""
|
|
253
|
+
seen: dict[str, str] = {}
|
|
254
|
+
for _sha, date, text in history(c.root, rel):
|
|
255
|
+
data = yaml_of(text)
|
|
256
|
+
for item in data.get(key) or []:
|
|
257
|
+
ident = pick(item)
|
|
258
|
+
if ident:
|
|
259
|
+
seen.setdefault(str(ident), date)
|
|
260
|
+
return seen
|
|
261
|
+
|
|
262
|
+
|
|
263
|
+
def gen_report(c: Corpus, timeline: dict, asof: dt.date) -> dict:
|
|
264
|
+
since, since_name = last_report(c, asof)
|
|
265
|
+
rtm = (load_yaml(c.root / ".control/generated/rtm.yaml").get("rtm") or [])
|
|
266
|
+
status = load_yaml(c.root / ".control/generated/status.yaml")
|
|
267
|
+
spans = {str(t.get("id")): ticket_span(c, sp, t) for sp, t in c.tickets()}
|
|
268
|
+
|
|
269
|
+
proven = []
|
|
270
|
+
for row in rtm:
|
|
271
|
+
sid = str(row.get("ticket") or "")
|
|
272
|
+
end = spans.get(sid, {}).get("end")
|
|
273
|
+
if row.get("green") and in_period(end, since, asof):
|
|
274
|
+
proven.append({"FR": row.get("FR"), "UC": row.get("UC"), "ticket": sid,
|
|
275
|
+
"test": row.get("test"), "closed": end})
|
|
276
|
+
proven.sort(key=lambda x: (str(x["closed"]), str(x["ticket"])))
|
|
277
|
+
|
|
278
|
+
moved = []
|
|
279
|
+
for row in timeline["capabilities"]:
|
|
280
|
+
for field, event in (("actual_start", "started"), ("actual_end", "closed")):
|
|
281
|
+
when = row.get(field) or None
|
|
282
|
+
if in_period(when, since, asof):
|
|
283
|
+
moved.append({"id": row["id"], "kind": "CAP", "event": event, "date": when})
|
|
284
|
+
for child in row["children"]:
|
|
285
|
+
for field, event in (("actual_start", "started"), ("actual_end", "closed")):
|
|
286
|
+
when = child.get(field) or None
|
|
287
|
+
if in_period(when, since, asof):
|
|
288
|
+
moved.append({"id": child["id"], "kind": "FR", "event": event, "date": when})
|
|
289
|
+
moved.sort(key=lambda x: (x["date"], x["id"], x["event"]))
|
|
290
|
+
|
|
291
|
+
late = []
|
|
292
|
+
for row in timeline["capabilities"]:
|
|
293
|
+
if row["state"] != "overdue":
|
|
294
|
+
continue
|
|
295
|
+
overdue_by = days_between(asof.isoformat(), row["planned_end"])
|
|
296
|
+
late.append({"id": row["id"], "text": row["text"], "owner": row["owner"],
|
|
297
|
+
"planned_end": row["planned_end"], "days_late": overdue_by,
|
|
298
|
+
"waiting_on": row["waiting_on"] or ["no ticket yet"]})
|
|
299
|
+
late.sort(key=lambda x: (-(x["days_late"] or 0), x["id"]))
|
|
300
|
+
|
|
301
|
+
closures = first_seen(c, ".control/registry/defects.yaml", "defects",
|
|
302
|
+
lambda d: d.get("id") if str(d.get("status")) == "fixed" else None)
|
|
303
|
+
opened, closed_rows = [], []
|
|
304
|
+
for defect in c.defect_list:
|
|
305
|
+
did = str(defect.get("id"))
|
|
306
|
+
entry = {"id": did, "title": str(defect.get("title") or ""),
|
|
307
|
+
"root_cause": str(defect.get("root_cause") or ""),
|
|
308
|
+
"violates": listy(defect, "violates")}
|
|
309
|
+
if in_period(str(defect.get("reported") or ""), since, asof):
|
|
310
|
+
opened.append(entry)
|
|
311
|
+
when = closures.get(did)
|
|
312
|
+
if in_period(when, since, asof):
|
|
313
|
+
closed_rows.append({**entry, "closed": when})
|
|
314
|
+
|
|
315
|
+
by_cause: dict[str, list[str]] = {}
|
|
316
|
+
for row in closed_rows:
|
|
317
|
+
by_cause.setdefault(row["root_cause"] or "?", []).append(row["id"])
|
|
318
|
+
|
|
319
|
+
# An empty `root_cause` is a valid state — the row was opened by someone who has not yet
|
|
320
|
+
# diagnosed it, and `defect-root-cause` does skip it. What MUST NOT happen is it aging unseen, so it is
|
|
321
|
+
# surfaced in the report instead of being held by a validator. The whole period, not just this one.
|
|
322
|
+
undiagnosed = sorted(
|
|
323
|
+
({"id": str(d.get("id")), "title": str(d.get("title") or ""),
|
|
324
|
+
"reported": str(d.get("reported") or ""),
|
|
325
|
+
"age_days": days_between(asof.isoformat(), str(d.get("reported") or ""))}
|
|
326
|
+
for d in c.defect_list
|
|
327
|
+
if not str(d.get("root_cause") or "").strip() and str(d.get("status")) != "fixed"),
|
|
328
|
+
key=lambda x: (-(x["age_days"] or 0), x["id"]))
|
|
329
|
+
|
|
330
|
+
gates = [{"gate": gid, "date": when} for gid, when in sorted(
|
|
331
|
+
first_seen(c, ".control/registry/index.yaml", "gates_passed", lambda g: g).items())
|
|
332
|
+
if in_period(when, since, asof)]
|
|
333
|
+
|
|
334
|
+
head = git(c.root, "rev-parse", "HEAD") or ""
|
|
335
|
+
dirty = bool(git(c.root, "status", "--porcelain", "--", ".control/registry"))
|
|
336
|
+
|
|
337
|
+
return {
|
|
338
|
+
"asof": asof.isoformat(),
|
|
339
|
+
"since": since or "",
|
|
340
|
+
"since_report": since_name or "",
|
|
341
|
+
"sha": head,
|
|
342
|
+
"registry_dirty": dirty,
|
|
343
|
+
# A done ticket that has not been committed still counts in the RTM — its status is read
|
|
344
|
+
# from the working tree — but it will never appear under "Proven", which needs a date from
|
|
345
|
+
# git. That gap MUST be visible in the report, not only in the timeline.
|
|
346
|
+
"uncommitted_tickets": timeline["uncommitted_tickets"],
|
|
347
|
+
"promise_progress": status.get("promise_progress", "n/a"),
|
|
348
|
+
"rtm_rows": status.get("rtm_rows", {}),
|
|
349
|
+
"work_progress": status.get("work_progress", []),
|
|
350
|
+
"gate_readiness": status.get("gate_readiness", "n/a"),
|
|
351
|
+
"proven": proven,
|
|
352
|
+
"moved": moved,
|
|
353
|
+
"late": late,
|
|
354
|
+
"defects": {"opened": sorted(opened, key=lambda x: x["id"]),
|
|
355
|
+
"closed": sorted(closed_rows, key=lambda x: x["id"]),
|
|
356
|
+
"closed_by_root_cause": {k: sorted(v) for k, v in sorted(by_cause.items())},
|
|
357
|
+
"undiagnosed": undiagnosed},
|
|
358
|
+
"gates": gates,
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
|
|
362
|
+
# -------------------------------------------------------------------- rendering
|
|
363
|
+
|
|
364
|
+
|
|
365
|
+
def cell(value: object) -> str:
|
|
366
|
+
text = ", ".join(str(v) for v in value) if isinstance(value, list) else str(value or "")
|
|
367
|
+
return text.replace("|", "\\|").replace("\n", " ").strip() or "—"
|
|
368
|
+
|
|
369
|
+
|
|
370
|
+
def table(headers: list[str], lines: list[list[object]]) -> str:
|
|
371
|
+
if not lines:
|
|
372
|
+
return "_None._\n"
|
|
373
|
+
out = ["| " + " | ".join(headers) + " |",
|
|
374
|
+
"|" + "|".join("---" for _ in headers) + "|"]
|
|
375
|
+
out += ["| " + " | ".join(cell(v) for v in line) + " |" for line in lines]
|
|
376
|
+
return "\n".join(out) + "\n"
|
|
377
|
+
|
|
378
|
+
|
|
379
|
+
def safe(text: str, width: int = 44) -> str:
|
|
380
|
+
"""Text safe to put into mermaid: without the `:` and `,` that break its syntax."""
|
|
381
|
+
clean = str(text or "").replace(":", " ").replace(",", " ").replace("#", "").strip()
|
|
382
|
+
return (clean[:width].rstrip() + "…") if len(clean) > width else (clean or "untitled")
|
|
383
|
+
|
|
384
|
+
|
|
385
|
+
def gantt(timeline: dict) -> str:
|
|
386
|
+
"""Mermaid Gantt: planned and actual side by side, so the gap is visible."""
|
|
387
|
+
lines = ["```mermaid", "gantt", " dateFormat YYYY-MM-DD", " axisFormat %d %b",
|
|
388
|
+
" title Planned vs actual per CAP", ""]
|
|
389
|
+
by_release: dict[str, list[dict]] = {}
|
|
390
|
+
for row in timeline["capabilities"]:
|
|
391
|
+
by_release.setdefault(row["target_release"] or "no release", []).append(row)
|
|
392
|
+
drawn = 0
|
|
393
|
+
for release in sorted(by_release):
|
|
394
|
+
rows_ = by_release[release]
|
|
395
|
+
drawable = [r for r in rows_ if (r["planned_start"] and r["planned_end"])
|
|
396
|
+
or (r["actual_start"] and r["actual_end"])]
|
|
397
|
+
if not drawable:
|
|
398
|
+
continue
|
|
399
|
+
lines.append(f" section {safe(release, 24)}")
|
|
400
|
+
for row in drawable:
|
|
401
|
+
slug = row["id"].replace("-", "").lower()
|
|
402
|
+
label = f"{row['id']} {safe(row['text'])}"
|
|
403
|
+
if row["planned_start"] and row["planned_end"]:
|
|
404
|
+
mark = "crit, " if row["state"] == "overdue" else ""
|
|
405
|
+
lines.append(f" {label} planned :{mark}{slug}p, "
|
|
406
|
+
f"{row['planned_start']}, {row['planned_end']}")
|
|
407
|
+
if row["actual_start"]:
|
|
408
|
+
end = row["actual_end"] or timeline["asof"]
|
|
409
|
+
mark = "done, " if row["state"] == "done" else "active, "
|
|
410
|
+
lines.append(f" {label} actual :{mark}{slug}a, {row['actual_start']}, {end}")
|
|
411
|
+
drawn += 1
|
|
412
|
+
lines.append("")
|
|
413
|
+
lines.append("```")
|
|
414
|
+
if drawn == 0:
|
|
415
|
+
return ("_No CAP has a planned or actual date yet — "
|
|
416
|
+
"the gantt is not drawn._\n")
|
|
417
|
+
return "\n".join(lines) + "\n"
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
HEADER = ("> Generated by `.constitution/method/scripts/timeline.py --generate`. "
|
|
421
|
+
"MUST NOT be hand-edited.\n")
|
|
422
|
+
|
|
423
|
+
|
|
424
|
+
def with_header(rendered: str) -> str:
|
|
425
|
+
"""Insert the warning right below the title — not above it, so the title stays H1."""
|
|
426
|
+
title, _, body = rendered.partition("\n")
|
|
427
|
+
return f"{title}\n\n{HEADER}{body}"
|
|
428
|
+
|
|
429
|
+
|
|
430
|
+
def render_timeline(timeline: dict) -> str:
|
|
431
|
+
out = ["# Timeline\n", f"\nAs of: **{timeline['asof']}**\n\n"]
|
|
432
|
+
out.append(gantt(timeline))
|
|
433
|
+
out.append("\n## Per CAP\n\n")
|
|
434
|
+
out.append(table(
|
|
435
|
+
["CAP", "Title", "Release", "Size", "Priority", "Owner",
|
|
436
|
+
"Planned", "Actual", "Δ days", "State"],
|
|
437
|
+
[[r["id"], r["text"], r["target_release"], r["size"], r["priority"], r["owner"],
|
|
438
|
+
f"{r['planned_start'] or '—'} → {r['planned_end'] or '—'}",
|
|
439
|
+
f"{r['actual_start'] or '—'} → {r['actual_end'] or '—'}",
|
|
440
|
+
"—" if r["delta_days"] is None else f"{r['delta_days']:+d}",
|
|
441
|
+
r["state"]] for r in timeline["capabilities"]]))
|
|
442
|
+
|
|
443
|
+
children = [[r["id"], ch["id"], ch["text"], ch["tickets"],
|
|
444
|
+
f"{ch['actual_start'] or '—'} → {ch['actual_end'] or '—'}", ch["state"]]
|
|
445
|
+
for r in timeline["capabilities"] if r["size"] == "L" for ch in r["children"]]
|
|
446
|
+
if children:
|
|
447
|
+
out.append("\n## FR detail for CAPs sized L\n\n")
|
|
448
|
+
out.append("A CAP sized `L` is drawn as one summary bar; this is what is inside it.\n\n")
|
|
449
|
+
out.append(table(["CAP", "FR", "Title", "Ticket", "Actual", "State"], children))
|
|
450
|
+
|
|
451
|
+
if timeline["uncommitted_tickets"]:
|
|
452
|
+
out.append("\n## Tickets with no git history\n\n")
|
|
453
|
+
out.append("The file exists on disk but has never been committed, so its date "
|
|
454
|
+
"MUST NOT be derived. Commit it first, then run again.\n\n")
|
|
455
|
+
out.append("".join(f"- `{sid}`\n" for sid in timeline["uncommitted_tickets"]))
|
|
456
|
+
return "".join(out)
|
|
457
|
+
|
|
458
|
+
|
|
459
|
+
def render_report(report: dict, title: str = "Report") -> str:
|
|
460
|
+
since = report["since"] or "the project's start"
|
|
461
|
+
edge = ("This period has no left bound — there is no earlier report yet."
|
|
462
|
+
if not report["since"] else
|
|
463
|
+
f"Since `{report['since_report']}` ({report['since']}).")
|
|
464
|
+
out = [f"# {title}\n\n"]
|
|
465
|
+
out.append(f"Period: **{since} → {report['asof']}**. {edge}\n\n")
|
|
466
|
+
out.append(f"Freshness: commit `{(report['sha'] or '?')[:12]}`.")
|
|
467
|
+
if report["registry_dirty"]:
|
|
468
|
+
out.append(" **The registry has uncommitted changes — the numbers below "
|
|
469
|
+
"may not reflect what is on `main`.**")
|
|
470
|
+
out.append("\n\n")
|
|
471
|
+
if report["uncommitted_tickets"]:
|
|
472
|
+
out.append("> **Warning.** The following tickets have a status read from the working "
|
|
473
|
+
"tree but have never been committed: "
|
|
474
|
+
+ ", ".join(f"`{s}`" for s in report["uncommitted_tickets"])
|
|
475
|
+
+ ". They still count toward promise progress, but MUST NOT appear in the "
|
|
476
|
+
"Proven section — there, the date must come from git. Commit them first, "
|
|
477
|
+
"then run again.\n\n")
|
|
478
|
+
|
|
479
|
+
out.append(f"## Promise progress — {report['promise_progress']}\n\n")
|
|
480
|
+
counts = report["rtm_rows"] or {}
|
|
481
|
+
out.append(f"This is the number that counts: green RTM rows divided by counted rows "
|
|
482
|
+
f"({counts.get('green', 0)} out of {counts.get('counted', 0)}; "
|
|
483
|
+
f"{counts.get('excluded_no_uc', 0)} excluded for having `no_uc`). "
|
|
484
|
+
f"It measures what is **proven**, not what has been worked on.\n\n")
|
|
485
|
+
out.append(table(["Other measure", "Value", "Answers"], [
|
|
486
|
+
["Work progress", ", ".join(f"{w.get('spec')} {w.get('work_progress')}"
|
|
487
|
+
for w in report["work_progress"]) or "n/a",
|
|
488
|
+
"how much has been worked on"],
|
|
489
|
+
["Gate readiness", report["gate_readiness"], "whether the next gate can open"],
|
|
490
|
+
]))
|
|
491
|
+
|
|
492
|
+
out.append("\n## 1. Proven\n\n")
|
|
493
|
+
out.append("RTM rows that turned green within this period.\n\n")
|
|
494
|
+
out.append(table(["FR", "UC", "Ticket", "Test", "Date"],
|
|
495
|
+
[[p["FR"], p["UC"], p["ticket"], p["test"], p["closed"]]
|
|
496
|
+
for p in report["proven"]]))
|
|
497
|
+
|
|
498
|
+
out.append("\n## 2. Moved\n\n")
|
|
499
|
+
out.append(table(["ID", "Layer", "Event", "Date"],
|
|
500
|
+
[[m["id"], m["kind"], m["event"], m["date"]] for m in report["moved"]]))
|
|
501
|
+
|
|
502
|
+
out.append("\n## 3. Late\n\n")
|
|
503
|
+
if report["late"]:
|
|
504
|
+
out.append("Named one by one. Summarizing it into a count is how a plan that missed "
|
|
505
|
+
"keeps feeling comfortable.\n\n")
|
|
506
|
+
out.append(table(["CAP", "Title", "Owner", "Planned end", "Late (days)", "Waiting on"],
|
|
507
|
+
[[l["id"], l["text"], l["owner"], l["planned_end"],
|
|
508
|
+
l["days_late"], l["waiting_on"]] for l in report["late"]]))
|
|
509
|
+
|
|
510
|
+
out.append("\n## 4. Defects\n\n")
|
|
511
|
+
defects = report["defects"]
|
|
512
|
+
out.append("**Opened**\n\n")
|
|
513
|
+
out.append(table(["ID", "Title", "Root cause", "Violates"],
|
|
514
|
+
[[d["id"], d["title"], d["root_cause"], d["violates"]]
|
|
515
|
+
for d in defects["opened"]]))
|
|
516
|
+
out.append("\n**Closed, grouped by root cause**\n\n")
|
|
517
|
+
out.append(table(["Root cause", "Defects", "Count"],
|
|
518
|
+
[[cause, ids, len(ids)]
|
|
519
|
+
for cause, ids in defects["closed_by_root_cause"].items()]))
|
|
520
|
+
if defects["closed_by_root_cause"]:
|
|
521
|
+
out.append("\nThe `requirement` and `architecture` rows are worth reading twice: "
|
|
522
|
+
"both count defects that turned out not to be bad code.\n")
|
|
523
|
+
if defects["undiagnosed"]:
|
|
524
|
+
out.append("\n**Not yet diagnosed** — open with no `root_cause`, the whole period\n\n")
|
|
525
|
+
out.append(table(["ID", "Title", "Reported", "Age (days)"],
|
|
526
|
+
[[d["id"], d["title"], d["reported"], d["age_days"]]
|
|
527
|
+
for d in defects["undiagnosed"]]))
|
|
528
|
+
out.append("\nA row with no `root_cause` violates nothing — it means no one has run "
|
|
529
|
+
"`wdi-systematic-debugging` on it yet. While that holds it also does not "
|
|
530
|
+
"count toward the ratio above, so that ratio applies only to defects that "
|
|
531
|
+
"have already been diagnosed.\n")
|
|
532
|
+
|
|
533
|
+
out.append("\n## 5. Gates\n\n")
|
|
534
|
+
out.append(table(["Gate", "Date"], [[g["gate"], g["date"]] for g in report["gates"]]))
|
|
535
|
+
return "".join(out)
|
|
536
|
+
|
|
537
|
+
|
|
538
|
+
# ---------------------------------------------------------------------- publish
|
|
539
|
+
|
|
540
|
+
|
|
541
|
+
def period_name(kind: str, asof: dt.date) -> str:
|
|
542
|
+
if kind == "weekly":
|
|
543
|
+
return asof.strftime("%G-W%V")
|
|
544
|
+
if kind == "monthly":
|
|
545
|
+
return asof.strftime("%Y-%m")
|
|
546
|
+
return kind
|
|
547
|
+
|
|
548
|
+
|
|
549
|
+
NOTE = """
|
|
550
|
+
## Notes
|
|
551
|
+
|
|
552
|
+
<!-- Written by a human once, at publish time. MUST cite the cause (ADR, OQ-, risk, or
|
|
553
|
+
defect), not retell it — a second telling will drift from the first.
|
|
554
|
+
Leave empty if there is truly nothing to add. -->
|
|
555
|
+
"""
|
|
556
|
+
|
|
557
|
+
|
|
558
|
+
def publish(c: Corpus, report: dict, kind: str, asof: dt.date) -> tuple[Path, str | None]:
|
|
559
|
+
name = period_name(kind, asof)
|
|
560
|
+
path = c.root / ".control" / "reports" / f"{name}.md"
|
|
561
|
+
if path.exists():
|
|
562
|
+
return path, (f"{path.name} has already been published. A published report is FROZEN — "
|
|
563
|
+
f"if it turns out to be wrong, the next report is what corrects it.")
|
|
564
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
565
|
+
front = dump({
|
|
566
|
+
"period": name,
|
|
567
|
+
"asof": report["asof"],
|
|
568
|
+
"since": report["since"],
|
|
569
|
+
"sha": report["sha"],
|
|
570
|
+
"promise_progress": report["promise_progress"],
|
|
571
|
+
"generated_by": ".constitution/method/scripts/timeline.py",
|
|
572
|
+
})
|
|
573
|
+
path.write_text(f"---\n{front}---\n\n{render_report(report, f'Report {name}')}{NOTE}",
|
|
574
|
+
encoding="utf-8")
|
|
575
|
+
return path, None
|
|
576
|
+
|
|
577
|
+
|
|
578
|
+
# -------------------------------------------------------------------------- CLI
|
|
579
|
+
|
|
580
|
+
|
|
581
|
+
def main(argv: list[str] | None = None) -> int:
|
|
582
|
+
parser = argparse.ArgumentParser(
|
|
583
|
+
prog="timeline", description="the time dimension from git: timeline, report, period report")
|
|
584
|
+
parser.add_argument("--generate", action="store_true",
|
|
585
|
+
help="write .control/generated/timeline.* and report.*")
|
|
586
|
+
parser.add_argument("--publish", metavar="PERIOD",
|
|
587
|
+
help="freeze .control/reports/<period>.md — weekly | monthly | <name>")
|
|
588
|
+
parser.add_argument("--refresh", action="store_true",
|
|
589
|
+
help="run validate --generate first so the tables are fresh")
|
|
590
|
+
parser.add_argument("--root", default=".", help="repo root (default: current directory)")
|
|
591
|
+
parser.add_argument("--asof", default=None,
|
|
592
|
+
help="reference date, YYYY-MM-DD (default: today)")
|
|
593
|
+
args = parser.parse_args(argv)
|
|
594
|
+
|
|
595
|
+
if not args.generate and not args.publish:
|
|
596
|
+
args.generate = True
|
|
597
|
+
|
|
598
|
+
root = Path(args.root).resolve()
|
|
599
|
+
if not (root / ".control" / "registry").is_dir():
|
|
600
|
+
print(f"timeline: {root} has no .control/registry/ — wrong repo root?", file=sys.stderr)
|
|
601
|
+
return 2
|
|
602
|
+
|
|
603
|
+
asof = dt.date.fromisoformat(args.asof) if args.asof else dt.date.today()
|
|
604
|
+
corpus = Corpus.load(root)
|
|
605
|
+
|
|
606
|
+
if git(root, "rev-parse", "HEAD") is None:
|
|
607
|
+
print("timeline: git did not respond at this root. Every delivery date is derived "
|
|
608
|
+
"from git, so with no git there is nothing that can be reported — and making "
|
|
609
|
+
"one up MUST NOT be done.", file=sys.stderr)
|
|
610
|
+
return 3
|
|
611
|
+
|
|
612
|
+
if args.refresh:
|
|
613
|
+
import validate
|
|
614
|
+
result = validate.run_checks(corpus, asof)
|
|
615
|
+
validate.generate(corpus, result)
|
|
616
|
+
print(f" refreshed .control/generated/ — {len(result.findings)} validator findings")
|
|
617
|
+
|
|
618
|
+
generated = root / ".control" / "generated"
|
|
619
|
+
missing = [n for n in ("rtm", "status") if not (generated / f"{n}.yaml").exists()]
|
|
620
|
+
if missing:
|
|
621
|
+
print(f"timeline: {', '.join(missing)} does not exist yet in .control/generated/. A "
|
|
622
|
+
f"report on top of a stale table is worse than no report — run "
|
|
623
|
+
f"`validate.py --generate`, or repeat with `--refresh`.", file=sys.stderr)
|
|
624
|
+
return 3
|
|
625
|
+
|
|
626
|
+
timeline = gen_timeline(corpus, asof)
|
|
627
|
+
report = gen_report(corpus, timeline, asof)
|
|
628
|
+
|
|
629
|
+
if args.generate:
|
|
630
|
+
generated.mkdir(parents=True, exist_ok=True)
|
|
631
|
+
for name, payload, rendered in (
|
|
632
|
+
("timeline", timeline, render_timeline(timeline)),
|
|
633
|
+
("report", report, render_report(report)),
|
|
634
|
+
):
|
|
635
|
+
(generated / f"{name}.yaml").write_text(dump(payload), encoding="utf-8")
|
|
636
|
+
(generated / f"{name}.md").write_text(with_header(rendered), encoding="utf-8")
|
|
637
|
+
print(f" wrote .control/generated/{name}.yaml")
|
|
638
|
+
print(f" wrote .control/generated/{name}.md")
|
|
639
|
+
|
|
640
|
+
if args.publish:
|
|
641
|
+
path, refusal = publish(corpus, report, args.publish, asof)
|
|
642
|
+
if refusal:
|
|
643
|
+
print(f"\ntimeline: {refusal}", file=sys.stderr)
|
|
644
|
+
return 4
|
|
645
|
+
print(f" published {path.relative_to(root).as_posix()}")
|
|
646
|
+
|
|
647
|
+
overdue = [r for r in timeline["capabilities"] if r["state"] == "overdue"]
|
|
648
|
+
print(f"\npromise progress: {report['promise_progress']}"
|
|
649
|
+
f" · CAP overdue: {len(overdue)}"
|
|
650
|
+
f" · as of: {asof.isoformat()}")
|
|
651
|
+
for row in overdue:
|
|
652
|
+
print(f" LATE {row['id']} — planned end {row['planned_end']}, "
|
|
653
|
+
f"waiting on {', '.join(row['waiting_on']) or 'no ticket yet'}")
|
|
654
|
+
if report["registry_dirty"]:
|
|
655
|
+
print("\nthe registry has uncommitted changes — the numbers above may not "
|
|
656
|
+
"reflect what is on main")
|
|
657
|
+
if timeline["uncommitted_tickets"]:
|
|
658
|
+
print(f"tickets with no git history: {', '.join(timeline['uncommitted_tickets'])}")
|
|
659
|
+
return 0
|
|
660
|
+
|
|
661
|
+
|
|
662
|
+
if __name__ == "__main__":
|
|
663
|
+
raise SystemExit(main())
|