Claude Skill

swmm-modeling-memory

Read historical Agentic SWMM experiment audit artifacts and summarize repeated assumptions, QA issues, failures, missing evidence, run-to-run differences, lessons learned, and controlled skill update proposals. Use downstream of swmm-experiment-audit when multiple audited runs ex

LLM Mart · 0 points · 4 views 0 listing impressions 0 install-command copies
Virus-scanned Reviewed automatically before listing.

Full trust report

Download zhonghao1995-agentic-swmm-workflow-skills_swmm-modeling-memory-2d743b9.zip · 16 KB
Part of zhonghao1995/agentic-swmm-workflow — 18 skills

Install

skills CLI npx skills add https://github.com/Zhonghao1995/agentic-swmm-workflow/tree/main/skills/swmm-modeling-memory
Claude Code claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install zhonghao1995-agentic-swmm-workflow@llmmart
Git git clone https://github.com/Zhonghao1995/agentic-swmm-workflow.git

The skills CLI installs just this skill, for any of its supported agents. Claude Code installs the whole zhonghao1995/agentic-swmm-workflow collection as a plugin from our marketplace. Git is the plain clone.

Skill manifest

SWMM Modeling Memory

Part of Agentic SWMM — install the project first for the executable toolchain (aiswmm CLI, SWMM solver, MCP servers).

What this skill provides

  • A downstream memory layer for audited Agentic SWMM runs.
  • Deterministic summaries of repeated assumptions, QA issues, failures, missing evidence, and run-to-run differences.
  • Run-level memory_summary.json cards that compress audit artifacts into reusable next-run context.
  • Project/case-level memory groups that keep Tod Creek, Tecnopolo, TUFLOW, Generate_SWMM_inp, acceptance, and other cases separate.
  • Summaries of deterministic SWMM-specific diagnostics when model_diagnostics.json is present.
  • Human-readable lessons learned from previous audit records.
  • Controlled skill update proposals that require human review and benchmark verification.

This skill does not run SWMM, build SWMM models, modify existing skills, or claim autonomous self-improvement.

Agentic SWMM is not only an automation workflow. It is a memory-informed, verification-first modeling system that can learn from audited modeling history through controlled skill refinement.

When to use this skill

Use this skill after swmm-experiment-audit has produced run-level artifacts such as:

  • experiment_provenance.json
  • comparison.json
  • experiment_note.md
  • model_diagnostics.json when available

Use it when:

  • multiple audited runs exist,
  • the user wants lessons learned across runs,
  • the user asks for recurring failure patterns or QA issues,
  • the user wants evidence-informed skill refinement proposals.

The proposals may point to relevant workflow skills such as end-to-end orchestration, audit reporting, QA verification, model building, or result parsing. They are not accepted changes.

Output contract

The script writes these files to the selected modeling-memory output directory:

  • modeling_memory_index.json
  • modeling_memory_index.md
  • run_memory_summaries.json
  • project_memory_index.md
  • projects/<project-key>/project_memory.json
  • projects/<project-key>/project_memory.md
  • lessons_learned.md
  • skill_update_proposals.md
  • benchmark_verification_plan.md

The script also writes memory_summary.json beside each audited run by default. The JSON index and run summaries are the machine-readable source. The Markdown files are human-readable and can be copied to Obsidian with --obsidian-dir.

CLI

python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
  --runs-dir runs \
  --out-dir memory/modeling-memory

To refresh only the aggregate output without writing run-level cards (only available via direct script invocation — aiswmm memory does not expose this flag):

python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
  --runs-dir runs \
  --out-dir memory/modeling-memory \
  --no-run-summaries

With optional Obsidian export:

python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
  --runs-dir runs \
  --out-dir memory/modeling-memory \
  --obsidian-dir "/path/to/Obsidian/Agentic SWMM/05_Modeling_Memory"

Safety rules

  • Read existing audit artifacts only.
  • Tolerate partial and failed runs.
  • Do not modify any existing SKILL.md files.
  • Do not modify benchmark behavior or audit output formats.
  • Do not write outside --out-dir, audited run directories under --runs-dir, or the optional --obsidian-dir.
  • Treat SWMM-specific diagnostics as deterministic audit evidence only; do not infer model errors from free-text notes.
  • Treat skill update proposals as proposals only.
  • Accept real skill refinements only after human review and benchmark verification.

Audit-end auto-trigger (M2)

aiswmm audit fires an auto-trigger after every successful audit that calls summarize_memory.py in the background to refresh lessons_learned.md and (unless --no-rag is given) rebuild the RAG corpus. This means lessons_learned.md can be written by two paths:

  1. Automatic — agentic_swmm/memory/audit_hook.py via the M2 hook after aiswmm audit succeeds.
  2. Manual — aiswmm memory --runs-dir runs or direct python3 skills/swmm-modeling-memory/scripts/summarize_memory.py.

Set AISWMM_SKIP_MEMORY=1 in the environment to suppress the auto-trigger (useful for CI or benchmark runs where memory mutation is unwanted). Pass --no-memory to aiswmm audit for the same effect on a single run.

The auto-trigger uses add_negative_lesson / NegativeLessonMd.update from agentic_swmm/memory/negative_lessons_markdown.py, which increments evidence_count and updates last_seen_utc on duplicate lesson names rather than clobbering the existing entry. Manual summarize_memory.py runs use the same merge logic.

Relationship to swmm-experiment-audit

swmm-experiment-audit records evidence for one run.

swmm-modeling-memory reads many audited runs and turns repeated evidence patterns into reusable project memory.

The intended controlled loop is:

  1. Run SWMM or attempt a workflow.
  2. Audit the run (aiswmm audit); the M2 hook refreshes lessons_learned.md automatically.
  3. Preserve an Obsidian-compatible experiment note.
  4. Summarize modeling memory across audited runs (manual aiswmm memory call when a full refresh is needed).
  5. Extract recurring failure patterns.
  6. Generate a skill update proposal.
  7. Review the proposal as a human.
  8. Verify with existing benchmarks before accepting any skill change.
Files (agentic-swmm-workflow)
  • examples
    • README.md 675 B
      # SWMM Modeling Memory Example
      
      Run modeling-memory summarization after one or more run directories have been audited by `swmm-experiment-audit`.
      
      ```bash
      python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
        --runs-dir runs \
        --out-dir memory/modeling-memory
      ```
      
      Optional Obsidian export:
      
      ```bash
      python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
        --runs-dir runs \
        --out-dir memory/modeling-memory \
        --obsidian-dir "/path/to/Obsidian/Agentic SWMM/05_Modeling_Memory"
      ```
      
      The skill reads existing audit artifacts and writes downstream memory files. It does not run SWMM, modify existing skills, or accept skill changes automatically.
      
  • scripts
    • summarize_memory.py 53.4 KB
      #!/usr/bin/env python3
      from __future__ import annotations
      
      import argparse
      import json
      import re
      import shutil
      from collections import Counter, defaultdict
      from datetime import datetime, timezone
      from pathlib import Path
      from typing import Any
      
      
      # ME-1 lifecycle metadata fence (issue #61). Kept as plain string
      # constants so this script remains import-free and runnable as a
      # standalone skill helper.
      _METADATA_OPEN = "<!-- aiswmm-metadata"
      _METADATA_CLOSE = "/aiswmm-metadata -->"
      _METADATA_FENCE_RE = re.compile(
          re.escape(_METADATA_OPEN) + r"\n.*?\n" + re.escape(_METADATA_CLOSE),
          re.DOTALL,
      )
      _PATTERN_HEADING_RE = re.compile(
          r"^##\s+(?P<name>[a-z][a-z0-9_]*)\s*$", re.MULTILINE
      )
      
      
      def _extract_metadata_fences(markdown_text: str) -> dict[str, str]:
          """Return ``{pattern_name: fence_block_with_delimiters}``.
      
          Only the fenced metadata payload (delimiters included) is captured.
          Patterns without a fence are absent from the result. Used by the
          one-shot migration to preserve metadata across summariser re-runs.
          """
          if not markdown_text:
              return {}
          out: dict[str, str] = {}
          matches = list(_PATTERN_HEADING_RE.finditer(markdown_text))
          for i, match in enumerate(matches):
              name = match.group("name")
              start = match.start()
              end = matches[i + 1].start() if i + 1 < len(matches) else len(markdown_text)
              block = markdown_text[start:end]
              fence_match = _METADATA_FENCE_RE.search(block)
              if fence_match:
                  out[name] = fence_match.group(0)
          return out
      
      
      def _inject_metadata_fence(pattern_block: str, fence: str) -> str:
          """Insert ``fence`` after the heading + blank line in ``pattern_block``."""
          lines = pattern_block.splitlines(keepends=True)
          if not lines:
              return pattern_block
          out: list[str] = [lines[0]]
          idx = 1
          if idx < len(lines) and lines[idx].strip() == "":
              out.append(lines[idx])
              idx += 1
          else:
              out.append("\n")
          out.append(fence + "\n")
          if idx < len(lines) and lines[idx].strip() != "":
              out.append("\n")
          out.extend(lines[idx:])
          return "".join(out)
      
      
      _ARCHIVE_HEADER = (
          "<!-- schema_version: 1.1 -->\n"
          "# Lessons Archived\n"
          "\n"
          "Patterns that decayed below the dormant threshold, or that a re-render\n"
          "no longer produced, land here. Each block keeps its metadata fence so\n"
          "the pattern can be revived by moving the block back into\n"
          "``lessons_learned.md``.\n"
          "\n"
      )
      
      
      def _archive_vanished_patterns(old_path: Path, old_text: str, new_lessons: str) -> list[str]:
          """Move ``## pattern`` blocks the re-render dropped into the archive.
      
          Live finding F-33 (2026-09-02): the first real audit of the day
          re-rendered lessons_learned.md from run summaries and the shipped,
          curated ``comparison_mismatch`` section simply vanished, while the
          decay design says retired patterns go to ``lessons_archived.md``.
          Returns the archived pattern names.
          """
          old_matches = list(_PATTERN_HEADING_RE.finditer(old_text))
          if not old_matches:
              return []
          new_names = {m.group("name") for m in _PATTERN_HEADING_RE.finditer(new_lessons)}
          archived: list[str] = []
          archive_path = old_path.parent / "lessons_archived.md"
          for i, match in enumerate(old_matches):
              name = match.group("name")
              if name in new_names:
                  continue
              end = old_matches[i + 1].start() if i + 1 < len(old_matches) else len(old_text)
              block = old_text[match.start():end].rstrip("\n") + "\n"
              block = re.sub(r"(?m)^(\s*status:\s*)\S+$", r"\1archived", block)
              try:
                  if not archive_path.is_file():
                      archive_path.write_text(_ARCHIVE_HEADER, encoding="utf-8")
                  existing = archive_path.read_text(encoding="utf-8")
                  if not existing.endswith("\n\n"):
                      existing = existing.rstrip("\n") + "\n\n"
                  archive_path.write_text(existing + block, encoding="utf-8")
              except OSError:
                  continue
              archived.append(name)
          return archived
      
      
      def _merge_existing_metadata(old_path: Path, new_lessons: str) -> str:
          """Re-attach metadata fences from ``old_path`` to ``new_lessons``."""
          if not old_path.exists():
              return new_lessons
          try:
              old_text = old_path.read_text(encoding="utf-8")
          except OSError:
              return new_lessons
          _archive_vanished_patterns(old_path, old_text, new_lessons)
          fences = _extract_metadata_fences(old_text)
          if not fences:
              return new_lessons
      
          updated = new_lessons
          # Find each ## <pattern> span in the freshly rendered document and
          # inject the preserved fence (if any) into the matching block.
          while True:
              matches = list(_PATTERN_HEADING_RE.finditer(updated))
              rewrote = False
              for i, match in enumerate(matches):
                  name = match.group("name")
                  if name not in fences:
                      continue
                  start = match.start()
                  end = matches[i + 1].start() if i + 1 < len(matches) else len(updated)
                  block = updated[start:end]
                  if _METADATA_FENCE_RE.search(block):
                      continue
                  new_block = _inject_metadata_fence(block, fences[name])
                  updated = updated[:start] + new_block + updated[end:]
                  rewrote = True
                  break
              if not rewrote:
                  break
          return updated
      
      
      AUDIT_FILES = ("experiment_provenance.json", "comparison.json", "experiment_note.md", "model_diagnostics.json")
      # Schema 1.1 stores audit artefacts in <run-dir>/09_audit/. Old runs that
      # still have files at run-dir root are tolerated as a read-only fallback.
      AUDIT_SUBDIR = "09_audit"
      
      
      def audit_dir_for(run_dir: Path) -> Path:
          """Return the canonical audit subdir under ``run_dir``.
      
          Falls back to ``run_dir`` itself when the legacy root-level layout is
          in use (so summarisation of un-migrated runs still works).
          """
          new = run_dir / AUDIT_SUBDIR
          if new.is_dir() and any((new / name).exists() for name in AUDIT_FILES):
              return new
          return run_dir
      MARKDOWN_OUTPUTS = (
          "modeling_memory_index.md",
          "project_memory_index.md",
          "lessons_learned.md",
          "skill_update_proposals.md",
          "benchmark_verification_plan.md",
      )
      
      
      def now_utc() -> str:
          return datetime.now(timezone.utc).isoformat(timespec="seconds")
      
      
      def read_json(path: Path) -> dict[str, Any]:
          if not path.exists():
              return {}
          try:
              parsed = json.loads(path.read_text(encoding="utf-8"))
          except (OSError, json.JSONDecodeError):
              return {}
          return parsed if isinstance(parsed, dict) else {}
      
      
      def read_text(path: Path) -> str:
          if not path.exists():
              return ""
          try:
              return path.read_text(encoding="utf-8")
          except OSError:
              return ""
      
      
      def safe_slug(value: str) -> str:
          text = re.sub(r"[^A-Za-z0-9._-]+", "-", value.lower()).strip("-._")
          return text or "unknown-project"
      
      
      def write_text(path: Path, text: str) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          path.write_text(text, encoding="utf-8")
      
      
      def write_json(path: Path, obj: Any) -> None:
          path.parent.mkdir(parents=True, exist_ok=True)
          path.write_text(json.dumps(obj, indent=2, sort_keys=True), encoding="utf-8")
      
      
      def relpath(path: Path, root: Path) -> str:
          try:
              return str(path.resolve().relative_to(root.resolve()))
          except ValueError:
              return str(path)
      
      
      def as_list(value: Any) -> list[Any]:
          if value is None:
              return []
          if isinstance(value, list):
              return value
          return [value]
      
      
      def stringify_items(items: list[Any]) -> list[str]:
          out: list[str] = []
          for item in items:
              if isinstance(item, str):
                  out.append(item)
              elif isinstance(item, dict):
                  label = item.get("id") or item.get("name") or item.get("status") or item.get("detail")
                  detail = item.get("detail") or item.get("message") or item.get("interpretation")
                  if label and detail and label != detail:
                      out.append(f"{label}: {detail}")
                  elif label:
                      out.append(str(label))
                  else:
                      out.append(json.dumps(item, sort_keys=True))
              else:
                  out.append(str(item))
          return out
      
      
      def discover_run_dirs(runs_dir: Path) -> list[Path]:
          """Discover run directories that carry audit artefacts.
      
          Recognises both the 1.1 layout (``<run-dir>/09_audit/<file>``) and the
          legacy root-level layout (``<run-dir>/<file>``). The returned paths
          are always run dirs, never the ``09_audit`` subdir itself.
          """
          if not runs_dir.exists():
              return []
          candidates: set[Path] = set()
          for name in AUDIT_FILES:
              for path in runs_dir.rglob(name):
                  parent = path.parent
                  # If the file lives inside a 09_audit/ subdir, the actual
                  # run dir is the grandparent. Otherwise, the parent is the
                  # run dir (legacy root layout).
                  if parent.name == AUDIT_SUBDIR:
                      parent = parent.parent
                  # Skip anything that ended up under .archive/ during cleanup.
                  try:
                      rel = parent.relative_to(runs_dir)
                  except ValueError:
                      continue
                  if any(part == ".archive" for part in rel.parts):
                      continue
                  candidates.add(parent)
          return sorted(candidates)
      
      
      def artifact_exists(record: Any) -> bool | None:
          if not isinstance(record, dict):
              return None
          exists = record.get("exists")
          if isinstance(exists, bool):
              return exists
          rel = record.get("relative_path")
          abs_path = record.get("absolute_path")
          if rel or abs_path:
              return True
          return None
      
      
      def artifact_status(provenance: dict[str, Any], artifact_id: str) -> str:
          artifacts = provenance.get("artifacts")
          if not isinstance(artifacts, dict):
              return "unknown"
          exists = artifact_exists(artifacts.get(artifact_id))
          if exists is True:
              return "found"
          if exists is False:
              return "missing"
          return "unknown"
      
      
      def collect_artifact_ids(provenance: dict[str, Any]) -> tuple[list[str], list[str]]:
          artifacts = provenance.get("artifacts")
          found: list[str] = []
          missing: list[str] = []
          if not isinstance(artifacts, dict):
              return found, missing
          for artifact_id, record in artifacts.items():
              exists = artifact_exists(record)
              if exists is True:
                  found.append(str(artifact_id))
              elif exists is False:
                  missing.append(str(artifact_id))
          return sorted(found), sorted(missing)
      
      
      def infer_qa_status(provenance: dict[str, Any]) -> str:
          qa = provenance.get("qa")
          if isinstance(qa, dict):
              status = qa.get("status")
              if isinstance(status, str) and status:
                  return status
              fail_count = qa.get("fail_count")
              if fail_count == 0:
                  return "pass"
              if isinstance(fail_count, int) and fail_count > 0:
                  return "fail"
          status = provenance.get("status")
          return str(status) if status else "unknown"
      
      
      def extract_limitations(provenance: dict[str, Any], note_text: str) -> list[str]:
          limitations = stringify_items(as_list(provenance.get("limitations")))
          if limitations:
              return limitations
      
          lines = note_text.splitlines()
          extracted: list[str] = []
          capture = False
          for line in lines:
              stripped = line.strip()
              heading = stripped.lower().lstrip("# ").strip()
              if heading in {"limitations", "evidence boundary", "evidence boundaries"}:
                  capture = True
                  continue
              if capture and stripped.startswith("#"):
                  break
              if capture and stripped.startswith("-"):
                  extracted.append(stripped.lstrip("-").strip())
          return extracted
      
      
      def extract_assumptions(provenance: dict[str, Any], note_text: str) -> list[str]:
          assumptions = stringify_items(as_list(provenance.get("assumptions")))
          if assumptions:
              return assumptions
      
          extracted: list[str] = []
          capture = False
          for line in note_text.splitlines():
              stripped = line.strip()
              heading = stripped.lower().lstrip("# ").strip()
              if heading in {"assumptions", "modeling assumptions", "modelling assumptions"}:
                  capture = True
                  continue
              if capture and stripped.startswith("#"):
                  break
              if capture and stripped.startswith("-"):
                  extracted.append(stripped.lstrip("-").strip())
          return extracted
      
      
      def evidence_boundary_notes(provenance: dict[str, Any], note_text: str) -> list[str]:
          notes = stringify_items(as_list(provenance.get("evidence_boundary_notes")))
          notes.extend(stringify_items(as_list(provenance.get("warnings"))))
          notes.extend(extract_limitations(provenance, note_text))
          deduped: list[str] = []
          seen: set[str] = set()
          for item in notes:
              if item and item not in seen:
                  deduped.append(item)
                  seen.add(item)
          return deduped
      
      
      def comparison_status(comparison: dict[str, Any]) -> str:
          if not comparison:
              return "missing"
          if comparison.get("comparison_available") is False:
              return "not_requested"
          checks = comparison.get("checks")
          if isinstance(checks, list) and checks:
              mismatches = [c for c in checks if isinstance(c, dict) and c.get("same") is False]
              if mismatches:
                  return "mismatch"
              return "match"
          return "available"
      
      
      def _registered_case_match(text: str) -> str | None:
          """Resolve ``text`` to a case_id using the project's case registry.
      
          PRD #118: watershed identification belongs in the registry, not in
          hardcoded if/elif chains. We import lazily so this script keeps
          running as a standalone helper when ``agentic_swmm`` is not on
          ``sys.path`` (e.g. in distributed skill bundles).
          """
          try:
              from agentic_swmm.case import case_registry  # type: ignore
          except Exception:
              return None
          try:
              cases = case_registry.list_cases()
          except Exception:
              return None
          for meta in cases:
              case_id = getattr(meta, "case_id", None)
              if not isinstance(case_id, str) or not case_id:
                  continue
              needles: list[str] = [case_id]
              display_name = getattr(meta, "display_name", None)
              if isinstance(display_name, str) and display_name:
                  needles.append(display_name)
              extra = getattr(meta, "extra", None)
              if isinstance(extra, dict):
                  aliases = extra.get("aliases")
                  if isinstance(aliases, list):
                      needles.extend(str(a) for a in aliases if isinstance(a, str))
              for needle in needles:
                  if needle and needle.lower() in text:
                      return case_id
          return None
      
      
      def project_key(record: dict[str, Any]) -> str:
          case = str(record.get("case_name") or record.get("run_id") or "").lower()
          workflow = str(record.get("workflow_mode") or "").lower()
          run_dir = str(record.get("run_dir") or "").lower()
          text = " ".join([case, workflow, run_dir])
          registry_hit = _registered_case_match(text)
          if registry_hit is not None:
              return registry_hit
          # Non-watershed buckets remain pinned: these are tool/family labels,
          # not watersheds, so they do not belong in the case registry.
          if "tuflow" in text:
              return "tuflow"
          if "generate_swmm_inp" in text or "generate-swmm-inp" in text:
              return "generate-swmm-inp"
          if "acceptance" in text:
              return "acceptance"
          return safe_slug(str(record.get("case_name") or record.get("workflow_mode") or "unknown-project"))
      
      
      def diagnostic_ids(model_diagnostics: dict[str, Any]) -> list[str]:
          out: list[str] = []
          seen: set[str] = set()
          for item in model_diagnostics.get("diagnostics") or []:
              if isinstance(item, dict) and item.get("id"):
                  item_id = str(item["id"])
                  if item_id not in seen:
                      out.append(item_id)
                      seen.add(item_id)
          return out
      
      
      def suspect_parameters(model_diagnostics: dict[str, Any]) -> list[str]:
          suspects: set[str] = set()
          mapping = {
              "continuity_error_high": "routing_step / storage / inflow-outflow accounting",
              "node_flooding_detected": "node surcharge/flooding settings",
              "conduit_slope_suspicious": "node invert elevation / conduit length / conduit direction",
              "subcatchment_area_nonpositive": "subcatchment area",
              "subcatchment_width_nonpositive": "subcatchment width",
              "imperviousness_out_of_range": "subcatchment imperviousness",
              "missing_rain_gage": "rain gage assignment",
              "subcatchment_outlet_missing": "subcatchment outlet",
              "outfall_disconnected": "outfall connectivity",
              "routing_step_large": "routing step",
          }
          for item in model_diagnostics.get("diagnostics") or []:
              if not isinstance(item, dict):
                  continue
              label = mapping.get(str(item.get("id") or ""))
              if label:
                  suspects.add(label)
          return sorted(suspects)
      
      
      def next_run_cautions(record: dict[str, Any]) -> list[str]:
          cautions: list[str] = []
          for pattern in record.get("failure_patterns", []):
              if pattern == "comparison_mismatch":
                  cautions.append("Review whether run differences are expected scenario changes or regressions.")
              elif pattern == "continuity_parse_missing":
                  cautions.append("Ensure continuity tables are available and referenced in run artifacts.")
              elif pattern == "missing_inp":
                  cautions.append("Record the runnable SWMM INP handoff before execution.")
              elif pattern == "peak_flow_parse_missing":
                  cautions.append("Confirm peak flow is parsed from Node Inflow Summary or documented fallback.")
              elif pattern == "partial_run":
                  cautions.append("Keep partial-run evidence explicit so downstream memory can reuse it safely.")
          for item in record.get("model_diagnostic_ids", []):
              if item == "continuity_error_high":
                  cautions.append("Inspect continuity error before treating the run as hydrologic evidence.")
              elif item == "node_flooding_detected":
                  cautions.append("Review node flooding before accepting the model behavior.")
              elif item == "routing_step_large":
                  cautions.append("Consider reducing routing step for the next diagnostic run.")
              elif item in {"subcatchment_area_nonpositive", "subcatchment_width_nonpositive", "imperviousness_out_of_range"}:
                  cautions.append("Check subcatchment physical parameters before rerunning.")
              elif item in {"missing_rain_gage", "subcatchment_outlet_missing", "outfall_disconnected"}:
                  cautions.append("Check model connectivity and rainfall assignments before rerunning.")
          deduped: list[str] = []
          seen: set[str] = set()
          for item in cautions:
              if item not in seen:
                  deduped.append(item)
                  seen.add(item)
          return deduped
      
      
      def detect_failure_patterns(
          *,
          run_dir: Path,
          provenance: dict[str, Any],
          comparison: dict[str, Any],
          model_diagnostics: dict[str, Any],
          artifacts_missing: list[str],
          audit_files_found: list[str],
      ) -> list[str]:
          patterns: set[str] = set()
      
          if "experiment_provenance.json" not in audit_files_found:
              patterns.add("missing_provenance")
          if "experiment_note.md" not in audit_files_found:
              patterns.add("missing_evidence_boundary")
          # "Has a manifest" must accept stage manifests: agent-path runs
          # never write a root manifest.json (only `aiswmm run` does), but
          # they DO carry the runner manifest under 06_runner/. Root-only
          # lookup stamped missing_manifest -> partial_run on every healthy
          # agent-driven run (found 2026-08-08). Stage names mirror the
          # audit script's RUNNER/BUILDER lists.
          manifest_on_disk = (run_dir / "manifest.json").exists() or any(
              (run_dir / stage / "manifest.json").exists()
              for stage in ("06_runner", "05_runner", "05_builder", "04_builder")
          )
          if not manifest_on_disk and artifact_status(provenance, "top_manifest") != "found":
              patterns.add("missing_manifest")
      
          # Filesystem ground truth beats a stale provenance record: legacy
          # flat-layout runs (and canonical runs re-audited by an older audit
          # version) can hold physical artifacts the recorded audit marked as
          # missing. Only claim a physical artifact is missing when it is also
          # absent from the run dir itself (2026-08-08 mining: provenance-only
          # trust made complete runs read as failures).
          def _absent(glob_pattern: str) -> bool:
              return next(run_dir.rglob(glob_pattern), None) is None
      
          if ("model_inp" in artifacts_missing or not provenance) and _absent("*.inp"):
              patterns.add("missing_inp")
          if "runner_rpt" in artifacts_missing and _absent("*.rpt"):
              patterns.add("missing_rpt")
          if "runner_out" in artifacts_missing and _absent("*.out"):
              patterns.add("missing_out")
      
          qa_status = infer_qa_status(provenance)
          if qa_status == "unknown":
              patterns.add("qa_missing")
          elif qa_status.lower() in {"fail", "failed"}:
              patterns.add("qa_failed")
      
          metrics = provenance.get("metrics") if isinstance(provenance.get("metrics"), dict) else {}
          swmm_return_code = metrics.get("swmm_return_code") if isinstance(metrics, dict) else None
          if swmm_return_code not in (None, 0):
              patterns.add("swmm_execution_failed")
      
          if isinstance(metrics, dict):
              # ``is None``, not falsiness: a parsed 0.0 (dry-run peak flow,
              # perfect continuity) is a legitimate value, not a parse gap.
              if metrics.get("peak_flow") is None:
                  patterns.add("peak_flow_parse_missing")
              if metrics.get("continuity_error") is None:
                  patterns.add("continuity_parse_missing")
      
          if comparison_status(comparison) == "mismatch":
              patterns.add("comparison_mismatch")
      
          if model_diagnostics.get("status") == "fail":
              patterns.add("swmm_model_diagnostic_error")
      
          # ``partial_run`` means run ARTIFACTS are incomplete. Metric-parse
          # gaps (peak/continuity not extracted by the audit) are evidence
          # gaps, not missing artifacts: a run with qa=pass and rpt/out on
          # disk is complete even when a metric went unparsed, so they no
          # longer escalate here (2026-08-08: the escalation made the
          # memory's top "failure patterns" mostly noise from healthy runs).
          if patterns & {
              "missing_provenance",
              "missing_manifest",
              "missing_inp",
              "missing_rpt",
              "missing_out",
              "qa_missing",
          }:
              patterns.add("partial_run")
      
          if not patterns:
              return ["no_detected_failure"]
          return sorted(patterns)
      
      
      def build_record(run_dir: Path, runs_dir: Path) -> dict[str, Any]:
          audit_dir = audit_dir_for(run_dir)
          provenance_path = audit_dir / "experiment_provenance.json"
          comparison_path = audit_dir / "comparison.json"
          note_path = audit_dir / "experiment_note.md"
          diagnostics_path = audit_dir / "model_diagnostics.json"
          provenance = read_json(provenance_path)
          comparison = read_json(comparison_path)
          model_diagnostics = read_json(diagnostics_path)
          note_text = read_text(note_path)
      
          audit_files_found = [name for name in AUDIT_FILES if (audit_dir / name).exists()]
          audit_files_missing = [name for name in AUDIT_FILES if name not in audit_files_found]
          artifacts_found, artifacts_missing = collect_artifact_ids(provenance)
          qa_status = infer_qa_status(provenance)
          metrics = provenance.get("metrics") if isinstance(provenance.get("metrics"), dict) else {}
      
          record = {
              "run_id": provenance.get("run_id") or run_dir.name,
              "run_dir": relpath(run_dir, runs_dir.parent),
              "case_name": provenance.get("case_name") or run_dir.name,
              "workflow_mode": provenance.get("workflow_mode") or "unknown",
              "objective": provenance.get("objective") or "",
              "audit_status": provenance.get("status") or ("partial" if audit_files_missing else "unknown"),
              "qa_status": qa_status,
              "swmm_return_code": metrics.get("swmm_return_code") if isinstance(metrics, dict) else None,
              "artifacts_found": artifacts_found,
              "artifacts_missing": sorted(set(artifacts_missing + audit_files_missing)),
              "warnings": stringify_items(as_list(provenance.get("warnings")) + as_list(comparison.get("warnings"))),
              "limitations": extract_limitations(provenance, note_text),
              "metrics": metrics,
              "model_diagnostics": model_diagnostics or provenance.get("model_diagnostics") or {},
              "comparison_status": comparison_status(comparison),
              "failure_patterns": [],
              "assumptions": extract_assumptions(provenance, note_text),
              "evidence_boundary_notes": evidence_boundary_notes(provenance, note_text),
          }
          record["failure_patterns"] = detect_failure_patterns(
              run_dir=run_dir,
              provenance=provenance,
              comparison=comparison,
              model_diagnostics=record["model_diagnostics"],
              artifacts_missing=record["artifacts_missing"],
              audit_files_found=audit_files_found,
          )
          record["project_key"] = project_key(record)
          record["model_diagnostic_ids"] = diagnostic_ids(record["model_diagnostics"])
          record["suspect_parameters"] = suspect_parameters(record["model_diagnostics"])
          record["next_run_cautions"] = next_run_cautions(record)
          return record
      
      
      def build_run_memory_summary(record: dict[str, Any], generated_at: str) -> dict[str, Any]:
          return {
              "schema_version": "1.0",
              "generated_by": "swmm-modeling-memory",
              "generated_at_utc": generated_at,
              "run_id": record.get("run_id"),
              "run_dir": record.get("run_dir"),
              "project_key": record.get("project_key"),
              "case_name": record.get("case_name"),
              "workflow_mode": record.get("workflow_mode"),
              "success": record.get("failure_patterns") == ["no_detected_failure"] and record.get("qa_status") == "pass",
              "audit_status": record.get("audit_status"),
              "qa_status": record.get("qa_status"),
              "swmm_return_code": record.get("swmm_return_code"),
              "comparison_status": record.get("comparison_status"),
              "qa_issues": [] if record.get("qa_status") == "pass" else [record.get("qa_status")],
              "failure_patterns": record.get("failure_patterns", []),
              "missing_evidence": record.get("artifacts_missing", []),
              "warnings": record.get("warnings", []),
              "assumptions": record.get("assumptions", []),
              "evidence_boundary_notes": record.get("evidence_boundary_notes", []),
              "model_diagnostics_status": (record.get("model_diagnostics") or {}).get("status"),
              "model_diagnostic_ids": record.get("model_diagnostic_ids", []),
              "suspect_parameters": record.get("suspect_parameters", []),
              "next_run_cautions": record.get("next_run_cautions", []),
          }
      
      
      def has_detected_failure(record: dict[str, Any]) -> bool:
          return record.get("failure_patterns") != ["no_detected_failure"]
      
      
      def md_escape(value: Any) -> str:
          text = "" if value is None else str(value)
          return text.replace("|", "\\|").replace("\n", " ")
      
      
      def render_index_md(records: list[dict[str, Any]], generated_at: str) -> str:
          lines = [
              "# Modeling Memory Index",
              "",
              f"Generated at UTC: `{generated_at}`",
              "",
              "| Run | Project | Case | Workflow | QA | SWMM RC | Comparison | Warnings | Failure patterns | Model diagnostics | Evidence boundary |",
              "|---|---|---|---|---:|---|---|---|---|---|---|",
          ]
          for r in records:
              lines.append(
                  "| "
                  + " | ".join(
                      [
                          md_escape(r["run_id"]),
                          md_escape(r["project_key"]),
                          md_escape(r["case_name"]),
                          md_escape(r["workflow_mode"]),
                          md_escape(r["qa_status"]),
                          md_escape(r["swmm_return_code"]),
                          md_escape(r["comparison_status"]),
                          md_escape("; ".join(r["warnings"][:3])),
                          md_escape(", ".join(r["failure_patterns"])),
                          md_escape(", ".join(r.get("model_diagnostic_ids", [])[:5])),
                          md_escape("; ".join(r["evidence_boundary_notes"][:3])),
                      ]
                  )
                  + " |"
              )
          lines.append("")
          return "\n".join(lines)
      
      
      def repeated_items(records: list[dict[str, Any]], key: str) -> list[tuple[str, int]]:
          counter: Counter[str] = Counter()
          for record in records:
              counter.update(str(item) for item in record.get(key, []) if item)
          return [(item, count) for item, count in counter.most_common() if count >= 2]
      
      
      def records_by_project(records: list[dict[str, Any]]) -> dict[str, list[dict[str, Any]]]:
          grouped: dict[str, list[dict[str, Any]]] = defaultdict(list)
          for record in records:
              grouped[str(record.get("project_key") or "unknown-project")].append(record)
          return dict(sorted(grouped.items()))
      
      
      def project_summary(project: str, records: list[dict[str, Any]], generated_at: str) -> dict[str, Any]:
          failure_counts: Counter[str] = Counter()
          diagnostic_counts: Counter[str] = Counter()
          missing_counts: Counter[str] = Counter()
          qa_counts: Counter[str] = Counter()
          for record in records:
              failure_counts.update(record.get("failure_patterns", []))
              diagnostic_counts.update(record.get("model_diagnostic_ids", []))
              missing_counts.update(record.get("artifacts_missing", []))
              qa_counts.update([str(record.get("qa_status") or "unknown")])
          return {
              "schema_version": "1.0",
              "generated_by": "swmm-modeling-memory",
              "generated_at_utc": generated_at,
              "project_key": project,
              "record_count": len(records),
              "run_ids": [record.get("run_id") for record in records],
              "qa_status_counts": dict(qa_counts),
              "failure_pattern_counts": dict(failure_counts),
              "model_diagnostic_counts": dict(diagnostic_counts),
              "missing_evidence_counts": dict(missing_counts),
              "next_run_cautions": sorted({item for record in records for item in record.get("next_run_cautions", [])}),
          }
      
      
      def render_project_memory_md(project: str, summary: dict[str, Any]) -> str:
          lines = [
              f"# Project Modeling Memory - {project}",
              "",
              f"Generated at UTC: `{summary['generated_at_utc']}`",
              "",
              f"- Runs: {summary['record_count']}",
              f"- Run IDs: {', '.join(f'`{run}`' for run in summary['run_ids'])}",
              "",
              "## QA States",
          ]
          for key, count in sorted(summary["qa_status_counts"].items()):
              lines.append(f"- `{key}`: {count} run(s)")
          lines.extend(["", "## Failure Patterns"])
          if summary["failure_pattern_counts"]:
              for key, count in sorted(summary["failure_pattern_counts"].items()):
                  lines.append(f"- `{key}`: {count} run(s)")
          else:
              lines.append("- No failure patterns were detected.")
          lines.extend(["", "## SWMM Model Diagnostics"])
          if summary["model_diagnostic_counts"]:
              for key, count in sorted(summary["model_diagnostic_counts"].items()):
                  lines.append(f"- `{key}`: {count} run(s)")
          else:
              lines.append("- No deterministic SWMM model diagnostics were recorded.")
          lines.extend(["", "## Missing Evidence"])
          if summary["missing_evidence_counts"]:
              for key, count in sorted(summary["missing_evidence_counts"].items()):
                  lines.append(f"- `{key}` missing in {count} run(s)")
          else:
              lines.append("- No missing evidence was detected.")
          lines.extend(["", "## Next-Run Cautions"])
          if summary["next_run_cautions"]:
              for item in summary["next_run_cautions"]:
                  lines.append(f"- {item}")
          else:
              lines.append("- No project-level cautions were generated.")
          lines.append("")
          return "\n".join(lines)
      
      
      def render_project_index_md(project_summaries: list[dict[str, Any]], generated_at: str) -> str:
          lines = [
              "# Project Memory Index",
              "",
              f"Generated at UTC: `{generated_at}`",
              "",
              "| Project | Runs | Failure patterns | Model diagnostics | Missing evidence |",
              "|---|---:|---|---|---|",
          ]
          for summary in project_summaries:
              lines.append(
                  "| "
                  + " | ".join(
                      [
                          md_escape(summary["project_key"]),
                          md_escape(summary["record_count"]),
                          md_escape(", ".join(sorted(summary["failure_pattern_counts"].keys())) or "none"),
                          md_escape(", ".join(sorted(summary["model_diagnostic_counts"].keys())) or "none"),
                          md_escape(", ".join(sorted(summary["missing_evidence_counts"].keys())) or "none"),
                      ]
                  )
                  + " |"
              )
          lines.append("")
          return "\n".join(lines)
      
      
      def render_lessons(records: list[dict[str, Any]], generated_at: str) -> str:
          failure_counts = Counter()
          qa_counts = Counter()
          comparison_counts = Counter()
          pattern_runs: dict[str, list[dict[str, Any]]] = {}
          for record in records:
              failure_counts.update(record["failure_patterns"])
              qa_counts.update([record["qa_status"]])
              comparison_counts.update([record["comparison_status"]])
              for pattern in record["failure_patterns"]:
                  pattern_runs.setdefault(pattern, []).append(record)
      
          successful = [r for r in records if r["failure_patterns"] == ["no_detected_failure"]]
          lines = [
              "<!-- schema_version: 1.1 -->",
              "# Lessons Learned",
              "",
              f"Generated at UTC: `{generated_at}`",
              "",
              "This synthesis is derived from historical experiment audit artifacts. It is project memory, not proof that a model is calibrated or validated.",
              "",
              "## Repeated Failure Patterns",
          ]
          if failure_counts:
              for name, count in failure_counts.most_common():
                  lines.append(f"- `{name}`: {count} run(s)")
          else:
              lines.append("- No audited runs were found.")
      
          lines.extend(["", "## Repeated Assumptions"])
          assumptions = repeated_items(records, "assumptions")
          if assumptions:
              for item, count in assumptions:
                  lines.append(f"- {item} ({count} run(s))")
          else:
              lines.append("- No repeated assumptions were detected in the audited records.")
      
          lines.extend(["", "## Repeated Missing Evidence"])
          missing_counter: Counter[str] = Counter()
          for record in records:
              missing_counter.update(record.get("artifacts_missing", []))
          if missing_counter:
              for name, count in missing_counter.most_common():
                  lines.append(f"- `{name}` missing in {count} run(s)")
          else:
              lines.append("- No repeated missing artifacts were detected.")
      
          lines.extend(["", "## Repeated QA Issues"])
          for status, count in qa_counts.most_common():
              lines.append(f"- QA status `{status}`: {count} run(s)")
      
          lines.extend(["", "## Run-to-Run Difference Signals"])
          for status, count in comparison_counts.most_common():
              lines.append(f"- Comparison status `{status}`: {count} run(s)")
      
          lines.extend(["", "## Repeated SWMM Model Diagnostics"])
          diagnostics = repeated_items(records, "model_diagnostic_ids")
          if diagnostics:
              for item, count in diagnostics:
                  lines.append(f"- `{item}`: {count} run(s)")
          else:
              lines.append("- No repeated deterministic SWMM model diagnostics were detected.")
      
          lines.extend(["", "## Successful Practices"])
          if successful:
              for record in successful:
                  lines.append(
                      f"- `{record['run_id']}` preserved audit evidence with QA `{record['qa_status']}` and comparison `{record['comparison_status']}`."
                  )
          else:
              lines.append("- No run was classified as `no_detected_failure`.")
          lines.append("")
      
          # PRD M1 contract: emit a per-pattern '## <pattern>' section so
          # recall_memory(pattern) can locate the relevant lesson fragment
          # by exact heading match. Sections are intentionally short
          # (<= ~400 estimated tokens) so the tool_registry truncator keeps
          # the planner context tight.
          for pattern, runs in sorted(pattern_runs.items()):
              if pattern == "no_detected_failure":
                  continue
              lines.extend([f"## {pattern}", ""])
              lines.append(
                  f"Observed in {len(runs)} run(s): "
                  + ", ".join(f"`{r['run_id']}`" for r in runs[:8])
                  + ("." if len(runs) <= 8 else f", and {len(runs) - 8} more.")
              )
              lines.append("")
              cautions: list[str] = []
              for record in runs:
                  for caution in record.get("next_run_cautions", []) or []:
                      if caution and caution not in cautions:
                          cautions.append(caution)
                  if len(cautions) >= 5:
                      break
              if cautions:
                  lines.append("Next-run cautions surfaced by past audits:")
                  for caution in cautions[:5]:
                      lines.append(f"- {caution}")
                  lines.append("")
              lines.append(
                  f"Recall this section via `recall_memory(\"{pattern}\")` "
                  "or by searching with `recall_memory_search`."
              )
              lines.append("")
      
          return "\n".join(lines)
      
      
      def proposal_for_pattern(pattern: str) -> tuple[str, str, list[str]]:
          mapping = {
              "missing_inp": (
                  "SWMM build/input handoff",
                  "Ensure the workflow records where the runnable INP should be produced before SWMM execution.",
                  ["swmm-builder", "swmm-end-to-end"],
              ),
              "missing_rpt": (
                  "SWMM runner artifact contract",
                  "Ensure failed and successful runs both record expected RPT paths and missing-artifact evidence.",
                  ["swmm-runner", "swmm-experiment-audit"],
              ),
              "missing_out": (
                  "SWMM runner artifact contract",
                  "Ensure failed and successful runs both record expected OUT paths and missing-artifact evidence.",
                  ["swmm-runner", "swmm-experiment-audit"],
              ),
              "swmm_execution_failed": (
                  "SWMM execution diagnostics",
                  "Improve command logging and failure explanation around non-zero SWMM return codes.",
                  ["swmm-runner", "swmm-end-to-end"],
              ),
              "qa_missing": (
                  "QA gate",
                  "Make QA generation or QA-missing reporting explicit before evidence is treated as checked.",
                  ["swmm-runner", "swmm-experiment-audit", "swmm-end-to-end"],
              ),
              "qa_failed": (
                  "QA gate",
                  "Clarify how failed QA is reported and preserved for audit rather than hidden.",
                  ["swmm-runner", "swmm-experiment-audit", "swmm-end-to-end"],
              ),
              "peak_flow_parse_missing": (
                  "Peak-flow parsing",
                  "Check whether the correct SWMM report section is available and whether the parser should report a clearer boundary.",
                  ["swmm-runner", "swmm-experiment-audit"],
              ),
              "continuity_parse_missing": (
                  "Continuity parsing",
                  "Check whether continuity tables are absent, malformed, or not referenced in the run manifest.",
                  ["swmm-runner", "swmm-experiment-audit"],
              ),
              "comparison_mismatch": (
                  "Run comparison",
                  "Review whether mismatches are expected scenario differences or regressions that need acceptance criteria.",
                  ["swmm-experiment-audit", "swmm-end-to-end"],
              ),
              "missing_manifest": (
                  "Manifest generation",
                  "Ensure each stage writes a manifest or that missing manifests are explicitly documented.",
                  ["swmm-builder", "swmm-runner", "swmm-experiment-audit", "swmm-end-to-end"],
              ),
              "missing_provenance": (
                  "Experiment audit",
                  "Run the audit layer for partial and failed runs so downstream memory can use stable provenance.",
                  ["swmm-experiment-audit", "swmm-end-to-end"],
              ),
              "missing_evidence_boundary": (
                  "Experiment note",
                  "Ensure human-readable audit notes state what is executed, inferred, missing, and outside scope.",
                  ["swmm-experiment-audit"],
              ),
              "partial_run": (
                  "Workflow stop handling",
                  "Make partial-run handoff to audit explicit so incomplete evidence is still reusable.",
                  ["swmm-end-to-end", "swmm-experiment-audit"],
              ),
              "swmm_model_diagnostic_error": (
                  "SWMM model diagnostics",
                  "Review deterministic model diagnostics before treating a run as valid modeling evidence.",
                  ["swmm-experiment-audit", "swmm-runner", "swmm-builder"],
              ),
              "unknown_failure": (
                  "Failure classification",
                  "Inspect the run manually and add a deterministic rule only after human review.",
                  ["swmm-modeling-memory"],
              ),
          }
          return mapping.get(
              pattern,
              (
                  _UNRECOGNISED_PATTERN_STEP,
                  "Inspect recurring evidence and decide whether a skill refinement is warranted.",
                  ["swmm-end-to-end"],
              ),
          )
      
      
      # Step label returned by ``proposal_for_pattern`` for any pattern not in its
      # explicit table. It is the signal that no existing skill recognises the
      # pattern, which routes it to a new-skill proposal instead of a refinement.
      _UNRECOGNISED_PATTERN_STEP = "Workflow review"
      
      # A pattern is surfaced as a proposal only after it has recurred across at
      # least this many distinct runs. Below the threshold it is tracked under
      # "Watching" so a single audit cannot trigger a skill change off weak, one-off
      # evidence — proposals must be earned by accumulated history.
      _MIN_EVIDENCE_RUNS = 3
      
      
      def _suggest_skill_name(pattern: str) -> str:
          """Suggest a kebab-case skill name for an unrecognised failure pattern."""
          slug = re.sub(r"[^a-z0-9]+", "-", pattern.lower()).strip("-")
          return f"swmm-{slug}" if slug else "swmm-new-skill"
      
      
      def render_new_skill_proposal(pattern: str, runs: list[str]) -> str:
          """Render a 'propose a new skill' block for a pattern no skill recognises.
      
          The body deliberately carries no single-backtick tokens: the MOC
          generator extracts backticked names as existing SKILL.md targets, and a
          suggested-but-not-yet-existing skill must not leak into that index.
          """
          name = _suggest_skill_name(pattern)
          evidence = ", ".join(runs) if runs else "(no run ids recorded)"
          return "\n".join(
              [
                  f"## `{pattern}`",
                  "",
                  "- No existing skill recognises this recurring pattern, so this is a candidate for a NEW skill rather than a refinement.",
                  f"- Suggested new skill name: {name} (rename as fits).",
                  f"- Evidence runs: {evidence}",
                  "- How to act: draft it with the skill-author skill (skills/skill-author/), review the draft, then add it under skills/ and re-run the benchmarks.",
                  "- Required control: human review plus benchmark verification before any new skill is added.",
                  "",
              ]
          )
      
      
      def render_proposals(records: list[dict[str, Any]], generated_at: str) -> str:
          pattern_to_runs: dict[str, list[str]] = defaultdict(list)
          for record in records:
              for pattern in record["failure_patterns"]:
                  if pattern != "no_detected_failure":
                      pattern_to_runs[pattern].append(str(record["run_id"]))
      
          # Evidence gate: only patterns that have recurred across >= _MIN_EVIDENCE_RUNS
          # distinct runs earn a proposal; the rest are merely watched.
          proposed = {p: sorted(set(r)) for p, r in pattern_to_runs.items() if len(set(r)) >= _MIN_EVIDENCE_RUNS}
          watching = {p: sorted(set(r)) for p, r in pattern_to_runs.items() if 0 < len(set(r)) < _MIN_EVIDENCE_RUNS}
      
          lines = [
              "# Skill Update Proposals",
              "",
              f"Generated at UTC: `{generated_at}`",
              "",
              "Agentic SWMM is not only an automation workflow; it is a memory-informed, verification-first modeling system that can learn from audited modeling history through controlled skill refinement.",
              "",
              "This document is only a proposal. It is not an automatic skill update and it is not evidence of correctness.",
              "",
              f"A pattern is surfaced as a proposal only once it has recurred across at least {_MIN_EVIDENCE_RUNS} distinct runs, so a single audit cannot trigger a skill change off weak evidence. Patterns with less evidence are listed under \"Watching\" and tracked until they accumulate enough. When a well-evidenced pattern is not recognised by any existing skill, the proposal is to draft a NEW skill (via the skill-author skill) rather than force it onto an unrelated skill.",
              "",
              "Accepted skill changes require human review and benchmark verification before any existing `SKILL.md` is modified or any new skill is added.",
              "",
          ]
      
          if not proposed:
              lines.extend([f"No pattern has reached the {_MIN_EVIDENCE_RUNS}-run evidence threshold for a skill proposal yet.", ""])
          for pattern in sorted(proposed):
              runs = proposed[pattern]
              step, reason, skills = proposal_for_pattern(pattern)
              # A pattern that falls through to the generic default is one no
              # existing skill recognises. Rather than pin it on an unrelated
              # skill, propose drafting a new skill for it.
              if step == _UNRECOGNISED_PATTERN_STEP:
                  lines.append(render_new_skill_proposal(pattern, runs))
                  continue
              lines.extend(
                  [
                      f"## `{pattern}`",
                      "",
                      f"- Potential skill or workflow step: {step}",
                      f"- Relevant workflow skill(s): {', '.join(f'`{skill}`' for skill in skills)}",
                      f"- Why it may need improvement: {reason}",
                      f"- Evidence runs ({len(runs)}): {', '.join(f'`{run}`' for run in runs)}",
                      "- Required control: human review plus benchmark verification before accepting any skill refinement.",
                      "",
                  ]
              )
      
          if watching:
              lines.extend(["## Watching (not enough evidence yet)", ""])
              for pattern in sorted(watching):
                  seen = len(watching[pattern])
                  lines.append(
                      f"- `{pattern}`: seen in {seen} run(s); needs {_MIN_EVIDENCE_RUNS} before a proposal is raised."
                  )
              lines.append("")
      
          return "\n".join(lines)
      
      
      def render_benchmark_plan(generated_at: str) -> str:
          return "\n".join(
              [
                  "# Benchmark Verification Plan",
                  "",
                  f"Generated at UTC: `{generated_at}`",
                  "",
                  "Use this checklist before accepting any skill refinement proposed by modeling memory.",
                  "",
                  "- Identify the exact proposed skill or workflow change and the runs that motivated it.",
                  "- Review the source audit artifacts manually, including `experiment_provenance.json`, `comparison.json`, and `experiment_note.md`.",
                  "- Confirm the proposal does not change scientific modeling rules without human approval.",
                  "- Run the existing acceptance check when available:",
                  "",
                  "```bash",
                  "python3 scripts/acceptance/run_acceptance.py --run-id latest",
                  "```",
                  "",
                  "- Run relevant benchmark commands when the proposed change touches benchmark behavior:",
                  "",
                  "```bash",
                  "python3 scripts/benchmarks/run_tuflow_swmm_module03_raw_path.py",
                  "python3 scripts/benchmarks/run_tecnopolo_199401.py",
                  "```",
                  "",
                  "- Re-run experiment audit on affected runs before treating the change as evidence-backed.",
                  "- Re-run modeling-memory summarization and check whether the repeated failure pattern is reduced without hiding missing evidence.",
                  "- Accept the skill refinement only after human review confirms the benchmark and audit outputs remain interpretable.",
                  "",
              ]
          )
      
      
      def export_obsidian(out_dir: Path, obsidian_dir: Path) -> None:
          obsidian_dir.mkdir(parents=True, exist_ok=True)
          for name in MARKDOWN_OUTPUTS:
              shutil.copy2(out_dir / name, obsidian_dir / name)
      
      
      def load_excluded_runs(out_dir: Path) -> set[str]:
          """Read run identifiers to exclude from the evidence base.
      
          One identifier per line in ``<out_dir>/excluded_runs.txt`` (``#`` comments
          and blank lines ignored), matched against a record's ``project_key``,
          ``case_name``, or ``run_id``. Non-destructive: the run folders stay on
          disk; they just do not count toward accumulated modeling-memory evidence.
          """
          path = out_dir / "excluded_runs.txt"
          if not path.is_file():
              return set()
          names: set[str] = set()
          for line in path.read_text(encoding="utf-8").splitlines():
              stripped = line.strip()
              if stripped and not stripped.startswith("#"):
                  names.add(stripped)
          return names
      
      
      def _is_excluded(record: dict[str, Any], excluded: set[str]) -> bool:
          return any(str(record.get(key, "")) in excluded for key in ("project_key", "case_name", "run_id"))
      
      
      def parse_args() -> argparse.Namespace:
          parser = argparse.ArgumentParser(
              description="Summarize Agentic SWMM experiment audit artifacts into modeling memory and controlled skill-update proposals."
          )
          parser.add_argument("--runs-dir", required=True, type=Path, help="Directory containing audited run folders.")
          parser.add_argument("--out-dir", required=True, type=Path, help="Directory for generated modeling-memory outputs.")
          parser.add_argument("--obsidian-dir", type=Path, help="Optional Obsidian folder for Markdown exports.")
          parser.add_argument(
              "--no-run-summaries",
              action="store_true",
              help="Do not write per-run memory_summary.json cards next to audited run artifacts.",
          )
          return parser.parse_args()
      
      
      def main() -> int:
          args = parse_args()
          runs_dir = args.runs_dir
          out_dir = args.out_dir
          generated_at = now_utc()
      
          run_dirs = discover_run_dirs(runs_dir)
          scanned_count = len(run_dirs)
          records = [build_record(run_dir, runs_dir) for run_dir in run_dirs]
          # Non-destructive evidence filter: drop runs listed in excluded_runs.txt so
          # demo/test runs do not pollute accumulated history. Runs stay on disk.
          excluded = load_excluded_runs(out_dir)
          excluded_count = 0
          if excluded:
              kept = [(rd, rec) for rd, rec in zip(run_dirs, records) if not _is_excluded(rec, excluded)]
              excluded_count = len(records) - len(kept)
              run_dirs = [rd for rd, _ in kept]
              records = [rec for _, rec in kept]
          failure_count = sum(1 for record in records if has_detected_failure(record))
          run_summaries = [build_run_memory_summary(record, generated_at) for record in records]
          project_summaries = [
              project_summary(project, project_records, generated_at)
              for project, project_records in records_by_project(records).items()
          ]
      
          index = {
              "schema_version": "1.0",
              "generated_at_utc": generated_at,
              "source_runs_dir": str(runs_dir),
              "record_count": len(records),
              "run_folder_count": len(run_dirs),
              "failure_record_count": failure_count,
              "failure_pattern_counts": dict(Counter(p for r in records for p in r["failure_patterns"])),
              "model_diagnostic_counts": dict(Counter(item for r in records for item in r.get("model_diagnostic_ids", []))),
              "qa_status_counts": dict(Counter(r["qa_status"] for r in records)),
              "comparison_status_counts": dict(Counter(r["comparison_status"] for r in records)),
              "project_counts": dict(Counter(r["project_key"] for r in records)),
              "records": records,
          }
      
          write_json(out_dir / "modeling_memory_index.json", index)
          write_json(out_dir / "run_memory_summaries.json", {"generated_at_utc": generated_at, "records": run_summaries})
          if not args.no_run_summaries:
              for run_dir, summary in zip(run_dirs, run_summaries):
                  write_json(run_dir / "memory_summary.json", summary)
      
          projects_dir = out_dir / "projects"
          for summary in project_summaries:
              project_dir = projects_dir / summary["project_key"]
              write_json(project_dir / "project_memory.json", summary)
              write_text(project_dir / "project_memory.md", render_project_memory_md(summary["project_key"], summary))
      
          write_text(out_dir / "modeling_memory_index.md", render_index_md(records, generated_at))
          write_text(out_dir / "project_memory_index.md", render_project_index_md(project_summaries, generated_at))
          # ME-1 (issue #61): preserve any existing per-pattern metadata block
          # when re-rendering lessons_learned.md so the audit-end hook can
          # bump evidence_count without the summariser overwriting it. The
          # merge is a no-op when the file does not exist yet.
          new_lessons = render_lessons(records, generated_at)
          new_lessons = _merge_existing_metadata(out_dir / "lessons_learned.md", new_lessons)
          write_text(out_dir / "lessons_learned.md", new_lessons)
          write_text(out_dir / "skill_update_proposals.md", render_proposals(records, generated_at))
          write_text(out_dir / "benchmark_verification_plan.md", render_benchmark_plan(generated_at))
      
          obsidian_used = False
          if args.obsidian_dir:
              export_obsidian(out_dir, args.obsidian_dir)
              obsidian_used = True
      
          print(f"run folders scanned: {scanned_count}")
          print(f"audit records found: {len(records)}")
          print(f"runs excluded from evidence: {excluded_count}")
          print(f"runs with detected failures: {failure_count}")
          print(f"run memory summaries written: {'no' if args.no_run_summaries else len(run_summaries)}")
          print(f"project memory groups written: {len(project_summaries)}")
          print(f"output directory: {out_dir}")
          print(f"obsidian export used: {'yes' if obsidian_used else 'no'}")
          return 0
      
      
      if __name__ == "__main__":
          raise SystemExit(main())
      
  • SKILL.md 5.9 KB
    ---
    name: swmm-modeling-memory
    description: Read historical Agentic SWMM experiment audit artifacts and summarize repeated assumptions, QA issues, failures, missing evidence, run-to-run differences, lessons learned, and controlled skill update proposals. Use downstream of swmm-experiment-audit when multiple audited runs exist or when a user asks for modeling memory, failure-pattern extraction, lessons learned, or human-reviewed skill refinement proposals.
    ---
    
    # SWMM Modeling Memory
    
    Part of [Agentic SWMM](https://github.com/Zhonghao1995/agentic-swmm-workflow) — install the project first for the executable toolchain (aiswmm CLI, SWMM solver, MCP servers).
    
    ## What this skill provides
    
    - A downstream memory layer for audited Agentic SWMM runs.
    - Deterministic summaries of repeated assumptions, QA issues, failures, missing evidence, and run-to-run differences.
    - Run-level `memory_summary.json` cards that compress audit artifacts into reusable next-run context.
    - Project/case-level memory groups that keep Tod Creek, Tecnopolo, TUFLOW, Generate_SWMM_inp, acceptance, and other cases separate.
    - Summaries of deterministic SWMM-specific diagnostics when `model_diagnostics.json` is present.
    - Human-readable lessons learned from previous audit records.
    - Controlled skill update proposals that require human review and benchmark verification.
    
    This skill does not run SWMM, build SWMM models, modify existing skills, or claim autonomous self-improvement.
    
    Agentic SWMM is not only an automation workflow. It is a memory-informed, verification-first modeling system that can learn from audited modeling history through controlled skill refinement.
    
    ## When to use this skill
    
    Use this skill after `swmm-experiment-audit` has produced run-level artifacts such as:
    
    - `experiment_provenance.json`
    - `comparison.json`
    - `experiment_note.md`
    - `model_diagnostics.json` when available
    
    Use it when:
    
    - multiple audited runs exist,
    - the user wants lessons learned across runs,
    - the user asks for recurring failure patterns or QA issues,
    - the user wants evidence-informed skill refinement proposals.
    
    The proposals may point to relevant workflow skills such as end-to-end orchestration, audit reporting, QA verification, model building, or result parsing. They are not accepted changes.
    
    ## Output contract
    
    The script writes these files to the selected modeling-memory output directory:
    
    - `modeling_memory_index.json`
    - `modeling_memory_index.md`
    - `run_memory_summaries.json`
    - `project_memory_index.md`
    - `projects/<project-key>/project_memory.json`
    - `projects/<project-key>/project_memory.md`
    - `lessons_learned.md`
    - `skill_update_proposals.md`
    - `benchmark_verification_plan.md`
    
    The script also writes `memory_summary.json` beside each audited run by default. The JSON index and run summaries are the machine-readable source. The Markdown files are human-readable and can be copied to Obsidian with `--obsidian-dir`.
    
    ## CLI
    
    ```bash
    python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
      --runs-dir runs \
      --out-dir memory/modeling-memory
    ```
    
    To refresh only the aggregate output without writing run-level cards (only
    available via direct script invocation — `aiswmm memory` does not expose
    this flag):
    
    ```bash
    python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
      --runs-dir runs \
      --out-dir memory/modeling-memory \
      --no-run-summaries
    ```
    
    With optional Obsidian export:
    
    ```bash
    python3 skills/swmm-modeling-memory/scripts/summarize_memory.py \
      --runs-dir runs \
      --out-dir memory/modeling-memory \
      --obsidian-dir "/path/to/Obsidian/Agentic SWMM/05_Modeling_Memory"
    ```
    
    ## Safety rules
    
    - Read existing audit artifacts only.
    - Tolerate partial and failed runs.
    - Do not modify any existing `SKILL.md` files.
    - Do not modify benchmark behavior or audit output formats.
    - Do not write outside `--out-dir`, audited run directories under `--runs-dir`, or the optional `--obsidian-dir`.
    - Treat SWMM-specific diagnostics as deterministic audit evidence only; do not infer model errors from free-text notes.
    - Treat skill update proposals as proposals only.
    - Accept real skill refinements only after human review and benchmark verification.
    
    ## Audit-end auto-trigger (M2)
    
    `aiswmm audit` fires an auto-trigger after every successful audit that calls
    `summarize_memory.py` in the background to refresh `lessons_learned.md` and
    (unless `--no-rag` is given) rebuild the RAG corpus. This means
    `lessons_learned.md` can be written by two paths:
    
    1. **Automatic** — `agentic_swmm/memory/audit_hook.py` via the M2 hook
       after `aiswmm audit` succeeds.
    2. **Manual** — `aiswmm memory --runs-dir runs` or direct
       `python3 skills/swmm-modeling-memory/scripts/summarize_memory.py`.
    
    Set `AISWMM_SKIP_MEMORY=1` in the environment to suppress the auto-trigger
    (useful for CI or benchmark runs where memory mutation is unwanted). Pass
    `--no-memory` to `aiswmm audit` for the same effect on a single run.
    
    The auto-trigger uses `add_negative_lesson` / `NegativeLessonMd.update` from
    `agentic_swmm/memory/negative_lessons_markdown.py`, which increments
    `evidence_count` and updates `last_seen_utc` on duplicate lesson names rather
    than clobbering the existing entry. Manual `summarize_memory.py` runs use the
    same merge logic.
    
    ## Relationship to `swmm-experiment-audit`
    
    `swmm-experiment-audit` records evidence for one run.
    
    `swmm-modeling-memory` reads many audited runs and turns repeated evidence patterns into reusable project memory.
    
    The intended controlled loop is:
    
    1. Run SWMM or attempt a workflow.
    2. Audit the run (`aiswmm audit`); the M2 hook refreshes `lessons_learned.md` automatically.
    3. Preserve an Obsidian-compatible experiment note.
    4. Summarize modeling memory across audited runs (manual `aiswmm memory` call when a full refresh is needed).
    5. Extract recurring failure patterns.
    6. Generate a skill update proposal.
    7. Review the proposal as a human.
    8. Verify with existing benchmarks before accepting any skill change.
    

Comments (0)

Sign in to join the conversation.

No comments yet.

Reviews (0)

No reviews yet.

Related