Skip to content
Open
6 changes: 6 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -109,3 +109,9 @@ poster resource /
*.out
*.bbl
*.blg

.projectmem/events.jsonl
.projectmem/watch.pid
.projectmem/watch.log
CLAUDE.md
AGENTS.md
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,22 @@
# Changelog

## 0.1.5

**MCP timeout hardening for stdio clients.** Projectmem's MCP tools now avoid letting git subprocesses inherit the MCP server's JSON-RPC stdin pipe, which could hang FastMCP stdio sessions on Windows even when the equivalent CLI command returned quickly.

### Fixed

- MCP-reachable git helpers now detach child-process stdin with `subprocess.DEVNULL`.
- Git commit lookup on write paths is bounded with a timeout, so tools such as `add_note` and `record_fix` do not wait forever if git stalls.
- `precheck_file` and related precheck helpers keep CLI behavior unchanged while hardening the MCP execution path.
- `pjm brief` rendering is safe on CP1252 and other non-UTF-8 Windows consoles.
- Windows hook-path tests tolerate environments where bash exits early on invalid inherited stdin.

### Tests and docs

- Added MCP stdio regression coverage for `precheck_file` and `add_note`.
- Added focused subprocess tests for the MCP-reachable git helper calls.

## 0.1.4

**The accountable-judgment release: memory that flags its own staleness instead of silently trusting (or deleting) it — plus a dashboard that opens on an all-at-a-glance Overview.** Six small features (~150 lines, no new dependencies, no schema breaks) sharpen what makes projectmem different: it never deletes a memory, it tells you when one may have gone stale, it lets you retire decisions without losing history, it lists what already failed before you try it again, it briefs you at session start, it snoozes politely when it's wrong, and it exports its judgment to CLAUDE.md for agents that don't speak MCP. Also bumps the version (the `__init__.py` / `pyproject.toml` mismatch is corrected to a single `0.1.4`).
Expand Down
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
| Doc | What's in it |
|---|---|
| **[TUTORIAL.md](TUTORIAL.md)** | 15-minute step-by-step walkthrough — set up projectmem on your own project, watch the lifecycle, see the pre-commit warning fire. |
| **[CHANGELOG.md](CHANGELOG.md)** | Release history. Latest: v0.1.4the accountable-judgment release: stale-memory detection, decision supersede, precheck snooze, `pjm brief`, failed-approach surfacing, CLAUDE.md export, dashboard Overview. |
| **[CHANGELOG.md](CHANGELOG.md)** | Release history. Latest: v0.1.5MCP stdio timeout hardening, bounded git subprocesses, CP1252-safe brief output, and focused MCP regression tests. |
| **[Research paper (arXiv:2606.12329)](https://arxiv.org/abs/2606.12329)** | *PROJECTMEM: A Local-First, Event-Sourced Memory and Judgment Layer for AI Coding Agents* — the peer-readable version: design, Memory-as-Governance framing, capability comparison, and the 207-event dogfooding study. |
| **[LICENSE](LICENSE)** | MIT |

Expand Down
2 changes: 1 addition & 1 deletion pyproject.toml
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ build-backend = "hatchling.build"

[project]
name = "projectmem"
version = "0.1.4"
version = "0.1.5"
description = "Local-first memory + judgment layer for AI coding agents — warns before repeating failed fixes."
authors = [{ name = "Ripon Chandra Malo" }]
readme = "README.md"
Expand Down
2 changes: 1 addition & 1 deletion src/projectmem/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,3 @@
"""Local-first memory for repos."""

__version__ = "0.1.4"
__version__ = "0.1.5"
15 changes: 12 additions & 3 deletions src/projectmem/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -106,10 +106,19 @@ def attempt(
@app.command()
def fix(
text: str,
at: str | None = typer.Option(None, "--at", help="Location (e.g. file:line, class.method)"),
at: str | None = typer.Option(
None,
"--at",
help="Location (e.g. file:line, class.method)",
),
issue: str | None = typer.Option(
None,
"--issue",
help="Close a specific issue ID instead of the active issue (e.g. 0042).",
),
) -> None:
"""Record a fix and close the current issue."""
fix_command.run(text, location=at)
"""Record a fix and close the active issue, or a specific issue with --issue."""
fix_command.run(text, location=at, issue=issue)


@app.command()
Expand Down
68 changes: 48 additions & 20 deletions src/projectmem/commands/brief.py
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
"""
from __future__ import annotations

import sys
from collections import defaultdict
from datetime import datetime, timedelta, timezone
from pathlib import Path
Expand All @@ -31,13 +32,40 @@
_RESET = "\033[0m"


def _stdout_encoding() -> str:
"""Return the active stdout encoding, falling back to UTF-8."""
return getattr(sys.stdout, "encoding", None) or "utf-8"


def _console_safe(text: object) -> str:
"""Return text that can be printed by the current console encoding."""
value = str(text)
encoding = _stdout_encoding()
try:
value.encode(encoding)
return value
except UnicodeEncodeError:
return value.encode(encoding, errors="replace").decode(encoding)


def _safe_echo(text: object = "") -> None:
typer.echo(_console_safe(text))


def _rule(width: int = 60) -> str:
encoding = _stdout_encoding().lower()
if "utf" in encoding:
return "─" * width
return "-" * width


def run(root: Path | None = None) -> None:
root_path = root or Path.cwd()
events = read_events(root)

typer.echo("")
typer.echo(f"{_BOLD}projectmem brief — {root_path.name}{_RESET}")
typer.echo(f"{_DIM}{'─' * 60}{_RESET}")
_safe_echo("")
_safe_echo(f"{_BOLD}projectmem brief — {root_path.name}{_RESET}")
_safe_echo(f"{_DIM}{_rule(60)}{_RESET}")

_section_warnings(events)
_section_stale(events, root_path)
Expand All @@ -46,8 +74,8 @@ def run(root: Path | None = None) -> None:
_section_gotchas(root_path)
_section_score(events)

typer.echo(f"{_DIM}{'─' * 60}{_RESET}")
typer.echo("")
_safe_echo(f"{_DIM}{_rule(60)}{_RESET}")
_safe_echo("")


def _recent(events: list[Event], days: int = RECENT_DAYS) -> list[Event]:
Expand Down Expand Up @@ -79,13 +107,13 @@ def _section_warnings(events: list[Event]) -> None:
by_file: dict[str, list[Event]] = defaultdict(list)
for e in failed:
by_file[_file_of(e) or "(no file)"].append(e)
typer.echo(f"{_YELLOW}⚠ Active warnings{_RESET}")
_safe_echo(f"{_YELLOW}⚠ Active warnings{_RESET}")
if not by_file:
typer.echo(f" {_DIM}none — no failed attempts in {RECENT_DAYS} days{_RESET}")
_safe_echo(f" {_DIM}none — no failed attempts in {RECENT_DAYS} days{_RESET}")
return
for file_path, items in sorted(by_file.items(), key=lambda kv: -len(kv[1]))[:4]:
last = items[-1]
typer.echo(
_safe_echo(
f" {file_path} — {len(items)} failed attempt"
f"{'s' if len(items) != 1 else ''} "
f"{_DIM}(last: {last.summary[:60]}){_RESET}"
Expand All @@ -101,18 +129,18 @@ def _section_stale(events: list[Event], root: Path) -> None:
stale = []
if not stale:
return # silence is the right default — no flags, no noise
typer.echo(f"{_YELLOW}⏳ Possibly stale{_RESET}")
_safe_echo(f"{_YELLOW}⏳ Possibly stale{_RESET}")
for item in stale[:MAX_STALE]:
event = item["event"]
if item["commits_since"] == -1:
reason = f"{item['file']} no longer exists"
else:
reason = f"{item['file']} changed {item['commits_since']}x since"
typer.echo(
_safe_echo(
f" {event.type} [{event.id}] {event.summary[:55]} "
f"{_DIM}— {reason}{_RESET}"
)
typer.echo(
_safe_echo(
f" {_DIM}confirm, or retire: pjm decision \"...\" --supersedes <id>{_RESET}"
)

Expand All @@ -122,25 +150,25 @@ def _section_open_issues(events: list[Event]) -> None:
open_issues = [
e for e in events if e.type == "issue" and e.issue_id not in fixed
]
typer.echo(f"{_RED}📋 Open issues{_RESET}")
_safe_echo(f"{_RED}📋 Open issues{_RESET}")
if not open_issues:
typer.echo(f" {_DIM}none open{_RESET}")
_safe_echo(f" {_DIM}none open{_RESET}")
return
for issue in open_issues[-4:]:
typer.echo(f" #{issue.issue_id} {issue.summary[:70]}")
_safe_echo(f" #{issue.issue_id} {issue.summary[:70]}")


def _section_decisions(events: list[Event]) -> None:
retired = superseded_ids(events)
live = [
e for e in events if e.type == "decision" and e.id not in retired
]
typer.echo(f"{_TEAL}🕑 Recent decisions{_RESET}")
_safe_echo(f"{_TEAL}🕑 Recent decisions{_RESET}")
if not live:
typer.echo(f" {_DIM}none logged yet{_RESET}")
_safe_echo(f" {_DIM}none logged yet{_RESET}")
return
for d in live[-MAX_DECISIONS:]:
typer.echo(f" {d.summary[:75]}")
_safe_echo(f" {d.summary[:75]}")


def _section_gotchas(root: Path) -> None:
Expand All @@ -153,11 +181,11 @@ def _section_gotchas(root: Path) -> None:
gotchas = []
if not gotchas:
return
typer.echo(f"{_TEAL}💡 Stack gotchas{_RESET}")
_safe_echo(f"{_TEAL}💡 Stack gotchas{_RESET}")
for g in gotchas[-MAX_GOTCHAS:]:
lib = g.get("library", "")
text = g.get("text") or g.get("summary") or ""
typer.echo(f" {lib}: {text[:70]}")
_safe_echo(f" {lib}: {text[:70]}")


def _section_score(events: list[Event]) -> None:
Expand Down Expand Up @@ -187,6 +215,6 @@ def _section_score(events: list[Event]) -> None:
trend = f" {_RED}▼ {delta} this week{_RESET}"
else:
trend = f" {_DIM}— unchanged this week{_RESET}"
typer.echo(
_safe_echo(
f"{_GREEN}📈 Score{_RESET} {current['grade']} ({current['score']}/100){trend}"
)
1 change: 1 addition & 0 deletions src/projectmem/commands/context.py
Original file line number Diff line number Diff line change
Expand Up @@ -422,6 +422,7 @@ def _get_git_status_files(root: Path) -> list[str]:
capture_output=True,
text=True,
timeout=5,
stdin=subprocess.DEVNULL,
)
files = []
for line in result.stdout.strip().split("\n"):
Expand Down
63 changes: 57 additions & 6 deletions src/projectmem/commands/fix.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,14 +15,57 @@
from projectmem.summary import regenerate_summary


def run(text: str, location: str | None = None) -> Event:
"""Close the current issue with a fix. Returns the created fix Event.
def _normalize_issue_id(issue_id: str | None) -> str | None:
"""Normalize issue IDs so `1`, `001`, and `0001` all become `0001`."""
if issue_id is None:
return None

cleaned = issue_id.strip().lstrip("#")

if not cleaned:
return None

if cleaned.isdigit():
return cleaned.zfill(4)

return cleaned


def _issue_exists(events: list[Event], issue_id: str) -> bool:
"""Return True if an issue event exists for the requested issue ID."""
return any(event.type == "issue" and event.issue_id == issue_id for event in events)


def run(
text: str,
location: str | None = None,
issue: str | None = None,
) -> Event:
"""Close an issue with a fix. Returns the created fix Event.

Clears the current-issue marker so subsequent `pjm attempt` calls do not
silently re-attach to the just-closed issue (the L-027a misattribution bug).
When `issue` is omitted, this preserves the existing behavior:
close the current issue and clear the current-issue marker.

When `issue` is provided, the fix is attached to that specific issue.
The current-issue marker is only cleared if it points at the same issue.
This prevents closing or clearing the wrong active issue when fixing an
older issue after newer issues have been created.
"""
events = read_events()
issue_id = read_current_issue() or current_issue_id(events)
requested_issue_id = _normalize_issue_id(issue)
active_issue_id = read_current_issue() or current_issue_id(events)

if requested_issue_id is not None:
issue_id = requested_issue_id

if not _issue_exists(events, issue_id):
raise ProjectMemError(
f"Issue #{issue_id} was not found. "
"Run `pjm search <query>` or `pjm brief` to find the issue ID."
)
else:
issue_id = active_issue_id

if issue_id is None:
raise ProjectMemError("No open issue found. Run `pjm log <text>` first.")

Expand All @@ -33,8 +76,16 @@ def run(text: str, location: str | None = None) -> Event:
git_commit=get_git_commit(),
location=location,
)

append_event(event)
clear_current_issue()

# Preserve old behavior when no specific issue was requested.
# For targeted fixes, only clear the active marker if it matches the issue
# being fixed. This avoids wiping a newer active issue by mistake.
if requested_issue_id is None or active_issue_id == issue_id:
clear_current_issue()

regenerate_summary()
typer.echo(f"Fixed issue #{issue_id}")

return event
2 changes: 1 addition & 1 deletion src/projectmem/commands/init.py
Original file line number Diff line number Diff line change
Expand Up @@ -113,7 +113,7 @@ def _claude_md_bridge() -> str:
"files directly via filesystem write:\n"
" - On a bug discovery → `log_issue(summary, location)`.\n"
" - After each fix attempt → `record_attempt(summary, outcome)`.\n"
" - After confirmation → `record_fix(summary)`.\n"
" - After confirmation → use `record_fix(summary)` for the active issue. If fixing a specific older issue, use `record_fix(summary, issue_id=\"<issue_id>\")` and replace `<issue_id>` with the actual Projectmem issue ID.\n"
" - On a design choice → `add_decision(summary)`.\n"
" - On a gotcha / setup detail → `add_note(summary)`.\n\n"
"Editing `.projectmem/summary.md` or `.projectmem/PROJECT_MAP.md`\n"
Expand Down
Loading