wdi-method 0.6.8 → 0.6.18
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 +213 -0
- package/README.md +13 -6
- package/bin/wdi-method.js +109 -1
- package/kit/.constitution/method/document/corpus-guide.md +41 -0
- package/kit/.constitution/method/document/decision-guide.md +216 -208
- package/kit/.constitution/method/method-glossary.md +184 -183
- package/kit/.constitution/method/scripts/timeline.py +676 -663
- package/kit/.constitution/method/scripts/validate.py +231 -26
- package/kit/skills/wdi-autopilot/SKILL.md +3 -2
- package/kit/skills/wdi-decision/SKILL.md +203 -197
- package/kit/skills/wdi-product/SKILL.md +22 -0
- package/kit/skills/wdi-upgrade/SKILL.md +23 -2
- package/package.json +2 -1
|
@@ -185,6 +185,27 @@ def git(root: Path, *args: str) -> str | None:
|
|
|
185
185
|
REQUIREMENT_KEYS = ("goals", "capabilities", "functional", "nonfunctional", "journeys")
|
|
186
186
|
|
|
187
187
|
|
|
188
|
+
def is_withdrawn(row: dict) -> bool:
|
|
189
|
+
"""`status: withdrawn` — the product stopped promising this, and the row STAYED.
|
|
190
|
+
|
|
191
|
+
Deleting it is what a repo used to do, and the cost was measured: two capabilities withdrawn by
|
|
192
|
+
decision, their rows removed, and twelve `refs-resolve` findings — eight `DEC-` rows still named
|
|
193
|
+
them, six of which genuinely served them at the time. `corpus-guide.md` forbids the other repair:
|
|
194
|
+
a retired name in a record of what happened is a fact about the past, and a `DEC-` is exactly
|
|
195
|
+
that record.
|
|
196
|
+
|
|
197
|
+
So a withdrawn row is read TWO ways. It is still **defined** — every old reference resolves and
|
|
198
|
+
`id-allocated-once` still refuses the number to anyone else. It is no longer **promised** — no UC
|
|
199
|
+
is owed, no ticket, no RTM row, and `promise_progress` is not dragged down by something nobody
|
|
200
|
+
promises.
|
|
201
|
+
"""
|
|
202
|
+
return str(row.get("status") or "").strip().lower() == "withdrawn"
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def promised(items: list[dict]) -> list[dict]:
|
|
206
|
+
return [row for row in items if not is_withdrawn(row)]
|
|
207
|
+
|
|
208
|
+
|
|
188
209
|
@dataclass
|
|
189
210
|
class Corpus:
|
|
190
211
|
root: Path
|
|
@@ -253,23 +274,23 @@ class Corpus:
|
|
|
253
274
|
# --- shortcuts used repeatedly
|
|
254
275
|
@property
|
|
255
276
|
def goals(self) -> list[dict]:
|
|
256
|
-
return rows(self.requirements, "goals")
|
|
277
|
+
return promised(rows(self.requirements, "goals"))
|
|
257
278
|
|
|
258
279
|
@property
|
|
259
280
|
def caps(self) -> list[dict]:
|
|
260
|
-
return rows(self.requirements, "capabilities")
|
|
281
|
+
return promised(rows(self.requirements, "capabilities"))
|
|
261
282
|
|
|
262
283
|
@property
|
|
263
284
|
def frs(self) -> list[dict]:
|
|
264
|
-
return rows(self.requirements, "functional")
|
|
285
|
+
return promised(rows(self.requirements, "functional"))
|
|
265
286
|
|
|
266
287
|
@property
|
|
267
288
|
def nfrs(self) -> list[dict]:
|
|
268
|
-
return rows(self.requirements, "nonfunctional")
|
|
289
|
+
return promised(rows(self.requirements, "nonfunctional"))
|
|
269
290
|
|
|
270
291
|
@property
|
|
271
292
|
def ucs(self) -> list[dict]:
|
|
272
|
-
return rows(self.usecases, "usecases")
|
|
293
|
+
return promised(rows(self.usecases, "usecases"))
|
|
273
294
|
|
|
274
295
|
@property
|
|
275
296
|
def decs(self) -> list[dict]:
|
|
@@ -282,6 +303,15 @@ class Corpus:
|
|
|
282
303
|
return own
|
|
283
304
|
return str(self.index.get("mode") or "").strip() or "catalog"
|
|
284
305
|
|
|
306
|
+
@property
|
|
307
|
+
def withdrawn_rows(self) -> list[dict]:
|
|
308
|
+
"""Every withdrawn requirement row, with the key it came from — the id side of the split."""
|
|
309
|
+
out = []
|
|
310
|
+
for key in REQUIREMENT_KEYS:
|
|
311
|
+
out += [(key, row) for row in rows(self.requirements, key) if is_withdrawn(row)]
|
|
312
|
+
out += [("usecases", row) for row in rows(self.usecases, "usecases") if is_withdrawn(row)]
|
|
313
|
+
return [row for _, row in out]
|
|
314
|
+
|
|
285
315
|
@property
|
|
286
316
|
def lcs(self) -> list[dict]:
|
|
287
317
|
return rows(self.components, "logical_components")
|
|
@@ -341,6 +371,18 @@ def _legacy_tickets(spec: dict) -> list[dict]:
|
|
|
341
371
|
yet scheduled, and a closed wave has nothing left to schedule.
|
|
342
372
|
"""
|
|
343
373
|
sid = str(spec.get("id") or "")
|
|
374
|
+
|
|
375
|
+
def scoped(raw: str) -> str:
|
|
376
|
+
"""`W7-S2` under wave `W7` stays `W7-S2`, not `W7-W7-S2`.
|
|
377
|
+
|
|
378
|
+
The prefix exists so two waves both naming a story `"1"` cannot collide into one node. A
|
|
379
|
+
story already scoped to its wave has nothing to collide with, and prefixing it again produces
|
|
380
|
+
an id that matches no file, no memlog line, and nothing a person would search for. Measured on
|
|
381
|
+
a live repo whose RTM read `W7-W7-S2`.
|
|
382
|
+
"""
|
|
383
|
+
sub = str(raw)
|
|
384
|
+
return sub if sid and sub.startswith(f"{sid}-") else f"{sid}-{sub}"
|
|
385
|
+
|
|
344
386
|
out = []
|
|
345
387
|
for epic in spec.get("epics") or []:
|
|
346
388
|
if not isinstance(epic, dict):
|
|
@@ -349,8 +391,8 @@ def _legacy_tickets(spec: dict) -> list[dict]:
|
|
|
349
391
|
if not isinstance(story, dict):
|
|
350
392
|
continue
|
|
351
393
|
ticket = {k: v for k, v in story.items() if k not in ("id", "depends_on")}
|
|
352
|
-
ticket["id"] =
|
|
353
|
-
ticket["blocked_by"] = [
|
|
394
|
+
ticket["id"] = scoped(story.get("id"))
|
|
395
|
+
ticket["blocked_by"] = [scoped(d) for d in (story.get("depends_on") or [])]
|
|
354
396
|
# The story's OWN id is kept because the file on disk is named after it, not after the
|
|
355
397
|
# synthesized ticket id — see `_ticket_files`.
|
|
356
398
|
ticket["_legacy_story_id"] = str(story.get("id") or "")
|
|
@@ -461,6 +503,8 @@ def refs_resolve(c: Corpus, r: Result) -> None: # was V6
|
|
|
461
503
|
defined.add(str(spec.get("id")))
|
|
462
504
|
for _, ticket in c.tickets():
|
|
463
505
|
defined.add(str(ticket.get("id")))
|
|
506
|
+
# Withdrawn, therefore still defined. This is the whole point of keeping the row.
|
|
507
|
+
defined |= {str(row.get("id")) for row in c.withdrawn_rows if row.get("id") is not None}
|
|
464
508
|
|
|
465
509
|
refs: list[tuple[str, str]] = []
|
|
466
510
|
for cap in c.caps:
|
|
@@ -482,9 +526,21 @@ def refs_resolve(c: Corpus, r: Result) -> None: # was V6
|
|
|
482
526
|
refs += [(str(ticket.get("id")), u) for u in listy(ticket, "satisfies")]
|
|
483
527
|
refs += [(str(ticket.get("id")), b) for b in listy(ticket, "blocked_by")]
|
|
484
528
|
|
|
529
|
+
# A promise's id going missing has one likely cause and one wrong-looking-obvious repair. The
|
|
530
|
+
# cause: the row was DELETED when the product stopped promising it. The wrong repair: edit the
|
|
531
|
+
# reference — which `corpus-guide.md` refuses, because a `DEC-` records what happened and it did
|
|
532
|
+
# serve that promise at the time. One repo carried twelve of these before anyone worked out that
|
|
533
|
+
# the row was meant to stay, so the route travels with the finding.
|
|
534
|
+
promise_id = re.compile(r"^(BG|CAP|FR|NFR|UC)-\d+$")
|
|
485
535
|
for owner, target in sorted(set(refs)):
|
|
486
536
|
if target and target not in defined:
|
|
487
|
-
|
|
537
|
+
hint = ""
|
|
538
|
+
if promise_id.match(target):
|
|
539
|
+
hint = (" — if it was withdrawn, the row STAYS with `status: withdrawn` and a "
|
|
540
|
+
"`withdrawn_by`, and deleting it is what broke this reference (corpus-guide.md). "
|
|
541
|
+
"Editing the reference instead rewrites a record of the past")
|
|
542
|
+
r.fail("refs-resolve", owner,
|
|
543
|
+
f"points to `{target}` which does not exist in any registry{hint}")
|
|
488
544
|
|
|
489
545
|
|
|
490
546
|
def _cycles(graph: dict[str, list[str]]) -> list[str]:
|
|
@@ -1079,18 +1135,64 @@ def _dec_date(c: Corpus, dec: dict) -> dt.date | None:
|
|
|
1079
1135
|
return None
|
|
1080
1136
|
|
|
1081
1137
|
|
|
1138
|
+
# A mandate whose authority ONCE stood. `accepted` and `applied` are live; `superseded` is retired,
|
|
1139
|
+
# and retirement is NOT retroactive — the owner accepted it in person, and what was taken under it
|
|
1140
|
+
# while it stood stays accepted. Reading this status as present tense is what turned a whole run's
|
|
1141
|
+
# decisions red the day the owner changed one setting of the mandate, with no repair available that
|
|
1142
|
+
# does not falsify the record: the decisions are frozen, and the supersession really happened.
|
|
1143
|
+
MANDATE_STOOD = ("accepted", "applied", "superseded")
|
|
1144
|
+
|
|
1145
|
+
|
|
1146
|
+
def _dec_fm(c: Corpus, dec: dict) -> dict:
|
|
1147
|
+
"""The frontmatter of a decision's own file — `supersedes`/`superseded_by` live there in the
|
|
1148
|
+
template, and a product that wrote them only in the file is not wrong."""
|
|
1149
|
+
did = str(dec.get("id") or "")
|
|
1150
|
+
if not did:
|
|
1151
|
+
return {}
|
|
1152
|
+
for path in sorted(c.root.glob(f".control/decisions/{did}-*.md")):
|
|
1153
|
+
return frontmatter(path) or {}
|
|
1154
|
+
return {}
|
|
1155
|
+
|
|
1156
|
+
|
|
1157
|
+
def _revoked_on(c: Corpus, mandate: dict, by_id: dict[str, dict]) -> dt.date | None:
|
|
1158
|
+
"""The day a superseded mandate stopped delegating: the date of the decision that replaced it.
|
|
1159
|
+
|
|
1160
|
+
For a mandate, supersession IS revocation — `wdi-autopilot` names it as the way a run is ended
|
|
1161
|
+
for good, and the way a setting is changed. So it binds tighter than `expires`, and the window
|
|
1162
|
+
ends at whichever of the two came first.
|
|
1163
|
+
"""
|
|
1164
|
+
if str(mandate.get("status") or "") != "superseded":
|
|
1165
|
+
return None
|
|
1166
|
+
ref = str(mandate.get("superseded_by") or _dec_fm(c, mandate).get("superseded_by") or "").strip()
|
|
1167
|
+
if not ref:
|
|
1168
|
+
return None
|
|
1169
|
+
return _dec_date(c, by_id.get(ref) or {})
|
|
1170
|
+
|
|
1171
|
+
|
|
1082
1172
|
def mandate_accept(c: Corpus, r: Result) -> None:
|
|
1083
|
-
"""A decision accepted BY DELEGATION points at a
|
|
1173
|
+
"""A decision accepted BY DELEGATION points at a mandate that stood, and had not ended, when it was taken.
|
|
1084
1174
|
|
|
1085
1175
|
`wdi-autopilot` lets the agent accept decisions the owner would have accepted, and that is legal
|
|
1086
1176
|
only because the owner accepted the MANDATE in person. So three things hold: a mandate is never
|
|
1087
|
-
itself accepted by another decision — the chain of authority has a person at its root;
|
|
1088
|
-
|
|
1089
|
-
a `DEC-` names one that is `type: mandate`,
|
|
1177
|
+
itself accepted by another decision — the chain of authority has a person at its root; a mandate
|
|
1178
|
+
that stood names the day it ends, or it is standing permission; and a decision whose `accepted_by`
|
|
1179
|
+
is a `DEC-` names one that is `type: mandate`, stood on the decision's own date, and had not ended
|
|
1180
|
+
by then — expired, or superseded, whichever came first.
|
|
1181
|
+
|
|
1182
|
+
Every question it asks is about the PAST, so every answer is read from the past. A mandate's
|
|
1183
|
+
status today says when its authority ENDED, never that it was never granted: `superseded` is a
|
|
1184
|
+
retired mandate, and the decisions taken under it while it stood are still accepted. The one
|
|
1185
|
+
thing supersession does change is the window — see `_revoked_on`.
|
|
1090
1186
|
|
|
1091
1187
|
It says nothing about WHAT was decided — that is the ledger's job and the owner's review.
|
|
1092
1188
|
"""
|
|
1093
1189
|
by_id = {str(d.get("id")): d for d in c.decs}
|
|
1190
|
+
# Which mandates were actually USED. A retired mandate's obligations are read from what was taken
|
|
1191
|
+
# under it, not from its status: superseding one that delegated nothing owes no account of a run
|
|
1192
|
+
# that never happened, and superseding one that delegated forty decisions owes exactly what it
|
|
1193
|
+
# owed the day before — otherwise supersession is a way to make the ledger demand disappear.
|
|
1194
|
+
delegated_under = {str(d.get("accepted_by") or "").strip() for d in c.decs
|
|
1195
|
+
if str(d.get("type") or "") != "mandate"}
|
|
1094
1196
|
for dec in c.decs:
|
|
1095
1197
|
did = str(dec.get("id"))
|
|
1096
1198
|
ref = str(dec.get("accepted_by") or "").strip()
|
|
@@ -1100,7 +1202,7 @@ def mandate_accept(c: Corpus, r: Result) -> None:
|
|
|
1100
1202
|
r.fail("mandate-accept", did,
|
|
1101
1203
|
f"is a mandate accepted by delegation (`accepted_by: {ref}`) — the mandate is the one "
|
|
1102
1204
|
f"decision the owner accepts in person")
|
|
1103
|
-
if status in ("accepted", "applied"):
|
|
1205
|
+
if status in ("accepted", "applied") or (status == "superseded" and did in delegated_under):
|
|
1104
1206
|
if not ref:
|
|
1105
1207
|
r.fail("mandate-accept", did, "is an accepted mandate and `accepted_by` names nobody — "
|
|
1106
1208
|
"a person and a date is enough")
|
|
@@ -1121,6 +1223,17 @@ def mandate_accept(c: Corpus, r: Result) -> None:
|
|
|
1121
1223
|
# silently disables the lapse comparison for every decision taken under this mandate.
|
|
1122
1224
|
r.fail("mandate-accept", did, f"`mandate.expires: {raw}` is not a date — write `YYYY-MM-DD`. "
|
|
1123
1225
|
f"An expiry nothing can read stops nothing")
|
|
1226
|
+
if status == "superseded" and _revoked_on(c, dec, by_id) is None:
|
|
1227
|
+
# Retiring a mandate and not dating the retirement leaves the delegation reading as
|
|
1228
|
+
# good until `expires` — the opposite of what superseding it was for. Same failure as
|
|
1229
|
+
# the unparseable expiry above: a bound that looks present and compares nothing.
|
|
1230
|
+
r.fail("mandate-accept", did,
|
|
1231
|
+
"is a superseded mandate with decisions accepted under it, and nothing dates the "
|
|
1232
|
+
"supersession — `superseded_by` naming the decision that replaced it, and "
|
|
1233
|
+
"`supersedes` back on that one (both sides, decision-guide.md), is what says when "
|
|
1234
|
+
"the delegation was revoked. Until it is there the mandate reads as delegating "
|
|
1235
|
+
"right up to its `expires`. Recording a supersession is the one edit an applied "
|
|
1236
|
+
"decision allows")
|
|
1124
1237
|
continue
|
|
1125
1238
|
if not ref.startswith("DEC-"):
|
|
1126
1239
|
continue
|
|
@@ -1132,19 +1245,26 @@ def mandate_accept(c: Corpus, r: Result) -> None:
|
|
|
1132
1245
|
r.fail("mandate-accept", did, f"`accepted_by: {ref}` is not a `type: mandate` decision — only a mandate "
|
|
1133
1246
|
f"delegates acceptance")
|
|
1134
1247
|
continue
|
|
1135
|
-
if str(target.get("status") or "") not in
|
|
1136
|
-
r.fail("mandate-accept", did, f"`accepted_by: {ref}` is `{target.get('status')}
|
|
1137
|
-
f"
|
|
1248
|
+
if str(target.get("status") or "") not in MANDATE_STOOD:
|
|
1249
|
+
r.fail("mandate-accept", did, f"`accepted_by: {ref}` is `{target.get('status')}` — nothing was ever "
|
|
1250
|
+
f"delegated. A mandate delegates from `accepted` onward, and a "
|
|
1251
|
+
f"`superseded` one still stands for what was taken before it ended")
|
|
1138
1252
|
continue
|
|
1139
1253
|
params = target.get("mandate") if isinstance(target.get("mandate"), dict) else {}
|
|
1140
1254
|
expires = _dec_date(c, {"date": params.get("expires")})
|
|
1255
|
+
revoked = _revoked_on(c, target, by_id)
|
|
1141
1256
|
when = _dec_date(c, dec)
|
|
1257
|
+
# Whichever end came first is the one that counts.
|
|
1258
|
+
limit, ended, tail = expires, "expired on", "the delegation had lapsed"
|
|
1259
|
+
if revoked and (expires is None or revoked < expires):
|
|
1260
|
+
limit, ended, tail = (revoked, "was superseded on",
|
|
1261
|
+
"the delegation was revoked then, whatever its `expires` still says")
|
|
1142
1262
|
if when is None:
|
|
1143
1263
|
r.fail("mandate-accept", did, f"is accepted under `{ref}` but no date says when — `date:` in its "
|
|
1144
|
-
f"frontmatter is what the
|
|
1145
|
-
elif
|
|
1146
|
-
r.fail("mandate-accept", did, f"was taken on {when.isoformat()}, after `{ref}`
|
|
1147
|
-
f"{
|
|
1264
|
+
f"frontmatter is what the mandate's window is checked against")
|
|
1265
|
+
elif limit and when > limit:
|
|
1266
|
+
r.fail("mandate-accept", did, f"was taken on {when.isoformat()}, after `{ref}` {ended} "
|
|
1267
|
+
f"{limit.isoformat()} — {tail}")
|
|
1148
1268
|
|
|
1149
1269
|
|
|
1150
1270
|
def defect_root_cause(c: Corpus, r: Result) -> None: # was V20
|
|
@@ -1246,18 +1366,62 @@ PRUNE_DIRS = frozenset({
|
|
|
1246
1366
|
})
|
|
1247
1367
|
|
|
1248
1368
|
|
|
1369
|
+
_IGNORED: dict[Path, frozenset[str]] = {}
|
|
1370
|
+
|
|
1371
|
+
|
|
1372
|
+
def _git_ignored(root: Path) -> frozenset[str]:
|
|
1373
|
+
"""What git ignores in this tree, repo-relative posix — a whole ignored directory as `name/`.
|
|
1374
|
+
|
|
1375
|
+
ONE call for the whole run. `git check-ignore` per folder inside `os.walk` is one subprocess per
|
|
1376
|
+
folder, and on a big tree that costs more than the walk it is protecting.
|
|
1377
|
+
|
|
1378
|
+
Git answers rather than a hand-written parser, for the reason `_ignore_rule` sets out: nested
|
|
1379
|
+
`.gitignore` files, negation with `!`, and `core.excludesFile` are exactly where a parser of this
|
|
1380
|
+
one repo is wrong. `--directory` collapses a wholly-ignored folder into a single entry, which is
|
|
1381
|
+
the granularity the walker prunes at — and a folder holding TRACKED files is never collapsed, so
|
|
1382
|
+
corpus that is in git cannot be pruned away by this.
|
|
1383
|
+
|
|
1384
|
+
Outside a repo, or with no git, this is empty and PRUNE_DIRS carries the walk alone. That is why
|
|
1385
|
+
PRUNE_DIRS stays: it is the fallback, and a `node_modules/` nobody remembered to ignore still has
|
|
1386
|
+
to be pruned — a dangling symlink in one took a whole run down once.
|
|
1387
|
+
"""
|
|
1388
|
+
got = _IGNORED.get(root)
|
|
1389
|
+
if got is None:
|
|
1390
|
+
out = git(root, "ls-files", "--others", "--ignored", "--exclude-standard", "--directory", "-z")
|
|
1391
|
+
got = frozenset(x for x in (out or "").split("\0") if x)
|
|
1392
|
+
_IGNORED[root] = got
|
|
1393
|
+
return got
|
|
1394
|
+
|
|
1395
|
+
|
|
1249
1396
|
def _walk_corpus(root: Path, suffixes: tuple[str, ...]) -> list[Path]:
|
|
1250
|
-
"""Every file under `root` with one of `suffixes`, sorted, pruning PRUNE_DIRS
|
|
1397
|
+
"""Every file under `root` with one of `suffixes`, sorted, pruning PRUNE_DIRS and what git ignores.
|
|
1398
|
+
|
|
1399
|
+
Ignored material is not this product's corpus: it is not in the clone, nobody reviews it, and
|
|
1400
|
+
nothing in it can be repaired by the reader of a finding. A vendored upstream checkout under
|
|
1401
|
+
`.temp/` produced 172 `cites-resolve` findings in one repo, every one of them about somebody
|
|
1402
|
+
else's source tree.
|
|
1251
1403
|
|
|
1252
1404
|
Sorted because determinism is this script's contract: two runs over the same tree MUST report the
|
|
1253
1405
|
same thing in the same order.
|
|
1254
1406
|
"""
|
|
1407
|
+
ignored = _git_ignored(root)
|
|
1408
|
+
|
|
1409
|
+
def rel(path: Path) -> str:
|
|
1410
|
+
try:
|
|
1411
|
+
return path.relative_to(root).as_posix()
|
|
1412
|
+
except ValueError: # a walk that left the tree — treat it as unignored and let PRUNE_DIRS rule
|
|
1413
|
+
return ""
|
|
1414
|
+
|
|
1255
1415
|
out: list[Path] = []
|
|
1256
1416
|
for dirpath, dirnames, filenames in os.walk(root, onerror=lambda _e: None):
|
|
1257
|
-
|
|
1417
|
+
here = Path(dirpath)
|
|
1418
|
+
dirnames[:] = sorted(d for d in dirnames
|
|
1419
|
+
if d not in PRUNE_DIRS and f"{rel(here / d)}/" not in ignored)
|
|
1258
1420
|
for name in filenames:
|
|
1259
|
-
|
|
1260
|
-
|
|
1421
|
+
# An ignored FILE inside a folder that is otherwise corpus: git lists it on its own,
|
|
1422
|
+
# because `--directory` only collapses folders that are ignored whole.
|
|
1423
|
+
if name.endswith(suffixes) and rel(here / name) not in ignored:
|
|
1424
|
+
out.append(here / name)
|
|
1261
1425
|
return sorted(out)
|
|
1262
1426
|
|
|
1263
1427
|
|
|
@@ -1619,6 +1783,47 @@ def engines_invocable(c: Corpus, r: Result) -> None:
|
|
|
1619
1783
|
f"update` restores it; `npx wdi-method engines --fix` strips it back out")
|
|
1620
1784
|
|
|
1621
1785
|
|
|
1786
|
+
def withdrawn_recorded(c: Corpus, r: Result) -> None:
|
|
1787
|
+
"""Two things, and without either one `withdrawn` is just a word that quiets a validator.
|
|
1788
|
+
|
|
1789
|
+
**It names the decision.** Withdrawing a promise is decision-worthy on the method's own terms —
|
|
1790
|
+
`corpus-guide.md` lists "no `BG`/`CAP`/`FR`/`NFR`/`UC`/`LC` id is born, renamed, or retired" as a
|
|
1791
|
+
test for whether something is a `DEC-`. So `withdrawn_by` MUST name one that exists.
|
|
1792
|
+
|
|
1793
|
+
**It does not orphan what is left.** A live `FR` whose capability is withdrawn still promises
|
|
1794
|
+
something whose capability nobody promises any more. Withdrawal that takes half a chain with it
|
|
1795
|
+
silently is worse than the deletion this replaced, because at least deletion went red.
|
|
1796
|
+
"""
|
|
1797
|
+
dec_ids = {str(d.get("id")) for d in c.decs}
|
|
1798
|
+
for row in c.withdrawn_rows:
|
|
1799
|
+
rid = str(row.get("id") or "")
|
|
1800
|
+
by = str(row.get("withdrawn_by") or "").strip()
|
|
1801
|
+
if not by:
|
|
1802
|
+
r.fail("withdrawn-recorded", rid, "is `status: withdrawn` and names no `withdrawn_by`. "
|
|
1803
|
+
"Withdrawing a promise is a decision — name the `DEC-` that took it, or the "
|
|
1804
|
+
"word is only silencing a validator")
|
|
1805
|
+
elif by not in dec_ids:
|
|
1806
|
+
r.fail("withdrawn-recorded", rid, f"names `withdrawn_by: {by}`, which is not a decision in "
|
|
1807
|
+
f"`decisions.yaml`")
|
|
1808
|
+
|
|
1809
|
+
withdrawn_ids = {str(row.get("id")) for row in c.withdrawn_rows}
|
|
1810
|
+
if not withdrawn_ids:
|
|
1811
|
+
return
|
|
1812
|
+
for row, parent_key, what in ([(x, "goal", "goal") for x in c.caps]
|
|
1813
|
+
+ [(x, "capability", "capability") for x in c.frs]
|
|
1814
|
+
+ [(x, "capability", "capability") for x in c.nfrs]):
|
|
1815
|
+
parent = str(row.get(parent_key) or "").strip()
|
|
1816
|
+
if parent and parent in withdrawn_ids:
|
|
1817
|
+
r.fail("withdrawn-recorded", str(row.get("id")),
|
|
1818
|
+
f"is live, and the {what} it hangs off (`{parent}`) is withdrawn. Withdraw this row "
|
|
1819
|
+
f"too, or move it under something still promised")
|
|
1820
|
+
for uc in c.ucs:
|
|
1821
|
+
for fr in listy(uc, "satisfies"):
|
|
1822
|
+
if fr in withdrawn_ids:
|
|
1823
|
+
r.fail("withdrawn-recorded", str(uc.get("id")),
|
|
1824
|
+
f"is live and satisfies `{fr}`, which is withdrawn")
|
|
1825
|
+
|
|
1826
|
+
|
|
1622
1827
|
def id_allocated_once(c: Corpus, r: Result) -> None: # was V28
|
|
1623
1828
|
"""One id, one row — across every file the requirement registry is split into.
|
|
1624
1829
|
|
|
@@ -1653,7 +1858,7 @@ def run_checks(c: Corpus, asof: dt.date) -> Result:
|
|
|
1653
1858
|
# no two copies left to compare.
|
|
1654
1859
|
# V19 is REPEALED. It checked one line item — an `RTR-` file in .control/reports/ — and the
|
|
1655
1860
|
# retrospective it archived was the only thing spec size `L` ever decided. Both went together.
|
|
1656
|
-
for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, engines_invocable, id_allocated_once):
|
|
1861
|
+
for fn in (goal_has_fr, fr_has_uc, uc_scheduled, ticket_has_test, nfr_has_enforcer, refs_resolve, no_cycles, applied_dec_touches, locked_gate_passed, parallel_tickets_blocked, lc_registered, review_trace, chain_links, memlog_home, spec_names_release_prd, ticket_status_one_home, defect_root_cause, entity_one_writer, spec_after_g4, high_risk_named, mandate_accept, cites_resolve, container_built, custom_room_declared, corpus_in_git, engines_invocable, withdrawn_recorded, id_allocated_once):
|
|
1657
1862
|
fn(c, r)
|
|
1658
1863
|
plan_dates(c, r, asof)
|
|
1659
1864
|
return r
|
|
@@ -1856,7 +2061,7 @@ def gen_status(c: Corpus, rtm: dict, result: Result) -> dict:
|
|
|
1856
2061
|
per_spec.append({"spec": wid, "status": spec.get("status"),
|
|
1857
2062
|
"tickets_done": done, "tickets_total": len(items),
|
|
1858
2063
|
"work_progress": _pct(done, len(items))})
|
|
1859
|
-
applicable =
|
|
2064
|
+
applicable = 28 # goal-has-fr..id-allocated-once minus V10 and V19, both repealed
|
|
1860
2065
|
return {
|
|
1861
2066
|
"promise_progress": _pct(green, len(counted)),
|
|
1862
2067
|
"rtm_rows": {"green": green, "counted": len(counted),
|
|
@@ -25,7 +25,8 @@ awake to refuse them — and the run would renew its own authority. The lapsed d
|
|
|
25
25
|
expiry ends the run instead of restarting it.
|
|
26
26
|
|
|
27
27
|
Typing `/wdi-autopilot` while a mandate is active opens the iteration door, not the preflight. To change a
|
|
28
|
-
setting, the owner supersedes the mandate with a new one — `wdi-decision` owns supersession.
|
|
28
|
+
setting, the owner supersedes the mandate with a new one — `wdi-decision` owns supersession. A superseded
|
|
29
|
+
mandate keeps everything it already accepted: the delegation ends that day, it does not unwind.
|
|
29
30
|
|
|
30
31
|
## Door 1 — Preflight
|
|
31
32
|
|
|
@@ -237,7 +238,7 @@ Stated on the preflight page, because a run nobody can stop is not a run anybody
|
|
|
237
238
|
|---|---|---|
|
|
238
239
|
| Pause | Cancel the loop, or interrupt the session | The current iteration finishes its step and lands its ledger row. Nothing is left half-written |
|
|
239
240
|
| Resume | `/wdi-autopilot` again, or start the loop again | The mandate is still active, so it comes in through the iteration door and continues from `## Resume` |
|
|
240
|
-
| End it for good | Supersede the mandate through `wdi-decision`, or let `expires` pass | **Cancelling the loop does NOT revoke the mandate.** Until it is superseded or lapses, any later firing resumes the run |
|
|
241
|
+
| End it for good | Supersede the mandate through `wdi-decision`, or let `expires` pass | **Cancelling the loop does NOT revoke the mandate.** Until it is superseded or lapses, any later firing resumes the run. The supersession names its replacement on both sides — that date is what revoked the delegation, and `mandate-accept` asks for it once the mandate has accepted anything |
|
|
241
242
|
|
|
242
243
|
## The ledger
|
|
243
244
|
|