1 Commits

Author SHA1 Message Date
Hongming Wang d84591e046 docs(readme): document all subcommands (was 3, actually 20+)
Previous README documented 3 subcommands and got the completion shape
wrong (`completion generate`). Code has 6 top-level commands plus init,
totalling 22 subcommands.

Add a full command reference covering every subcommand in cmd tree:

- workspace: list, create, inspect, delete, restart, audit, delegate
- agent: list, inspect, send, peers
- platform: audit, health
- config: list, get, set, init, view
- connect: bridge external workspace (existing Quick Start)
- init: scaffold molecule.yaml
- completion: bash | zsh | fish | powershell  (was: completion generate)

Each entry has a one-line purpose, key flags where applicable, and an
example. Also calls out `connect --mode push` (M4, not yet wired) and
the canvas-origin reply TODO in internal/connect/connect.go so users
know what isn't ready yet.

README-only change. No Go code edits.
2026-05-01 19:18:10 -07:00
31 changed files with 211 additions and 1334 deletions
-1
View File
@@ -1 +0,0 @@
ci-auth-test-1778443313
-1
View File
@@ -1 +0,0 @@
ci-auth-test2-1778443456
-823
View File
@@ -1,823 +0,0 @@
#!/usr/bin/env python3
# sop-checklist-gate — evaluate whether a PR has peer-acked each
# SOP-checklist item. Posts a commit-status that branch protection
# can require.
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
# Invoked by .gitea/workflows/sop-checklist-gate.yml on:
# - pull_request_target: [opened, edited, synchronize, reopened]
# - issue_comment: [created, edited, deleted]
#
# Flow:
# 1. Load .gitea/sop-checklist-config.yaml (from BASE ref — trusted).
# 2. GET /repos/{R}/pulls/{N} — author, head.sha, tier label
# 3. GET /repos/{R}/issues/{N}/comments — extract /sop-ack and /sop-revoke
# 4. For each checklist item:
# a. Is the section marker present in PR body? (author answered)
# b. Is there ≥1 unrevoked /sop-ack from a non-author whose
# team-membership matches required_teams?
# 5. POST /repos/{R}/statuses/{sha} — context
# `sop-checklist / all-items-acked (pull_request)`,
# state=success | failure | pending, description=`acked: N/M …`.
#
# Trust boundary (mirrors RFC#324 §A4):
# This script is loaded from the BASE branch. The workflow's
# actions/checkout step pins ref=base.sha. PR-HEAD code is never
# executed. We only HTTP-call the Gitea API.
#
# Token scope:
# - read:repository / read:organization to enumerate PR + comments
# + team membership (Gitea 1.22.6 quirk: team-membership endpoint
# returns 403 if token owner is not in the team; see review-check.sh
# for the same gotcha — we surface the same fail-closed message).
# - write:repository for `POST /repos/{R}/statuses/{sha}`. Unlike
# RFC#324's pattern (which uses the JOB's own pass/fail as the
# status), we POST the status explicitly because the gate posts
# a single multi-item status with a richer description than a
# bare success/failure context can carry.
#
# Slug normalization rules (canonical form: kebab-case):
# - Lowercase
# - Whitespace + underscores → single dash
# - Strip non [a-z0-9-] characters
# - Collapse adjacent dashes
# - Strip leading/trailing dashes
# - If the result is a digit string (e.g. "1"), look up via
# config.items[*].numeric_alias to get the kebab-case slug.
#
# Examples:
# "Comprehensive_Testing" → "comprehensive-testing"
# "comprehensive testing" → "comprehensive-testing"
# "1" → "comprehensive-testing"
# "Five-Axis-Review" → "five-axis-review"
#
# Revoke semantics:
# /sop-revoke <slug> [reason] — most-recent comment per (slug, user)
# wins. So if Alice posts /sop-ack X then later /sop-revoke X, her ack
# for X is invalidated. Bob's prior /sop-ack X is unaffected. If Alice
# posts /sop-revoke X then later /sop-ack X again, the ack is restored.
from __future__ import annotations
import argparse
import json
import os
import re
import sys
import urllib.error
import urllib.parse
import urllib.request
from typing import Any
# ---------------------------------------------------------------------------
# Slug normalization
# ---------------------------------------------------------------------------
_NORMALIZE_REPLACE_RE = re.compile(r"[\s_]+")
_NORMALIZE_STRIP_RE = re.compile(r"[^a-z0-9-]")
_NORMALIZE_DASH_RE = re.compile(r"-+")
def normalize_slug(raw: str, numeric_aliases: dict[int, str] | None = None) -> str:
"""Normalize a user-supplied slug to canonical kebab-case form.
See module header for the rules.
If the input is a pure digit string AND numeric_aliases is provided,
the alias mapping is consulted. Unknown digits return "" so the caller
can flag the comment as unparseable.
"""
if raw is None:
return ""
s = raw.strip().lower()
s = _NORMALIZE_REPLACE_RE.sub("-", s)
s = _NORMALIZE_STRIP_RE.sub("", s)
s = _NORMALIZE_DASH_RE.sub("-", s)
s = s.strip("-")
if s.isdigit() and numeric_aliases is not None:
return numeric_aliases.get(int(s), "")
return s
# ---------------------------------------------------------------------------
# Comment parsing — /sop-ack and /sop-revoke
# ---------------------------------------------------------------------------
# A directive must be on its own line. Permits leading whitespace.
# Optional trailing note after the slug for /sop-ack and required reason
# for /sop-revoke (RFC#351 open question 4 — reason is captured but not
# yet validated; future iteration may require a min-length).
_DIRECTIVE_RE = re.compile(
r"^[ \t]*/(sop-ack|sop-revoke)[ \t]+([A-Za-z0-9_\- ]+?)(?:[ \t]+(.*))?[ \t]*$",
re.MULTILINE,
)
def parse_directives(
comment_body: str,
numeric_aliases: dict[int, str],
) -> list[tuple[str, str, str]]:
"""Extract /sop-ack and /sop-revoke directives from a comment body.
Returns a list of (kind, canonical_slug, note) tuples where:
kind is "sop-ack" or "sop-revoke"
canonical_slug is the normalized form (or "" if unparseable)
note is the trailing free-text (may be "")
"""
out: list[tuple[str, str, str]] = []
if not comment_body:
return out
for m in _DIRECTIVE_RE.finditer(comment_body):
kind = m.group(1)
raw_slug = (m.group(2) or "").strip()
# If the raw match included trailing words, the regex non-greedy
# captured only the first token; strip again for safety.
# We split on whitespace to keep the FIRST word as the slug, and
# everything after as the note.
parts = raw_slug.split()
if not parts:
continue
first = parts[0]
# If the slug-capture greedily matched multiple words (e.g.
# "comprehensive testing"), preserve normalize behavior: join
# the WHOLE first-word-token only; trailing words get appended to
# the note. The regex limits group(2) to [A-Za-z0-9_\- ] so we
# may have multi-word forms here — normalize handles them.
if len(parts) > 1:
# User wrote "/sop-ack comprehensive testing extra-note"
# → treat "comprehensive testing" as the slug source if it
# normalizes to a known item; otherwise treat "comprehensive"
# as slug and "testing extra-note" as note. We defer the
# disambiguation to the caller via the returned canonical
# slug. For simplicity: try the WHOLE captured string first.
canonical = normalize_slug(raw_slug, numeric_aliases)
else:
canonical = normalize_slug(first, numeric_aliases)
note_from_group = (m.group(3) or "").strip()
# If we collapsed multi-word slug into kebab and there's a
# trailing-text group too, append it.
out.append((kind, canonical, note_from_group))
return out
# ---------------------------------------------------------------------------
# PR body section detection
# ---------------------------------------------------------------------------
def section_marker_present(body: str, marker: str) -> bool:
"""Return True if `marker` appears in `body` case-insensitively
on a non-empty line (i.e. the author actually filled it in).
We require the marker substring AND non-whitespace content on the
same line OR within the next line — this prevents trivially-empty
checklists like:
## SOP-Checklist
- [ ] **Comprehensive testing performed**:
- [ ] **Local-postgres E2E run**:
from auto-passing the section-present check. The peer-ack is still
required, but answering with empty content is captured as a soft
finding via the section-present test alone.
"""
if not body or not marker:
return False
body_lower = body.lower()
marker_lower = marker.lower()
idx = body_lower.find(marker_lower)
if idx < 0:
return False
# Walk to end of line.
line_end = body.find("\n", idx)
if line_end < 0:
line_end = len(body)
line = body[idx + len(marker):line_end]
# Strip the colon + checkbox tail patterns; require at least one
# non-whitespace, non-punctuation char.
stripped = re.sub(r"[\s\*:\-\[\]]+", "", line)
if stripped:
return True
# Fall through: check the NEXT line (multi-line answers).
next_line_end = body.find("\n", line_end + 1)
if next_line_end < 0:
next_line_end = len(body)
next_line = body[line_end + 1:next_line_end]
stripped_next = re.sub(r"[\s\*:\-\[\]]+", "", next_line)
return bool(stripped_next)
# ---------------------------------------------------------------------------
# Ack-state computation
# ---------------------------------------------------------------------------
def compute_ack_state(
comments: list[dict[str, Any]],
pr_author: str,
items_by_slug: dict[str, dict[str, Any]],
numeric_aliases: dict[int, str],
team_membership_probe: "callable[[str, list[str]], list[str]]",
) -> dict[str, dict[str, Any]]:
"""Compute per-item ack state.
Each comment is processed in chronological order. The most-recent
directive per (commenter, slug) wins.
Returns a dict keyed by canonical slug:
{
"comprehensive-testing": {
"ackers": ["bob"], # non-author, team-verified
"rejected_ackers": { # debugging info
"self_ack": ["alice"],
"unknown_slug": [],
"not_in_team": ["eve"],
}
},
...
}
"""
# Step 1: collapse directives per (commenter, slug) — most recent wins.
# comments are expected to come in chronological order from the
# API (Gitea returns oldest-first by default for issues/{N}/comments).
latest_directive: dict[tuple[str, str], str] = {} # (user, slug) → kind
unparseable_per_user: dict[str, int] = {}
for c in comments:
body = c.get("body", "") or ""
user = (c.get("user") or {}).get("login", "")
if not user:
continue
for kind, slug, _note in parse_directives(body, numeric_aliases):
if not slug:
unparseable_per_user[user] = unparseable_per_user.get(user, 0) + 1
continue
latest_directive[(user, slug)] = kind
# Step 2: build candidate ackers per slug.
# Filter out self-acks and unknown slugs.
ackers_per_slug: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_self: dict[str, list[str]] = {s: [] for s in items_by_slug}
rejected_unknown: dict[str, list[str]] = {s: [] for s in items_by_slug}
pending_team_check: dict[str, list[str]] = {s: [] for s in items_by_slug}
for (user, slug), kind in latest_directive.items():
if kind != "sop-ack":
continue # revokes leave the (user,slug) state as "no ack"
if slug not in items_by_slug:
# Slug normalized to something not in our config — store
# under a synthetic key for diagnostic surfacing. Don't add
# to any item.
continue
if user == pr_author:
rejected_self[slug].append(user)
continue
pending_team_check[slug].append(user)
# Step 3: team membership probe per slug (batched per slug to keep
# API call count down — same user may ack multiple items but the
# required_teams differ per item, so we MUST probe per (user, item)).
rejected_not_in_team: dict[str, list[str]] = {s: [] for s in items_by_slug}
for slug, candidates in pending_team_check.items():
if not candidates:
continue
required = items_by_slug[slug]["required_teams"]
approved = team_membership_probe(slug, candidates) # returns subset
rejected_not_in_team[slug] = [u for u in candidates if u not in approved]
ackers_per_slug[slug] = approved
# Stash required teams for description rendering.
items_by_slug[slug]["_required_resolved"] = required
return {
slug: {
"ackers": ackers_per_slug[slug],
"rejected": {
"self_ack": rejected_self[slug],
"not_in_team": rejected_not_in_team[slug],
},
}
for slug in items_by_slug
}
# ---------------------------------------------------------------------------
# Gitea API client
# ---------------------------------------------------------------------------
class GiteaClient:
def __init__(self, host: str, token: str):
self.base = f"https://{host}/api/v1"
self.token = token
# Cache team-name → team-id resolutions per org.
self._team_id_cache: dict[tuple[str, str], int | None] = {}
def _req(
self,
method: str,
path: str,
body: dict[str, Any] | None = None,
ok_codes: tuple[int, ...] = (200, 201, 204),
) -> tuple[int, Any]:
url = self.base + path
data = None
headers = {
"Authorization": f"token {self.token}",
"Accept": "application/json",
}
if body is not None:
data = json.dumps(body).encode("utf-8")
headers["Content-Type"] = "application/json"
req = urllib.request.Request(url, method=method, data=data, headers=headers)
try:
with urllib.request.urlopen(req, timeout=20) as r:
raw = r.read()
code = r.getcode()
except urllib.error.HTTPError as e:
code = e.code
raw = e.read()
try:
parsed = json.loads(raw.decode("utf-8")) if raw else None
except json.JSONDecodeError:
parsed = raw.decode("utf-8", errors="replace") if raw else None
return code, parsed
def get_pr(self, owner: str, repo: str, pr: int) -> dict[str, Any]:
code, data = self._req("GET", f"/repos/{owner}/{repo}/pulls/{pr}")
if code != 200:
raise RuntimeError(f"GET pulls/{pr} → HTTP {code}: {data!r}")
return data
def get_issue_comments(
self, owner: str, repo: str, issue: int
) -> list[dict[str, Any]]:
# Paginate. Gitea default page size 50.
out: list[dict[str, Any]] = []
page = 1
while True:
code, data = self._req(
"GET",
f"/repos/{owner}/{repo}/issues/{issue}/comments?limit=50&page={page}",
)
if code != 200:
raise RuntimeError(
f"GET issues/{issue}/comments page={page} → HTTP {code}: {data!r}"
)
if not data:
break
out.extend(data)
if len(data) < 50:
break
page += 1
return out
def resolve_team_id(self, org: str, team_name: str) -> int | None:
key = (org, team_name)
if key in self._team_id_cache:
return self._team_id_cache[key]
code, data = self._req("GET", f"/orgs/{org}/teams/search?q={urllib.parse.quote(team_name)}")
team_id = None
if code == 200 and isinstance(data, dict):
for t in data.get("data", []):
if t.get("name") == team_name:
team_id = t.get("id")
break
if team_id is None and code == 200 and isinstance(data, list):
for t in data:
if t.get("name") == team_name:
team_id = t.get("id")
break
self._team_id_cache[key] = team_id
return team_id
def is_team_member(self, team_id: int, login: str) -> bool | None:
"""Return True / False / None (unknown — 403 from API)."""
code, _ = self._req(
"GET", f"/teams/{team_id}/members/{urllib.parse.quote(login)}"
)
if code in (200, 204):
return True
if code == 404:
return False
# 403 means the token owner isn't in this team, so the API
# refuses to confirm membership. Fail-closed at the caller.
return None
def post_status(
self,
owner: str,
repo: str,
sha: str,
state: str,
context: str,
description: str,
target_url: str = "",
) -> None:
body = {
"state": state,
"context": context,
"description": description[:140], # Gitea truncates to 255 but be safe
"target_url": target_url or "",
}
code, data = self._req(
"POST",
f"/repos/{owner}/{repo}/statuses/{sha}",
body=body,
ok_codes=(201,),
)
if code not in (200, 201):
raise RuntimeError(
f"POST statuses/{sha} → HTTP {code}: {data!r}"
)
# ---------------------------------------------------------------------------
# Config loader (PyYAML-free — config file is intentionally tiny + flat)
# ---------------------------------------------------------------------------
def load_config(path: str) -> dict[str, Any]:
"""Load .gitea/sop-checklist-config.yaml.
Uses PyYAML if available, otherwise falls back to a built-in
minimal parser sufficient for our flat config shape. Bundling
PyYAML on the runner is one apt install away but we avoid the
dep by keeping the config shape constrained.
"""
try:
import yaml # type: ignore[import-not-found]
with open(path) as f:
return yaml.safe_load(f)
except ImportError:
return _load_config_minimal(path)
def _load_config_minimal(path: str) -> dict[str, Any]:
"""Minimal YAML subset parser for our config shape.
Supports: top-level scalar:value, top-level map-of-map (e.g.
tier_failure_mode), top-level list of maps (items:), and within an
item map: scalars + lists of scalars. Does NOT support nested lists,
YAML anchors, multi-doc, or flow style.
"""
with open(path) as f:
lines = f.readlines()
return _parse_minimal_yaml(lines)
def _parse_minimal_yaml(lines: list[str]) -> dict[str, Any]: # noqa: C901
"""Hand-rolled subset parser. See _load_config_minimal docstring."""
# Strip comments + blank lines but preserve indentation.
cleaned: list[tuple[int, str]] = []
for raw in lines:
# Don't strip a "#" that is inside a quoted value.
body = raw.rstrip("\n")
# Remove trailing comment.
idx = body.find("#")
if idx >= 0 and (idx == 0 or body[idx - 1] in " \t"):
body = body[:idx].rstrip()
if not body.strip():
continue
indent = len(body) - len(body.lstrip(" "))
cleaned.append((indent, body.strip()))
root: dict[str, Any] = {}
i = 0
n = len(cleaned)
def parse_scalar(s: str) -> Any:
s = s.strip()
if s.startswith('"') and s.endswith('"'):
return s[1:-1]
if s.startswith("'") and s.endswith("'"):
return s[1:-1]
if s.lower() in ("true", "yes"):
return True
if s.lower() in ("false", "no"):
return False
try:
return int(s)
except ValueError:
pass
return s
def parse_inline_list(s: str) -> list[Any]:
s = s.strip()
if not (s.startswith("[") and s.endswith("]")):
return [parse_scalar(s)]
inner = s[1:-1]
if not inner.strip():
return []
return [parse_scalar(x.strip()) for x in inner.split(",")]
while i < n:
indent, line = cleaned[i]
if indent != 0:
i += 1
continue
if ":" not in line:
i += 1
continue
key, _, rest = line.partition(":")
key = key.strip()
rest = rest.strip()
if rest == "":
# Block — could be map or list.
i += 1
# Look ahead for first child.
if i < n and cleaned[i][1].startswith("- "):
# List of items.
items: list[Any] = []
while i < n and cleaned[i][0] > indent and cleaned[i][1].startswith("- "):
item_indent = cleaned[i][0]
first_kv = cleaned[i][1][2:].strip() # strip "- "
item: dict[str, Any] = {}
if ":" in first_kv:
k, _, v = first_kv.partition(":")
k = k.strip()
v = v.strip()
if v == "":
item[k] = ""
elif v.startswith(">-") or v.startswith(">"):
# Folded scalar continues on subsequent indented lines
collected: list[str] = []
i += 1
while i < n and cleaned[i][0] > item_indent:
collected.append(cleaned[i][1])
i += 1
item[k] = " ".join(collected)
items.append(item)
continue
elif v.startswith("["):
item[k] = parse_inline_list(v)
else:
item[k] = parse_scalar(v)
i += 1
# Subsequent k:v lines at deeper indent belong to this item.
while i < n and cleaned[i][0] > item_indent and not cleaned[i][1].startswith("- "):
sub_indent, sub_line = cleaned[i]
if ":" in sub_line:
k, _, v = sub_line.partition(":")
k = k.strip()
v = v.strip()
if v == "":
item[k] = ""
i += 1
elif v.startswith(">-") or v.startswith(">"):
collected = []
i += 1
while i < n and cleaned[i][0] > sub_indent:
collected.append(cleaned[i][1])
i += 1
item[k] = " ".join(collected)
elif v.startswith("["):
item[k] = parse_inline_list(v)
i += 1
else:
item[k] = parse_scalar(v)
i += 1
else:
i += 1
items.append(item)
root[key] = items
else:
# Sub-map.
submap: dict[str, Any] = {}
while i < n and cleaned[i][0] > indent:
sub_indent, sub_line = cleaned[i]
if ":" in sub_line:
k, _, v = sub_line.partition(":")
k = k.strip().strip('"').strip("'")
v = v.strip()
if v.startswith("[") and v.endswith("]"):
submap[k] = parse_inline_list(v)
else:
submap[k] = parse_scalar(v)
i += 1
root[key] = submap
else:
# Inline scalar or list.
if rest.startswith("[") and rest.endswith("]"):
root[key] = parse_inline_list(rest)
else:
root[key] = parse_scalar(rest)
i += 1
return root
# ---------------------------------------------------------------------------
# Main entry point
# ---------------------------------------------------------------------------
def render_status(
items: list[dict[str, Any]],
ack_state: dict[str, dict[str, Any]],
body_state: dict[str, bool],
) -> tuple[str, str]:
"""Return (state, description) for the commit-status post.
state is "success" if every item has at least one valid ack
(body section presence is informational only — peer-ack is the
real gate). "pending" is reserved for the soft-fail path
(tier:low) and is set by the caller.
"""
n = len(items)
fully_acked = [
it["slug"] for it in items if ack_state[it["slug"]]["ackers"]
]
missing = [
it["slug"] for it in items if not ack_state[it["slug"]]["ackers"]
]
missing_body = [it["slug"] for it in items if not body_state.get(it["slug"], False)]
desc_parts = [f"acked: {len(fully_acked)}/{n}"]
if missing:
# Show up to 3 missing slugs to stay inside the 140-char budget.
shown = ", ".join(missing[:3])
if len(missing) > 3:
shown += f", +{len(missing) - 3}"
desc_parts.append(f"missing: {shown}")
if missing_body:
desc_parts.append(f"body-unfilled: {len(missing_body)}")
state = "success" if not missing else "failure"
return state, "".join(desc_parts)
def get_tier_mode(pr: dict[str, Any], cfg: dict[str, Any]) -> str:
"""Read tier label, return 'hard' or 'soft' per cfg.tier_failure_mode."""
labels = pr.get("labels") or []
tier_labels = [l.get("name", "") for l in labels if (l.get("name", "") or "").startswith("tier:")]
mode_map = cfg.get("tier_failure_mode") or {}
default_mode = cfg.get("default_mode", "hard")
for tl in tier_labels:
if tl in mode_map:
return mode_map[tl]
return default_mode
def main(argv: list[str] | None = None) -> int:
p = argparse.ArgumentParser()
p.add_argument("--owner", required=True)
p.add_argument("--repo", required=True)
p.add_argument("--pr", type=int, required=True)
p.add_argument("--config", default=".gitea/sop-checklist-config.yaml")
p.add_argument("--gitea-host", default="git.moleculesai.app")
p.add_argument(
"--dry-run",
action="store_true",
help="Compute state but do not POST the status.",
)
p.add_argument(
"--status-context",
default="sop-checklist / all-items-acked (pull_request)",
)
p.add_argument(
"--exit-on-state",
action="store_true",
help=(
"If set, exit non-zero when state=failure. Default OFF so the "
"job-level conclusion is independent of ack-state — the only "
"thing BP sees is the POSTed status. Useful for local debugging."
),
)
args = p.parse_args(argv)
token = os.environ.get("GITEA_TOKEN", "")
if not token and not args.dry_run:
print("::error::GITEA_TOKEN env required", file=sys.stderr)
return 2
cfg = load_config(args.config)
items: list[dict[str, Any]] = cfg["items"]
items_by_slug = {it["slug"]: it for it in items}
numeric_aliases = {
int(it["numeric_alias"]): it["slug"] for it in items if it.get("numeric_alias")
}
client = GiteaClient(args.gitea_host, token) if token else None
if not client:
print("::error::No client (dry-run without token has nothing to do)", file=sys.stderr)
return 2
pr = client.get_pr(args.owner, args.repo, args.pr)
if pr.get("state") != "open":
print(f"::notice::PR #{args.pr} is {pr.get('state')} — gate is a no-op")
return 0
author = (pr.get("user") or {}).get("login", "")
head_sha = (pr.get("head") or {}).get("sha", "")
body = pr.get("body", "") or ""
if not author or not head_sha:
print("::error::PR payload missing user.login or head.sha", file=sys.stderr)
return 1
comments = client.get_issue_comments(args.owner, args.repo, args.pr)
# Build team-membership probe closure that caches results per
# (user, team-id) so a user acking multiple items only triggers
# one membership lookup per team.
team_member_cache: dict[tuple[str, int], bool | None] = {}
def probe(slug: str, users: list[str]) -> list[str]:
item = items_by_slug[slug]
team_names: list[str] = item["required_teams"]
# Resolve names → ids. NOTE: orgs/{org}/teams/search may not be
# available — fall back to the list endpoint.
team_ids: list[int] = []
for tn in team_names:
tid = client.resolve_team_id(args.owner, tn)
if tid is None:
# Try the list endpoint as a fallback.
code, data = client._req( # noqa: SLF001
"GET", f"/orgs/{args.owner}/teams"
)
if code == 200 and isinstance(data, list):
for t in data:
if t.get("name") == tn:
tid = t.get("id")
client._team_id_cache[(args.owner, tn)] = tid # noqa: SLF001
break
if tid is not None:
team_ids.append(tid)
else:
print(
f"::warning::could not resolve team-id for '{tn}' "
f"in org '{args.owner}' — item '{slug}' will fail closed",
file=sys.stderr,
)
approved: list[str] = []
for u in users:
for tid in team_ids:
cache_key = (u, tid)
if cache_key not in team_member_cache:
team_member_cache[cache_key] = client.is_team_member(tid, u)
result = team_member_cache[cache_key]
if result is True:
approved.append(u)
break
if result is None:
print(
f"::warning::team-probe for {u} in team-id {tid} returned 403 "
"(token owner not in that team — fail-closed per RFC#324)",
file=sys.stderr,
)
# Treat as not-in-team for this user/team pair; loop
# may still find membership in another team.
return approved
ack_state = compute_ack_state(comments, author, items_by_slug, numeric_aliases, probe)
body_state = {it["slug"]: section_marker_present(body, it["pr_section_marker"]) for it in items}
state, description = render_status(items, ack_state, body_state)
mode = get_tier_mode(pr, cfg)
if state == "failure" and mode == "soft":
state = "pending"
description = f"[soft-fail tier:low] {description}"
# Diagnostics to job log.
print(f"::notice::PR #{args.pr} author={author} head={head_sha[:7]} mode={mode}")
for it in items:
slug = it["slug"]
ackers = ack_state[slug]["ackers"]
if ackers:
print(f"::notice:: [PASS] {slug} — acked by {','.join(ackers)}")
else:
r = ack_state[slug]["rejected"]
extras: list[str] = []
if r["self_ack"]:
extras.append(f"self-acks-rejected:{','.join(r['self_ack'])}")
if r["not_in_team"]:
extras.append(f"not-in-team:{','.join(r['not_in_team'])}")
extra = " (" + "; ".join(extras) + ")" if extras else ""
print(f"::notice:: [WAIT] {slug} — no valid peer-ack yet{extra}")
print(f"::notice::posting status: state={state} desc={description!r}")
if args.dry_run:
print("::notice::--dry-run: not posting status")
if args.exit_on_state:
return 0 if state in ("success", "pending") else 1
return 0
target_url = f"https://{args.gitea_host}/{args.owner}/{args.repo}/pulls/{args.pr}"
client.post_status(
args.owner, args.repo, head_sha,
state=state, context=args.status_context,
description=description, target_url=target_url,
)
print(f"::notice::status posted: {args.status_context}{state}")
# By default exit 0 — the POSTed status IS the gate, NOT the job
# conclusion. If the job exits 1 BP will see TWO failure signals
# (one from the job's auto-status, one from our POST), making the
# description less actionable. --exit-on-state restores the old
# behavior for local debugging.
if args.exit_on_state:
return 0 if state in ("success", "pending") else 1
return 0
if __name__ == "__main__":
sys.exit(main())
-109
View File
@@ -1,109 +0,0 @@
# SOP-Checklist gate — per-item required reviewer teams.
#
# RFC#351 v1 starter set. Each item lists:
# slug — canonical kebab-case form used in /sop-ack <slug>
# pr_section_marker — substring matched in the PR body to detect that
# the author filled in this item (case-insensitive)
# required_teams — list of Gitea team names; an ack from ANY one of
# these teams (logical OR) satisfies the item.
# Membership is probed at gate-time via
# GET /api/v1/teams/{id}/members/{login}.
# Team-id resolution happens at script start via
# GET /api/v1/orgs/{org}/teams (cheap, one call).
# numeric_alias — 1..7; lets reviewers type `/sop-ack 3` as a
# shortcut for `/sop-ack staging-smoke`.
#
# WHY THESE TEAM MAPPINGS:
# The RFC table referenced persona-role names like `core-qa`,
# `core-be`, `core-devops` — these are individual Gitea user logins,
# not teams. The Gitea team-membership API is /teams/{id}/members/{u},
# so we need actual teams. Orchestrator preflight 2026-05-12 verified
# only these teams exist on molecule-ai: ceo(5), engineers(2),
# managers(6), qa(20), security(21), Owners(1), and bot teams. We
# map the RFC roles to the closest existing team and surface the
# mapping explicitly so it's reviewable.
#
# HOW TO EDIT:
# - Tightening: replace `engineers` with a smaller team after creating
# it (e.g. a new `senior-engineers` team if needed).
# - Loosening: add another team to required_teams (OR semantics).
# - Add an item: append to items list and document the slug below.
#
# AUTHOR SELF-ACK IS FORBIDDEN regardless of which team contains them
# — the gate script enforces commenter != PR author before checking
# team membership.
version: 1
# Tier-aware failure mode (RFC#351 open question 2):
# For tier:high — hard-fail (status `failure`, blocks merge via BP).
# For tier:medium — hard-fail (same as high; medium is non-trivial).
# For tier:low — soft-fail (status `pending` with `acked: N/M` in the
# description). BP can choose to require the context
# or not for low-tier PRs.
# If no tier label is present, default to medium (hard-fail) — every PR
# should have a tier label per sop-tier-check, and absence indicates
# a missing-tier defect we should surface, not silently lower the bar.
tier_failure_mode:
"tier:high": hard
"tier:medium": hard
"tier:low": soft
default_mode: hard # used when no tier:* label is present
items:
- slug: comprehensive-testing
numeric_alias: 1
pr_section_marker: "Comprehensive testing performed"
required_teams: [qa, engineers]
description: >-
What was tested, how, edge cases covered. Ack from any qa-team
member (or engineers fallback while qa is small).
- slug: local-postgres-e2e
numeric_alias: 2
pr_section_marker: "Local-postgres E2E run"
required_teams: [engineers]
description: >-
Link to local CI artifact, or "N/A: pure-frontend change". Ack
from any engineer who can verify the local DB test actually ran.
- slug: staging-smoke
numeric_alias: 3
pr_section_marker: "Staging-smoke verified or pending"
required_teams: [engineers]
description: >-
Link to canary run, or "scheduled post-merge". Ack from any
engineer (core-devops/infra-sre are members of engineers team).
- slug: root-cause
numeric_alias: 4
pr_section_marker: "Root-cause not symptom"
required_teams: [managers, ceo]
description: >-
One-sentence root-cause statement. Ack from managers tier
(team-leads) or ceo. Senior judgment required to attest
root-cause-versus-symptom.
- slug: five-axis-review
numeric_alias: 5
pr_section_marker: "Five-Axis review walked"
required_teams: [engineers]
description: >-
Correctness / readability / architecture / security / performance.
Ack from any non-author engineer.
- slug: no-backwards-compat
numeric_alias: 6
pr_section_marker: "No backwards-compat shim / dead code added"
required_teams: [managers, ceo]
description: >-
Yes/no + justification if no. Senior ack required because
backward-compat shims are how dead-code accretes.
- slug: memory-consulted
numeric_alias: 7
pr_section_marker: "Memory/saved-feedback consulted"
required_teams: [engineers]
description: >-
List of feedback memories applicable to this change. Ack from
any engineer who has the same memory access.
-47
View File
@@ -1,47 +0,0 @@
name: CI / Test
# Runs on every PR touching Go code, and on every push to main.
# Mirrors the test job from release.yml so PRs are validated independently
# of the release workflow. Uses Go 1.25 to match release.yml.
on:
push:
branches: [main]
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.gitea/workflows/*.yml'
- '.gitea/workflows/*.yaml'
- '.gitea/scripts/**'
- '.gitea/sop-checklist-config.yml'
- '.gitea/sop-checklist-config.yaml'
pull_request:
paths:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.gitea/workflows/*.yml'
- '.gitea/workflows/*.yaml'
- '.gitea/scripts/**'
- '.gitea/sop-checklist-config.yml'
- '.gitea/sop-checklist-config.yaml'
permissions:
contents: read
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: '1.25'
cache: true
- name: Tidy
run: go mod tidy && git diff --exit-code go.sum
- name: Vet
run: go vet ./...
- name: Test
run: go test -race -count=1 ./...
-121
View File
@@ -1,121 +0,0 @@
# sop-checklist-gate — peer-ack merge gate for SOP-checklist items.
#
# RFC#351 Step 2 of 6 (implementation MVP).
#
# === DESIGN ===
#
# Goal: each PR must answer 7 SOP-checklist questions in its body,
# and each item must have at least one /sop-ack <slug> comment from
# a non-author peer in the required team. BP requires the
# `sop-checklist / all-items-acked (pull_request)` status to merge.
#
# Triggers:
# - `pull_request_target`: opened, edited, synchronize, reopened
# → fires when PR opens, body is edited (refire — RFC#351 §4),
# or new code is pushed (head.sha changes → stale status would
# be auto-discarded by BP via dismiss_stale_reviews, but the
# status itself is per-SHA so we re-post on the new head).
# - `issue_comment`: created, edited, deleted
# → fires on any new comment so /sop-ack / /sop-revoke take
# effect immediately (Gitea 1.22.6 doesn't refire on
# pull_request_review per feedback_pull_request_review_no_refire,
# so issue_comment is the canonical refire channel).
#
# Trust boundary (mirrors RFC#324 §A4 + sop-tier-check security note):
# `pull_request_target` (not `pull_request`) — workflow def is loaded
# from BASE branch, so a PR cannot rewrite this workflow to exfiltrate
# the token. The `actions/checkout` step pins `ref: base.sha` so the
# script ALSO comes from BASE. PR-HEAD code is never executed in the
# runner.
#
# Token scope:
# - read:repository, read:organization for PR + comments + team probes
# - write:repository for POST /statuses/{sha}
# - The token owner MUST be a member of every team referenced by the
# config's required_teams (else /teams/{id}/members/{login} returns
# 403 — see review-check.sh same-gotcha doc). For the MVP we use
# the dev-lead token (a member of engineers, managers, qa, security)
# via `SOP_CHECKLIST_GATE_TOKEN`, the org-level `SOP_TIER_CHECK_TOKEN`, or a repo-scoped fallback token. Provisioning of that
# secret is a follow-up authorization step (separate from this PR).
#
# Failure mode: tier-aware (RFC#351 open question 2):
# - tier:high → state=failure (hard-fail; BP blocks merge)
# - tier:medium → state=failure (hard-fail; same)
# - tier:low → state=pending (soft-fail; BP can choose to require
# this context or skip for low-tier PRs)
# - missing/no-tier → state=failure (default-mode: hard — never lower
# the bar per feedback_fix_root_not_symptom)
#
# Slash-command contract (RFC#351 v1 + §A1.1-style notes from RFC#324):
#
# /sop-ack <slug-or-numeric-alias> [optional note]
# — register a peer-ack for one checklist item.
# — slug accepts kebab-case, snake_case, or natural-spaces
# (all normalize to canonical kebab-case).
# — numeric 1..7 maps via config.items[*].numeric_alias.
# — most-recent (user, slug) directive wins.
#
# /sop-revoke <slug-or-numeric-alias> [reason]
# — invalidate the commenter's own prior /sop-ack for this slug.
# — does NOT affect other peers' acks on the same slug.
# — most-recent (user, slug) directive wins, so a later /sop-ack
# re-restores the ack.
#
# The eval is read-only + idempotent (read PR + comments + team
# membership, compute, post status). Re-running on any event is safe —
# the new status overwrites the previous one for the same context.
name: sop-checklist-gate
on:
pull_request_target:
types: [opened, edited, synchronize, reopened]
issue_comment:
types: [created, edited, deleted]
permissions:
contents: read
pull-requests: read
# NOTE: `statuses: write` is the GitHub-Actions name for POST /statuses.
# Gitea 1.22.6 may not gate on this permission key (it just checks the
# token), but listing it explicitly documents intent for the next
# platform-version upgrade.
statuses: write
jobs:
gate:
# Run on pull_request_target events always. On issue_comment events,
# only when the comment is on a PR (issue_comment fires for issues
# too) and the body contains one of the slash-commands.
if: |
github.event_name == 'pull_request_target' ||
(github.event_name == 'issue_comment' &&
github.event.issue.pull_request != null &&
(contains(github.event.comment.body, '/sop-ack') ||
contains(github.event.comment.body, '/sop-revoke')))
runs-on: ubuntu-latest
steps:
- name: Check out BASE ref (trust boundary — never PR-head)
uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
with:
# For pull_request_target, the default branch is the trust
# anchor. For issue_comment the PR base may differ from the
# default branch (PR targeting `staging`), so we use the
# default-branch ref explicitly — same approach as
# qa-review.yml so the script source is always trusted.
ref: ${{ github.event.repository.default_branch }}
- name: Run sop-checklist-gate
env:
GITEA_TOKEN: ${{ secrets.SOP_CHECKLIST_GATE_TOKEN || secrets.SOP_TIER_CHECK_TOKEN || secrets.RFC_324_TEAM_READ_TOKEN || secrets.GITHUB_TOKEN }}
PR_NUMBER: ${{ github.event.pull_request.number || github.event.issue.number }}
OWNER: ${{ github.repository_owner }}
REPO_NAME: ${{ github.event.repository.name }}
run: |
set -euo pipefail
python3 .gitea/scripts/sop-checklist-gate.py \
--owner "$OWNER" \
--repo "$REPO_NAME" \
--pr "$PR_NUMBER" \
--config .gitea/sop-checklist-config.yaml \
--gitea-host git.moleculesai.app
@@ -19,11 +19,7 @@ on:
- '**.go'
- 'go.mod'
- 'go.sum'
- '.gitea/workflows/*.yml'
- '.gitea/workflows/*.yaml'
- '.gitea/scripts/**'
- '.gitea/sop-checklist-config.yml'
- '.gitea/sop-checklist-config.yaml'
- '.github/workflows/release.yml'
- '.goreleaser.yaml'
permissions:
-1
View File
@@ -1 +0,0 @@
verify-fix-1778444420
+7 -5
View File
@@ -4,7 +4,7 @@ Go CLI for the Molecule AI agent platform. Wraps the platform's workspace runtim
**Users:** Platform operators and developers integrating with the Molecule AI platform.
**Repo state:** Core commands implemented. 32 integration tests in `cmd/molecule/molecule_test.go`.
**Repo state (2026-04-16):** Thin/stub. Go module is initialized; core CLI commands are not yet implemented. CI (Go binary release via GoReleaser + GitHub Actions) is wired up.
---
@@ -23,16 +23,18 @@ This CLI is the primary user-facing tool for interacting with the Molecule AI pl
# Build the binary to ./bin/molecule (or $GOBIN/molecule)
go build -o bin/molecule ./cmd/molecule
# Run tests — uses httptest.Server fixtures, no live platform required
# Run tests (none yet; add as commands are implemented)
go test ./...
# Run the CLI locally (requires platform env vars — see Section 5)
./bin/molecule --help
```
There is no `main.go` or `cmd/molecule/main.go` yet. Creating it is the first implementation task. The module path will be auto-detected from `go.mod`.
## 3. Go Module Conventions
**Module path:** `go.moleculesai.app/cli` (from `go.mod`)
**Module path:** `github.com/Molecule-AI/molecule-cli` (from `go.mod`)
**Dependency management:**
- Use `go mod tidy` after adding or removing dependencies.
@@ -129,8 +131,8 @@ See `known-issues.md` at the repo root for the full tracked list.
- [x] Control plane API client (initialized with `MOLECULE_API_URL`)
- [ ] Workspace runtime client (for dev/proxy mode)
- [ ] Configuration file (e.g., `~/.config/molecule/cli.yaml`) — workspace template per platform rules
- [x] Unit tests for core command logic (32 integration tests)
- [x] `molecule init` (bootstrap local workspace config)
- [ ] Unit tests for core command logic
- [ ] `molecule init` (bootstrap local workspace config)
**Platform constraint reminders (from `constraints-and-rules.md`):**
- Postgres is the source of truth. CLI commands that mutate state ultimately write to Postgres via the control plane.
+154 -21
View File
@@ -8,23 +8,12 @@ command, or a mock for CI).
## Install
```bash
go install go.moleculesai.app/cli/cmd/molecule@latest
go install github.com/Molecule-AI/molecule-cli/cmd/molecule@latest
```
The vanity import path `go.moleculesai.app/cli` resolves via the
Molecules AI go-get responder (issue [internal#71][i71]) to our
canonical SCM at git.moleculesai.app. It is independent of any specific
SCM host — when we move SCMs again, no install command changes.
Alternatively, build from source:
```bash
git clone https://git.moleculesai.app/molecule-ai/molecule-cli.git
cd molecule-cli
go build -o molecule ./cmd/molecule
```
[i71]: https://git.moleculesai.app/molecule-ai/internal/issues/71
Or download a binary from [Releases](https://github.com/Molecule-AI/molecule-cli/releases).
Releases ship Linux/macOS/Windows × amd64/arm64 archives plus a sha256
checksums file (see `.goreleaser.yaml`).
## Quick start — connect an external workspace
@@ -70,7 +59,7 @@ molecule connect ws_abc \
### Other useful flags
```
--mode poll|push delivery mode (default: poll)
--mode poll|push delivery mode (default: poll; push is M4, not yet implemented)
--interval-ms 2000 poll cadence
--since-secs 60 initial activity cursor lookback
--token TOKEN override MOLECULE_WORKSPACE_TOKEN
@@ -81,15 +70,159 @@ State (the activity cursor) is persisted to
`~/.config/molecule/state/<workspace-id>.json` (mode 0600) so a restart
resumes from the last delivered message.
## Other subcommands
> Note: poll-mode dispatch into backends works end-to-end. Posting the
> backend's reply back to the canvas-origin sender is still wired as a
> TODO (see `internal/connect/connect.go` — M1.3); platform-API replies
> for non-canvas A2A flow correctly.
## Command reference
The full top-level tree:
```
molecule agent list / inspect agent sessions
molecule config view / set CLI defaults
molecule completion generate shell completions
molecule
├── workspace list / create / inspect / delete / restart / audit / delegate
├── agent list / inspect / send / peers
├── platform audit / health
├── config list / get / set / init / view
├── connect bridge an external workspace to a local backend
├── init scaffold a molecule.yaml in the current directory
└── completion emit shell completion script (bash | zsh | fish | powershell)
```
The full M1 design is in [RFC #10](https://git.moleculesai.app/molecule-ai/molecule-cli/issues/10) (originally filed on the suspended GitHub org; the issue may be re-filed on Gitea — check the issue tracker for the live discussion).
Global flags (apply to every subcommand): `--api-url`, `--config`,
`-o/--output table|json|yaml`, `-v/--verbose`.
### `molecule workspace`
Manage Molecule AI workspaces.
- **`workspace list`** — list all workspaces in the org.
```bash
molecule workspace list
molecule workspace list -o json
```
- **`workspace create --name <name> [flags]`** — create a workspace.
Flags: `--role`, `--runtime`, `--template`, `--parent-id`,
`--workspace-dir`, `--tier`.
```bash
molecule workspace create --name pm-bot --role pm --runtime claude-code
```
- **`workspace inspect <workspace-id>`** — show full details for a workspace.
```bash
molecule workspace inspect ws_01HF2K...
```
- **`workspace delete <workspace-id>`** — delete a workspace (irreversible).
```bash
molecule workspace delete ws_01HF2K...
```
- **`workspace restart <workspace-id>`** — trigger a restart.
```bash
molecule workspace restart ws_01HF2K...
```
- **`workspace audit`** — workspaces + agents report grouped by status.
```bash
molecule workspace audit -o yaml
```
- **`workspace delegate <workspace-id> <target-workspace-id> <task>`** —
enqueue a non-blocking delegation from one workspace to another.
```bash
molecule workspace delegate ws_pm ws_research "summarize last week"
```
### `molecule agent`
Inspect and interact with agents.
- **`agent list [workspace-id]`** — list all agents, optionally scoped
to one workspace.
```bash
molecule agent list
molecule agent list ws_01HF2K...
```
- **`agent inspect <agent-id>`** — show full details for an agent.
```bash
molecule agent inspect agent_abc
```
- **`agent send <agent-id> <message>`** — send a one-shot A2A message
to an agent and print the reply.
```bash
molecule agent send agent_abc "what's the deploy status?"
```
- **`agent peers <workspace-id>`** — list peer workspaces reachable
from the given workspace.
```bash
molecule agent peers ws_01HF2K...
```
### `molecule platform`
Platform-level operations.
- **`platform audit`** — full audit: workspaces, agents, delegation
counts per workspace.
```bash
molecule platform audit -o json
```
- **`platform health`** — check `/health` and version (falls back to
raw probe on older platforms).
```bash
molecule platform health
```
### `molecule config`
View and manage CLI configuration. Values resolve in order: env vars >
config file > defaults.
- **`config list`** — list all known config keys + effective values + source.
- **`config get <key>`** — print a single config value.
- **`config set <key> <value>`** — write a key to `~/.config/molecule.yaml`.
- **`config init`** — scaffold a default `molecule.yaml` in the current dir.
- **`config view`** — print the active config file with annotations.
```bash
molecule config set api_url https://your-tenant.staging.moleculesai.app
molecule config get api_url
molecule config list
```
### `molecule connect`
See [Quick start](#quick-start--connect-an-external-workspace) above.
Push mode (`--mode push`) is reserved for M4 and currently exits with a
clear error — use the default `--mode poll`.
### `molecule init`
Bootstrap a workspace by scaffolding `molecule.yaml` in the current
directory. Use `--force` to replace an existing file.
```bash
molecule init
molecule init --force
```
### `molecule completion`
Emit a shell completion script. The shell name is a positional arg —
one of `bash`, `zsh`, `fish`, `powershell`.
```bash
# bash
source <(molecule completion bash)
# zsh
source <(molecule completion zsh)
# fish
molecule completion fish | source
# PowerShell
molecule completion powershell | Out-String | Invoke-Expression
```
The full M1 design is in [RFC #10](https://github.com/Molecule-AI/molecule-cli/issues/10).
## License
+1 -1
View File
@@ -6,7 +6,7 @@ package main
import (
"os"
"go.moleculesai.app/cli/internal/cmd"
"github.com/Molecule-AI/molecule-cli/internal/cmd"
)
func main() {
+1 -1
View File
@@ -1,4 +1,4 @@
module go.moleculesai.app/cli
module github.com/Molecule-AI/molecule-cli
go 1.25.0
+1 -1
View File
@@ -5,7 +5,7 @@
// that registers itself via `Register()` from an `init()` block.
// Runtime selection is done via the --backend flag.
//
// See RFC: https://git.moleculesai.app/molecule-ai/molecule-cli/issues/10
// See RFC: https://github.com/Molecule-AI/molecule-cli/issues/10
package backends
import (
+2 -2
View File
@@ -5,8 +5,8 @@ import (
"strings"
"testing"
"go.moleculesai.app/cli/internal/backends"
_ "go.moleculesai.app/cli/internal/backends/mock" // register
"github.com/Molecule-AI/molecule-cli/internal/backends"
_ "github.com/Molecule-AI/molecule-cli/internal/backends/mock" // register
)
func TestRegister_DuplicatePanics(t *testing.T) {
+2 -2
View File
@@ -29,8 +29,8 @@ package claudecode
import (
"strings"
"go.moleculesai.app/cli/internal/backends"
exec "go.moleculesai.app/cli/internal/backends/exec"
"github.com/Molecule-AI/molecule-cli/internal/backends"
exec "github.com/Molecule-AI/molecule-cli/internal/backends/exec"
)
func init() {
@@ -6,8 +6,8 @@ import (
"strings"
"testing"
"go.moleculesai.app/cli/internal/backends"
_ "go.moleculesai.app/cli/internal/backends/claudecode" // register
"github.com/Molecule-AI/molecule-cli/internal/backends"
_ "github.com/Molecule-AI/molecule-cli/internal/backends/claudecode" // register
)
func requireUnix(t *testing.T) {
+1 -1
View File
@@ -41,7 +41,7 @@ import (
"strings"
"time"
"go.moleculesai.app/cli/internal/backends"
"github.com/Molecule-AI/molecule-cli/internal/backends"
)
func init() {
+2 -2
View File
@@ -7,8 +7,8 @@ import (
"strings"
"testing"
"go.moleculesai.app/cli/internal/backends"
_ "go.moleculesai.app/cli/internal/backends/exec" // register
"github.com/Molecule-AI/molecule-cli/internal/backends"
_ "github.com/Molecule-AI/molecule-cli/internal/backends/exec" // register
)
// requireUnix skips Windows tests that depend on /bin/sh shell semantics.
+1 -1
View File
@@ -14,7 +14,7 @@ import (
"context"
"strings"
"go.moleculesai.app/cli/internal/backends"
"github.com/Molecule-AI/molecule-cli/internal/backends"
)
func init() {
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"os"
"text/tabwriter"
"go.moleculesai.app/cli/internal/client"
"github.com/Molecule-AI/molecule-cli/internal/client"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -121,7 +121,7 @@ var configInitCmd = &cobra.Command{
}
func runConfigInit(cmd *cobra.Command, _ []string) error {
const defaultConfig = `# molecule CLI config — https://git.moleculesai.app/molecule-ai/molecule-cli
const defaultConfig = `# molecule CLI config — https://github.com/Molecule-AI/molecule-cli
#
# All values can be overridden by environment variables:
# MOLECULE_API_URL, MOLECULE_RUNTIME_URL, MOL_OUTPUT, MOL_VERBOSE, etc.
+7 -7
View File
@@ -9,11 +9,11 @@ import (
"syscall"
"time"
"go.moleculesai.app/cli/internal/backends"
_ "go.moleculesai.app/cli/internal/backends/claudecode" // register backend
_ "go.moleculesai.app/cli/internal/backends/exec" // register backend
_ "go.moleculesai.app/cli/internal/backends/mock" // register backend
"go.moleculesai.app/cli/internal/connect"
"github.com/Molecule-AI/molecule-cli/internal/backends"
_ "github.com/Molecule-AI/molecule-cli/internal/backends/claudecode" // register backend
_ "github.com/Molecule-AI/molecule-cli/internal/backends/exec" // register backend
_ "github.com/Molecule-AI/molecule-cli/internal/backends/mock" // register backend
"github.com/Molecule-AI/molecule-cli/internal/connect"
"github.com/spf13/cobra"
)
@@ -21,7 +21,7 @@ import (
// molecule connect — bridge an external-runtime workspace to a local backend.
//
// The full M1+ design lives in the RFC at
// https://git.moleculesai.app/molecule-ai/molecule-cli/issues/10. This file owns the
// https://github.com/Molecule-AI/molecule-cli/issues/10. This file owns the
// command surface; the wiring (heartbeat, activity poll, dispatch) lands in
// internal/connect/ in subsequent PRs.
// ---------------------------------------------------------------------------
@@ -68,7 +68,7 @@ Examples:
molecule connect ws_01HF2K... --backend exec \
--backend-opt cmd="python myhandler.py"
See full design: https://git.moleculesai.app/molecule-ai/molecule-cli/issues/10`,
See full design: https://github.com/Molecule-AI/molecule-cli/issues/10`,
Args: cobra.ExactArgs(1),
RunE: runConnect,
}
+2 -2
View File
@@ -40,7 +40,7 @@ func runInit(cmd *cobra.Command, _ []string) error {
if _, err := os.Stat(cfgPath); err == nil {
if initForce {
content := `# molecule CLI configuration — https://git.moleculesai.app/molecule-ai/molecule-cli
content := `# molecule CLI configuration — https://github.com/Molecule-AI/molecule-cli
#
# All values can be overridden by environment variables:
# MOLECULE_API_URL, MOLECULE_RUNTIME_URL, MOL_OUTPUT, MOL_VERBOSE, etc.
@@ -69,7 +69,7 @@ func runInit(cmd *cobra.Command, _ []string) error {
return fmt.Errorf("init: %s already exists — not overwriting (use --force to replace)", cfgPath)
}
content := `# molecule CLI configuration — https://git.moleculesai.app/molecule-ai/molecule-cli
content := `# molecule CLI configuration — https://github.com/Molecule-AI/molecule-cli
#
# All values can be overridden by environment variables:
# MOLECULE_API_URL, MOLECULE_RUNTIME_URL, MOL_OUTPUT, MOL_VERBOSE, etc.
+1 -1
View File
@@ -8,7 +8,7 @@ import (
"os"
"text/tabwriter"
"go.moleculesai.app/cli/internal/client"
"github.com/Molecule-AI/molecule-cli/internal/client"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -7,7 +7,7 @@ import (
"os"
"text/tabwriter"
"go.moleculesai.app/cli/internal/client"
"github.com/Molecule-AI/molecule-cli/internal/client"
"github.com/spf13/cobra"
)
+1 -1
View File
@@ -9,7 +9,7 @@ import (
"sync"
"time"
"go.moleculesai.app/cli/internal/backends"
"github.com/Molecule-AI/molecule-cli/internal/backends"
)
// Options carries the runtime knobs Run needs. Constructed by the cmd
+3 -3
View File
@@ -13,9 +13,9 @@ import (
"testing"
"time"
"go.moleculesai.app/cli/internal/backends"
_ "go.moleculesai.app/cli/internal/backends/mock" // register mock for tests
"go.moleculesai.app/cli/internal/connect"
"github.com/Molecule-AI/molecule-cli/internal/backends"
_ "github.com/Molecule-AI/molecule-cli/internal/backends/mock" // register mock for tests
"github.com/Molecule-AI/molecule-cli/internal/connect"
)
// fakeServer is the minimum workspace-server stub the loops need:
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"path/filepath"
"testing"
"go.moleculesai.app/cli/internal/connect"
"github.com/Molecule-AI/molecule-cli/internal/connect"
)
func TestState_LoadMissingReturnsZero(t *testing.T) {
-146
View File
@@ -1,146 +0,0 @@
// Issue molecule-ai/internal#71 lint gate.
//
// Walks every *.go file in the module + the go.mod declaration + any
// Dockerfile in the repo, and rejects any reference to the dead
// github.com/Molecule-AI/* identity (or the historical
// Molecule-AI/molecule-monorepo path).
//
// We had a 374+131+30+1-line "github.com/Molecule-AI/" footprint across
// the org pre-migration. The class of bug this gate prevents:
//
// - copy-pastes from old branches re-introducing the dead path
// - Dockerfile -ldflags strings drifting back to github.com on a
// refactor (the path has to match the module declaration to inject
// buildinfo correctly; if they disagree the binary builds but
// reports a wrong / stale GitSHA)
// - new modules added to the repo with the wrong import root because
// someone copied an old go.mod without thinking
//
// Why not just a CI shell grep: a Go test runs everywhere `go test ./...`
// runs, including local pre-push hooks and contributor IDEs. The gate
// fires immediately, with a per-file message that points at the line —
// CI shell grep failures are silent until the runner picks them up.
package lint
import (
"os"
"path/filepath"
"strings"
"testing"
)
// forbiddenSubstrings is the literal-match list. Each string MUST NOT
// appear anywhere under the module root. Entries are checked with
// substring matching, not regex — keep the patterns specific enough
// that a false-positive needs an explicit allowlist entry.
var forbiddenSubstrings = []string{
"github.com/Molecule-AI/",
"Molecule-AI/molecule-monorepo",
}
// allowlistedFiles is the per-file escape hatch. Empty by default —
// add an entry only when there is a documented reason a forbidden
// string MUST appear (e.g. a regression-test fixture that asserts
// the lint gate itself rejects the string). Each entry MUST be
// accompanied by a comment explaining why.
var allowlistedFiles = map[string]bool{
// (intentionally empty — add only with justification)
}
func TestNoLegacyGitHubImportPaths(t *testing.T) {
moduleRoot, err := findModuleRoot()
if err != nil {
t.Fatalf("findModuleRoot: %v", err)
}
checkExt := map[string]bool{
".go": true,
".mod": true,
".sum": false, // go.sum is auto-generated, refs flow from go.mod
".sh": true,
".yml": true,
".yaml": true,
".toml": true,
".md": true,
".json": true, // package.json / tsconfig.json — catches ref drift in package metadata
}
checkBasename := map[string]bool{
"Dockerfile": true,
"Dockerfile.tenant": true,
}
violations := 0
walkErr := filepath.Walk(moduleRoot, func(path string, info os.FileInfo, err error) error {
if err != nil {
return err
}
if info.IsDir() {
// Skip vendor + .git + node_modules — not our code.
base := info.Name()
if base == "vendor" || base == ".git" || base == "node_modules" || base == "testdata" {
return filepath.SkipDir
}
return nil
}
ext := filepath.Ext(path)
base := filepath.Base(path)
if !checkExt[ext] && !checkBasename[base] {
return nil
}
rel, _ := filepath.Rel(moduleRoot, path)
if allowlistedFiles[rel] {
return nil
}
// Skip the lint test itself — it legitimately names the forbidden
// strings as match patterns.
if strings.HasSuffix(rel, "import_path_lint_test.go") {
return nil
}
data, err := os.ReadFile(path)
if err != nil {
return err
}
text := string(data)
for _, bad := range forbiddenSubstrings {
if strings.Contains(text, bad) {
// Find the line number for a useful error message.
for lineNo, line := range strings.Split(text, "\n") {
if strings.Contains(line, bad) {
t.Errorf("%s:%d — forbidden substring %q (use go.moleculesai.app/<area>/... per molecule-ai/internal#71)", rel, lineNo+1, bad)
violations++
break
}
}
}
}
return nil
})
if walkErr != nil {
t.Fatalf("walk: %v", walkErr)
}
if violations > 0 {
t.Logf("Total violations: %d. Add to allowlistedFiles ONLY with a documented justification.", violations)
}
}
// findModuleRoot walks up from the test's CWD to find go.mod. The Go
// test harness sets CWD to the package directory; the module root may
// be one or more parents up.
func findModuleRoot() (string, error) {
cwd, err := os.Getwd()
if err != nil {
return "", err
}
dir := cwd
for {
if _, err := os.Stat(filepath.Join(dir, "go.mod")); err == nil {
return dir, nil
}
parent := filepath.Dir(dir)
if parent == dir {
return "", os.ErrNotExist
}
dir = parent
}
}
+18 -23
View File
@@ -93,7 +93,7 @@ entries may cause CI divergence or checksum mismatches.
### Impact
`go mod verify` in CI may fail if `go.sum` has extra entries not in the
lock file. Additionally, if the module path (`go.moleculesai.app/cli`)
lock file. Additionally, if the module path (`github.com/Molecule-AI/molecule-cli`)
is referenced via `replace` directives from other repos, those references may
persist stale entries.
@@ -130,29 +130,24 @@ is set to `.` (repo root) since the main package is at `cmd/molecule`.
## KI-005 — No integration test for the full CLI lifecycle
**File:** `cmd/molecule/molecule_test.go`, `internal/`
**Status:** ✅ Resolved
**File:** `tests/` (does not exist)
**Status:** Not yet implemented
**Severity:** Medium
### Resolution
`cmd/molecule/molecule_test.go` contains 32 table-driven integration tests
covering the full CLI command surface. Each test:
- Starts a `httptest.Server` that mirrors the platform REST API (workspace CRUD,
agent inspection, delegation, health, audit, config, completion scripts).
- Builds the `molecule` binary via `go build -o <tempdir>/molecule .`
- Executes it with `exec.Command` and validates stdout/stderr output.
### Symptom
There are no tests at all (per `go test ./...` — no packages match).
As subcommands are built, there is no test harness for end-to-end CLI testing
(e.g. `molecule workspace create --name test --output json` → verify JSON output).
Test coverage includes:
- Root help, workspace/agent/platform/config help
- `workspace list`, `workspace inspect`, `workspace create`, `workspace delete`,
`workspace restart`, `workspace audit`, `workspace delegate`
- `agent list`, `agent inspect`, `agent send`, `agent peers`
- `platform health`, `platform audit`
- `config init`, `config list`
- `init`, `init --force`
- `completion bash/zsh/fish/powershell`
- Error paths: missing workspace, missing agent, unknown subcommand,
missing required args, duplicate init
### Impact
Each subcommand will be shipped without regression protection. Manual testing
is required for every release. The absence of a `tests/` directory also means
there is no fixture for CLI integration testing with recorded API responses.
Run `go test ./...` from the repo root to execute. No live platform required —
all tests use `httptest.Server` fixtures.
### Suggested fix
Add `tests/` with:
- `cmd/molecule/molecule_test.go` — table-driven tests for each subcommand
using `exec.Command("molecule", ...)` against a built binary
- Use `molecule-sdk-python` fixture server or recorded API responses for
offline testing
- Add `go test ./...` to CI; require >0 test packages before merge