From f0711fe47ca2ddbd3b39c336fbf4e37ad79ddce1 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 23 Aug 2026 15:08:19 +0000 Subject: [PATCH] intake: read task dates and render the dashboard's Recent table MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Mind now dates every task the moment it leaves the backlog (PyAutoMind REFERENCE.md "Task dates"). The census reads those dates back and the dashboard grows the one section laid out by DATE rather than by state. - parse_registry reads each entry's date via the key that names its event (`issued:` / `parked:` / `filed:`, most-specific first); a date sitting in another field's prose stays invisible, since that is the un-parseable habit the convention replaced. parse_header reads a prompt's own `Issued:` copy as the fallback that keeps an orphan in-flight prompt dated. - completed_records walks complete/// newest-first and stops once it has enough — the ledger is 1000+ files and a 20-row table must not open all of them on every render. A record with no `completed:` field dates by the month folder the lifecycle engine filed it into. - Recent sits between Backlog and Epics in both the markdown page and its Pages twin (a real table, with a copy button per row on the HTML side). Live work is SELECTED first and completions fill the rest: a straight date sort on a Mind that ships two hundred records a month is twenty receipts and no work, which would hide the dates on the very page they were added for. - In flight / Parked / Planned rows now carry their date as a facet too. Co-Authored-By: Claude Opus 5 Claude-Session: https://claude.ai/code/session_01L4HPWjv5rdzBAkKpfW1SbR --- agents/conductors/intake/AGENTS.md | 13 +- agents/conductors/intake/_intake.py | 266 +++++++++++++++++++++++++++- tests/test_intake_dashboard.py | 172 +++++++++++++++++- 3 files changed, 442 insertions(+), 9 deletions(-) diff --git a/agents/conductors/intake/AGENTS.md b/agents/conductors/intake/AGENTS.md index 660a7d0..1ef6dd9 100644 --- a/agents/conductors/intake/AGENTS.md +++ b/agents/conductors/intake/AGENTS.md @@ -83,11 +83,22 @@ schema — light structure over free-form prose. | **classify** | `intake ""` / `intake classify --file P` | classify one raw input; `--apply` writes the prompt | | **ideas** | `intake ideas` | scan `ideas.md`, propose one prompt per bullet; `--apply` writes them | | **census** | `intake census` | inventory every filed prompt (work-type/target/difficulty/status + hygiene flags); always read-only | -| **dashboard** | `intake dashboard` | render the census as the Mind **task** page — picks, in flight, parked, planned, backlog; `--apply` writes `PyAutoMind/dashboard.md`, `--check` exits 1 on drift | +| **dashboard** | `intake dashboard` | render the census as the Mind **task** page — picks, in flight, parked, planned, backlog, recent, epics; `--apply` writes `PyAutoMind/dashboard.md`, `--check` exits 1 on drift | | **formalise** | `intake formalise [prefix]` | retroactively header the prompts census flags — derive the missing fields, insert in place, prose untouched; `--apply` writes | | **reconcile** | `intake reconcile [prefix]` | rank backlog prompts that look already-shipped (vs the `complete/` records / `active/`); always read-only — retiring stays human | | **reconcile --repo** | `intake reconcile --repo [prefix]` | **also** read the target repo's source for identifiers the prompts name — the one signal that sees a prompt with no Mind-side trace. Opt-in; the default path is offline | +**Recent** is the one section laid out by *date* rather than by state: the 20 +newest task events merged across every bucket — issued, parked, filed, +completed — sitting between the Backlog and the Epics. Every other section +answers "what should I do now?"; recency is orthogonal to state, so none of +them can answer "what has been happening?". Dates come from the registry key +that names the event (`issued:` / `parked:` / `filed:`, PyAutoMind REFERENCE.md +"Task dates"), a prompt's own `Issued:` header, or a record's `completed:`. +Live work is selected first and completions fill the rest — on a Mind that +ships two hundred records a month a straight date sort is twenty receipts and +no work. + Census/dashboard are the Mind *backlog* view — deliberately distinct from Heart's `/health status` health view (see "must never do"). The prompt-taxonomy folder is authoritative for a prompt's work-type/target; header fields are diff --git a/agents/conductors/intake/_intake.py b/agents/conductors/intake/_intake.py index 7bad3a0..06b7893 100755 --- a/agents/conductors/intake/_intake.py +++ b/agents/conductors/intake/_intake.py @@ -337,13 +337,14 @@ def parse_header(text: str) -> dict: Only scans the top of the file so a stray "Status:" deep in prose does not fire; first occurrence of each field wins. No YAML — the blessed convention. - `Epic:`/`Phase:` are optional epic-membership fields (dashboard grouping); - they are not in HEADER_FIELDS, so their absence is never header hygiene. + `Epic:`/`Phase:` are optional epic-membership fields (dashboard grouping) + and `Issued:` is the prompt's own copy of its registry date; none are in + HEADER_FIELDS, so their absence is never header hygiene. """ fields = {} for line in text.splitlines()[:30]: m = re.match(r"(Type|Target|Difficulty|Autonomy|Priority|Status|" - r"Epic|Phase):\s*(\S.*)", + r"Issued|Epic|Phase):\s*(\S.*)", line.strip()) if m: fields.setdefault(m.group(1).lower(), m.group(2).strip()) @@ -364,6 +365,25 @@ def _prefix_match(path: str, prefix: str) -> bool: _REG_HEAD = re.compile(r"^##\s+(\S.*?)\s*$") _REG_FIELD = re.compile(r"^-\s+([a-z][a-z-]*):\s*(.*)$") _ISSUE_URL = re.compile(r"https://github\.com/[^/\s]+/[^/\s]+/issues/(\d+)") +_ISO_DATE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b") + +# The Mind dates a task the moment it leaves the backlog (PyAutoMind +# REFERENCE.md "Task dates"). The KEY names the event, so a merged feed can say +# what each date means; most-specific event first, so a task that was filed and +# later issued dates from its issue. A date sitting in some other field's prose +# is deliberately not read — that is the un-parseable habit the convention +# replaced. +DATE_KEYS = ("issued", "registered", "started", "planned", "filed", "parked", + "found", "completed", "shipped") + + +def _entry_date(fields: dict) -> tuple: + """(date, event) for a registry entry, or ('', '') when it carries none.""" + for key in DATE_KEYS: + m = _ISO_DATE.search(fields.get(key) or "") + if m: + return m.group(1), key + return "", "" def parse_registry(path: Path) -> list: @@ -380,7 +400,8 @@ def parse_registry(path: Path) -> list: head = _REG_HEAD.match(line) if head: cur = {"slug": head.group(1), "issue": "", "issue_no": "", - "status": "", "prompt": ""} + "status": "", "prompt": "", "fields": {}, + "date": "", "event": ""} entries.append(cur) continue if cur is None: @@ -389,6 +410,7 @@ def parse_registry(path: Path) -> list: if not field: continue key, value = field.group(1), field.group(2).strip() + cur["fields"].setdefault(key, value) if key in ("issue", "status", "prompt") and not cur[key]: cur[key] = value if key == "issue": @@ -397,6 +419,8 @@ def parse_registry(path: Path) -> list: m = _ISSUE_URL.search(value) if m: cur["issue"], cur["issue_no"] = m.group(0), m.group(1) + for e in entries: + e["date"], e["event"] = _entry_date(e["fields"]) return entries @@ -466,6 +490,146 @@ def _clip(text: str, limit: int = 130) -> str: return text[:limit].rsplit(" ", 1)[0].rstrip(" .,;:—-") + "…" +# --- the recent feed ----------------------------------------------------------- +# The dashboard answers "what should I do now?" everywhere else on the page. +# This one section answers "what has been happening?" — the single question a +# task page laid out by STATE cannot answer, because a task's recency is +# orthogonal to which bucket it sits in. Merging the buckets by date puts the +# week's issuing, parking and shipping in one place, which is also the fastest +# way to spot a Mind that has drifted (three weeks of `filed` and nothing +# `completed` is visible at a glance and invisible section by section). +RECENT_MAX = 20 + +# How many month folders of `complete/` to open. The records are the bulk of +# the Mind (1000+ files), and only the newest handful can reach a 20-row feed, +# so the scan is bounded by folder rather than reading the whole ledger on +# every render. Two months is comfortably more than 20 records in practice; +# the scan widens on its own if they are not. +RECENT_MONTHS = 2 + +# Records open with their fields (`- completed: …`) but the ledger is a decade +# of hand-written prose, so a fifth of them say it another way. Read the head +# of the file only: a date deep in a narrative is something the task mentions, +# not when it shipped. +_RECORD_HEAD_BYTES = 4096 +_RECORD_DATE = re.compile(r"^-\s+(completed|shipped):\s*(.*)$", re.MULTILINE) + +# The verb each event reads as in the feed. Past tense throughout — every row +# is something that already happened. +EVENT_LABEL = {"issued": "issued", "registered": "issued", "started": "started", + "planned": "planned", "filed": "filed", "parked": "parked", + "found": "found", "completed": "completed", "shipped": "completed"} + + +def _anchor(heading: str) -> str: + """GitHub's heading anchor for an `## ` registry entry.""" + return re.sub(r"[^a-z0-9-]+", "-", heading.lower()).strip("-") + + +def _record_date(path: Path, text: str) -> str: + """The day a completion record's task shipped. + + `completed:`/`shipped:` when the record states one; otherwise the record's + own `complete///` folder, which the lifecycle engine files it + into by completion month — coarse, but never wrong about the month, and + the alternative is dropping a fifth of the ledger out of the feed.""" + m = _RECORD_DATE.search(text) + if m: + d = _ISO_DATE.search(m.group(2)) + if d: + return d.group(1) + parts = path.parts + if len(parts) >= 3 and parts[-3].isdigit() and parts[-2].isdigit(): + return f"{parts[-3]}-{parts[-2]}" + return "" + + +def completed_records(mind: Path, months: int = RECENT_MONTHS, + want: int = RECENT_MAX) -> list: + """The newest completion records, newest first — `[{date, slug, path}]`. + + Walks `complete///` newest-first and stops one month after it has + enough, so the whole ledger is never read to render a 20-row table. A month + is opened whole: records inside one are unordered, and a record can carry a + date from the month before it was filed under.""" + root = mind / "complete" + if not root.is_dir(): + return [] + folders = sorted( + (d for y in root.iterdir() if y.is_dir() and y.name.isdigit() + for d in y.iterdir() if d.is_dir() and d.name.isdigit()), + key=lambda d: (d.parent.name, d.name), reverse=True) + out, opened = [], 0 + for folder in folders: + opened += 1 + for f in folder.glob("*.md"): + text = f.read_text(encoding="utf-8", errors="replace")[:_RECORD_HEAD_BYTES] + out.append({"date": _record_date(f, text), + "slug": f.stem, + "path": str(f.relative_to(mind))}) + if opened >= months and len(out) >= want: + break + out.sort(key=lambda r: r["date"], reverse=True) + return out + + +def recent_events(c: dict, limit: int = RECENT_MAX) -> list: + """The Mind's newest task events, newest first — `[{date, event, title, …}]`. + + One row per task, not per event: a task that was filed and later issued + appears once, on its latest date (which is what `_entry_date` already picks + per entry). + + **Live work is never crowded out by shipped work.** A straight date sort + would be all completions — this Mind ships ~200 records a month, so the + twenty newest dates in it are twenty records — and the one thing the feed + must show is the work still in hand. So the rows are *selected* live-first + (in flight, parked, planned), completions fill whatever room is left, and + the selected set is then sorted by date like any other feed. On a quiet + Mind that changes nothing; on a busy one it is the difference between a + table of work and a table of receipts. + + Undated rows are absent rather than sorted to the bottom: `lifecycle.py + dates` is where a missing date gets reported, and padding this table with + unknowns would bury the answer it exists to give. + """ + live, done = [], [] + for r in c.get("in_flight") or []: + if r.get("date"): + live.append({"date": r["date"], "event": r.get("event") or "issued", + "title": r["title"], "path": r["path"], "live": True, + "payload": f"/start_dev {r['path']}"}) + for key, verb in (("parked", "resume"), ("planned", "start")): + for e in c.get(key) or []: + if e.get("date"): + live.append({"date": e["date"], + "event": e.get("event") or key, + "title": e["slug"], + # The entry's own heading anchor — a registry file + # is long enough that landing at its top is not + # the same as landing on the task. + "path": f"{key}.md#{_anchor(e['slug'])}", + "live": True, + "payload": _registry_payload(e, key, verb)}) + for r in c.get("completed") or []: + if r.get("date"): + done.append({"date": r["date"], "event": "completed", + "title": r["slug"], "path": r["path"], "live": False, + "payload": f"/memory recall the completed PyAutoMind " + f"task {r['slug']} — its record is " + f"{r['path']}"}) + + def _newest(rows): + return sorted(rows, key=lambda e: (e["date"], e["title"]), reverse=True) + + chosen = _newest(live)[:limit] + chosen += _newest(done)[:max(0, limit - len(chosen))] + chosen = _newest(chosen) + for e in chosen: + e["event"] = EVENT_LABEL.get(e["event"], e["event"]) + return chosen + + def census(mind: Path) -> dict: """Inventory the Mind's work — filed prompts plus the registry's live rows. @@ -546,11 +710,20 @@ def _count(key): continue header = parse_header(text) row = by_prompt.get(rel, {}) + # The registry row is the live record, so its date wins; the prompt's + # own `Issued:` header is the fallback that keeps an orphan (no row + # claims it) dated rather than dropping it out of the recent feed. + date, event = row.get("date", ""), row.get("event", "") + if not date: + m = _ISO_DATE.search(header.get("issued", "")) + date, event = (m.group(1), "issued") if m else ("", "") in_flight.append({ "path": rel, "title": _title(text), "target": header.get("target", "-"), "priority": header.get("priority", "-"), + "date": date, + "event": event, "issue": row.get("issue", ""), "issue_no": row.get("issue_no", ""), # The registry row only. A prompt's own `Status:` header is written @@ -559,7 +732,7 @@ def _count(key): "status": row.get("status", ""), }) - return { + c = { "generated": _dt.date.today().isoformat(), "home": _mind_home(mind), "total": len(records), @@ -576,6 +749,9 @@ def _count(key): "hygiene": hygiene, "drift": drift, } + c["completed"] = completed_records(mind) + c["recent"] = recent_events(c) + return c def _cell(value: str) -> str: @@ -718,6 +894,32 @@ def _epic_members(c: dict) -> dict: return groups +RECENT_BLURB = ( + "The Mind's most recent {n} task events, newest first — what was issued, " + "parked, filed and shipped, in one place. Every other section on this page " + "is laid out by state, which is exactly why none of them can answer " + "\u201cwhat has been happening?\u201d. Work still in hand is listed " + "first-class here; completed records fill whatever room is left.") + + +def _dated(row: dict) -> str: + """`— issued 2026-08-19`, the facet every live task row now carries. + + The date is worth showing where the task IS, not only in the Recent feed: + a status line reads very differently against a row issued yesterday than + against one issued in May. Empty for an undated row rather than a + placeholder — `lifecycle.py dates` is where the gap gets reported.""" + if not row.get("date"): + return "" + event = EVENT_LABEL.get(row.get("event", ""), row.get("event") or "dated") + return f" — {event} {row['date']}" + + +def _recent_link(e: dict) -> str: + """The task cell of a Recent row — its title, linked to where it lives.""" + return f"{_summary_label(_clip(e['title'], 70))}" + + EPIC_ORDER_CAUTION = ("Members are worked in order through the epic's ledger " "— continue the epic rather than starting one standalone.") @@ -751,7 +953,9 @@ def render_dashboard(c: dict) -> str: L += [ "Every task the Mind is holding, on one page: what is in flight, what " "is parked, and the whole backlog to pick from. Pick a task and run " - "its `/start_dev` command in a Claude Code chat to start it.", + "its `/start_dev` command in a Claude Code chat to start it. " + "[Recent](#recent) is the same work by date — what has been happening " + "rather than what to do next.", "", "| Where | Count |", "|-------|------:|", @@ -794,6 +998,7 @@ def render_dashboard(c: dict) -> str: head = f"{_summary_label(r['title'])}" if r["issue_no"]: head += f" — issue #{r['issue_no']}" + head += _dated(r) if r["status"]: head += f" — {_summary_label(_clip(r['status']))}" flight.append(_task_row(head, f"/start_dev {r['path']}")) @@ -817,6 +1022,7 @@ def render_dashboard(c: dict) -> str: head = f"{_summary_label(e['slug'])}" if e["issue_no"]: head += f" — issue #{e['issue_no']}" + head += _dated(e) if e["status"]: head += f" — {_summary_label(_clip(e['status']))}" items.append(_task_row(head, _registry_payload(e, key, verb))) @@ -838,6 +1044,21 @@ def render_dashboard(c: dict) -> str: L += _items([_bullet(r) for r in rows]) L += ["", "", ""] + # Recency is orthogonal to state, so it gets its own table rather than a + # column on any section above. A table, not the page's usual copy rows: + # this section is read, not picked from — the task's own section is where + # it is picked up. + recent = c.get("recent") or [] + if recent: + L += ["## Recent", "", + RECENT_BLURB.format(n=len(recent)), "", + "| Date | Event | Task |", + "|------|-------|------|"] + L += [f"| {r['date']} | {r['event']} | {_cell(_recent_link(r))} |" + for r in recent] + L += ["", "_Dates come from each task's registry entry and each record's " + "`completed:` — `lifecycle.py dates` reports anything undated._", ""] + # Epics live at the bottom, whole: each epic's resume prompt sits with its # queued member prompts, grouped and phase-ordered, so nobody picks a # member standalone out of order from a work-type section above. @@ -913,6 +1134,15 @@ def render_dashboard(c: dict) -> str: button.copy.ok{color:var(--ok);border-color:var(--ok)} details{margin:.5rem 0} summary{cursor:pointer;font-weight:600;padding:.4rem 0} +table.recent{width:100%;border-collapse:collapse;font-size:.95em} +table.recent td{border-bottom:1px solid var(--line);padding:.45rem .4rem .45rem 0; + vertical-align:top;overflow-wrap:anywhere} +table.recent td.when{white-space:nowrap;color:var(--muted);font-variant-numeric: + tabular-nums} +table.recent td.what{white-space:nowrap;color:var(--muted);font-size:.85em; + padding-top:.58rem} +table.recent td.pick{width:2.6rem;padding-right:0} +table.recent button.copy{width:2.2rem;height:2.2rem;font-size:.95rem} """ # One tap on 📋 → the command is on the clipboard; the button flashes ✓. The @@ -988,7 +1218,9 @@ def record_row(r): "

📋 PyAutoMind Dashboard

", "

Every task the Mind is holding. Tap a task's 📋 and its " "/start_dev command is on your clipboard — paste it into " - "a Claude Code chat to route Claude straight to that task.

", + "a Claude Code chat to route Claude straight to that task. " + 'Recent is the same work by date — what has been ' + "happening rather than what to do next.

", f'

In flight {c["issued_count"]} · ' f'Parked {len(c["parked"])} · Planned {len(c["planned"])} · ' f'Backlog {c["total"]}' @@ -1031,6 +1263,8 @@ def h2(title, src): text = link(r["path"], _summary_label(r["title"])) if r["issue_no"]: text += f' — issue #{r["issue_no"]}' + if r.get("date"): + text += f'{_dated(r)}' if r["status"]: text += (f' — ' f'{_summary_label(_clip(r["status"]))}') @@ -1048,6 +1282,8 @@ def h2(title, src): if e["issue_no"]: text += (f' — ' f'issue #{e["issue_no"]}') + if e.get("date"): + text += f'{_dated(e)}' if e["status"]: text += (f' — ' f'{_summary_label(_clip(e["status"]))}') @@ -1070,6 +1306,22 @@ def h2(title, src): H += [record_row(r) for r in rows] H += [""] + recent = c.get("recent") or [] + if recent: + H += ['' + h2("Recent", "dashboard.md#recent"), + f'

{RECENT_BLURB.format(n=len(recent))}

', + ''] + for r in recent: + H += ["", + f'', + f'', + f'', + f'', + ""] + H += ["
{r["date"]}{_summary_label(r["event"])}{link(r["path"], _summary_label(_clip(r["title"], 70)))}
"] + known = {e["slug"] for e in c.get("epics") or []} stray = [s for s in members if s not in known] if c.get("epics") or stray: diff --git a/tests/test_intake_dashboard.py b/tests/test_intake_dashboard.py index 6eb0238..4781d53 100644 --- a/tests/test_intake_dashboard.py +++ b/tests/test_intake_dashboard.py @@ -33,11 +33,16 @@ def _prompt(title, difficulty="medium", autonomy="supervised", priority="normal" f"Priority: {priority}\nStatus: {status}\n\nBody prose.\n") -def _mind(root: Path, drafts=None, active=None, registries=None) -> Path: +def _mind(root: Path, drafts=None, active=None, registries=None, + complete=None) -> Path: for rel, body in (drafts or {}).items(): p = root / "draft" / rel p.parent.mkdir(parents=True, exist_ok=True) p.write_text(body, encoding="utf-8") + for rel, body in (complete or {}).items(): + p = root / "complete" / rel + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body, encoding="utf-8") for name, body in (active or {}).items(): p = root / "active" / name p.parent.mkdir(parents=True, exist_ok=True) @@ -452,3 +457,168 @@ def test_html_sections_link_their_markdown_source(tmp_path): for src in ("active.md", "epics.md", "parked.md", "planned.md"): assert f'/blob/main/{src}">markdown version' in html, src assert '/tree/main/draft">markdown version' in html + + +# --------------------------------------------------------------------------- # +# recent: the one section laid out by date rather than by state +# --------------------------------------------------------------------------- # +def _record(slug, date): + return f"## {slug}\n- completed: {date}\n- summary: it shipped.\n" + + +def _recent_mind(root, n_records=0, month="08"): + """A Mind with one task in each live state plus `n_records` completions.""" + return _mind( + root, + active={"sprocket_calibration.md": _prompt("Sprocket calibration")}, + complete={f"2026/{month}/shipped-{i:02d}.md": _record(f"shipped-{i:02d}", + f"2026-{month}-2{i % 10}") + for i in range(n_records)}, + registries={ + "active.md": "## sprocket-calibration\n- issued: 2026-08-19\n" + "- prompt: active/sprocket_calibration.md\n", + "parked.md": "## flywheel-balance\n- parked: 2026-08-18 — deferred\n", + "planned.md": "## gearbox-survey\n- filed: 2026-07-01\n", + }) + + +def test_recent_merges_every_state_into_one_dated_feed(tmp_path): + """Recency is orthogonal to state, so it is the one question no other + section on the page can answer.""" + page = _page(_recent_mind(tmp_path, n_records=3)) + recent = page.split("## Recent")[1] + for slug in ("sprocket-calibration", "flywheel-balance", "gearbox-survey", + "shipped-00"): + assert slug in recent or slug.replace("-", " ") in recent.lower() + assert "| Date | Event | Task |" in recent + + +def test_recent_names_the_event_each_date_records(tmp_path): + """A bare date says nothing; `parked` / `issued` / `filed` says what + happened — the whole reason the registry key is the event name.""" + rows = _intake.census(_recent_mind(tmp_path))["recent"] + assert {r["title"]: r["event"] for r in rows} == { + "Sprocket calibration": "issued", + "flywheel-balance": "parked", + "gearbox-survey": "filed", + } + + +def test_recent_is_newest_first(tmp_path): + rows = _intake.census(_recent_mind(tmp_path))["recent"] + assert [r["date"] for r in rows] == sorted( + (r["date"] for r in rows), reverse=True) + + +def test_recent_sits_after_the_backlog_and_before_the_epics(tmp_path): + mind = _recent_mind(tmp_path) + (mind / "epics.md").write_text(_EPICS, encoding="utf-8") + page = _page(mind) + assert page.index("## Backlog") < page.index("## Recent") < page.index("## Epics") + + +def test_a_busy_ledger_never_crowds_live_work_out_of_the_feed(tmp_path): + """A straight date sort on a Mind that ships 200 records a month is 20 + receipts and no work. Live tasks are selected first; records fill the + rest — otherwise the dates on active tasks would be invisible on the very + page they were added for.""" + rows = _intake.census(_recent_mind(tmp_path, n_records=40))["recent"] + assert len(rows) == _intake.RECENT_MAX + live = [r for r in rows if r["live"]] + assert {r["title"] for r in live} == {"Sprocket calibration", + "flywheel-balance", "gearbox-survey"} + + +def test_an_undated_task_is_absent_rather_than_sorted_to_the_bottom(tmp_path): + """`lifecycle.py dates` is where a missing date gets reported; padding the + feed with unknowns would bury the answer it exists to give.""" + mind = _mind(tmp_path, registries={ + "planned.md": "## gearbox-survey\n- status: planned\n"}) + assert _intake.census(mind)["recent"] == [] + assert "## Recent" not in _page(mind) + + +def test_recent_links_a_registry_row_to_its_own_entry_not_the_file_top(tmp_path): + """parked.md is long enough that landing at its top is not the same as + landing on the task.""" + page = _page(_recent_mind(tmp_path)) + assert 'href="parked.md#flywheel-balance"' in page + + +def test_an_in_flight_prompt_can_be_dated_by_its_own_header(tmp_path): + """The prompt's `Issued:` header is its own copy of the registry date, so + an orphan (no row claims it) still dates rather than dropping out.""" + body = _prompt("Sprocket calibration").replace( + "Status: formalised", "Status: formalised\nIssued: 2026-08-19") + rows = _intake.census( + _mind(tmp_path, active={"sprocket_calibration.md": body}))["recent"] + assert [(r["date"], r["event"]) for r in rows] == [("2026-08-19", "issued")] + + +def test_a_registry_date_beats_the_prompts_own_copy(tmp_path): + """The registry row is the live record; the header is the fallback.""" + body = _prompt("Sprocket calibration").replace( + "Status: formalised", "Status: formalised\nIssued: 2026-07-01") + mind = _mind(tmp_path, active={"sprocket_calibration.md": body}, + registries={"active.md": "## sprocket-calibration\n" + "- issued: 2026-08-19\n" + "- prompt: active/sprocket_calibration.md\n"}) + assert _intake.census(mind)["recent"][0]["date"] == "2026-08-19" + + +def test_a_date_in_another_fields_prose_does_not_count_as_a_date(tmp_path): + """`- issue: …/1501 (issued 2026-08-19)` is the un-parseable habit the + convention replaced — reading it back would re-legitimise it.""" + mind = _mind(tmp_path, registries={ + "planned.md": "## gearbox-survey\n" + "- issue: https://example.invalid/issues/1 (filed 2026-08-19)\n"}) + assert _intake.census(mind)["recent"] == [] + + +def test_a_record_without_a_completed_field_still_dates_by_its_folder(tmp_path): + """A fifth of the ledger states its date in prose. The month folder the + lifecycle engine filed it into is coarse but never wrong about the month, + and the alternative is dropping those records out of the feed.""" + mind = _mind(tmp_path, complete={ + "2026/07/legacy_record.md": "Shipped one July afternoon.\n"}) + assert _intake.census(mind)["recent"][0]["date"] == "2026-07" + + +def test_the_html_twin_carries_the_same_feed_with_real_copy_buttons(tmp_path): + html = _intake.render_dashboard_html(_intake.census(_recent_mind(tmp_path))) + assert "

Recent" in html + assert html.index("Backlog") < html.index("

Recent") < html.index("

Epics") \ + if "

Epics" in html else True + assert '' in html + assert 'data-cmd="/start_dev active/sprocket_calibration.md"' in html + + +def test_the_completed_scan_does_not_read_the_whole_ledger(tmp_path): + """1000+ records is the bulk of the Mind; a 20-row table must not open all + of them on every render.""" + mind = _mind(tmp_path, complete={ + f"2026/{m:02d}/rec-{m}-{i:02d}.md": _record(f"rec-{m}-{i}", f"2026-{m:02d}-01") + for m in range(1, 9) for i in range(30)}) + scanned = _intake.completed_records(mind) + assert len(scanned) < 240 + assert scanned[0]["date"] == "2026-08-01" + + +def test_a_live_row_wears_its_date_where_the_task_is(tmp_path): + """A status line reads very differently against a row issued yesterday + than against one issued in May, so the date rides on the row too — not + only down in the Recent feed.""" + page = _page(_recent_mind(tmp_path)) + flight = page.split("## In flight")[1].split("## Parked")[0] + assert "issued 2026-08-19" in flight + assert "parked 2026-08-18" in page.split("## Parked")[1].split("## Planned")[0] + assert "filed 2026-07-01" in page.split("## Planned")[1].split("## Backlog")[0] + + +def test_an_undated_row_gets_no_placeholder(tmp_path): + """`lifecycle.py dates` reports the gap; the page must not invent one.""" + mind = _mind(tmp_path, active={"sprocket_calibration.md": _prompt("Sprocket")}, + registries={"active.md": "## sprocket-calibration\n" + "- prompt: active/sprocket_calibration.md\n"}) + row = _page(mind).split("## In flight")[1].split("
")[1] + assert "—" not in row.split("")[0]