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
17 changes: 17 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,23 @@ All notable changes to the AIM CLI and Control Hub project will be documented in
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).

## [1.5.0] - 2026-06-11

Roadmap Phase 5 (final) — GitHub sync. Projects AIM tasks onto GitHub Issues
and Projects so a team gets a familiar board; AIM stays the agent's working
layer. Zero-dependency: shells out to the `gh` CLI (no OAuth libraries).

### Added
- **`aim github push [id] [--all] [--project N]`** — create/update a GitHub
issue per task (idempotent via a stored `**GitHub Issue:**` number). Maps
done → closed, otherwise open; optionally adds issues to a Project (v2).
- **`aim github status`** — show each task's linked issue.
- **`aim github create-project <title>`** — create a GitHub Project (v2) for the
repo owner.
- Tasks carry a `githubIssue` field (a `**GitHub Issue:**` line).

---

## [1.4.0] - 2026-06-11

Roadmap Phase 4 — spec-driven development. Makes the existing `spec`/`plan`
Expand Down
17 changes: 17 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ every tool, so the next session (in any client) does not repeat the mistake.

---

## 🐙 GitHub sync (`aim github`)

Project AIM tasks onto GitHub Issues + Projects so your team gets a familiar
board, while AIM stays the agent's working layer. Zero-dependency — it shells out
to the `gh` CLI (requires `gh auth login`).

```bash
aim github create-project "AIM Roadmap" # create a Project (v2)
aim github push --all --project 3 # create/update an issue per task, add to the project
aim github status # show task <-> issue linkage
```

Issues are idempotent (the issue number is stored back in each task); a `done`
task closes its issue.

---

## 🩺 Context Health (`aim doctor`)

AIM's wedge is fighting **context drift** — stale rules and decisions that make
Expand Down
2 changes: 1 addition & 1 deletion aim/__init__.py
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
# AIM: AI Memory & Specialist Agent Suite package
__version__ = "1.4.0"
__version__ = "1.5.0"
67 changes: 67 additions & 0 deletions aim/aim_cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -321,6 +321,7 @@ def parse_task_file(path):
"id": "", "title": "", "status": "todo", "priority": "medium",
"assignee": "unassigned", "timeSpent": 0,
"parent": None, "dependsOn": [], "labels": [], "spec": "", "plan": "",
"githubIssue": None,
"description": "", "ac": [], "notes": "", "extraSections": []
}

Expand Down Expand Up @@ -362,6 +363,9 @@ def parse_task_file(path):
elif line.startswith("**Plan:**"):
val = line.replace("**Plan:**", "").strip()
meta["plan"] = val if val and val != "none" else ""
elif line.startswith("**GitHub Issue:**"):
val = line.replace("**GitHub Issue:**", "").replace("#", "").strip()
meta["githubIssue"] = int(val) if val.isdigit() else None

for header, body in sections:
key = header.strip().lower()
Expand Down Expand Up @@ -404,6 +408,7 @@ def render_task_content(meta):
labels_str = f"**Labels:** {', '.join(meta['labels'])}" if meta.get("labels") else "**Labels:** none"
spec_str = f"**Spec:** {meta['spec']}" if meta.get("spec") else "**Spec:** none"
plan_str = f"**Plan:** {meta['plan']}" if meta.get("plan") else "**Plan:** none"
gh_str = f"**GitHub Issue:** #{meta['githubIssue']}" if meta.get("githubIssue") else "**GitHub Issue:** none"

# Preserve user notes; only the "Last updated" stamp line is managed.
notes = meta.get("notes", "")
Expand All @@ -429,6 +434,7 @@ def render_task_content(meta):
{labels_str}
{spec_str}
{plan_str}
{gh_str}

## Description
{meta['description']}
Expand Down Expand Up @@ -923,6 +929,54 @@ def cmd_validate(args):
print(f"[-] Found {total} issue(s).")
sys.exit(1)

# ==========================================
# 7.6. GITHUB SYNC COMMANDS
# ==========================================
def cmd_github(args):
ensure_directories()
core = _core()
if not core.github_available():
print("[-] GitHub CLI (gh) not found or not authenticated.")
print(" Install gh (https://cli.github.com) and run `gh auth login`.")
sys.exit(1)

if args.github_action == "create-project":
try:
data = core.create_project(args.title)
except RuntimeError as e:
print(f"[-] {e}")
sys.exit(1)
print(f"[+] Created project #{data.get('number')}: {data.get('url')}")
print(f"[*] Push tasks into it with: aim github push --all --project {data.get('number')}")

elif args.github_action == "push":
try:
if args.id:
results = [core.push_task(args.id, project=args.project)]
else:
results = core.push_all(project=args.project)
except RuntimeError as e:
print(f"[-] {e}")
sys.exit(1)
if not results:
print("[*] No tasks to push.")
return
for r in results:
print(f"[+] TASK-{r['taskId']} -> issue #{r['issue']} ({r['action']}, status={r['status']})")
if args.project:
print(f"[+] Linked issues to project #{args.project}.")

elif args.github_action == "status":
rows = core.github_status()
if not rows:
print("[*] No tasks found.")
return
print(f"{'Task':<6} {'Issue':<8} {'Status':<12} {'Title'}")
print("-" * 70)
for r in rows:
issue = f"#{r['issue']}" if r["issue"] else "-"
print(f"{r['taskId']:<6} {issue:<8} {r['status']:<12} {r['title'][:40]}")

# ==========================================
# 7.5. SPEC COMMANDS (spec-driven development)
# ==========================================
Expand Down Expand Up @@ -2093,6 +2147,17 @@ def main():
import_spec_p.add_argument("--name", help="Override the spec name (default: directory name)")
spec_sub.add_parser("coverage", help="Show spec-link coverage across tasks")

# github
github_parser = subparsers.add_parser("github", help="Sync tasks to GitHub Issues / Projects (via the gh CLI)")
github_sub = github_parser.add_subparsers(dest="github_action", required=True)
gh_push = github_sub.add_parser("push", help="Create/update a GitHub issue per task (idempotent)")
gh_push.add_argument("id", type=int, nargs="?", help="Task ID (omit, or use --all, to push every task)")
gh_push.add_argument("--all", action="store_true", help="Push every task (default when no ID is given)")
gh_push.add_argument("--project", type=int, help="Also add issues to this Project (v2) number")
github_sub.add_parser("status", help="Show task <-> GitHub issue linkage")
gh_proj = github_sub.add_parser("create-project", help="Create a GitHub Project (v2) for this repo")
gh_proj.add_argument("title", help="Project title")

# ingest
ingest_parser = subparsers.add_parser("ingest", help="Import existing hand-written rule files (CLAUDE.md, .cursorrules, ...) into AIM")
ingest_parser.add_argument("--dry-run", action="store_true", help="Preview what would be imported without writing")
Expand Down Expand Up @@ -2193,6 +2258,8 @@ def main():
cmd_validate(args)
elif args.command == "spec":
cmd_spec(args)
elif args.command == "github":
cmd_github(args)
elif args.command == "ingest":
cmd_ingest(args)
elif args.command == "doctor":
Expand Down
141 changes: 141 additions & 0 deletions aim/core.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
"""
import datetime
import getpass
import json
import os
import re
import subprocess
Expand Down Expand Up @@ -612,6 +613,146 @@ def collect_status():
}


# ==========================================
# GitHub sync — `aim github` (Issues / Projects via the gh CLI)
# ==========================================
def _gh(args, input_text=None):
"""Run a gh CLI command in the workspace root. Returns stdout on success;
raises RuntimeError (with stderr) on failure or if gh is unavailable.
Isolated as a seam so the sync logic is unit-testable without real gh."""
cli = _cli()
try:
result = subprocess.run(
["gh", *args], cwd=cli.ROOT_DIR, capture_output=True,
text=True, input=input_text, timeout=60,
)
except (FileNotFoundError, OSError) as e:
raise RuntimeError("GitHub CLI (gh) is not installed or not on PATH.") from e
except subprocess.SubprocessError as e:
raise RuntimeError(f"gh command failed: {e}") from e
if result.returncode != 0:
raise RuntimeError((result.stderr or result.stdout or "gh command failed").strip())
return result.stdout


def github_available():
"""True if gh is installed and authenticated."""
try:
_gh(["auth", "status"])
return True
except RuntimeError:
return False


def repo_slug():
"""owner/repo for the current workspace, via gh."""
return _gh(["repo", "view", "--json", "nameWithOwner", "-q", ".nameWithOwner"]).strip()


_ISSUE_NUM_RE = re.compile(r"/issues/(\d+)\s*$")


def parse_issue_number(url):
m = _ISSUE_NUM_RE.search((url or "").strip())
return int(m.group(1)) if m else None


def task_to_issue_body(task):
"""Render an AIM task as a GitHub issue body, ending with a hidden marker
that ties the issue back to the AIM task id."""
lines = []
if task.get("description"):
lines.append(task["description"].strip())
lines.append("")
if task.get("ac"):
lines.append("### Acceptance Criteria")
for ac in task["ac"]:
box = "x" if ac.get("checked") else " "
lines.append(f"- [{box}] {ac['text']}")
lines.append("")
if task.get("dependsOn"):
lines.append("**Depends on:** " + ", ".join(f"AIM task-{d}" for d in task["dependsOn"]))
lines.append("")
lines.append("---")
lines.append(f"_Synced from AIM · task-{task['id']} · do not edit this marker_")
lines.append(f"<!-- aim-task:{task['id']} -->")
return "\n".join(lines).strip()


def _issue_title(task):
return f"[AIM-{task['id']}] {task['title']}"


def push_task(task_id, project=None):
"""Create or update a GitHub issue for one AIM task (idempotent via the
stored githubIssue number). Maps done->closed, else open. Optionally adds
the issue to a Project v2. Returns {taskId, issue, action, status}."""
cli = _cli()
task = get_task(task_id)
if task is None:
raise RuntimeError(f"Task {task_id} not found.")

title = _issue_title(task)
body = task_to_issue_body(task)
issue = task.get("githubIssue")
status = task.get("status", "todo").lower()

if issue:
_gh(["issue", "edit", str(issue), "--title", title, "--body", body])
action = "updated"
else:
out = _gh(["issue", "create", "--title", title, "--body", body])
url = out.strip().splitlines()[-1] if out.strip() else ""
issue = parse_issue_number(url)
if issue is None:
raise RuntimeError(f"Could not parse issue number from gh output: {out!r}")
task["githubIssue"] = issue
cli.write_task_file(task)
action = "created"

# Reflect AIM status on the issue: done -> closed, otherwise open.
try:
if status == "done":
_gh(["issue", "close", str(issue)])
else:
_gh(["issue", "reopen", str(issue)])
except RuntimeError:
pass # already in that state, or not permitted — non-fatal

if project:
try:
slug = repo_slug()
owner = slug.split("/")[0]
issue_url = f"https://github.com/{slug}/issues/{issue}"
_gh(["project", "item-add", str(project), "--owner", owner, "--url", issue_url])
except RuntimeError:
pass # project linking is best-effort

return {"taskId": task["id"], "issue": issue, "action": action, "status": status}


def push_all(project=None):
"""Push every task to GitHub. Returns a list of per-task results."""
tasks, _errors = load_tasks()
return [push_task(t["id"], project=project) for t in tasks]


def github_status():
"""Per-task linkage view: {taskId, title, status, issue}."""
tasks, _errors = load_tasks()
return [{"taskId": t["id"], "title": t["title"],
"status": t.get("status", "todo"), "issue": t.get("githubIssue")}
for t in tasks]


def create_project(title):
"""Create a GitHub Project (v2) owned by the repo owner. Returns the parsed
JSON (number, url, ...)."""
owner = repo_slug().split("/")[0]
out = _gh(["project", "create", "--owner", owner, "--title", title, "--format", "json"])
return json.loads(out)


# ==========================================
# Spec-driven development — `aim spec`
# ==========================================
Expand Down
29 changes: 29 additions & 0 deletions docs/cli-reference.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,7 @@ This document provides a comprehensive command-line reference for **AIM** (AI Me
9. [AI Assistant Integration](#9-ai-assistant-integration)
* [`aim mcp`](#aim-mcp)
* [`aim browser`](#aim-browser)
* [`aim github`](#aim-github)
10. [Demo Workspace](#10-demo-workspace)
* [`aim demo`](#aim-demo)

Expand Down Expand Up @@ -496,6 +497,34 @@ Launch the AIM Control Hub web dashboard (Kanban board, docs library, memory, ti
aim browser -p 7000 --no-open
```

### `aim github`
Sync tasks to GitHub Issues / Projects via the `gh` CLI (no extra dependencies;
requires `gh` installed and `gh auth login`). GitHub becomes the team's display
layer while AIM stays the agent's working layer.

#### `aim github push`
Create or update a GitHub issue per task — idempotent via the `**GitHub Issue:**`
number stored back in each task. Maps a `done` task to a closed issue, otherwise
open.
* **Arguments:** `id` (optional) — a single task; omit to push all tasks.
* **Options:** `--project N` — also add the issues to Project (v2) number `N`.
* **Example:**
```bash
aim github push --all --project 3
```

#### `aim github status`
Show each task and its linked GitHub issue (or `-` if not pushed).
* **Usage:** `aim github status`

#### `aim github create-project`
Create a GitHub Project (v2) owned by the repo owner.
* **Arguments:** `title` (Required).
* **Example:**
```bash
aim github create-project "AIM Roadmap"
```

---

## 10. Demo Workspace
Expand Down
Loading
Loading