diff --git a/AGENTS.md b/AGENTS.md index 01ec04b6..24336fac 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -68,7 +68,10 @@ For the full workflow narrative, conventions, and registry schemas, read coupled to the registry. Claude and Codex discovery is installed by PyAutoBrain; they source `scripts/prompt_sync.sh` for commit/push. - **Scripts** — `scripts/status.sh` (inventory), `scripts/prompt_sync.sh` - (commit/push helpers). + (commit/push helpers), `scripts/lifecycle.py` (state moves + drift checks; + `lifecycle.py dates [--write]` reports/backfills the date every registry + entry and issued prompt carries — see [REFERENCE.md](REFERENCE.md) "Task + dates"). ## Hard rules diff --git a/REFERENCE.md b/REFERENCE.md index 8f156692..f73c3b91 100644 --- a/REFERENCE.md +++ b/REFERENCE.md @@ -300,6 +300,7 @@ Free-form markdown. Strong conventions: Autonomy: supervised # safe | supervised | human-required Priority: normal # low | normal | high Status: draft + Issued: 2026-08-19 # optional; set when the prompt advances to active/ Blocked-by: PyAutoFit#1436 # optional; see "Declaring a gate" below ``` @@ -354,6 +355,7 @@ Each task is an H2 section: ```markdown ## - issue: https://github.com///issues/ +- issued: YYYY-MM-DD # the day the task was issued - session: claude --resume # optional - status: - location: # optional, used by /handoff @@ -375,6 +377,54 @@ Each task is an H2 section: Free-form summary of progress and next steps. ``` +### Task dates + +The Mind used to date only what it **finished** — every completion record +carries `completed:` — so it could answer "what shipped in July?" but not "what +did we start?". Every task now carries a machine-readable date from the moment +it leaves the backlog: + +| Where | Field | The event it dates | +|-------|-------|--------------------| +| `active.md` | `- issued: YYYY-MM-DD` | the day the task got its GitHub issue | +| `planned.md` | `- filed: YYYY-MM-DD` | the day it was scoped | +| `parked.md` | `- parked: YYYY-MM-DD` | the day it stopped | +| `active/.md` | `Issued: YYYY-MM-DD` | the prompt's own copy, in its light header | +| `complete///.md` | `- completed: YYYY-MM-DD` | unchanged — the ledger already did this | + +The **key names the event**, so a merged feed can say what each date means +rather than showing a bare timestamp. Reading is tolerant: `registered:`, +`started:`, `planned:`, `found:` and `shipped:` are all read as dates too (the +registries are hand-edited by many sessions, and an entry that says when it +happened should count however it said it) — the table is what a *writer* +should use. The most specific event wins when an entry carries several, so a +task that was filed and later issued dates from its issue. + +A date inside another field's prose (`- issue: …/1501 (issued 2026-08-19)`) is +deliberately **not** read — that is the un-parseable habit this convention +replaces. The prompt's `Issued:` header is its own copy of the registry date, +so an issued prompt stays dated even if its registry row goes missing. + +`scripts/lifecycle.py dates` reports every entry and issued prompt carrying no +date; `dates --write` backfills them retroactively from the evidence the repo +already holds, annotating each inferred date with where it came from: + +``` +Issued: 2026-08-18 (backfilled from parked.md `parked:`) +``` + +The sources, in order: the commit that introduced the entry or moved the prompt +into `active/`; the dated registry entry that claims the prompt; a date the +entry already stated in its own prose. Nothing is guessed — an entry with no +evidence is reported for a human to date by hand. A **shallow** clone (CI, a +cloud session) cannot see past its boundary commit, so git dates at or before +it are discarded rather than stamping every task with the day the clone was +made. + +The dashboard's [Recent](dashboard.md#recent) table is the payoff: the 20 +newest events across the whole Mind — issued, parked, filed and completed — in +one place. + ### Completion record (`complete///.md`) schema The dated records are the completion ledger (`complete/AGENTS.md`; the diff --git a/active.md b/active.md index d0ef1fe7..fb25a165 100644 --- a/active.md +++ b/active.md @@ -2,6 +2,7 @@ ## transformed-message-factor-gradient-unpack - issue: https://github.com/PyAutoLabs/PyAutoFit/issues/1501 (issued 2026-08-19) +- issued: 2026-08-19 - prompt: active/16_transformed_message_factor_gradient_unpack.md - status: HOLD — do not start dev. Fix-or-delete hangs off the PyAutoFit#1498 logpdf-contract decision (parked #1500 design bundle); dead code (zero production callers), crashes on first diff --git a/active/12_single_source_density_refactor.md b/active/12_single_source_density_refactor.md index 9d58599b..60af4c6d 100644 --- a/active/12_single_source_density_refactor.md +++ b/active/12_single_source_density_refactor.md @@ -6,6 +6,7 @@ Difficulty: too-large Autonomy: supervised Priority: normal Status: formalised +Issued: 2026-08-18 (backfilled from parked.md `parked:`) Found during the priors/messages audit (see `PyAutoPrompt/autofit/priors_and_messages_math_audit.md`, finding C1). diff --git a/active/13_collapse_prior_and_message.md b/active/13_collapse_prior_and_message.md index 1dcbbb52..24030fb0 100644 --- a/active/13_collapse_prior_and_message.md +++ b/active/13_collapse_prior_and_message.md @@ -6,6 +6,7 @@ Difficulty: too-large Autonomy: supervised Priority: normal Status: formalised +Issued: 2026-08-18 (backfilled from parked.md `parked:`) Found during the priors/messages audit (see `PyAutoPrompt/autofit/priors_and_messages_math_audit.md`, finding C4). diff --git a/active/16_transformed_message_factor_gradient_unpack.md b/active/16_transformed_message_factor_gradient_unpack.md index ec9422eb..7a007476 100644 --- a/active/16_transformed_message_factor_gradient_unpack.md +++ b/active/16_transformed_message_factor_gradient_unpack.md @@ -7,6 +7,7 @@ Autonomy: supervised Priority: normal Status: issued 2026-08-19 as PyAutoFit#1501 — awaiting external verification (fix-or-delete hangs off the #1498 contract decision); do not start dev +Issued: 2026-08-19 (backfilled from active.md `registered:`) Same shape as census finding A1 (#1331-01): a code path that has never run end-to-end, dead on arrival. diff --git a/active/pyautoreduce_slacs1430_acs_comparison.md b/active/pyautoreduce_slacs1430_acs_comparison.md index aeca0091..5b35463f 100644 --- a/active/pyautoreduce_slacs1430_acs_comparison.md +++ b/active/pyautoreduce_slacs1430_acs_comparison.md @@ -9,6 +9,7 @@ Difficulty: medium Autonomy: safe Priority: normal Status: formalised +Issued: 2026-08-08 (backfilled from parked.md `parked:`) Validate PyAutoReduce against a trusted legacy reduction: reduce the SLACS lens slacs1430+4105 from archival HST/ACS data using PyAutoReduce, then compare the resulting modeling-ready dataset (data, noise map, PSF) to the long-used collaborator-provided dataset at /mnt/c/Users/Jammy/Science/subhalo/dataset/slacs/slacs1430+4105 (used for many years in subhalo work). Comparison is a science project driven through autolens_assistant: image/noise/PSF residuals plus lens-model parity fits on both datasets. diff --git a/dashboard.html b/dashboard.html index fa5f2627..bb6ecd71 100644 --- a/dashboard.html +++ b/dashboard.html @@ -33,11 +33,20 @@ 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}

📋 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.

+

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. Recent is the same work by date — what has been happening rather than what to do next.

In flight 1 · Parked 3 · Planned 6 · Backlog 150 · markdown version

Start here

Highest priority (filed as high) — showing 12 of 17

@@ -58,23 +67,23 @@

Quick wins (small enough, and safe enough to run unatte

In flight markdown version

Issued — each has an open GitHub issue and usually a branch.

-

@PyAutoFit TransformedMessage.factor_gradient crashes on first callissue #1501HOLD — do not start dev. Fix-or-delete hangs off the PyAutoFit#1498 logpdf-contract

+

@PyAutoFit TransformedMessage.factor_gradient crashes on first callissue #1501 — issued 2026-08-19HOLD — do not start dev. Fix-or-delete hangs off the PyAutoFit#1498 logpdf-contract

Parked markdown version

3 task(s) -

single-source-density-designissue #1500

-

prior-message-collapse-designissue #1500

-

pyautoreduce-slacs1430-acs-comparison

+

single-source-density-designissue #1500 — parked 2026-08-18

+

prior-message-collapse-designissue #1500 — parked 2026-08-18

+

pyautoreduce-slacs1430-acs-comparison — parked 2026-08-08

Planned markdown version

6 task(s) -

isothermal-ell-sph-oversampling-at-the-cuspplanned — NOT yet a prompt file; file one via /intake before starting

-

remote-mcp-deployment-tiersissue #20DESIGN-COMPLETE, build BLOCKED-ON-DEMAND — issue #20 holds the full auth/transport/hosting design + Richard/PyAutoMCP…

-

samples-parameter-pathsissue #1327parked

-

jax-point-source-point-smoke-sentinelplanned

-

piemass-potentialplanned

-

latent-nan-guard-honest-run

+

isothermal-ell-sph-oversampling-at-the-cusp — found 2026-08-09planned — NOT yet a prompt file; file one via /intake before starting

+

remote-mcp-deployment-tiersissue #20 — filed 2026-07-21DESIGN-COMPLETE, build BLOCKED-ON-DEMAND — issue #20 holds the full auth/transport/hosting design + Richard/PyAutoMCP…

+

samples-parameter-pathsissue #1327 — filed 2026-07-08parked

+

jax-point-source-point-smoke-sentinel — filed 2026-05-21planned

+

piemass-potential — filed 2026-06-05planned

+

latent-nan-guard-honest-run — planned 2026-07-22

Backlog markdown version

150 filed prompts, not started — sorted most-pickable first (priority, then size). 23 of them belong to an epic and are listed only under Epics below.

@@ -232,6 +241,130 @@

Backlog

CTI release-train wiring — first modern autocti releaseautocti · medium · human-required · normal

+

Recent markdown version

+

The Mind's most recent 20 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 “what has been happening?”. Work still in hand is listed first-class here; completed records fill whatever room is left.

+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
2026-08-23completedpyautonerves-release-for-regime-stamp
2026-08-23completedbump-autonerves-floor
2026-08-22completedsmall-datasets-regime-stamp
2026-08-22completedshould-simulate-capped-branch-reuse
2026-08-22completedplot-utils-duplicate-modules
2026-08-22completedoptional-dependency-skip-guards
2026-08-22completedjax-grad-local-vs-ci-assertions
2026-08-22completedfits-header-comment-literal-list
2026-08-21completedqueue-intake-claude
2026-08-21completedpixelization-eager-jit-divergence
2026-08-19issued@PyAutoFit TransformedMessage.factor_gradient crashes on first…
2026-08-18parkedsingle-source-density-design
2026-08-18parkedprior-message-collapse-design
2026-08-09foundisothermal-ell-sph-oversampling-at-the-cusp
2026-08-08parkedpyautoreduce-slacs1430-acs-comparison
2026-07-22plannedlatent-nan-guard-honest-run
2026-07-21filedremote-mcp-deployment-tiers
2026-07-08filedsamples-parameter-paths
2026-06-05filedpiemass-potential
2026-05-21filedjax-point-source-point-smoke-sentinel

Epics markdown version

Long-running multi-phase programmes — 📋 copies a prompt that works out where the epic stands from its ledger and continues it from the next logical point. Members are worked in order through the epic's ledger — continue the epic rather than starting one standalone.

JAX profiling — inference programmeledger: autolens_profiling/results/notes/inference/PROGRAMME.md

diff --git a/dashboard.md b/dashboard.md index 5b605caf..5cf060c1 100644 --- a/dashboard.md +++ b/dashboard.md @@ -4,7 +4,7 @@ This is the markdown version of the [PyAutoMind Dashboard](https://pyautolabs.github.io/PyAutoMind/), which puts a task's command on your clipboard with a single tap of 📋. -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. +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. [Recent](#recent) is the same work by date — what has been happening rather than what to do next. | Where | Count | |-------|------:| @@ -135,7 +135,7 @@ Every task the Mind is holding, on one page: what is in flight, what is parked, Issued — each has an open GitHub issue and usually a branch. The full record for each is in [`active.md`](active.md). -
📋 @PyAutoFit TransformedMessage.factor_gradient crashes on first callissue #1501 — HOLD — do not start dev. Fix-or-delete hangs off the PyAutoFit#1498 logpdf-contract +
📋 @PyAutoFit TransformedMessage.factor_gradient crashes on first callissue #1501 — issued 2026-08-19 — HOLD — do not start dev. Fix-or-delete hangs off the PyAutoFit#1498 logpdf-contract ``` /start_dev active/16_transformed_message_factor_gradient_unpack.md @@ -150,7 +150,7 @@ Started or scoped, not currently in flight — resume by moving the row back to
3 task(s) -
📋 single-source-density-designissue #1500 +
📋 single-source-density-designissue #1500 — parked 2026-08-18 ``` /start_dev active/12_single_source_density_refactor.md @@ -158,7 +158,7 @@ Started or scoped, not currently in flight — resume by moving the row back to
-
📋 prior-message-collapse-designissue #1500 +
📋 prior-message-collapse-designissue #1500 — parked 2026-08-18 ``` /start_dev active/13_collapse_prior_and_message.md @@ -166,7 +166,7 @@ Started or scoped, not currently in flight — resume by moving the row back to
-
📋 pyautoreduce-slacs1430-acs-comparison +
📋 pyautoreduce-slacs1430-acs-comparison — parked 2026-08-08 ``` /start_dev active/pyautoreduce_slacs1430_acs_comparison.md @@ -183,7 +183,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
6 task(s) -
📋 isothermal-ell-sph-oversampling-at-the-cusp — planned — NOT yet a prompt file; file one via /intake before starting +
📋 isothermal-ell-sph-oversampling-at-the-cusp — found 2026-08-09 — planned — NOT yet a prompt file; file one via /intake before starting ``` /route start the planned PyAutoMind task isothermal-ell-sph-oversampling-at-the-cusp — its record is in planned.md @@ -191,7 +191,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
-
📋 remote-mcp-deployment-tiersissue #20 — DESIGN-COMPLETE, build BLOCKED-ON-DEMAND — issue #20 holds the full auth/transport/hosting design + Richard/PyAutoMCP… +
📋 remote-mcp-deployment-tiersissue #20 — filed 2026-07-21 — DESIGN-COMPLETE, build BLOCKED-ON-DEMAND — issue #20 holds the full auth/transport/hosting design + Richard/PyAutoMCP… ``` /start_dev draft/feature/autofit_assistant/remote_mcp_deployment_tiers.md @@ -199,7 +199,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
-
📋 samples-parameter-pathsissue #1327 — parked +
📋 samples-parameter-pathsissue #1327 — filed 2026-07-08 — parked ``` /start_dev draft/bug/health_fixes/samples_parameter_paths.md @@ -207,7 +207,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
-
📋 jax-point-source-point-smoke-sentinel — planned +
📋 jax-point-source-point-smoke-sentinel — filed 2026-05-21 — planned ``` /start_dev draft/bug/autolens/jax_point_source_point_smoke_sentinel.md @@ -215,7 +215,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
-
📋 piemass-potential — planned +
📋 piemass-potential — filed 2026-06-05 — planned ``` /start_dev draft/feature/autogalaxy/piemass_potential.md @@ -223,7 +223,7 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
-
📋 latent-nan-guard-honest-run +
📋 latent-nan-guard-honest-run — planned 2026-07-22 ``` /route start the planned PyAutoMind task latent-nan-guard-honest-run — its record is in planned.md @@ -1298,6 +1298,35 @@ Scoped but not started; some are not yet prompt files. Full detail in [`planned.
+## Recent + +The Mind's most recent 20 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 “what has been happening?”. Work still in hand is listed first-class here; completed records fill whatever room is left. + +| Date | Event | Task | +|------|-------|------| +| 2026-08-23 | completed | pyautonerves-release-for-regime-stamp | +| 2026-08-23 | completed | bump-autonerves-floor | +| 2026-08-22 | completed | small-datasets-regime-stamp | +| 2026-08-22 | completed | should-simulate-capped-branch-reuse | +| 2026-08-22 | completed | plot-utils-duplicate-modules | +| 2026-08-22 | completed | optional-dependency-skip-guards | +| 2026-08-22 | completed | jax-grad-local-vs-ci-assertions | +| 2026-08-22 | completed | fits-header-comment-literal-list | +| 2026-08-21 | completed | queue-intake-claude | +| 2026-08-21 | completed | pixelization-eager-jit-divergence | +| 2026-08-19 | issued | @PyAutoFit TransformedMessage.factor_gradient crashes on first… | +| 2026-08-18 | parked | single-source-density-design | +| 2026-08-18 | parked | prior-message-collapse-design | +| 2026-08-09 | found | isothermal-ell-sph-oversampling-at-the-cusp | +| 2026-08-08 | parked | pyautoreduce-slacs1430-acs-comparison | +| 2026-07-22 | planned | latent-nan-guard-honest-run | +| 2026-07-21 | filed | remote-mcp-deployment-tiers | +| 2026-07-08 | filed | samples-parameter-paths | +| 2026-06-05 | filed | piemass-potential | +| 2026-05-21 | filed | jax-point-source-point-smoke-sentinel | + +_Dates come from each task's registry entry and each record's `completed:` — `lifecycle.py dates` reports anything undated._ + ## Epics Long-running multi-phase programmes. Each epic's 📋 prompt has Claude read its ledger, work out where it stands, and continue from the next logical point. Members are worked in order through the epic's ledger — continue the epic rather than starting one standalone. Full record in [`epics.md`](epics.md). diff --git a/scripts/lifecycle.py b/scripts/lifecycle.py index 9ddc5992..c1455361 100644 --- a/scripts/lifecycle.py +++ b/scripts/lifecycle.py @@ -29,6 +29,11 @@ Generate complete/index.md (token-light navigation over the records); --check fails if it is stale (CI). + dates [--write | --check] + The date on every active/issued task: report what carries none, or + --write to backfill it from git history (the commit that introduced + the entry / moved the prompt into active/). See "task dates" below. + check Drift guard (mirrors repos_sync.py --check; non-zero exit on drift): * no active.md slug has a complete/ record (finished but still active) @@ -235,6 +240,318 @@ def registry_problems(root: Path) -> "list[str]": return problems +# --------------------------------------------------------------------------- # +# task dates +# +# Until 2026-08-23 the Mind dated only what it FINISHED: every complete/ record +# carries `completed:`, but a task that had merely been picked up carried no +# date at all. active.md rows said it in prose when a session remembered to +# ("- issue: …/1501 (issued 2026-08-19)"), which nothing could read back — so +# the Mind could answer "what shipped in July?" and not "what did we start?". +# +# The convention: every registry entry carries ONE zero-indent date field, and +# every issued prompt an `Issued:` header line. The KEY names the event, so the +# date reads as something that happened rather than a bare timestamp: +# +# active.md - issued: YYYY-MM-DD the day the task got its issue +# planned.md - filed: YYYY-MM-DD the day it was scoped +# parked.md - parked: YYYY-MM-DD the day it stopped +# active/*.md Issued: YYYY-MM-DD the prompt's own copy (survives a +# registry row going missing) +# +# `dates` reports what carries none; `dates --write` backfills from git — the +# commit that first introduced the entry, or that moved the prompt into +# active/. Retroactive by construction: the history already knows the dates, +# they were simply never written down where a reader could see them. +# --------------------------------------------------------------------------- # +ISO_DATE_RE = re.compile(r"\b(\d{4}-\d{2}-\d{2})\b") + +# Date keys a registry entry may carry, most task-authoritative first. The +# first one present wins, so an entry that was filed, then issued, dates from +# the issue — the later, more specific event. +DATE_KEYS = ("issued", "registered", "started", "planned", "filed", "parked", + "found", "completed", "shipped") + +# The key each registry's entries SHOULD carry — what `--write` inserts. +REGISTRY_DATE_KEY = {"active.md": "issued", "planned.md": "filed", + "parked.md": "parked"} + +# The prompt-header form. `Issued:` joins the light header convention +# (REFERENCE.md "Prompt file format") — optional like every other key, and +# never YAML. +PROMPT_DATE_KEY = "Issued" +_HEADER_FIELD_RE = re.compile( + r"^(Type|Target|Repos|Difficulty|Autonomy|Priority|Status|Issued|Epic|" + r"Phase|Blocked-by|Closes-when):", re.IGNORECASE) + + +def entry_date(fields: "dict[str, str]") -> "tuple[str | None, str | None]": + """(date, key) for a registry entry, or (None, None) when it carries none. + + Only a key from DATE_KEYS counts. A date sitting in some other field's + prose is invisible on purpose — `- issue: …/1501 (issued 2026-08-19)` is + exactly the un-machine-readable habit this convention replaces (backfill + still mines it, see `_prose_date`).""" + for key in DATE_KEYS: + value = fields.get(key) + if not value: + continue + m = ISO_DATE_RE.search(value) + if m: + return m.group(1), key + return None, None + + +def prompt_issued_date(text: str) -> "str | None": + """The `Issued: YYYY-MM-DD` header of an issued prompt, else None. + + Header only (first 30 lines), matching how every other prompt field is + read — a date deep in the prose is prose.""" + for line in text.splitlines()[:30]: + m = re.match(r"Issued:\s*(\S.*)", line.strip(), re.IGNORECASE) + if m: + d = ISO_DATE_RE.search(m.group(1)) + if d: + return d.group(1) + return None + + +def _prose_date(fields: "dict[str, str]") -> "str | None": + """Earliest ISO date anywhere in an entry's field values. + + The backfill's second source: entries that DID record when they started, + just not where `entry_date` looks. Earliest wins because the later dates + in an entry are progress notes, and the entry's own date is its first.""" + found = sorted(m for v in fields.values() for m in ISO_DATE_RE.findall(v)) + return found[0] if found else None + + +def _git(root: Path, args: "list[str]") -> "list[str]": + """`git` output lines, or [] on any failure. + + Empty on no git, not a repo, a fresh template checkout — the backfill + degrades to its other sources rather than dying.""" + import subprocess + try: + out = subprocess.run(["git", "-C", str(root), *args], + capture_output=True, text=True, timeout=30) + except (OSError, subprocess.SubprocessError): + return [] + if out.returncode != 0: + return [] + return [ln.strip() for ln in out.stdout.splitlines() if ln.strip()] + + +def _shallow_boundary(root: Path) -> "str | None": + """Date of the oldest commit a SHALLOW clone can see, else None. + + A shallow checkout (CI, a cloud session) has no history before its + boundary, so `git log` reports the boundary commit as the day everything + older was "introduced" — every backfill in one clone would come out the + same wrong date. Anything at or before the boundary is therefore not + evidence, and the caller falls through to a slower but honest source.""" + if _git(root, ["rev-parse", "--is-shallow-repository"])[:1] != ["true"]: + return None + dates = _git(root, ["log", "--format=%ad", "--date=short"]) + return dates[-1] if dates else None + + +def _git_dates(root: Path, args: "list[str]") -> "list[str]": + """`git log` short author dates for a pathspec/pickaxe, newest first.""" + return _git(root, ["log", "--format=%ad", "--date=short", *args]) + + +def _oldest(dates: "list[str]", boundary: "str | None") -> "str | None": + """The oldest date in a `git log` answer, unless the clone truncates it.""" + if not dates: + return None + oldest = dates[-1] + return None if boundary and oldest <= boundary else oldest + + +def git_entry_date(root: Path, registry: str, slug: str, + boundary: "str | None" = None) -> "str | None": + """The day this entry's `## slug` heading first appeared in its registry.""" + return _oldest(_git_dates(root, [f"-S## {slug}", "--", registry]), boundary) + + +def git_prompt_issued_date(root: Path, rel: str, + boundary: "str | None" = None) -> "str | None": + """The day this prompt arrived in `active/` — i.e. the day it was issued. + + `git mv` from draft/ records as an add at the new path, which is the event + wanted: the prompt entering the issued state.""" + return _oldest(_git_dates(root, ["--diff-filter=A", "--", rel]), boundary) + + +def _insert_registry_date(lines: "list[str]", start: int, key: str, + date: str) -> "list[str]": + """Insert `- key: date` into the entry whose `## ` heading is at `start`. + + Placed after the `- issue:` field (and its wrapped continuation lines) when + there is one, so an entry reads identity-then-date; otherwise it becomes + the entry's first field.""" + i = start + 1 + at = i + while i < len(lines) and not H2_RE.match(lines[i]): + f = FIELD_RE.match(lines[i]) + if f and f.group(1).strip() == "issue": + i += 1 + while i < len(lines) and lines[i][:1].isspace() and lines[i].strip(): + i += 1 + at = i + break + if f: + at = i + break + i += 1 + return lines[:at] + [f"- {key}: {date}"] + lines[at:] + + +def _insert_prompt_date(text: str, date: str) -> str: + """Insert `Issued: ` into a prompt's light metadata header. + + After the last header field (skipping the bullet list a `Repos:` field + owns) when there is a header; else under the title heading; else at the + very top. Every existing line survives verbatim, including line endings — + the prompt body is the human's, not the tool's.""" + lines = text.splitlines() + head = lines[:30] + last = max((i for i, ln in enumerate(head) if _HEADER_FIELD_RE.match(ln.strip())), + default=None) + if last is not None: + # A header field's value may run on: the bullet list a `Repos:` field + # owns, an indented wrap, or a bare continuation line ("Status: issued + # … / (do not start dev)"). The header block ends at the first blank + # line, so append there rather than splitting somebody's value in two. + at = last + 1 + while at < len(lines) and lines[at].strip(): + at += 1 + new = lines[:at] + [f"{PROMPT_DATE_KEY}: {date}"] + lines[at:] + else: + first = next((i for i, ln in enumerate(lines) if ln.strip()), 0) + if lines and lines[first].lstrip().startswith("#"): + new = (lines[:first + 1] + ["", f"{PROMPT_DATE_KEY}: {date}"] + + lines[first + 1:]) + else: + new = [f"{PROMPT_DATE_KEY}: {date}", ""] + lines + nl = "\r\n" if "\r\n" in text else "\n" + return nl.join(new) + (nl if text.endswith("\n") else "") + + +def undated_entries(root: Path) -> "list[dict]": + """Registry entries carrying no DATE_KEYS date.""" + out = [] + for reg in REGISTRY_FILES: + for slug, fields in registry_entries(root / reg): + date, _ = entry_date(fields) + if date is None: + out.append({"registry": reg, "slug": slug, "fields": fields}) + return out + + +def undated_prompts(root: Path) -> "list[Path]": + """Issued (`active/`) prompts carrying no `Issued:` header.""" + d = root / "active" + return [f for f in sorted(d.glob("*.md")) if d.is_dir() + and prompt_issued_date(f.read_text(errors="replace")) is None] + + +# A backfilled date is INFERRED, and says so in the file. A writer that knows +# the date (create_issue, at the moment it opens the issue) writes a bare +# `Issued: 2026-08-19`; anything reconstructed afterwards carries its source, +# so a reader can tell "this is when it happened" from "this is our best +# reconstruction of when it happened" without going back to git. +BACKFILL_NOTE = "(backfilled from {source})" + + +def _registry_claim(root: Path) -> "dict[str, tuple[str, str, str]]": + """{prompt path -> (date, key, registry)} for every dated registry entry. + + The Mind's own record of when a task changed state — the best evidence + left for a prompt whose issue date pre-dates what this clone can see.""" + out = {} + for reg in REGISTRY_FILES: + for _slug, fields in registry_entries(root / reg): + raw = (fields.get("prompt") or "").split() + date, key = entry_date(fields) + if raw and date: + out.setdefault(raw[0], (date, key, reg)) + return out + + +def backfill_dates(root: Path, apply: bool = False) -> "list[dict]": + """Date every undated registry entry and issued prompt, retroactively. + + Read-only unless `apply`. Each result records where the date came from, so + a backfill is auditable rather than anonymous, and every inferred date is + annotated in the file it lands in: + + `git` the commit that introduced the entry / the active/ prompt + `` the dated registry entry that claims this prompt + `prose` a date the entry or prompt already stated in its own text + `unknown` nothing to go on — reported, never guessed + + A shallow clone cannot see far enough back for the `git` source (see + `_shallow_boundary`); the chain then falls through to the Mind's own + records, which is why they are consulted at all. + """ + results = [] + boundary = _shallow_boundary(root) + + for reg in REGISTRY_FILES: + path = root / reg + if not path.exists(): + continue + key = REGISTRY_DATE_KEY[reg] + pending = [] + for slug, fields in registry_entries(path): + if entry_date(fields)[0] is not None: + continue + date, source = git_entry_date(root, reg, slug, boundary), "git" + if date is None: + date, source = _prose_date(fields), "prose" + if date is None: + source = "unknown" + results.append({"what": f"{reg}: {slug}", "key": key, + "date": date, "source": source}) + if date: + pending.append((slug, date, source)) + if not (apply and pending): + continue + lines = path.read_text(errors="replace").splitlines() + # Bottom-up: inserting into a later entry cannot shift an earlier + # entry's heading index out from under the next insert. + by_slug = {s: (d, src) for s, d, src in pending} + heads = [(i, _slugify_h2(m.group(1))) + for i, ln in enumerate(lines) if (m := H2_RE.match(ln))] + for i, slug in reversed(heads): + if slug in by_slug: + date, source = by_slug[slug] + value = f"{date} {BACKFILL_NOTE.format(source=source)}" + lines = _insert_registry_date(lines, i, key, value) + path.write_text("\n".join(lines) + "\n") + + claims = _registry_claim(root) + for f in undated_prompts(root): + rel = f.relative_to(root).as_posix() + text = f.read_text(errors="replace") + date, source = git_prompt_issued_date(root, rel, boundary), "git" + if date is None and rel in claims: + claimed, claim_key, claim_reg = claims[rel] + date, source = claimed, f"{claim_reg} `{claim_key}:`" + if date is None: + m = ISO_DATE_RE.search(text) + date, source = (m.group(1), "prose") if m else (None, "unknown") + results.append({"what": rel, "key": PROMPT_DATE_KEY, "date": date, + "source": source}) + if date and apply: + value = f"{date} {BACKFILL_NOTE.format(source=source)}" + f.write_text(_insert_prompt_date(text, value)) + + return results + + # --------------------------------------------------------------------------- # # the online leg — tracking-issue state # @@ -1050,6 +1367,35 @@ def cmd_index(args) -> int: # --------------------------------------------------------------------------- # # check # --------------------------------------------------------------------------- # +def cmd_dates(args) -> int: + """Report — and with --write, backfill — the dates on active/issued tasks.""" + root = ROOT + if args.write: + results = backfill_dates(root, apply=True) + wrote = [r for r in results if r["date"]] + for r in wrote: + print(f" + {r['what']}: {r['key']}: {r['date']} ({r['source']})") + unknown = [r for r in results if not r["date"]] + for r in unknown: + print(f" ? {r['what']}: no date in git history or prose — date it by hand") + print(f"dates: wrote {len(wrote)}, undatable {len(unknown)}") + return 0 + + missing = undated_entries(root) + prompts = undated_prompts(root) + for e in missing: + key = REGISTRY_DATE_KEY[e["registry"]] + print(f" - {e['registry']}: {e['slug']}: no `{key}:` date") + for f in prompts: + print(f" - {f.relative_to(root)}: no `Issued:` header") + if not (missing or prompts): + print("dates: OK — every registry entry and issued prompt is dated") + return 0 + print(f"dates: {len(missing) + len(prompts)} undated " + f"(run `lifecycle.py dates --write` to backfill from git history)") + return 1 if args.check else 0 + + def cmd_check(args) -> int: problems: "list[str]" = [] a_slugs = {safe_name(s) for s in ledger_slugs(ACTIVE_MD)} @@ -1119,6 +1465,17 @@ def main() -> int: c = sub.add_parser("check", help="drift guard (non-zero exit on drift)") c.set_defaults(func=cmd_check) + d = sub.add_parser( + "dates", + help="report (or --write to backfill from git) the date on every " + "registry entry and issued prompt", + ) + d.add_argument("--write", action="store_true", + help="insert the missing date fields in place") + d.add_argument("--check", action="store_true", + help="non-zero exit when anything is undated (CI)") + d.set_defaults(func=cmd_dates) + iss = sub.add_parser( "issues", help="cross-check registry tracking issues + pr-open PRs against " diff --git a/skills/create_issue/SKILL.md b/skills/create_issue/SKILL.md index 15fe137c..d5428440 100755 --- a/skills/create_issue/SKILL.md +++ b/skills/create_issue/SKILL.md @@ -114,9 +114,23 @@ ISSUE_EOF ### 4. Register the task in active.md (Mind) -Add the task entry to `PyAutoMind/active.md` with the issue URL (schema in -README). **If the caller is handling registration itself** — e.g. `$start-dev` -routing a conflicted task to `planned.md` — skip this step and let it register. +Add the task entry to `PyAutoMind/active.md` with the issue URL and today's +date (schema in [REFERENCE.md](../../REFERENCE.md) → "`active.md` schema"): + +```markdown +## +- issue: +- issued: +``` + +`issued:` is the date the issue was created — this step is the only moment +anyone knows it for certain, so write it now rather than leaving it to be +reconstructed later (`lifecycle.py dates --write` can only infer). It is what +the dashboard's "Recent" table reads. + +**If the caller is handling registration itself** — e.g. `$start-dev` routing a +conflicted task to `planned.md` — skip this step and let it register (that +registry's date key is `filed:`). ### 5. Move the prompt to active/ @@ -134,6 +148,19 @@ yet exist (it holds only instance state, so the template ships without it) — `git mv` errors if the destination dir is missing. Timestamp-suffix the filename if one already exists in `active/`. +Then add the prompt's own copy of the date to its light header, so an issued +prompt stays dated even if its registry row later goes missing: + +```markdown +Issued: +``` + +Confirm both landed before pushing: + +```bash +python3 PyAutoMind/scripts/lifecycle.py dates # OK when nothing is undated +``` + ### 6. Push Mind ```bash diff --git a/tests/test_lifecycle_dates.py b/tests/test_lifecycle_dates.py new file mode 100644 index 00000000..37ea9727 --- /dev/null +++ b/tests/test_lifecycle_dates.py @@ -0,0 +1,258 @@ +"""Contract tests for the task-date leg of `lifecycle.py`. + +The Mind used to date only what it FINISHED — every `complete/` record carries +`completed:`, but a task that had merely been picked up carried no date a +reader could parse, so nothing could answer "what did we start recently?". +These tests pin the convention that fixed it: one machine-readable date field +per registry entry, an `Issued:` header on every issued prompt, and a backfill +that reconstructs both from evidence the repo already holds. + +Same two rules as `test_lifecycle_check.py`: + +1. **Fictional fixtures only.** `tests/**` is KEEP-copied verbatim into the + public template, so nothing here names a real repo, task or prompt. +2. **Prove each leg fails.** A backfill that cannot report a gap is + decoration — every case below drives input that must trip it. +""" + +import subprocess +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[1] / "scripts")) + +import lifecycle # noqa: E402 + + +# --------------------------------------------------------------------------- # +# fixtures +# --------------------------------------------------------------------------- # +def _mind(root: Path, *, active=None, registries=None) -> Path: + for name, body in (active or {}).items(): + p = root / "active" / name + p.parent.mkdir(parents=True, exist_ok=True) + p.write_text(body) + for name, body in (registries or {}).items(): + (root / name).write_text(body) + return root + + +def _prompt(status="formalised", extra=""): + return ("# Sprocket calibration\n\nType: feature\nTarget: flywheel\n" + f"Difficulty: medium\nAutonomy: safe\nStatus: {status}\n{extra}" + "\nBody prose about sprockets.\n") + + +# --------------------------------------------------------------------------- # +# reading a date back +# --------------------------------------------------------------------------- # +def test_the_key_names_the_event_not_just_the_date(): + """A bare timestamp says nothing; `- parked: ` says what happened.""" + assert lifecycle.entry_date({"parked": "2026-08-18 — deferred"}) == ( + "2026-08-18", "parked") + + +def test_the_most_specific_event_wins_when_an_entry_carries_several(): + """Filed, then issued: the task dates from the later, more specific event.""" + date, key = lifecycle.entry_date({"filed": "2026-07-01", + "issued": "2026-08-19"}) + assert (date, key) == ("2026-08-19", "issued") + + +def test_a_date_buried_in_another_fields_prose_is_not_a_date(): + """`- issue: …/1501 (issued 2026-08-19)` is exactly the habit the + convention replaces — invisible on purpose, so the gap gets reported.""" + fields = {"issue": "https://example.invalid/issues/1 (issued 2026-08-19)"} + assert lifecycle.entry_date(fields) == (None, None) + + +def test_an_issued_prompt_carries_its_own_date_in_the_header(): + assert lifecycle.prompt_issued_date( + _prompt(extra="Issued: 2026-08-19\n")) == "2026-08-19" + + +def test_a_date_deep_in_the_prompt_body_is_prose_not_a_header(): + body = _prompt() + "\n" * 40 + "Issued: 2026-08-19\n" + assert lifecycle.prompt_issued_date(body) is None + + +# --------------------------------------------------------------------------- # +# reporting the gap +# --------------------------------------------------------------------------- # +def test_an_undated_entry_and_an_undated_prompt_are_both_reported(tmp_path): + root = _mind(tmp_path, + active={"sprocket_calibration.md": _prompt()}, + registries={"active.md": "## sprocket-calibration\n" + "- status: library-dev\n"}) + assert [e["slug"] for e in lifecycle.undated_entries(root)] == [ + "sprocket-calibration"] + assert [f.name for f in lifecycle.undated_prompts(root)] == [ + "sprocket_calibration.md"] + + +def test_a_dated_registry_and_a_dated_prompt_report_nothing(tmp_path): + root = _mind(tmp_path, + active={"sprocket_calibration.md": + _prompt(extra="Issued: 2026-08-19\n")}, + registries={"active.md": "## sprocket-calibration\n" + "- issued: 2026-08-19\n"}) + assert lifecycle.undated_entries(root) == [] + assert lifecycle.undated_prompts(root) == [] + + +# --------------------------------------------------------------------------- # +# backfilling it +# --------------------------------------------------------------------------- # +def test_backfill_reconstructs_a_prompt_date_from_the_registry_that_claims_it(tmp_path): + """No git history to read here — the Mind's own record is the evidence, + and the written date says so rather than passing itself off as observed.""" + root = _mind(tmp_path, + active={"sprocket_calibration.md": _prompt()}, + registries={"parked.md": + "## sprocket-calibration\n" + "- parked: 2026-08-18 — deferred\n" + "- prompt: active/sprocket_calibration.md\n"}) + results = lifecycle.backfill_dates(root, apply=True) + written = (root / "active" / "sprocket_calibration.md").read_text() + assert "Issued: 2026-08-18 (backfilled from parked.md `parked:`)" in written + assert lifecycle.prompt_issued_date(written) == "2026-08-18" + assert [r["source"] for r in results] == ["parked.md `parked:`"] + + +def test_backfill_never_guesses_and_says_so(tmp_path): + root = _mind(tmp_path, active={"sprocket_calibration.md": _prompt()}) + results = lifecycle.backfill_dates(root, apply=True) + assert [(r["date"], r["source"]) for r in results] == [(None, "unknown")] + assert "Issued:" not in (root / "active" / "sprocket_calibration.md").read_text() + + +def test_backfill_is_read_only_without_apply(tmp_path): + body = _prompt() + root = _mind(tmp_path, + active={"sprocket_calibration.md": body}, + registries={"parked.md": "## sprocket-calibration\n" + "- parked: 2026-08-18\n" + "- prompt: active/sprocket_calibration.md\n"}) + assert lifecycle.backfill_dates(root)[0]["date"] == "2026-08-18" + assert (root / "active" / "sprocket_calibration.md").read_text() == body + + +def test_the_date_lands_after_the_header_never_inside_a_wrapped_value(tmp_path): + """A header value may run onto a bare continuation line. Inserting between + the two halves would silently rewrite what the value says.""" + prompt = ("# Sprocket calibration\n\nType: feature\n" + "Status: issued as flywheel#1 — awaiting review\n" + "(do not start dev until it lands)\n\nBody prose.\n") + root = _mind(tmp_path, + active={"sprocket_calibration.md": prompt}, + registries={"active.md": "## sprocket-calibration\n" + "- issued: 2026-08-19\n" + "- prompt: active/sprocket_calibration.md\n"}) + lifecycle.backfill_dates(root, apply=True) + written = (root / "active" / "sprocket_calibration.md").read_text() + assert "(do not start dev until it lands)\nIssued: 2026-08-19" in written + + +def test_a_repos_bullet_list_survives_the_insert(tmp_path): + prompt = ("# Sprocket calibration\n\nType: feature\nRepos:\n- Flywheel\n" + "- Sprocket\nStatus: formalised\n\nBody prose.\n") + root = _mind(tmp_path, + active={"sprocket_calibration.md": prompt}, + registries={"active.md": "## sprocket-calibration\n" + "- issued: 2026-08-19\n" + "- prompt: active/sprocket_calibration.md\n"}) + lifecycle.backfill_dates(root, apply=True) + written = (root / "active" / "sprocket_calibration.md").read_text() + assert "Repos:\n- Flywheel\n- Sprocket\nStatus: formalised\nIssued:" in written + + +def test_backfill_dates_a_registry_entry_after_its_issue_field(tmp_path): + root = _mind(tmp_path, registries={ + "active.md": "## sprocket-calibration\n" + "- issue: https://example.invalid/issues/1 (opened 2026-08-19)\n" + "- status: library-dev\n"}) + lifecycle.backfill_dates(root, apply=True) + written = (root / "active.md").read_text().splitlines() + assert written[1].startswith("- issue:") + assert written[2] == "- issued: 2026-08-19 (backfilled from prose)" + + +def test_every_registry_gets_the_key_that_names_its_own_state(tmp_path): + """`issued:` for in flight, `filed:` for planned, `parked:` for parked — + one vocabulary, so a merged feed can say what each date means.""" + root = _mind(tmp_path, registries={ + "active.md": "## alpha\n- note: started 2026-08-19\n", + "planned.md": "## beta\n- note: scoped 2026-08-10\n", + "parked.md": "## gamma\n- note: stopped 2026-08-01\n"}) + lifecycle.backfill_dates(root, apply=True) + assert "- issued: 2026-08-19" in (root / "active.md").read_text() + assert "- filed: 2026-08-10" in (root / "planned.md").read_text() + assert "- parked: 2026-08-01" in (root / "parked.md").read_text() + + +def test_several_undated_entries_in_one_registry_all_land_correctly(tmp_path): + """Inserting into one entry shifts every later entry's line numbers — the + off-by-one that would put a date under the wrong task.""" + root = _mind(tmp_path, registries={ + "planned.md": "## alpha\n- note: scoped 2026-08-01\n\n" + "## beta\n- note: scoped 2026-08-02\n\n" + "## gamma\n- note: scoped 2026-08-03\n"}) + lifecycle.backfill_dates(root, apply=True) + entries = dict(lifecycle.registry_entries(root / "planned.md")) + assert lifecycle.entry_date(entries["alpha"])[0] == "2026-08-01" + assert lifecycle.entry_date(entries["beta"])[0] == "2026-08-02" + assert lifecycle.entry_date(entries["gamma"])[0] == "2026-08-03" + + +# --------------------------------------------------------------------------- # +# git as evidence — and knowing when it is not +# --------------------------------------------------------------------------- # +def _git(root, *args): + subprocess.run(["git", "-C", str(root), *args], check=True, + capture_output=True) + + +def _repo(root: Path) -> Path: + root.mkdir(parents=True, exist_ok=True) + _git(root, "init", "-q", "-b", "main") + _git(root, "config", "user.email", "fixture@example.invalid") + _git(root, "config", "user.name", "Fixture") + return root + + +def test_backfill_prefers_the_commit_that_introduced_the_entry(tmp_path): + root = _repo(tmp_path / "mind") + (root / "active.md").write_text("## sprocket-calibration\n- status: library-dev\n") + _git(root, "add", "-A") + _git(root, "commit", "-q", "-m", "register sprocket calibration", + "--date=2026-08-19T12:00:00") + results = lifecycle.backfill_dates(root, apply=True) + assert [(r["date"], r["source"]) for r in results] == [("2026-08-19", "git")] + assert "- issued: 2026-08-19 (backfilled from git)" in (root / "active.md").read_text() + + +def test_a_shallow_clone_is_not_evidence_of_when_anything_started(tmp_path): + """A shallow checkout reports its boundary commit as the day everything + older appeared, so every backfill in one clone would come out the same + wrong date. The boundary is discarded and the Mind's own record used.""" + origin = _repo(tmp_path / "origin") + (origin / "active.md").write_text("## sprocket-calibration\n- status: library-dev\n") + _git(origin, "add", "-A") + _git(origin, "commit", "-q", "-m", "register", "--date=2026-06-01T12:00:00") + (origin / "active.md").write_text( + "## sprocket-calibration\n- status: library-dev\n" + "- prompt: active/sprocket_calibration.md\n") + (origin / "active").mkdir() + (origin / "active" / "sprocket_calibration.md").write_text(_prompt()) + _git(origin, "add", "-A") + _git(origin, "commit", "-q", "-m", "attach prompt", "--date=2026-08-21T12:00:00") + + clone = tmp_path / "clone" + subprocess.run(["git", "clone", "-q", "--depth", "1", + f"file://{origin}", str(clone)], check=True, + capture_output=True) + assert lifecycle._shallow_boundary(clone) == "2026-08-21" + # The prompt's only visible "add" is the boundary commit — refused, so the + # gap is reported honestly instead of being dated 2026-08-21. + assert lifecycle.git_prompt_issued_date( + clone, "active/sprocket_calibration.md", "2026-08-21") is None