Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 21 additions & 10 deletions .add/milestones/v3-final-collateral.md
Original file line number Diff line number Diff line change
@@ -1,26 +1,37 @@
---
type: Milestone
title: v3.0.0 final collateral
status: direction
status: archived
generated: { by: add/3.0.0, at: 2026-08-11 }
verified: []
---
## CARD
goal: <one line>
why: <why this milestone exists — required>
next: add new task <slug>
goal: everything 3.0.0 needed that was not the engine itself — the docs that teach it, the post that announces it, and the four defects a real first run trips over.
why: authored retrospectively 2026-09-03. The milestone was scaffolded 2026-08-11 and driven to six closed tasks without its CARD or EXIT ever being filled in — `freeze` did not refuse a template Milestone until the `authoring-beat-named` task shipped that guard, so nothing ever asked. Its work is done and released; this records what it was.

## SCOPE
In: <what>
Out: <what not>
In: the 3.0.0 release collateral — docs site, launch post — and the engine defects a first run hits: digest root, `--scope` append, `upgrade` leaving a working bundle, Persona scaffold keys
Out: the 3.0 graft itself, and every method change after it — those are their own milestones

## GROUND
touches: <paths>
touches: add-method/docs/, blog/, add-method/tooling/add.py, add-method/tooling/templates/
risks:
- <the one that would hurt>
- collateral is the part a release forgets; a shipped engine nobody can start is not shipped

## EXIT
- [ ] <criterion> (← <task>)
- [x] the receipt digest and the gate's freshness check resolve `scope:` from the same root, and a degrade is said out loud (← run-digest-root)
- [x] `add new Persona` scaffolds every contract-recommended routing key plus OKF description/sources (← okf-persona-template)
- [x] the mkdocs book teaches the engine that ships (← docs-beta2-refresh)
- [x] repeated `--scope` flags append in order, and the comma form keeps working (← scope-flag-append)
- [x] a launch post a normal user can act on, with the honest numbers (← launch-blog)
- [x] after `add upgrade` the very next `add status` runs (← upgrade-working-bundle)

## CLOSE
evidence: <one row per task>
| task | verdict | receipt |
|---|---|---|
| run-digest-root | PASS | closed 2026-08 — one digest root, degrade stated |
| okf-persona-template | PASS | closed 2026-08 — routing keys + OKF fields scaffolded |
| docs-beta2-refresh | PASS | closed 2026-08 — book teaches the shipped engine |
| scope-flag-append | PASS | closed 2026-08 — repeated flags append, comma form kept |
| launch-blog | PASS | closed 2026-08 — post written against the honest numbers |
| upgrade-working-bundle | PASS | closed 2026-08 — upgrade leaves a runnable bundle |
62 changes: 62 additions & 0 deletions .add/tasks/doctor-nudges-what-you-can-act-on.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
---
type: Task
title: an advisory nudge names a node you can still advise
status: done
depth: quick
kind: feature
scope:
- add-method/tooling/add.py
- add-method/tests/engine/
gives:
- S1 <the surface this publishes — an endpoint, function, or section>
generated: { by: add/3.4.0, at: 2026-09-03 }
verified:
- { by: "Tin Dang", at: 2026-09-03, act: freeze, authority: plan, direction: "sha256:9fdf0952e0e1351f", binding: "sha256:66eb975a05423ae8" }
- { by: "cli", at: 2026-09-03, act: brief, authority: process, brief: "sha256:37c7474b66d9eac4" }
- { by: "process:run", at: 2026-09-03, act: run, authority: process, outcome: PASS, receipt: /tasks/doctor-nudges-what-you-can-act-on.d/runs/1.md }
- { by: "Tin Dang", at: 2026-09-03, act: gate, authority: process, outcome: PASS, receipt: /tasks/doctor-nudges-what-you-can-act-on.d/runs/1.md, brief: "sha256:13e8cfbf6dc13b5b" }
---
## CARD
goal: `unadvised_sensitive` reports only nodes that can still take a lens, so `doctor`'s output is the set of things you can act on.
why: measured 2026-09-03 on this bundle — 23 of doctor's 25 findings are `unadvised_sensitive`, and every one names a task already `done`. The advice each gives ("advise it") is unreachable: the node is closed, its gate is stamped, and re-opening it to attach a retrospective lens is not something anyone will do. A reader learns to skim the whole report, which is how the two findings that ARE actionable get missed.

## RULES
<must>
- M1 `unadvised_sensitive` is not reported for a node whose status is `done`
- M2 an OPEN sensitive node with no lens is still reported, at the severity it has today
- M3 the severity split is untouched — `security` stays `warn`, the softer floors stay `info`
- M4 no other finding changes which nodes it reports
</must>
<reject>
- R:BLINDCLOSE a closed node hides a finding that was never about the lens at all -> "BLINDCLOSE"
</reject>

## ASSUMPTIONS
- A1 [who] covers: S1 · n/a · the report is the same for every reader; no authority sees a different finding set
- A2 [which] covers: S1 · the request says "open nodes" and does not say which statuses count as open; taking `done` as the ONLY exclusion -> a node in `verify` can still be advised before its gate, and excluding it would hide a finding while it is still actionable · probe: a `verify` node with no lens is still reported
- A3 [when] covers: S1 · the request does not say whether to drop the closed ones or tally them; taking DROP -> a tally line is still a line the reader must decide to ignore, and the archived record is the node itself, which keeps its own frontmatter · probe: no summary line replaces the dropped findings
- A4 [absent] covers: S1 · the request does not say what an ABSENT status means; taking absent-as-open -> a node with no status has not been closed, and hiding a finding on a malformed node is R:BLINDCLOSE · probe: a node with no `status:` is still reported
- A5 [order] covers: S1 · n/a · the finding is emitted inside one sorted pass and the exclusion does not reorder it
- A6 [experience] covers: S1 · the request is about what a reader can ACT on; taking the report as a worklist, not an audit log -> if the archived count matters later it is recoverable by reading the bundle, which is where it already lives · probe: the finding count on this bundle drops to the actionable set

## PLAN
contract: `doctor`'s `unadvised_sensitive` loop skips a node whose `status:` is `done`. Every other condition — type, sensitivity floor, lens presence, severity split — is unchanged, and no other finding is touched.

## EDGES
- E1 a node with no `status:` at all — absent is not closed
- E2 a `security` node that is done — the HARD floor does not survive closure either, or M1 is not a rule

## CHECKS
- test_a_done_node_is_not_nudged_for_a_lens · covers: M1, A3, A6 · the 23 measured findings
- test_an_open_node_is_still_nudged · covers: M2, A2 · the finding must not go silent
- test_a_node_with_no_status_is_still_nudged · covers: A4, E1, R:BLINDCLOSE · absent is not closed
- test_the_severity_split_survives · covers: M3, E2 · security warns while open, silent when done
- test_no_other_finding_changed_its_reach · covers: M4 · the exclusion is scoped to one finding
red-first: every check MUST fail first.

## EVIDENCE
receipt: <runs/<n>.md>
gate: <PASS | RISK-ACCEPTED | HARD-STOP>

## LESSONS
- a report is read as a worklist whether or not it was written as one -> add learn method
9 changes: 9 additions & 0 deletions add-method/src/add_method/_bundled/tooling/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -4559,6 +4559,15 @@ def find(severity, code, detail, node=None):
fm = node["fm"] or {}
if fm.get("type") != "Task" or SENSITIVITY_FLOOR.get(fm.get("sensitivity"), "process") == "process":
continue
# M1: only a node that can still TAKE a lens. On a closed, gated node the advice this
# finding carries is unreachable, and 23 of 25 findings on this repo's own bundle were
# exactly that — a wall of unactionable lines the two real ones sat inside of. A report
# is read as a worklist whether or not it was written as one.
# `done` is the ONLY exclusion (A2): a node in `verify` is still advisable before its
# gate. An ABSENT status is NOT closed (A4, R:BLINDCLOSE) — hiding a finding on a
# malformed node is the failure this rule is meant to prevent, not an instance of it.
if fm.get("status") == "done":
continue
if not fm.get("persona") and not fm.get("advised_by"):
# Severity agrees with the gate floor (A2): security is a HARD gate refusal (R:NOCOVERAGE),
# so doctor says `warn`; the softer data/architecture floors stay `info` nudges.
Expand Down
93 changes: 93 additions & 0 deletions add-method/tests/engine/test_doctor_nudges_what_you_can_act_on.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
"""An advisory nudge names a node you can still advise.

Measured on this repo's own bundle 2026-09-03: 23 of `doctor`'s 25 findings were
`unadvised_sensitive`, and every one named a task already `done`. The advice each carries —
attach a lens — is unreachable on a closed, gated node, so the whole report reads as noise and
the two findings that WERE actionable sat in the middle of it.

The finding is true either way; the question is whether a report is a worklist or an audit log.
It is read as a worklist whether or not it was written as one.
"""
import sys
from pathlib import Path

REPO = Path(__file__).resolve().parents[2]
sys.path.insert(0, str(REPO / "tooling"))
import add # noqa: E402


def _sensitive(tmp_path, slug, sensitivity="architecture", status=None):
"""A sensitive Task carrying no lens, at whatever status the caller names."""
if not (tmp_path / ".add").exists():
add.init(tmp_path, "code", "T") # returns a TUPLE; nothing here needs it
cid, _ = add.new(tmp_path, "Task", slug, title=slug, sensitivity=sensitivity)
node = Path(add.scan(tmp_path)[cid]["path"])
lines = node.read_text(encoding="utf-8").splitlines()
# E1 strips the field entirely; otherwise rewrite whatever status `new` chose — it seeds
# `direction`, not `created`, so a literal replace of the wrong word silently does nothing.
lines = [l for l in lines if not l.startswith("status:")]
if status is not None:
lines.insert(next(i for i, l in enumerate(lines) if l.startswith("type:")) + 1,
f"status: {status}")
node.write_text("\n".join(lines) + "\n", encoding="utf-8")
return cid


def _codes(tmp_path, cid):
"""Findings doctor reports against one node.

`doctor` takes the PROJECT root — the parent of `.add` — and returns a list of dicts.
Handing it the bundle root returns `[]`, which is a green anchor that proves nothing.
"""
return [f for f in add.doctor(tmp_path) if f["node"] == cid]


def test_a_done_node_is_not_nudged_for_a_lens(tmp_path):
"""covers: M1, A3, A6 — the 23 measured findings."""
cid = _sensitive(tmp_path, "closed", status="done")
hits = [f for f in _codes(tmp_path, cid) if f["code"] == "unadvised_sensitive"]
assert not hits, f"a closed node was told to attach a lens it can no longer attach: {hits}"


def test_an_open_node_is_still_nudged(tmp_path):
"""covers: M2, A2 — the finding must not go silent; `verify` is still advisable."""
for status in ("created", "direction", "build", "verify"):
p = tmp_path / status
p.mkdir()
cid = _sensitive(p, f"open-{status}", status=status)
hits = [f for f in _codes(p, cid) if f["code"] == "unadvised_sensitive"]
assert hits, f"an OPEN ({status}) sensitive node with no lens went unreported"


def test_a_node_with_no_status_is_still_nudged(tmp_path):
"""covers: A4, E1, R:BLINDCLOSE — absent is not closed."""
cid = _sensitive(tmp_path, "statusless", status=None)
hits = [f for f in _codes(tmp_path, cid) if f["code"] == "unadvised_sensitive"]
assert hits, "a node with no `status:` was treated as closed and its finding hidden"


def test_the_severity_split_survives(tmp_path):
"""covers: M3, E2 — security warns while open, and is silent once done like any other."""
a = tmp_path / "open"; a.mkdir()
cid = _sensitive(a, "sec-open", sensitivity="security", status="verify")
hits = [f for f in _codes(a, cid) if f["code"] == "unadvised_sensitive"]
assert hits and any(f["severity"] == "warn" for f in hits), f"security lost its `warn` severity: {hits}"

b = tmp_path / "done"; b.mkdir()
cid = _sensitive(b, "sec-done", sensitivity="security", status="done")
assert not [f for f in _codes(b, cid) if f["code"] == "unadvised_sensitive"], \
"M1 does not hold for the HARD floor — a done security node is still unadvisable"


def test_no_other_finding_changed_its_reach(tmp_path):
"""covers: M4 — the exclusion is scoped to ONE finding.

A done node still carries every other property doctor reports on. Proved by giving the same
closed node a defect a different finding owns and asserting that one still fires.
"""
cid = _sensitive(tmp_path, "closed-too", status="done")
node = Path(add.scan(tmp_path)[cid]["path"])
node.write_text(node.read_text(encoding="utf-8").replace("---\n", "", 1), encoding="utf-8")
codes = {f["code"] for f in add.doctor(tmp_path)}
assert "missing_frontmatter" in codes, (
"closing a node silenced a finding that was never about the lens: " + str(codes))
9 changes: 9 additions & 0 deletions add-method/tooling/add.py
Original file line number Diff line number Diff line change
Expand Up @@ -4559,6 +4559,15 @@ def find(severity, code, detail, node=None):
fm = node["fm"] or {}
if fm.get("type") != "Task" or SENSITIVITY_FLOOR.get(fm.get("sensitivity"), "process") == "process":
continue
# M1: only a node that can still TAKE a lens. On a closed, gated node the advice this
# finding carries is unreachable, and 23 of 25 findings on this repo's own bundle were
# exactly that — a wall of unactionable lines the two real ones sat inside of. A report
# is read as a worklist whether or not it was written as one.
# `done` is the ONLY exclusion (A2): a node in `verify` is still advisable before its
# gate. An ABSENT status is NOT closed (A4, R:BLINDCLOSE) — hiding a finding on a
# malformed node is the failure this rule is meant to prevent, not an instance of it.
if fm.get("status") == "done":
continue
if not fm.get("persona") and not fm.get("advised_by"):
# Severity agrees with the gate floor (A2): security is a HARD gate refusal (R:NOCOVERAGE),
# so doctor says `warn`; the softer data/architecture floors stay `info` nudges.
Expand Down
2 changes: 1 addition & 1 deletion add-method/tooling/engine_pin.py
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
this file only ever holds the newest pointer.
"""

ENGINE_MD5 = "7932ec061ddf19310c138766a691bfdf" # re-aimed @ 3.4.0 release: the ENGINE version stamp. prior: 9a800083… @ scaffold-truth
ENGINE_MD5 = "28cb0c115ec30f58c2d498f3cfc0eb68" # re-aimed @ doctor-nudges-what-you-can-act-on: `unadvised_sensitive` reports only a node that can still take a lens. prior: 7932ec06… @ 3.4.0
# ADD 3.0 (ABF-1): the engine is a flat two-file pair (add.py + cli.py), no add_engine/ package.
# ENGINE_PKG_MD5 is repurposed to pin the dispatch entry cli.py (the second engine file).
ENGINE_PKG_MD5 = "b41bc148795c695b7f04ebd10c1098c1" # re-aimed @ scaffold-truth (`join` propagates its refusal to a non-zero exit). prior: ad04b73a… @ method-truth-sweep