diff --git a/CHANGELOG.md b/CHANGELOG.md
index f858fcf..b8afa79 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
`** — 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`
diff --git a/README.md b/README.md
index 33b0a21..75377c4 100644
--- a/README.md
+++ b/README.md
@@ -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
diff --git a/aim/__init__.py b/aim/__init__.py
index 8ceb83f..5764e81 100644
--- a/aim/__init__.py
+++ b/aim/__init__.py
@@ -1,2 +1,2 @@
# AIM: AI Memory & Specialist Agent Suite package
-__version__ = "1.4.0"
+__version__ = "1.5.0"
diff --git a/aim/aim_cli.py b/aim/aim_cli.py
index f434906..b58901a 100644
--- a/aim/aim_cli.py
+++ b/aim/aim_cli.py
@@ -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": []
}
@@ -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()
@@ -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", "")
@@ -429,6 +434,7 @@ def render_task_content(meta):
{labels_str}
{spec_str}
{plan_str}
+{gh_str}
## Description
{meta['description']}
@@ -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)
# ==========================================
@@ -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")
@@ -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":
diff --git a/aim/core.py b/aim/core.py
index 2aa0485..8d136bd 100644
--- a/aim/core.py
+++ b/aim/core.py
@@ -11,6 +11,7 @@
"""
import datetime
import getpass
+import json
import os
import re
import subprocess
@@ -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"")
+ 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`
# ==========================================
diff --git a/docs/cli-reference.md b/docs/cli-reference.md
index e260a16..61a4676 100644
--- a/docs/cli-reference.md
+++ b/docs/cli-reference.md
@@ -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)
@@ -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
diff --git a/tests/test_github.py b/tests/test_github.py
new file mode 100644
index 0000000..7b48ad1
--- /dev/null
+++ b/tests/test_github.py
@@ -0,0 +1,94 @@
+from aim import aim_cli, core
+
+
+# ---------- task model: githubIssue round-trip ----------
+
+def test_github_issue_roundtrip(workspace):
+ t = core.create_task("syncable")
+ full = core.get_task(t["id"])
+ full["githubIssue"] = 42
+ aim_cli.write_task_file(full)
+ reread = core.get_task(t["id"])
+ assert reread["githubIssue"] == 42
+ with open(f"{aim_cli.TASKS_DIR}/task-{t['id']}.md", encoding="utf-8") as f:
+ assert "**GitHub Issue:** #42" in f.read()
+
+
+# ---------- pure helpers ----------
+
+def test_parse_issue_number():
+ assert core.parse_issue_number("https://github.com/o/r/issues/57\n") == 57
+ assert core.parse_issue_number("nope") is None
+
+
+def test_task_to_issue_body():
+ task = {"id": 3, "title": "X", "description": "Do the thing",
+ "ac": [{"text": "works", "checked": True}, {"text": "tested", "checked": False}],
+ "dependsOn": [1, 2]}
+ body = core.task_to_issue_body(task)
+ assert "Do the thing" in body
+ assert "- [x] works" in body
+ assert "- [ ] tested" in body
+ assert "AIM task-1" in body
+ assert "" in body
+
+
+# ---------- push_task with a mocked gh seam ----------
+
+def test_push_task_creates_and_stores_issue(workspace, monkeypatch):
+ core.create_task("first") # id 1
+ calls = []
+
+ def fake_gh(args, input_text=None):
+ calls.append(args)
+ if args[:2] == ["issue", "create"]:
+ return "https://github.com/phuonghx/aim-cli/issues/101\n"
+ return ""
+
+ monkeypatch.setattr(core, "_gh", fake_gh)
+ result = core.push_task(1)
+ assert result["action"] == "created"
+ assert result["issue"] == 101
+ # stored back into the task file
+ assert core.get_task(1)["githubIssue"] == 101
+ # opened (status not done -> reopen attempted)
+ assert any(a[:2] == ["issue", "reopen"] for a in calls)
+
+
+def test_push_task_updates_when_linked_and_closes_done(workspace, monkeypatch):
+ core.create_task("done task", status="done") # id 1
+ full = core.get_task(1)
+ full["githubIssue"] = 55
+ aim_cli.write_task_file(full)
+ calls = []
+ monkeypatch.setattr(core, "_gh", lambda args, input_text=None: calls.append(args) or "")
+
+ result = core.push_task(1)
+ assert result["action"] == "updated"
+ assert result["issue"] == 55
+ assert any(a == ["issue", "edit", "55", "--title", core._issue_title(core.get_task(1)),
+ "--body", core.task_to_issue_body(core.get_task(1))] for a in calls)
+ assert any(a[:3] == ["issue", "close", "55"] for a in calls) # done -> closed
+
+
+def test_github_status(workspace, monkeypatch):
+ core.create_task("a")
+ b = core.create_task("b")
+ full = core.get_task(b["id"])
+ full["githubIssue"] = 7
+ aim_cli.write_task_file(full)
+ rows = {r["taskId"]: r for r in core.github_status()}
+ assert rows[1]["issue"] is None
+ assert rows[2]["issue"] == 7
+
+
+def test_create_project_parses_json(workspace, monkeypatch):
+ def fake_gh(args, input_text=None):
+ if args[:2] == ["repo", "view"]:
+ return "phuonghx/aim-cli\n"
+ if args[:2] == ["project", "create"]:
+ return '{"number": 9, "url": "https://github.com/users/phuonghx/projects/9"}'
+ return ""
+ monkeypatch.setattr(core, "_gh", fake_gh)
+ data = core.create_project("AIM Roadmap")
+ assert data["number"] == 9