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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,8 @@ All consequential edits follow the kernel's own loop:

High-impact decisions must record to `.episteme/reasoning-surface.json` before the action. See `kernel/HOOKS_MAP.md`.

**Docs follow implementation (E173).** When you edit an implementation file, the `workflow_guard` hook names the docs whose citations claim to describe that path — repair them in the same change or record why not. Query the same map directly: `episteme docs map <path>` (full dump with no args). The map is derived from citation edges (see `src/episteme/doc_references.py`), so it cannot rot; a doc earns edit-time surfacing by citing the code it describes.

> **Novel decisions vs. mechanical implementations.** A full Reasoning Surface fires on *novel decisions* — questions still open, alternatives still live. Mechanical implementations of an already-declared pattern produce a minimal `implementation-of` reference instead. See [`kernel/PATTERN_GOVERNANCE.md`](./kernel/PATTERN_GOVERNANCE.md) for the pattern-declaration artifact shape, the promotion gate, and the deviation-from-pattern escape hatch that promotes a "mostly mechanical" change back to novel.

---
Expand Down
112 changes: 106 additions & 6 deletions core/hooks/workflow_guard.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,14 @@

This is intentionally non-blocking. It nudges the agent to keep authoritative
project docs in sync when editing implementation files directly.

Event 173: when the governed project's tracked markdown cites the edited path
(exactly, or via an enclosing directory), the advisory names those docs with
their lifecycle state instead of the generic two-file reminder — the citation
walk is episteme.doc_references' (one notion of "what a doc cites"), imported
from this checkout's src/ with the E172 self-resolve pattern. Any failure on
that path — package unimportable (plugin context), project not a git repo,
no citing docs — falls back to the original generic advisory, never a crash.
"""
from __future__ import annotations

Expand All @@ -11,6 +19,9 @@
import sys
from pathlib import Path

#: Cap on docs named inline; the overflow is counted, never silently dropped.
_MAX_ADVISORY_DOCS = 6


ALLOWED_DOC_PATHS = {
"AGENTS.md",
Expand Down Expand Up @@ -58,6 +69,70 @@ def _project_has_authoritative_docs(cwd: Path) -> bool:
return any(p.exists() for p in required)


def _load_doc_references():
"""Import ``episteme.doc_references`` from this checkout's ``src/``.

E172 self-resolve pattern: the hook file lives inside the episteme
checkout, so ``src/`` is findable from ``__file__`` regardless of the
governed project's cwd. ``None`` on ANY failure — the caller keeps the
generic advisory; a plugin install without the package must not break.
"""
try:
src = Path(__file__).resolve().parents[2] / "src"
if src.is_dir() and str(src) not in sys.path:
sys.path.insert(0, str(src))
from episteme import doc_references # noqa: PLC0415

return doc_references
except Exception:
return None


def _relative_to_cwd(cwd: Path, target_path: str) -> str:
try:
return str(Path(target_path).resolve().relative_to(cwd.resolve()))
except (ValueError, OSError):
return Path(target_path).name


def _targeted_advisory(cwd: Path, target_path: str) -> "str | None":
"""The doc-map advisory for ``target_path``, or ``None`` for fallback.

``None`` whenever the answer is unavailable (no package, no git, IO
error) or genuinely empty (positive system: no citation, no obligation).
"""
dr = _load_doc_references()
if dr is None:
return None
try:
index = dr.cached_reverse_index(cwd)
edges = dr.edges_for_path(cwd, target_path, index=index)
if not edges:
return None
docs = [e.doc for e in edges]
labels = dict(dr.annotate_docs(cwd, docs))
rel = _relative_to_cwd(cwd, target_path)
lines = [f"DOC ADVISORY: {len(docs)} doc(s) claim to describe '{rel}' —"]
for e in edges[:_MAX_ADVISORY_DOCS]:
label = labels.get(e.doc, "")
via = f" [via {e.target}/]" if e.kind == "dir" else ""
lines.append(
f" {e.doc}" + (f" ({label})" if label else "") + via
)
overflow = len(docs) - _MAX_ADVISORY_DOCS
if overflow > 0:
lines.append(
f" … and {overflow} more — `episteme docs map '{rel}'` lists all."
)
lines.append(
"Verify they still tell the truth about this edit; "
"repair in the same change or record why not."
)
return "\n".join(lines)
except Exception:
return None


def main() -> int:
raw = sys.stdin.read().strip()
if not raw:
Expand Down Expand Up @@ -85,20 +160,45 @@ def main() -> int:
if not target_path:
return 0

if _is_doc_or_policy_path(target_path):
cwd = Path(payload.get("cwd") or os.getcwd())

# E173 repair of a latent bug: harness payloads carry ABSOLUTE file paths,
# and `lstrip("./")` strips a character set, not a prefix, so the doc-path
# suppression never matched absolute spellings — the guard nagged on doc
# edits it was designed to stay silent on. Normalize to the project-relative
# spelling before the policy test. Textual prefix-strip FIRST: this repo's
# authoritative docs are symlinks into a private tree, and resolve() would
# follow them out of the project and defeat the suppression for exactly the
# files it exists to cover; resolve() is only the fallback for spelling
# mismatches like /tmp vs /private/tmp.
policy_path = target_path
if os.path.isabs(target_path):
norm_t = os.path.normpath(target_path)
norm_c = os.path.normpath(str(cwd))
if norm_t.startswith(norm_c + os.sep):
policy_path = norm_t[len(norm_c) + 1 :].replace(os.sep, "/")
else:
try:
policy_path = str(
Path(target_path).resolve().relative_to(cwd.resolve())
).replace(os.sep, "/")
except (ValueError, OSError):
policy_path = target_path

if _is_doc_or_policy_path(policy_path):
return 0

cwd = Path(payload.get("cwd") or os.getcwd())
if not _project_has_authoritative_docs(cwd):
return 0

context = _targeted_advisory(cwd, target_path) or (
f"WORKFLOW ADVISORY: Editing '{Path(target_path).name}' outside authoritative docs. "
"Keep docs/EVENTS.md and docs/NEXT_STEPS.md aligned with this change."
)
advisory = {
"hookSpecificOutput": {
"hookEventName": "PreToolUse",
"additionalContext": (
f"WORKFLOW ADVISORY: Editing '{Path(target_path).name}' outside authoritative docs. "
"Keep docs/EVENTS.md and docs/NEXT_STEPS.md aligned with this change."
),
"additionalContext": context,
}
}
sys.stdout.write(json.dumps(advisory))
Expand Down
7 changes: 6 additions & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
@@ -1,9 +1,14 @@
<!-- episteme-lifecycle: status=living; reviewed_as_of=E172 -->
<!-- episteme-lifecycle: status=living; reviewed_as_of=E173 -->
# episteme — command reference

A one-page map of every `episteme` subcommand, grouped by lifecycle phase.
Run `episteme <command> --help` for flags and arguments.

Source of truth: the argparse tree in `src/episteme/cli.py` — the
completeness of this page against it is CI-enforced both directions
(`tests/test_commands_doc_drift.py`), and naming the source here puts this
page on the reverse index so CLI edits surface it (`episteme docs map`).

Scope key:
- **global** — targets `~/episteme` or `~/.claude` / `~/.hermes`, ignores CWD
- **project** — takes a path arg (default `.`), acts on the current project
Expand Down
2 changes: 1 addition & 1 deletion docs/HOOKS.md
Original file line number Diff line number Diff line change
Expand Up @@ -25,7 +25,7 @@ change; it is the actual registry, not this prose.
| `reasoning_surface_guard.py` | `PreToolUse Bash\|Write\|Edit\|MultiEdit` | Blocks high-impact / irreversible ops and architectural-cascade edits that lack a valid Reasoning Surface |
| `block_dangerous.py` | `PreToolUse Bash` | Blocks `rm -rf`, `git reset --hard`, `git push --force`, `sudo`, destructive SQL, and more |
| `_arm_a_pre.py` | `PreToolUse Write\|Edit\|MultiEdit` | Cognitive Arm A pre-snapshot of watched profile/policy files for trajectory diffing |
| `workflow_guard.py` | `PreToolUse Write\|Edit\|MultiEdit` (balanced/strict) | Advisory nudge to keep authoritative docs (`EVENTS.md` / `NEXT_STEPS.md`) aligned with edits |
| `workflow_guard.py` | `PreToolUse Write\|Edit\|MultiEdit` (balanced/strict) | Targeted DOC ADVISORY (E173): names the docs whose citations claim to describe the edited path — the reverse index of `episteme docs map`, derived from citation edges — with lifecycle state; falls back to the generic `EVENTS.md` / `NEXT_STEPS.md` nudge when no doc cites the path (positive system) or the index is unavailable (non-git project, plugin-only install) |
| `prompt_guard.py` | `PreToolUse Write\|Edit\|MultiEdit` (balanced/strict) | Advisory prompt-injection detection when writing durable context |
| `format.py` | `PostToolUse Write\|Edit\|MultiEdit` (async) | Auto-runs `ruff` (Python) / `prettier` (JS/TS) after a file write |
| `test_runner.py` | `PostToolUse Write\|Edit\|MultiEdit` | Runs pytest / jest when the edited file is a test |
Expand Down
1 change: 1 addition & 0 deletions docs/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,7 @@ hand-edit between the markers; run the command and commit the result.
- [`README.md`](./README.md) · living · episteme docs index
- [`SETUP.md`](./SETUP.md) · living · Setup — Profile, Cognition, One-Command
- [`SKILLS_AND_PERSONAS.md`](./SKILLS_AND_PERSONAS.md) · living · Skills and Agent Personas
- [`SPEC_DOC_MAP.md`](./SPEC_DOC_MAP.md) · spec-implemented · SPEC — Code→Doc Reverse Index (`docs map`) and the Targeted DOC ADVISORY
- [`SPEC_REASONING_SURFACE_BRANCHING.md`](./SPEC_REASONING_SURFACE_BRANCHING.md) · spec-implemented · Spec — Reasoning Surface branching
- [`SPEC_RIGOR_KNOB.md`](./SPEC_RIGOR_KNOB.md) · spec-implemented · Spec — `episteme rigor` (rigor knob)
- [`SUBSTRATE_BRIDGE.md`](./SUBSTRATE_BRIDGE.md) · living · Substrate Bridge — A Generalizable Contract for External Memory Systems
Expand Down
107 changes: 107 additions & 0 deletions docs/SPEC_DOC_MAP.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
<!-- episteme-lifecycle: status=spec-implemented; reviewed_as_of=E173 -->
# SPEC — Code→Doc Reverse Index (`docs map`) and the Targeted DOC ADVISORY

Event 173. Operator-ratified (design C of three; wiki-pages and a hand-authored
`DOC_MAP.json` were rejected — both add a hand-maintained surface that itself
rots, which is the disease under treatment).

## Failure mode countered (anti-accretion rule 7)

**Doc-code drift discovered late.** A code edit lands; the docs that claim to
describe that code stay unchanged; the divergence surfaces only in a later
batch sweep (the E172 class of repair: 13 findings, a full day) or in the
SessionStart staleness banner (22 living docs >15 events behind at the time of
this event). The write path — the only moment when the edit and its doc
obligations coexist in one context — carried no doc-specific signal.

## Mechanism replaced (anti-accretion rule 7)

`core/hooks/workflow_guard.py`'s **path-blind static advisory** ("Keep
docs/EVENTS.md and docs/NEXT_STEPS.md aligned"), which named the same two files
regardless of what was edited. The static string survives only as the fallback
tier (below). No new hook, no new queue, no new hand-maintained file.

## Rule-shape (kernel rule 8)

**Positive system.** An edit-time obligation exists ONLY where a live authored
doc cites the edited path — exactly, or via an enclosing directory. Uncited
code carries no obligation. Consequence: a doc earns edit-time surfacing by
citing the code it describes, so citation hygiene and map fidelity are the
same act. The obligation-citing corpus additionally excludes `archive/`
(frozen history must not generate present-tense obligations) on top of the
drift linter's fixture-tree exemptions.

## Architecture — one notion of "what a doc cites"

The map is the **inverse of the citation edges the drift linter already
extracts** (`src/episteme/doc_references.py::extract_references`; CI keeps them
non-dangling via `tests/test_doc_references.py`). Nothing is authored; the map
cannot rot by construction. New functions, same module:

| Function | Contract |
|---|---|
| `build_reverse_index(root, doc_files=None)` | claimed cited path → `[DocEdge]` over the obligation corpus. Unresolved citations are indexed as claims: a doc that pre-documents a not-yet-existing file must obligate the Write that creates it, and claims-in-index keeps the index a pure function of the markdown corpus — the property the cache digest relies on (review finding, this event). Dangling claims remain `find_drift`'s findings; the mechanisms stay separate |
| `edges_for_path(root, path, index=None)` | citing edges, strongest claim first: exact file > deeper dir > shallower dir; one edge per doc; self-citations excluded |
| `docs_for_path(root, path, ...)` | the sorted doc list (thin wrapper) |
| `annotate_docs(root, docs)` | display labels: `status · reviewed_as_of · N events behind` (lifecycle marker + `docs/EVENTS.md` scan), `manifest-managed` (kernel manifest membership), else empty — every lookup degrades, never raises |
| `cached_reverse_index(root)` | the index behind an mtime+size digest cache at `.episteme/cache/doc_map.json`; ~200ms cold / ~10ms steady-state; cache written ONLY where `.episteme/` already exists; any cache failure → fresh build |

## Consumers

1. **`workflow_guard.py` (PreToolUse Write|Edit|MultiEdit).** On an
implementation-file edit: `DOC ADVISORY: N doc(s) claim to describe
'<path>'` + up to 6 docs (overflow counted, never silent) + lifecycle
labels + `[via <dir>/]` tags for directory edges. Fallback ladder, each
step caught and degraded, never a crash: package unimportable (plugin-only
install) → generic advisory · project not a git repo → generic · no citing
docs → generic (positive system: no citation, no obligation). The package
imports via the E172 self-resolve pattern (`__file__ → ../../src`).
Also repaired in this event: the doc-path suppression never matched the
absolute file paths harness payloads actually carry (`lstrip("./")` strips
a character set, not a prefix) — targets are now normalized to
project-relative form before the policy test.
2. **`episteme docs map [path ...]`** — the agent/operator query surface
("the wiki"): per-path citing docs with the same ordering and labels as the
hook; full `target ← docs` dump with no args. Informational, exit 0.
3. **`AGENTS.md` → Workflow convention** names the mechanism and the repair
duty ("repair in the same change or record why not").

## Drain (kernel rule 6 — no new operator-judgment queues)

The advisory is discharged **in the same change** (repair the doc, or record
why not) — the write path is the drain. Nothing is enqueued anywhere; ignoring
the advisory leaves the corpus exactly as it was (the staleness banner and the
E172-class sweeps remain the backstop, now with a smaller tail to catch).

## Declared limits

- **Conceptual docs that cite no code paths are invisible to the map** (e.g.
a philosophy doc that names no files). Their alignment stays with the event
process; this mechanism owns only the mechanically derivable edges.
- Directory citations are weak claims; they rank below exact-file citations
and carry `[via …/]` so the reader can weigh them.
- The map covers the governed project's own tracked markdown only (tracked =
environment-stable; private symlinked docs are auto-excluded).

## Verification

`tests/test_doc_map.py`: inversion semantics (exact/dir/dangling-as-claim/
exempt/self), query normalization (absolute, outside-root, not-yet-existing
files under a cited dir — a Write creating a hook must fire), strongest-first
ordering, cache hit/invalidation/corruption/footprint rules plus the
adversarial-review scenario (cache built while a cited target is absent, file
created later with no markdown change — the cached answer must still name the
citing doc), symlinked-doc suppression (the guard must not `resolve()` the
private-doc symlinks out of the project), two real-corpus edges this repo
relies on (`core/hooks/workflow_guard.py` → `docs/HOOKS.md`;
`src/episteme/cli.py` → `docs/COMMANDS.md` via the source-of-truth citation
added in this event), and the hook's full fallback ladder end-to-end.

Independent review (E173, adversarial): no blockers; one should-fix (the
cache-staleness hole above — fixed by claims-in-index) and residual nits
consciously accepted: compound symlink-escape via the `resolve()` fallback
branch (reasoned, not reproducible from real harness payloads), ~40ms
steady-state hook overhead on this repo's 233-file corpus (rebuild ~180ms when
markdown changed), deleted-but-indexed markdown disabling the cache until
`git rm` (self-healing, correct answers throughout), and case-sensitive index
keys.
6 changes: 3 additions & 3 deletions kernel/MANIFEST.sha256
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

133bc78628c2dda23b3b07e72977ece258dea0b68f0b9ed6075fbec0095610a0 kernel/CONSTITUTION.md
6c30b85216318af3dff1e8bde3a4074131fc0c39d986c5c2a76fd3b32b182b3a kernel/FAILURE_MODES.md
b441a08bb7fdae2a76f9edc8c0cc9f90fd235ef7895a250d08f5f41b6ed7342d kernel/HOOKS_MAP.md
7df54b8a5c73fd7e7b02bdf3d5feab92b57a14c1437b7cf56e95ef2fbb1653aa kernel/HOOKS_MAP.md
bb738c912af69302139af72e5501f94c9bbd0f570c7197a0a5d58636a57fef85 kernel/OPERATOR_PROFILE_SCHEMA.md
a33dd5523b45c7f327d8d90eb1c09ad75ec8e203c5c700aebd89c3e7aa1b397d kernel/README.md
75b021d8e799c59eb5fc476c7d66024487a1c5eff642abd906fb4a481a5af19b kernel/REASONING_SURFACE.md
14da782893d54fd26aa6842b037c80685b46b03ea931ed55c8806a8b469c9c8f kernel/REFERENCES.md
4a197a97b00d69a30ff5f07d3c44423835f092163ff2469bf0b10e1ff15f6956 kernel/REASONING_SURFACE.md
4ff46e527d12ce0597f4be42ebeb06d1fdc504e496b10aeef1cca9f5f573d690 kernel/REFERENCES.md
9 changes: 9 additions & 0 deletions src/episteme/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -6152,6 +6152,9 @@ def build_parser() -> argparse.ArgumentParser:
d_index = docs_sub.add_parser("index", help="Regenerate the docs/README.md index from lifecycle markers")
d_index.add_argument("--check", action="store_true", help="Verify the committed index is up to date (CI gate); do not write")
d_index.add_argument("--root", type=Path, default=None, help="Repo root to index (default: git top-level of the current working directory)")
d_map = docs_sub.add_parser("map", help="Code→doc reverse index: which docs claim to describe a code path (derived from citation edges; E173)")
d_map.add_argument("paths", nargs="*", help="Code paths to query (repo-relative or absolute); omit to dump the full target ← docs map")
d_map.add_argument("--root", type=Path, default=None, help="Repo root to map (default: git top-level of the current working directory)")

kernel = sub.add_parser("kernel", help="Kernel integrity manifest + ledger maintenance operations")
kernel_sub = kernel.add_subparsers(dest="kernel_action", required=True)
Expand Down Expand Up @@ -6959,6 +6962,12 @@ def main(argv: Iterable[str] | None = None) -> int:
root=getattr(args, "root", None),
check=getattr(args, "check", False),
)
if args.docs_action == "map":
from . import doc_references as _dr
return _dr.run_map_cli(
root=getattr(args, "root", None),
paths=getattr(args, "paths", []),
)
return 0
if args.command == "memory":
if args.memory_action == "record":
Expand Down
Loading
Loading