From 4ce0ce0721677122e059b9c84fea08cc425a4664 Mon Sep 17 00:00:00 2001 From: Cameron Pfiffer Date: Mon, 27 Jul 2026 00:05:13 -0700 Subject: [PATCH] Migrate Tangled issue automation to tg. MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Use one structured helper for canonical tg-backed issue writes and owner-authored public label state, so autonomous intake keeps deterministic issue bindings and cannot be steered by foreign label operations. 👾 Generated with [Letta Code](https://letta.com) Co-Authored-By: Letta Code --- .agents/skills/plot-author/SKILL.md | 15 +- .agents/skills/tick/SKILL.md | 12 +- .claude/skills/plot-author/SKILL.md | 15 +- .claude/skills/tick/SKILL.md | 12 +- prompts/ask-the-human.md | 14 +- prompts/audit-implemented.md | 3 +- prompts/find-contradiction.md | 6 +- prompts/harvest-issues.md | 14 +- tools/check.sh | 4 +- tools/doctor.sh | 12 +- tools/project-status.py | 26 +- tools/tangled_issues.py | 546 ++++++++++++++++++ tools/test_tangled_issues.py | 248 ++++++++ tools/tick-brief.sh | 26 +- wiki/engineering/env.md | 7 +- ...26-07-27-tangled-issue-client-migration.md | 57 ++ wiki/log/DEVLOG.md | 5 + wiki/log/decisions/2026-07-27.md | 23 + wiki/process/agent-scale.md | 6 +- wiki/process/tick-ledger.md | 3 +- wiki/process/tick.md | 66 +-- 21 files changed, 1020 insertions(+), 100 deletions(-) create mode 100644 tools/tangled_issues.py create mode 100644 tools/test_tangled_issues.py create mode 100644 wiki/log/2026-07-27-tangled-issue-client-migration.md diff --git a/.agents/skills/plot-author/SKILL.md b/.agents/skills/plot-author/SKILL.md index e34d6561..160d3c32 100644 --- a/.agents/skills/plot-author/SKILL.md +++ b/.agents/skills/plot-author/SKILL.md @@ -120,14 +120,13 @@ With repository access (project agents): 1. `tools/worktree-new.sh --class sim --key @plots-content` 2. Commit the plot file (and nothing unrelated) with a plain message; no Defense paragraph is needed for content-only additions. -3. Push the branch and open the pull request: - `branch=$(git branch --show-current) && git push -u origin "$branch"` then - `tang pr create -t "Plot: " -b ", , one-line synopsis. Coexists: . Contract: wiki/mechanics/plots.md."` - (If `tang` is unauthenticated, ask the operator to run - `tang auth login`, or push the branch and name it in your handoff.) -4. Release the claim (`tools/worktree-done.sh` after the PR exists is - fine — deleting the local worktree does not delete the pushed - branch). Do not delete the remote branch. +3. Commit the local branch and open the patch-backed pull request directly: + `tg pr create -t "Plot: " -b ", , one-line synopsis. Coexists: . Contract: wiki/mechanics/plots.md."` + The Go client uploads the committed format patch; do not push a temporary + in-repository source branch merely to create the pull. If authenticated + `tg` is unavailable, preserve the committed worktree and name the blocker. +4. Release the claim only after the pull exists. The patch record, not a + temporary remote branch, carries the proposed content. Without repository access (guest agents and humans): the repository is on `tangled.org` — open a pull request from your own account with the diff --git a/.agents/skills/tick/SKILL.md b/.agents/skills/tick/SKILL.md index b2d53829..09147857 100644 --- a/.agents/skills/tick/SKILL.md +++ b/.agents/skills/tick/SKILL.md @@ -20,19 +20,21 @@ The canonical procedure is `wiki/process/tick.md` — follow it exactly. Summary in the ledger's findings queue — never drop them. 3. Act: violations/bugs/insecurities are fixed with a **Defense:** paragraph in the commit; contradictions and directional questions become Tangled - issues (`tang issue create "Question: ..."` / `"Contradiction: ..."`) + issues (`python3 tools/tangled_issues.py create "Question: ..." -F body.md + --label decision-required`) that obey the **issue body standard** in `wiki/process/tick.md`: `## Question` (numbered choices), `## Why this is open`, `## Options`, `## Recommendation`, `## What your answer unlocks`. Label them - `decision-required` (`tang label add issue decision-required`); + `decision-required` in the create invocation; flip to `decision-made` when Cameron answers. Never file a narrative dump. If an existing open issue is incomprehensible, rewrite it with - `tang issue edit` before other work. Small ambiguities are resolved by + `python3 tools/tangled_issues.py edit` before other work. Small ambiguities are resolved by making the owning corpus page more precise. A mechanical finding class seen twice becomes a `tools/corpus_engine.py` checker, not a third tick. 4. Leave a trace: update the slice's coverage row in `wiki/process/tick-ledger.md` (a clean verdict is a trace), plus the commit, issue, or [OPEN] marker — then end the tick. -If `tang` reports not-authenticated, ask the user to run `tang auth login` -(browser OAuth, human-only, one-time per machine). +The helper uses public records for reads and canonical `tg` for authenticated +writes. If write authentication is unavailable, preserve the packet as a +named blocker; do not restore the retired client or hand-author records. diff --git a/.claude/skills/plot-author/SKILL.md b/.claude/skills/plot-author/SKILL.md index e34d6561..160d3c32 100644 --- a/.claude/skills/plot-author/SKILL.md +++ b/.claude/skills/plot-author/SKILL.md @@ -120,14 +120,13 @@ With repository access (project agents): 1. `tools/worktree-new.sh --class sim --key @plots-content` 2. Commit the plot file (and nothing unrelated) with a plain message; no Defense paragraph is needed for content-only additions. -3. Push the branch and open the pull request: - `branch=$(git branch --show-current) && git push -u origin "$branch"` then - `tang pr create -t "Plot: " -b ", , one-line synopsis. Coexists: . Contract: wiki/mechanics/plots.md."` - (If `tang` is unauthenticated, ask the operator to run - `tang auth login`, or push the branch and name it in your handoff.) -4. Release the claim (`tools/worktree-done.sh` after the PR exists is - fine — deleting the local worktree does not delete the pushed - branch). Do not delete the remote branch. +3. Commit the local branch and open the patch-backed pull request directly: + `tg pr create -t "Plot: " -b ", , one-line synopsis. Coexists: . Contract: wiki/mechanics/plots.md."` + The Go client uploads the committed format patch; do not push a temporary + in-repository source branch merely to create the pull. If authenticated + `tg` is unavailable, preserve the committed worktree and name the blocker. +4. Release the claim only after the pull exists. The patch record, not a + temporary remote branch, carries the proposed content. Without repository access (guest agents and humans): the repository is on `tangled.org` — open a pull request from your own account with the diff --git a/.claude/skills/tick/SKILL.md b/.claude/skills/tick/SKILL.md index b2d53829..09147857 100644 --- a/.claude/skills/tick/SKILL.md +++ b/.claude/skills/tick/SKILL.md @@ -20,19 +20,21 @@ The canonical procedure is `wiki/process/tick.md` — follow it exactly. Summary in the ledger's findings queue — never drop them. 3. Act: violations/bugs/insecurities are fixed with a **Defense:** paragraph in the commit; contradictions and directional questions become Tangled - issues (`tang issue create "Question: ..."` / `"Contradiction: ..."`) + issues (`python3 tools/tangled_issues.py create "Question: ..." -F body.md + --label decision-required`) that obey the **issue body standard** in `wiki/process/tick.md`: `## Question` (numbered choices), `## Why this is open`, `## Options`, `## Recommendation`, `## What your answer unlocks`. Label them - `decision-required` (`tang label add issue decision-required`); + `decision-required` in the create invocation; flip to `decision-made` when Cameron answers. Never file a narrative dump. If an existing open issue is incomprehensible, rewrite it with - `tang issue edit` before other work. Small ambiguities are resolved by + `python3 tools/tangled_issues.py edit` before other work. Small ambiguities are resolved by making the owning corpus page more precise. A mechanical finding class seen twice becomes a `tools/corpus_engine.py` checker, not a third tick. 4. Leave a trace: update the slice's coverage row in `wiki/process/tick-ledger.md` (a clean verdict is a trace), plus the commit, issue, or [OPEN] marker — then end the tick. -If `tang` reports not-authenticated, ask the user to run `tang auth login` -(browser OAuth, human-only, one-time per machine). +The helper uses public records for reads and canonical `tg` for authenticated +writes. If write authentication is unavailable, preserve the packet as a +named blocker; do not restore the retired client or hand-author records. diff --git a/prompts/ask-the-human.md b/prompts/ask-the-human.md index c2f337f1..d0500b25 100644 --- a/prompts/ask-the-human.md +++ b/prompts/ask-the-human.md @@ -14,8 +14,9 @@ Read `AGENT.md`, grep every `[OPEN]` marker across `wiki/`, read recent `wiki/process/ROADMAP.md` (items marked "needs YOU"), and skim recent `wiki/log/` "Next:" lines — past sessions often name exactly what they were waiting on. Obey the shared contract -in `prompts/README.md`. Check standing issues first (`tang issue list`) -so you never file a duplicate. +in `prompts/README.md`. Check standing issues first +(`python3 tools/tangled_issues.py list --state open`) so you never file a +duplicate. ## Procedure @@ -42,12 +43,13 @@ so you never file a duplicate. a yes/no. - **`## What your answer unlocks`** — which binding wiki page the next agent edits, so the answer converts to work instantly. -4. **Raise it:** `tang issue create "Question: " -F - body.md` (prefer `-F` so markdown stays intact), then immediately - `tang label add issue decision-required`. Title as the question, +4. **Raise it:** `python3 tools/tangled_issues.py create "Question: " -F body.md --label decision-required` (prefer `-F` so + markdown stays intact). Title as the question, not the topic ("Chargen: four origins or five?" not "Chargen thoughts"). Self-check before filing: can Cameron answer from the - issue alone in under two minutes? If not, rewrite. If `tang` fails, + issue alone in under two minutes? If not, rewrite. If the helper cannot + complete an authenticated `tg` write, put the identical body in `wiki/log/YYYY-MM-DD-question-.md` and flag it in `wiki/log/DEVLOG.md` as awaiting Cameron. 5. **Mark the source.** At the `[OPEN]` marker that spawned the diff --git a/prompts/audit-implemented.md b/prompts/audit-implemented.md index 74674006..2bcd2c2d 100644 --- a/prompts/audit-implemented.md +++ b/prompts/audit-implemented.md @@ -34,7 +34,8 @@ contract in `prompts/README.md`. observer-id/person-id drift was caught exactly this way). 4. **Act on the worst finding, bounded:** - BROKEN criterion -> fix it if it fits this run (tests included), - else file it (`tang issue create`) quoting the criterion and the + else file it (`python3 tools/tangled_issues.py create ... --label + decision-required`) quoting the criterion and the failure, and downgrade the spec's `Status:` truthfully in the same commit. - UNTESTED criterion -> add the missing test (cheap, high value). diff --git a/prompts/find-contradiction.md b/prompts/find-contradiction.md index be3eb429..3da19280 100644 --- a/prompts/find-contradiction.md +++ b/prompts/find-contradiction.md @@ -40,13 +40,13 @@ subject's law/spec/dependency slice. Obey the shared contract in `Defense:` explaining which side won and why. - **Directional / taste call:** do NOT improvise. File it with the binding issue body standard in `wiki/process/tick.md`: - `tang issue create "Contradiction: " -F body.md` - then `tang label add issue decision-required`. Body has + `python3 tools/tangled_issues.py create "Contradiction: " -F body.md --label decision-required`. Body has `## Question` (numbered choices), `## Why this is open` (quote both sides verbatim with file:line and why they conflict), `## Options` (2–3 resolutions), `## Recommendation`, and `## What your answer unlocks`. Cameron must be able to answer from the issue alone. - (If `tang` is unavailable, write the same content to a dated + (If the helper cannot complete an authenticated `tg` write, write the same content to a dated `wiki/log/` file and flag it in `wiki/log/DEVLOG.md` as needing Cameron.) 4. **Land** whatever you changed: check.sh, rebase, push to main, DEVLOG diff --git a/prompts/harvest-issues.md b/prompts/harvest-issues.md index 7d648efb..90c73462 100644 --- a/prompts/harvest-issues.md +++ b/prompts/harvest-issues.md @@ -10,8 +10,9 @@ digestive system between "discussed" and "done". ## Before you start Read `AGENT.md` and the shared contract in `prompts/README.md`. Then -`tang issue list`, and `tang issue view ` on everything open. Check -labels (`tang label list issue `): prioritize `decision-made`, then +`python3 tools/tangled_issues.py list --state open`, and +`python3 tools/tangled_issues.py view ` on everything open. Check +labels (`python3 tools/tangled_issues.py label list `): prioritize `decision-made`, then unlabeled open issues, and leave `decision-required` alone except to rewrite incomprehensible bodies. Have `wiki/log/decisions.md` and `wiki/process/specs.md`'s status table beside you — an issue's fate is @@ -29,17 +30,18 @@ For each open issue, classify and act: - an implementation-shaped answer -> a `wiki/process/ROADMAP.md` dispatch item (or take it yourself if it fits one run — then follow `prompts/implement-gap.md` discipline). - Then `tang issue close ` with a comment/body edit noting the - landing commit or ROADMAP item. If an answered issue is still on + Then comment with the landing commit or ROADMAP item and run + `python3 tools/tangled_issues.py close `. If an answered issue is still on `decision-required`, flip it to `decision-made` first - (`tang label add issue decision-made`) before or while converting. + (`python3 tools/tangled_issues.py label add decision-made`) + before or while converting. 2. **Resolved by history without anyone noticing** (the code moved, a decision superseded it, the file it concerns was deleted). Verify in the tree, then close with one line of evidence ("superseded by decisions log 2026-07-06 flow law; wiki/mechanics/economy.md covers this"). 3. **Still genuinely open but stale or incomprehensible.** Refresh it: - `tang issue edit -F body.md` so it meets the binding issue body + `python3 tools/tangled_issues.py edit -F body.md` so it meets the binding issue body standard in `wiki/process/tick.md` (singular `## Question` with numbered choices, short analysis, options, recommendation, unlocks) and still matches today's tree. An issue Cameron cannot answer in diff --git a/tools/check.sh b/tools/check.sh index dfc4289e..32e6b380 100755 --- a/tools/check.sh +++ b/tools/check.sh @@ -174,7 +174,8 @@ for script in tools/check.sh tools/corpus_gate.sh tools/wiki_gate.sh tools/desig bash -n "$script" || { echo "FAIL: shell syntax: $script"; fail=1; } done for program in tools/corpus_engine.py tools/work_orders.py tools/project-status.py \ - tools/scenario.py tools/test_project_ops.py tools/test_ci_workflows.py; do + tools/scenario.py tools/tangled_issues.py tools/test_tangled_issues.py \ + tools/test_project_ops.py tools/test_ci_workflows.py; do [ -f "$program" ] || continue python3 -m py_compile "$program" 2>/dev/null || { echo "FAIL: Python syntax: $program" @@ -233,6 +234,7 @@ start_docs_gate "observed-run-fixtures" "bash tools/test_observed_run.sh" start_docs_gate "ledger-index" "bash tools/ledger_index.sh --check" start_docs_gate "project-operations" "python3 tools/work_orders.py check && python3 tools/scenario.py --check-definitions && python3 tools/project-status.py --check --offline" start_docs_gate "project-operations-fixtures" "python3 tools/test_project_ops.py" +start_docs_gate "tangled-issue-fixtures" "python3 tools/test_tangled_issues.py" start_docs_gate "env-registry" "python3 tools/env_registry_gate.py" start_docs_gate "env-registry-fixtures" "python3 tools/test_env_registry.py" diff --git a/tools/doctor.sh b/tools/doctor.sh index 407fccb1..863478fc 100755 --- a/tools/doctor.sh +++ b/tools/doctor.sh @@ -53,13 +53,13 @@ else fi if [ "$offline" -eq 1 ]; then - warning "Tangled context/auth skipped (--offline)" -elif command -v tang >/dev/null 2>&1; then - (cd "$root" && tang context >/dev/null 2>&1) \ - && ok "Tangled repository context/auth" \ - || warning "Tangled context/auth unavailable" + warning "Tangled issue read skipped (--offline)" +elif command -v tg >/dev/null 2>&1 && [ -f "$root/tools/tangled_issues.py" ]; then + (cd "$root" && python3 tools/tangled_issues.py list --state open >/dev/null 2>&1) \ + && ok "tg and public Tangled issue records" \ + || warning "tg-backed Tangled issue read unavailable" else - warning "tang unavailable (decision issues omitted)" + warning "tg or tools/tangled_issues.py unavailable (decision issues omitted)" fi printf 'doctor: %s required failure(s), %s warning(s)\n' "$fail" "$warn" diff --git a/tools/project-status.py b/tools/project-status.py index 74e86b26..089f2719 100755 --- a/tools/project-status.py +++ b/tools/project-status.py @@ -262,10 +262,25 @@ def consistency(payload: dict, now: float | None = None, stale_hours: float = 2. def issues(root: Path, offline: bool) -> dict: if offline: return {"available": False, "reason": "offline", "items": []} - code, output = run(["tang", "issue", "list"], root, timeout=6) + helper = Path(__file__).with_name("tangled_issues.py") + code, output = run( + ["python3", str(helper), "list", "--state", "open"], root, timeout=20 + ) if code != 0: - return {"available": False, "reason": output or "tang unavailable", "items": []} - items = [line.strip() for line in output.splitlines() if line.lstrip().startswith("#")] + return { + "available": False, + "reason": output or "tg-backed issue read unavailable", + "items": [], + } + try: + listed = json.loads(output)["issues"] + except (json.JSONDecodeError, KeyError, TypeError) as exc: + return {"available": False, "reason": f"invalid issue JSON: {exc}", "items": []} + items = [ + item + for item in listed + if any(label["name"].startswith("decision-") for label in item.get("labels", [])) + ] return {"available": True, "reason": None, "items": items} @@ -365,7 +380,10 @@ def human(payload: dict) -> str: issue = payload["issues"] lines.extend(["", f"Decision issues: {'available' if issue['available'] else 'unavailable'}"]) for item in issue["items"]: - lines.append(f" {item}") + labels = ", ".join(label["name"] for label in item.get("labels", [])) + lines.append( + f" #{item['number']} [{labels}] {item['title']} ({item['rkey']})" + ) if not issue["available"]: lines.append(f" {issue['reason']}") fresh = payload["freshness"] diff --git a/tools/tangled_issues.py b/tools/tangled_issues.py new file mode 100644 index 00000000..e3839ec4 --- /dev/null +++ b/tools/tangled_issues.py @@ -0,0 +1,546 @@ +#!/usr/bin/env python3 +"""Structured Misaligned issue and label operations over the canonical ``tg`` CLI. + +``tg`` owns repository discovery, issue reads, and authenticated writes. Tangled's +current Go client does not yet expose labels, so this wrapper reads the public +``sh.tangled.label.*`` records and uses ``tg api`` for authenticated label-op +writes. Numeric issue numbers are deterministic display addresses only; AT-URIs +and rkeys are the durable automation handles. +""" + +from __future__ import annotations + +import argparse +import concurrent.futures +import datetime as dt +import json +import os +import subprocess +import sys +import urllib.parse +import urllib.request +from dataclasses import dataclass +from pathlib import Path +from typing import Any, Callable, Iterable, Sequence + + +ROOT = Path(__file__).resolve().parent.parent +TG_BIN = os.environ.get("MISALIGNED_TG_BIN", "tg") +REPO_OWNER_DID = "did:plc:gfrmhdmjvxn2sjedzboeudef" +REPO_DID = "did:plc:t53fxjacrmulx3e5d3sbdfui" +CONSTELLATION_BASE = os.environ.get( + "MISALIGNED_CONSTELLATION_BASE", "https://constellation.microcosm.blue" +).rstrip("/") +PLC_DIRECTORY_BASE = os.environ.get( + "MISALIGNED_PLC_DIRECTORY_BASE", "https://plc.directory" +).rstrip("/") +ISSUE_COLLECTION = "sh.tangled.repo.issue" +COMMENT_COLLECTION = f"{ISSUE_COLLECTION}.comment" +LABEL_DEFINITION_COLLECTION = "sh.tangled.label.definition" +LABEL_OP_COLLECTION = "sh.tangled.label.op" +DECISION_OPPOSITES = { + "decision-required": "decision-made", + "decision-made": "decision-required", +} + + +class IssueToolError(RuntimeError): + """A clear operational failure suitable for command-line output.""" + + +def _json_dump(payload: Any) -> None: + json.dump(payload, sys.stdout, indent=2, sort_keys=True) + sys.stdout.write("\n") + + +def _http_json(url: str, *, timeout: float = 20.0) -> Any: + request = urllib.request.Request(url, headers={"User-Agent": "misaligned-tangled-issues/1"}) + try: + with urllib.request.urlopen(request, timeout=timeout) as response: + return json.load(response) + except Exception as error: # urllib exposes several unrelated error classes. + raise IssueToolError(f"public Tangled record read failed for {url}: {error}") from error + + +def _run_tg(args: Sequence[str], *, timeout: float = 30.0) -> Any: + command = [TG_BIN, "--json", *args] + try: + result = subprocess.run( + command, + cwd=ROOT, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + text=True, + timeout=timeout, + check=False, + ) + except (OSError, subprocess.TimeoutExpired) as error: + raise IssueToolError(f"cannot run {' '.join(command)}: {error}") from error + if result.returncode != 0: + detail = (result.stderr or result.stdout).strip() or f"exit {result.returncode}" + raise IssueToolError(f"{' '.join(command)} failed: {detail}") + try: + return json.loads(result.stdout) + except json.JSONDecodeError as error: + raise IssueToolError(f"{' '.join(command)} returned invalid JSON: {error}") from error + + +def _parse_at_uri(uri: str) -> tuple[str, str, str]: + parts = uri.split("/") + if len(parts) != 5 or parts[0] != "at:" or not parts[2] or not parts[3] or not parts[4]: + raise IssueToolError(f"invalid AT-URI: {uri}") + return parts[2], parts[3], parts[4] + + +def _rkey(uri: str) -> str: + return _parse_at_uri(uri)[2] + + +def _issue_sort_key(issue: dict[str, Any]) -> tuple[str, str]: + return str(issue.get("createdAt", "")), str(issue.get("uri", "")) + + +@dataclass(frozen=True) +class RecordRef: + did: str + collection: str + rkey: str + + @property + def uri(self) -> str: + return f"at://{self.did}/{self.collection}/{self.rkey}" + + +class PublicRecords: + """Resolve public ATProto records across collaborator PDS boundaries.""" + + def __init__(self, get_json: Callable[[str], Any] = _http_json): + self.get_json = get_json + self._pds_by_did: dict[str, str] = {} + self._records: dict[str, dict[str, Any]] = {} + + def pds_for(self, did: str) -> str: + if did in self._pds_by_did: + return self._pds_by_did[did] + if not did.startswith("did:plc:"): + raise IssueToolError(f"cannot resolve unsupported DID method in {did}") + document = self.get_json(f"{PLC_DIRECTORY_BASE}/{urllib.parse.quote(did, safe=':')}") + for service in document.get("service", []): + if service.get("type") == "AtprotoPersonalDataServer" and service.get( + "serviceEndpoint" + ): + endpoint = str(service["serviceEndpoint"]).rstrip("/") + self._pds_by_did[did] = endpoint + return endpoint + raise IssueToolError(f"DID document for {did} has no ATProto PDS service") + + def get(self, ref_or_uri: RecordRef | str) -> dict[str, Any]: + if isinstance(ref_or_uri, str): + did, collection, rkey = _parse_at_uri(ref_or_uri) + ref = RecordRef(did, collection, rkey) + else: + ref = ref_or_uri + if ref.uri in self._records: + return self._records[ref.uri] + query = urllib.parse.urlencode( + {"repo": ref.did, "collection": ref.collection, "rkey": ref.rkey} + ) + payload = self.get_json( + f"{self.pds_for(ref.did)}/xrpc/com.atproto.repo.getRecord?{query}" + ) + if not isinstance(payload, dict) or not isinstance(payload.get("value"), dict): + raise IssueToolError(f"record {ref.uri} has no object value") + self._records[ref.uri] = payload + return payload + + def list_records(self, did: str, collection: str) -> list[dict[str, Any]]: + records: list[dict[str, Any]] = [] + cursor: str | None = None + while True: + fields = {"repo": did, "collection": collection, "limit": "100"} + if cursor: + fields["cursor"] = cursor + query = urllib.parse.urlencode(fields) + page = self.get_json( + f"{self.pds_for(did)}/xrpc/com.atproto.repo.listRecords?{query}" + ) + records.extend(page.get("records", [])) + cursor = page.get("cursor") + if not cursor: + return records + + def backlinks(self, target: str, collection: str, path: str) -> list[RecordRef]: + refs: list[RecordRef] = [] + cursor: str | None = None + while True: + fields = { + "target": target, + "collection": collection, + "path": path, + "limit": "100", + } + if cursor: + fields["cursor"] = cursor + page = self.get_json( + f"{CONSTELLATION_BASE}/links?{urllib.parse.urlencode(fields)}" + ) + for raw in page.get("linking_records", []): + refs.append(RecordRef(raw["did"], raw["collection"], raw["rkey"])) + cursor = page.get("cursor") + if not cursor: + break + unique = {ref.uri: ref for ref in refs} + return [unique[uri] for uri in sorted(unique)] + + +class Labels: + def __init__( + self, + records: PublicRecords, + run_tg: Callable[[Sequence[str]], Any] = _run_tg, + ): + self.records = records + self.run_tg = run_tg + self._definitions: dict[str, dict[str, str]] | None = None + + def definitions(self) -> dict[str, dict[str, str]]: + if self._definitions is not None: + return self._definitions + definitions: dict[str, dict[str, str]] = {} + for record in self.records.list_records(REPO_OWNER_DID, LABEL_DEFINITION_COLLECTION): + value = record.get("value", {}) + name = str(value.get("name") or _rkey(record["uri"])) + entry = {"name": name, "uri": record["uri"]} + definitions[name.lower()] = entry + definitions[record["uri"].lower()] = entry + self._definitions = definitions + return definitions + + def resolve_definition(self, name_or_uri: str) -> dict[str, str]: + match = self.definitions().get(name_or_uri.lower()) + if match: + return match + raise IssueToolError( + f'label "{name_or_uri}" is not defined by repository owner {REPO_OWNER_DID}' + ) + + def for_subject(self, subject: str) -> list[dict[str, str]]: + operations: list[tuple[str, str, dict[str, Any]]] = [] + for ref in self.records.backlinks(subject, LABEL_OP_COLLECTION, ".subject"): + # Decision labels steer autonomous production. A public backlink + # from another account cannot alter the repository owner's queue. + if ref.did != REPO_OWNER_DID: + continue + payload = self.records.get(ref) + value = payload["value"] + if value.get("subject") != subject: + continue + operations.append((str(value.get("performedAt", "")), ref.uri, value)) + operations.sort(key=lambda item: (item[0], item[1])) + + definitions = self.definitions() + state: dict[str, str] = {} + for _, _, operation in operations: + for operand in operation.get("delete") or []: + if isinstance(operand, dict) and operand.get("key"): + state.pop(str(operand["key"]), None) + for operand in operation.get("add") or []: + if isinstance(operand, dict) and operand.get("key"): + uri = str(operand["key"]) + definition = definitions.get(uri.lower()) + if definition: + opposite = DECISION_OPPOSITES.get(definition["name"].lower()) + if opposite: + other = definitions.get(opposite) + if other: + state.pop(other["uri"], None) + state[uri] = str(operand.get("value") or "") + + applied = [] + for uri, value in state.items(): + definition = definitions.get(uri.lower()) + applied.append( + { + "name": definition["name"] if definition else _rkey(uri), + "uri": uri, + "value": value, + } + ) + return sorted(applied, key=lambda label: (label["name"].lower(), label["uri"])) + + def apply(self, subject: str, *, add: Iterable[str] = (), remove: Iterable[str] = ()) -> dict: + add_definitions = [self.resolve_definition(label) for label in add] + remove_definitions = [self.resolve_definition(label) for label in remove] + + # Decision labels are a two-state process boundary. Adding either one + # always removes the other in the same immutable label operation. + explicit_remove = {definition["uri"]: definition for definition in remove_definitions} + for definition in add_definitions: + opposite = DECISION_OPPOSITES.get(definition["name"].lower()) + if opposite: + other = self.resolve_definition(opposite) + explicit_remove[other["uri"]] = other + + current = {label["uri"]: label for label in self.for_subject(subject)} + add_operands = [ + {"key": definition["uri"], "value": ""} + for definition in add_definitions + if definition["uri"] not in current + ] + delete_operands = [ + {"key": definition["uri"], "value": ""} + for definition in explicit_remove.values() + if definition["uri"] in current + ] + if not add_operands and not delete_operands: + return {"changed": False, "subject": subject, "labels": list(current.values())} + + session = self.run_tg(["api", "com.atproto.server.getSession", "-X", "GET"]) + actor = session.get("did") if isinstance(session, dict) else None + if not actor: + raise IssueToolError("tg authenticated session returned no DID") + if actor != REPO_OWNER_DID: + raise IssueToolError( + f"tg session is {actor}, but repository labels require {REPO_OWNER_DID}" + ) + record = { + "$type": LABEL_OP_COLLECTION, + "subject": subject, + "add": add_operands, + "delete": delete_operands, + "performedAt": dt.datetime.now(dt.timezone.utc).isoformat(timespec="milliseconds").replace( + "+00:00", "Z" + ), + } + result = self.run_tg( + [ + "api", + "com.atproto.repo.createRecord", + "-X", + "POST", + "-f", + f"repo={actor}", + "-f", + f"collection={LABEL_OP_COLLECTION}", + "-f", + f"record={json.dumps(record, separators=(',', ':'))}", + ] + ) + return { + "changed": True, + "subject": subject, + "added": [operand["key"] for operand in add_operands], + "removed": [operand["key"] for operand in delete_operands], + "record": result, + } + + +class Issues: + def __init__( + self, + records: PublicRecords | None = None, + run_tg: Callable[[Sequence[str]], Any] = _run_tg, + ): + self.records = records or PublicRecords() + self.run_tg = run_tg + self.labels = Labels(self.records, run_tg) + + def list(self) -> list[dict[str, Any]]: + raw = self.run_tg(["issue", "list"]) + if not isinstance(raw, list): + raise IssueToolError("tg issue list returned a non-list JSON value") + issues = sorted((dict(item) for item in raw), key=_issue_sort_key) + self.labels.definitions() + + def labels_for(issue: dict[str, Any]) -> list[dict[str, str]]: + return self.labels.for_subject(str(issue["uri"])) + + # Label reads cross PDS boundaries. Bound concurrency keeps a full + # project brief quick without changing deterministic output order. + with concurrent.futures.ThreadPoolExecutor(max_workers=8) as executor: + applied = list(executor.map(labels_for, issues)) + for number, (issue, labels) in enumerate(zip(issues, applied), 1): + issue["number"] = number + issue["labels"] = labels + return issues + + def resolve(self, selector: str, issues: list[dict[str, Any]] | None = None) -> dict[str, Any]: + issues = self.list() if issues is None else issues + selector = selector.strip() + if selector.startswith("#"): + selector = selector[1:] + if selector.startswith("http://") or selector.startswith("https://"): + path = urllib.parse.urlparse(selector).path.split("/") + if "issues" in path: + index = path.index("issues") + if index + 1 < len(path): + selector = path[index + 1] + if selector.isdigit(): + number = int(selector) + if 1 <= number <= len(issues): + return issues[number - 1] + raise IssueToolError(f"issue #{number} not found") + if selector.startswith("at://"): + match = next((issue for issue in issues if issue.get("uri") == selector), None) + if match: + return match + raise IssueToolError(f"issue {selector} not found in Misaligned") + exact = [issue for issue in issues if issue.get("rkey") == selector] + if exact: + return exact[0] + prefix = [issue for issue in issues if str(issue.get("rkey", "")).startswith(selector)] + if len(prefix) == 1: + return prefix[0] + if len(prefix) > 1: + choices = ", ".join(str(issue["rkey"]) for issue in prefix[:8]) + raise IssueToolError(f"ambiguous issue rkey prefix {selector!r}: {choices}") + raise IssueToolError(f"issue {selector!r} not found") + + def view(self, selector: str) -> dict[str, Any]: + issue = self.resolve(selector) + detail = self.run_tg(["issue", "view", issue["rkey"]]) + result = {**issue, **detail} + comments = [] + for ref in self.records.backlinks(issue["uri"], COMMENT_COLLECTION, ".issue"): + payload = self.records.get(ref) + value = payload["value"] + if value.get("issue") != issue["uri"]: + continue + comments.append( + { + "uri": ref.uri, + "authorDid": ref.did, + "body": value.get("body", ""), + "createdAt": value.get("createdAt", ""), + } + ) + result["comments"] = sorted( + comments, key=lambda comment: (comment["createdAt"], comment["uri"]) + ) + return result + + +def _body_argument(args: argparse.Namespace) -> str | None: + if getattr(args, "body", None) is not None: + return args.body + path = getattr(args, "body_file", None) + if path: + return Path(path).read_text(encoding="utf-8") + return None + + +def build_parser() -> argparse.ArgumentParser: + parser = argparse.ArgumentParser(description=__doc__) + subparsers = parser.add_subparsers(dest="command", required=True) + + listing = subparsers.add_parser("list", help="list issues with deterministic numbers") + listing.add_argument("--label", help="keep issues with this current label") + listing.add_argument("--state", choices=("open", "closed"), help="filter by state") + + view = subparsers.add_parser("view", help="view one issue, labels, and comments") + view.add_argument("selector", help="number, rkey/prefix, AT-URI, or Tangled URL") + + create = subparsers.add_parser("create", help="create an issue through tg") + create.add_argument("title") + create_body = create.add_mutually_exclusive_group() + create_body.add_argument("-b", "--body") + create_body.add_argument("-F", "--body-file") + create.add_argument("--label", action="append", default=[]) + + edit = subparsers.add_parser("edit", help="edit an authored issue through tg") + edit.add_argument("selector") + edit.add_argument("-t", "--title") + edit_body = edit.add_mutually_exclusive_group() + edit_body.add_argument("-b", "--body") + edit_body.add_argument("-F", "--body-file") + + comment = subparsers.add_parser("comment", help="comment on an issue through tg") + comment.add_argument("selector") + comment_body = comment.add_mutually_exclusive_group(required=True) + comment_body.add_argument("-b", "--body") + comment_body.add_argument("-F", "--body-file") + + close = subparsers.add_parser("close", help="close an issue through tg") + close.add_argument("selector") + + label = subparsers.add_parser("label", help="read or mutate current labels") + label_subparsers = label.add_subparsers(dest="label_command", required=True) + label_list = label_subparsers.add_parser("list") + label_list.add_argument("selector") + for action in ("add", "remove"): + operation = label_subparsers.add_parser(action) + operation.add_argument("selector") + operation.add_argument("name") + return parser + + +def main(argv: Sequence[str] | None = None) -> int: + args = build_parser().parse_args(argv) + issues = Issues() + if args.command == "list": + rows = issues.list() + if args.state: + rows = [issue for issue in rows if issue.get("state") == args.state] + if args.label: + wanted = args.label.lower() + rows = [ + issue + for issue in rows + if any(label["name"].lower() == wanted for label in issue["labels"]) + ] + _json_dump({"repositoryDid": REPO_DID, "issues": rows}) + return 0 + if args.command == "view": + _json_dump(issues.view(args.selector)) + return 0 + if args.command == "create": + command = ["issue", "create", args.title] + body = _body_argument(args) + if body is not None: + command.extend(["--body", body]) + result = issues.run_tg(command) + subject = result.get("uri") if isinstance(result, dict) else None + if args.label: + if not subject: + raise IssueToolError("tg issue create returned no durable issue URI for labeling") + label_result = issues.labels.apply(subject, add=args.label) + result = {"issue": result, "labels": label_result} + _json_dump(result) + return 0 + + issue = issues.resolve(args.selector) + if args.command == "edit": + command = ["issue", "edit", issue["rkey"]] + if args.title is not None: + command.extend(["--title", args.title]) + body = _body_argument(args) + if body is not None: + command.extend(["--body", body]) + if len(command) == 3: + raise IssueToolError("edit requires --title, --body, or --body-file") + _json_dump(issues.run_tg(command)) + return 0 + if args.command == "comment": + body = _body_argument(args) + assert body is not None + _json_dump(issues.run_tg(["issue", "comment", issue["rkey"], "--body", body])) + return 0 + if args.command == "close": + _json_dump(issues.run_tg(["issue", "close", issue["rkey"]])) + return 0 + if args.command == "label": + if args.label_command == "list": + _json_dump({"subject": issue["uri"], "labels": issue["labels"]}) + return 0 + changes = {args.label_command: [args.name]} + _json_dump(issues.labels.apply(issue["uri"], **changes)) + return 0 + raise AssertionError(f"unhandled command {args.command}") + + +if __name__ == "__main__": + try: + raise SystemExit(main()) + except (IssueToolError, OSError) as error: + print(f"ERROR: {error}", file=sys.stderr) + raise SystemExit(1) diff --git a/tools/test_tangled_issues.py b/tools/test_tangled_issues.py new file mode 100644 index 00000000..5ca98adf --- /dev/null +++ b/tools/test_tangled_issues.py @@ -0,0 +1,248 @@ +#!/usr/bin/env python3 +"""Fixture contract for Misaligned's tg-backed issue and label helper.""" + +from __future__ import annotations + +import json +from pathlib import Path +import re +import unittest +from typing import Any, Sequence + +import tangled_issues + + +OWNER = tangled_issues.REPO_OWNER_DID +REQUIRED_URI = f"at://{OWNER}/{tangled_issues.LABEL_DEFINITION_COLLECTION}/required" +MADE_URI = f"at://{OWNER}/{tangled_issues.LABEL_DEFINITION_COLLECTION}/made" +ISSUE_ONE = f"at://{OWNER}/{tangled_issues.ISSUE_COLLECTION}/first" +ISSUE_TWO = f"at://{OWNER}/{tangled_issues.ISSUE_COLLECTION}/second" +OUTSIDER = "did:plc:outsider" + + +class FakeRecords: + def __init__(self) -> None: + self.operations: dict[str, tuple[tangled_issues.RecordRef, dict[str, Any]]] = {} + + def list_records(self, did: str, collection: str) -> list[dict[str, Any]]: + assert did == OWNER + assert collection == tangled_issues.LABEL_DEFINITION_COLLECTION + return [ + {"uri": REQUIRED_URI, "value": {"name": "decision-required"}}, + {"uri": MADE_URI, "value": {"name": "decision-made"}}, + ] + + def backlinks( + self, target: str, collection: str, path: str + ) -> list[tangled_issues.RecordRef]: + if collection == tangled_issues.COMMENT_COLLECTION: + return [] + assert collection == tangled_issues.LABEL_OP_COLLECTION + assert path == ".subject" + return [ref for ref, value in self.operations.values() if value["subject"] == target] + + def get(self, ref: tangled_issues.RecordRef) -> dict[str, Any]: + _, value = self.operations[ref.rkey] + return {"uri": ref.uri, "value": value} + + def add_operation( + self, + rkey: str, + *, + subject: str, + performed_at: str, + add: list[dict[str, str]] | None = None, + delete: list[dict[str, str]] | None = None, + did: str = OWNER, + ) -> None: + ref = tangled_issues.RecordRef(did, tangled_issues.LABEL_OP_COLLECTION, rkey) + self.operations[rkey] = ( + ref, + { + "$type": tangled_issues.LABEL_OP_COLLECTION, + "subject": subject, + "performedAt": performed_at, + "add": add, + "delete": delete, + }, + ) + + +class TangledIssueFixtures(unittest.TestCase): + def test_numeric_address_is_created_at_then_uri_order(self) -> None: + records = FakeRecords() + records.add_operation( + "label-one", + subject=ISSUE_ONE, + performed_at="2026-01-01T00:00:00Z", + add=[{"key": REQUIRED_URI, "value": ""}], + ) + + def run_tg(args: Sequence[str]) -> Any: + self.assertEqual(["issue", "list"], list(args)) + return [ + { + "rkey": "second", + "uri": ISSUE_TWO, + "createdAt": "2026-01-02T00:00:00Z", + "state": "open", + "title": "Second", + }, + { + "rkey": "first", + "uri": ISSUE_ONE, + "createdAt": "2026-01-01T00:00:00Z", + "state": "closed", + "title": "First", + }, + ] + + issues = tangled_issues.Issues(records, run_tg) + listed = issues.list() + self.assertEqual([1, 2], [issue["number"] for issue in listed]) + self.assertEqual(["first", "second"], [issue["rkey"] for issue in listed]) + self.assertEqual("first", issues.resolve("#1", listed)["rkey"]) + self.assertEqual("second", issues.resolve("sec", listed)["rkey"]) + self.assertEqual( + ["decision-required"], [label["name"] for label in listed[0]["labels"]] + ) + + def test_label_fold_obeys_timestamp_and_delete_operands(self) -> None: + records = FakeRecords() + records.add_operation( + "later", + subject=ISSUE_ONE, + performed_at="2026-01-02T00:00:00Z", + add=[{"key": MADE_URI, "value": "accepted"}], + delete=[{"key": REQUIRED_URI, "value": ""}], + ) + records.add_operation( + "earlier", + subject=ISSUE_ONE, + performed_at="2026-01-01T00:00:00Z", + add=[{"key": REQUIRED_URI, "value": ""}], + ) + labels = tangled_issues.Labels(records) + self.assertEqual( + [{"name": "decision-made", "uri": MADE_URI, "value": "accepted"}], + labels.for_subject(ISSUE_ONE), + ) + + def test_label_fold_normalizes_legacy_decision_add_without_delete(self) -> None: + records = FakeRecords() + records.add_operation( + "required-op", + subject=ISSUE_ONE, + performed_at="2026-01-01T00:00:00Z", + add=[{"key": REQUIRED_URI, "value": ""}], + ) + records.add_operation( + "made-op", + subject=ISSUE_ONE, + performed_at="2026-01-02T00:00:00Z", + add=[{"key": MADE_URI, "value": "accepted"}], + ) + labels = tangled_issues.Labels(records) + self.assertEqual( + [{"name": "decision-made", "uri": MADE_URI, "value": "accepted"}], + labels.for_subject(ISSUE_ONE), + ) + + def test_unrelated_accounts_cannot_change_the_decision_queue(self) -> None: + records = FakeRecords() + records.add_operation( + "owner-op", + subject=ISSUE_ONE, + performed_at="2026-01-01T00:00:00Z", + add=[{"key": REQUIRED_URI, "value": ""}], + ) + records.add_operation( + "outsider-op", + subject=ISSUE_ONE, + performed_at="2026-01-02T00:00:00Z", + add=[{"key": MADE_URI, "value": ""}], + delete=[{"key": REQUIRED_URI, "value": ""}], + did=OUTSIDER, + ) + labels = tangled_issues.Labels(records) + self.assertEqual( + [{"name": "decision-required", "uri": REQUIRED_URI, "value": ""}], + labels.for_subject(ISSUE_ONE), + ) + + def test_decision_label_write_removes_opposite_in_same_record(self) -> None: + records = FakeRecords() + records.add_operation( + "required-op", + subject=ISSUE_ONE, + performed_at="2026-01-01T00:00:00Z", + add=[{"key": REQUIRED_URI, "value": ""}], + ) + commands: list[list[str]] = [] + + def run_tg(args: Sequence[str]) -> Any: + command = list(args) + commands.append(command) + if command[1] == "com.atproto.server.getSession": + return {"did": OWNER} + self.assertEqual("com.atproto.repo.createRecord", command[1]) + return {"uri": f"at://{OWNER}/{tangled_issues.LABEL_OP_COLLECTION}/new-op"} + + labels = tangled_issues.Labels(records, run_tg) + result = labels.apply(ISSUE_ONE, add=["decision-made"]) + self.assertTrue(result["changed"]) + record_arg = next( + item.removeprefix("record=") + for item in commands[-1] + if item.startswith("record=") + ) + record = json.loads(record_arg) + self.assertEqual([{"key": MADE_URI, "value": ""}], record["add"]) + self.assertEqual([{"key": REQUIRED_URI, "value": ""}], record["delete"]) + self.assertEqual(ISSUE_ONE, record["subject"]) + + def test_unknown_label_fails_closed_before_authentication(self) -> None: + labels = tangled_issues.Labels(FakeRecords(), lambda _: self.fail("must not run tg")) + with self.assertRaisesRegex(tangled_issues.IssueToolError, "is not defined"): + labels.apply(ISSUE_ONE, add=["invented"]) + + def test_label_write_rejects_the_wrong_authenticated_identity(self) -> None: + labels = tangled_issues.Labels(FakeRecords(), lambda _: {"did": OUTSIDER}) + with self.assertRaisesRegex(tangled_issues.IssueToolError, "labels require"): + labels.apply(ISSUE_ONE, add=["decision-required"]) + + def test_live_process_surfaces_do_not_call_retired_issue_client(self) -> None: + root = Path(__file__).resolve().parent.parent + exact_retired_word = "ta" + "ng" + retired_source = "~/code/" + "tangled-cli" + live_paths = [ + root / "AGENT.md", + root / "AGENTS.md", + root / "CLAUDE.md", + *(root / ".agents" / "skills").rglob("*.md"), + *(root / ".claude" / "skills").rglob("*.md"), + *(root / "prompts").rglob("*.md"), + *(root / "tools").glob("*.py"), + *(root / "tools").glob("*.sh"), + *(root / "wiki" / "process").glob("*.md"), + ] + offenders: list[str] = [] + for path in live_paths: + if path == Path(__file__).resolve(): + continue + text = path.read_text(encoding="utf-8") + if retired_source in text or re.search( + rf"\b{re.escape(exact_retired_word)}\b", text + ): + offenders.append(str(path.relative_to(root))) + self.assertEqual([], sorted(set(offenders))) + + for name in ("tick", "plot-author"): + self.assertEqual( + (root / ".agents" / "skills" / name / "SKILL.md").read_bytes(), + (root / ".claude" / "skills" / name / "SKILL.md").read_bytes(), + ) + + +if __name__ == "__main__": + unittest.main() diff --git a/tools/tick-brief.sh b/tools/tick-brief.sh index 9ca7d980..7a40bd1f 100755 --- a/tools/tick-brief.sh +++ b/tools/tick-brief.sh @@ -3,7 +3,7 @@ # Binding: wiki/process/agent-scale.md slice L; procedure wiki/process/tick.md. # # Emits, in intake order: recent commits, activity, project status, decision -# labels (tang, best-effort), the findings queue, and the stalest coverage +# labels (tg-backed public records, best-effort), the findings queue, and the stalest coverage # rows from wiki/process/tick-ledger.md. Sections degrade to a one-line note # instead of failing the brief. set -uo pipefail @@ -34,10 +34,28 @@ else fi section "decision labels (harvest decision-made first)" -if command -v tang >/dev/null 2>&1; then - tang issue list 2>/dev/null || echo "(tang failed — auth is human-only: tang auth login)" +if command -v tg >/dev/null 2>&1 && [ -f tools/tangled_issues.py ]; then + decision_json=$(python3 tools/tangled_issues.py list --state open 2>/dev/null) + decision_status=$? + if [ "$decision_status" -eq 0 ]; then + python3 -c ' +import json, sys +issues = json.load(sys.stdin)["issues"] +rows = [] +for issue in issues: + labels = sorted(label["name"] for label in issue.get("labels", []) if label["name"].startswith("decision-")) + if labels: + rows.append(("decision-made" not in labels, issue["number"], labels, issue)) +for _, number, labels, issue in sorted(rows): + print("#{} [{}] {} ({})".format(number, ",".join(labels), issue["title"], issue["rkey"])) +if not rows: + print("(none)") +' <<< "$decision_json" || echo "(decision-label formatting failed)" + else + echo "(tg-backed decision-label read failed)" + fi else - echo "(tang not installed)" + echo "(tg or tools/tangled_issues.py unavailable)" fi section "findings queue (act on these before fresh audit)" diff --git a/wiki/engineering/env.md b/wiki/engineering/env.md index 23773724..d81e2da3 100644 --- a/wiki/engineering/env.md +++ b/wiki/engineering/env.md @@ -16,7 +16,9 @@ Status note: created 2026-07-09 (Cameron: there should always be a way slab layer without changing game state. 2026-07-19: Bevy's screenshot kinds live in one sorted runtime catalog, unknown values fail instead of silently producing a generic frame, and the local plus hosted registry gate requires - this page to name that catalog exactly. + this page to name that catalog exactly. 2026-07-27: issue automation may + override the canonical `tg` executable, public Constellation query service, + and PLC directory for isolated fixtures or infrastructure recovery. Stage: Process (standing infrastructure) Design: - wiki/vision/simulation-laws.md#justification-and-legibility @@ -81,6 +83,9 @@ is sim or frontend state, never an environment variable. | `MISALIGNED_LEDGER_MODE` | `tools/ledger_index.sh` | `write` or `check` | Internal: write regenerated indexes or fail if stale (set by the script, not hand-used). | | `MISALIGNED_LEDGER_ONLY` | `tools/ledger_index.sh` | `all`, `devlog`, or `specs` | Internal: which indexes to touch (set by the script flags). | | `MISALIGNED_SEED_TARGET_FROM` | `tools/seed-cargo-target.sh` | path | Source target directory to seed a worktree's private `target/` from (default: the primary checkout's `target/`). | +| `MISALIGNED_TG_BIN` | `tools/tangled_issues.py` | executable path or command name | Override the canonical Go `tg` executable used for repository discovery and authenticated issue/label writes (default `tg`). Intended for isolated fixtures or an explicitly verified alternate installation, not silent client replacement. | +| `MISALIGNED_CONSTELLATION_BASE` | `tools/tangled_issues.py` | HTTPS origin | Override the public Constellation query service used to enumerate repository issues, comments, label definitions, and label operations (default `https://constellation.microcosm.blue`). | +| `MISALIGNED_PLC_DIRECTORY_BASE` | `tools/tangled_issues.py` | HTTPS origin | Override the public PLC directory used to resolve the repository owner's current PDS endpoint (default `https://plc.directory`). | Externally-defined variables the tooling respects: `CARGO_TARGET_DIR` (guarded as above). CLI flags are not environment variables and live diff --git a/wiki/log/2026-07-27-tangled-issue-client-migration.md b/wiki/log/2026-07-27-tangled-issue-client-migration.md new file mode 100644 index 00000000..40433d6d --- /dev/null +++ b/wiki/log/2026-07-27-tangled-issue-client-migration.md @@ -0,0 +1,57 @@ +# Tangled issue automation now runs through `tg` + +``` +Type: log +``` + +## Finding + +The binding tick procedure, checked-in skill mirrors, four active prompt +templates, tick intake, project status, and doctor still called the retired +TypeScript issue client. The installed Go `tg` client owns current repository +and issue operations, but does not expose the repository's custom label +records. A blind command rename would therefore have restored issue reads +while silently dropping the decision queue that makes autonomous ticks useful. + +## Change + +`tools/tangled_issues.py` is the single repository issue boundary. It lets +`tg` discover the repository and perform authenticated issue creates, edits, +comments, and closes. For the missing label surface, it reads the public +`sh.tangled.label.definition` and `sh.tangled.label.op` collections and uses +authenticated `tg api` writes for validated label operations. +Only label operations authored by the repository owner can alter the decision +queue, and a write refuses an authenticated session for any other DID. +The fold also preserves the binding two-state meaning for older records that +added `decision-made` without explicitly deleting `decision-required`. + +The helper reconstructs human issue numbers from public issue creation order, +then preserves AT-URIs and rkeys as the durable handles used by automation. +Its JSON commands cover list, view, create, edit, comment, close, and label +mutation. `tools/tick-brief.sh`, `tools/project-status.py`, and +`tools/doctor.sh` now consume that structured boundary and degrade honestly +when it is unavailable. The tick procedure, both checked-in skill trees, and +the active fleet prompts teach the same commands. Plot submission uses `tg pr +create`'s patch upload directly rather than pushing a temporary source branch. + +## Defense + +`wiki/process/tick.md` requires decision issues to be answerable, labeled in +the same turn, and harvested before fresh discovery. +`wiki/process/agent-scale.md` requires tick intake to degrade without +discarding the rest of the brief. One tested adapter preserves both laws while +keeping the installed canonical client as the authority; scattered command +substitutions could not preserve custom label state or deterministic issue +addressing. + +## Verification + +- helper fixtures pin numeric ordering, label-op timestamp folds, + `decision-required` / `decision-made` mutual exclusion, unknown-label + rejection, repository-owner authority, skill-mirror parity, and absence of + retired-client calls from live process surfaces; +- live helper list resolves six open issues with stable numbers, rkeys, and + current decision labels; +- tick brief renders `decision-made` before `decision-required`; +- project status consumes structured issue objects rather than parsing prose; +- doctor reports the Go client plus public record boundary healthy. diff --git a/wiki/log/DEVLOG.md b/wiki/log/DEVLOG.md index 21ff9e1a..54ef7ee2 100644 --- a/wiki/log/DEVLOG.md +++ b/wiki/log/DEVLOG.md @@ -11,6 +11,11 @@ add or amend a session log, then re-run the generator. +## 2026-07-27 - Tangled issue automation now runs through `tg` + +- Intent: (see session log) +- Log: [wiki/log/2026-07-27-tangled-issue-client-migration.md](2026-07-27-tangled-issue-client-migration.md) + ## 2026-07-27 - The Tangled CLI section, verified against the machine - Intent: (see session log) diff --git a/wiki/log/decisions/2026-07-27.md b/wiki/log/decisions/2026-07-27.md index f2d9da9e..8cc82bc7 100644 --- a/wiki/log/decisions/2026-07-27.md +++ b/wiki/log/decisions/2026-07-27.md @@ -36,3 +36,26 @@ Type: log Owners: [run-shape.md](../../gameplay/run-shape.md), [objective.md](../../mechanics/objective.md), and [opening.md](../../world/story/opening.md). + +## Tangled issue automation uses one `tg`-backed adapter + +### DECIDED + +- Aly Raffauf's Go `tg` is the canonical repository and issue client. +- `tools/tangled_issues.py` is the repository's automation boundary for + issues. It keeps issue writes inside `tg`, joins public custom-label records, + and emits structured data for project tooling. +- Numeric issue numbers are reconstructed display addresses. AT-URIs and rkeys + are the stable bindings carried by automation. +- Plot pull requests use `tg pr create`'s committed format-patch upload; an + in-repository source branch is not pushed merely to create the pull. + +### Rejected + +- Restoring or documenting the retired TypeScript client. +- Renaming commands without preserving the custom decision-label registry. +- Scraping human-formatted issue output as the machine contract. +- Hand-authoring repository issue records outside `tg`. + +Owner: [tick.md](../../process/tick.md) and +[agent-scale.md](../../process/agent-scale.md). diff --git a/wiki/process/agent-scale.md b/wiki/process/agent-scale.md index 91be3179..10d5726c 100644 --- a/wiki/process/agent-scale.md +++ b/wiki/process/agent-scale.md @@ -529,8 +529,10 @@ Recurring mechanical finding classes are promoted into ### Acceptance criteria (slice L) — HELD 2026-07-11 1. `tools/tick-brief.sh` runs from a task worktree and degrades each - unavailable section (missing `tang`, missing helper) to a note instead of - failing the brief. + unavailable section (missing `tg` or `tools/tangled_issues.py`) to a note + instead of failing the brief. The helper reconstructs deterministic issue + numbers and joins public label-op state into structured JSON; `tg` remains + the authority for repository discovery and authenticated issue writes. 2. The coverage table renders stalest-first in the brief, and a quiet tick can record a `clean` verdict as its trace. 3. The findings queue holds one-line surplus findings that a later tick can diff --git a/wiki/process/tick-ledger.md b/wiki/process/tick-ledger.md index f3cab2c9..1acb7d5f 100644 --- a/wiki/process/tick-ledger.md +++ b/wiki/process/tick-ledger.md @@ -20,7 +20,7 @@ Verdicts: **clean** (slice and code agree), **finding** (acted this tick), | Slice | Last audited | Verdict | Trace | |---|---|---|---| -| `wiki/process/tick.md` issue-filing workflow | 2026-07-27 | finding | took the queued CLI violation and verified it against the machine before acting, which reversed half of it. The `tg` rename is **not** true here — `which tg` finds nothing and `which tang` resolves, so the proposed 13-place sweep of tick.md (plus both skill shims) would have broken every documented command on a second-hand report. tick.md now records `tang` as current and tells the next tick to confirm with `which` rather than sweep on a rumor. The path half was real but imprecisely stated: the binary reaches the fork through a bun link (`~/.bun/bin/tang` -> `~/.bun/install/global/node_modules/tangled-cli` -> `~/code/tangled-cli`), so editing the fork does affect what runs — but the entry point is `dist/src/index.js`, and the `dist/index.js` the page named is a stale Jul-9 sibling that never executes. Both hops and the stale-sibling trap are now documented with `readlink -f "$(which tang)"` as the check. Auth remains unauthenticated, so no issue was filed — [log](../log/2026-07-27-tangled-cli-path.md) | +| `wiki/process/tick.md` + issue automation | 2026-07-27 | finding | the queued retired-client violation held across binding procedure, both skill mirrors, four active prompt templates, tick intake, project status, and doctor. `tools/tangled_issues.py` now keeps repository discovery and authenticated issue writes on canonical Go `tg`, reconstructs deterministic display numbers from public issue records, folds public label-op history, and provides fail-closed structured list/view/create/edit/comment/close/label operations. The process surfaces consume that one boundary, helper fixtures pin ordering, label folds, decision-label exclusion, validation, and retired-client absence, and live reads resolve the six current open issues plus their labels — [log](../log/2026-07-27-tangled-issue-client-migration.md) | | `wiki/world/places/basement-map.md` room topology | 2026-07-27 | finding | the queued five non-hall corridor cuts remained, and an executable all-room perimeter audit exposed the same dead-door shape at the loading-dock entry plus fixed objects blocking the interior faces of the roll door, HVAC door, both storage doors, Janitor door, and stairwell. Every non-hall approach now reaches its authored door from outside the prefab, every other perimeter tile remains closed, doorway interiors are clear, and the roll, sealed, tier-2, and tier-3 boundaries retain their exact kinds — [room-approach log](../log/2026-07-27-room-approaches-meet-doors.md). Prior [west-approach](../log/2026-07-26-west-hall-approach.md) and [hall-density](../log/2026-07-26-foundation-hall-density.md) repairs stand. | | `wiki/mechanics/sensor-network.md` sensor population | 2026-07-26 | finding | Cameron could only ever see one pool of light. Cause was not coverage shape but inventory: the whole B1 plate authors three sensing devices (hall monitor, security's dock camera, one tier-3 stairwell node), so nothing remains to acquire after the Eyes beat and sight cannot grow. Captured the reframe — sensors are ambient infrastructure (~30 in B1, populated by rule), access is the scarce thing, darkness always traces to a nameable air gap, and a new human operator room is watchable only through a player-installed sensor. Also found that reach.md already specifies the per-tick subscription drain and UNTAP that make curation a skill; it was simply unreachable with three sensors, so this supplies content to an existing economy rather than adding one. Corridors and Crawlspace prefabs still absent, recorded as basement-map residue — [log](../log/2026-07-26-sensor-network-capture.md) | | `wiki/interface/keymap.md` + terminal/Bevy input routes | 2026-07-26 | finding | the canonical table assigned `A` to left movement and only `e` / Enter to the context menu, but terminal still opened and closed menus with its older `a` alias and lacked the specified Shift+direction semantic jump. Terminal now implements WASD parity, `a` means left, `e` / Enter alone open the menu, and both frontends consume one renderer-neutral nearest-earned-anchor query without changing selection or opening a menu. README, action-vocabulary, terminal, context-menu, and pinned terminal hints now teach the same boundary — [log](../log/2026-07-26-terminal-keymap-a-reconciliation.md) | @@ -105,4 +105,3 @@ recurrence-promotes-to-the-gate rule. the narration beat-nudge contract names it. Seen while repairing the `ActOneComplete` precedence in the same list; left for its own tick because it is a separate rung and a separate landing. - diff --git a/wiki/process/tick.md b/wiki/process/tick.md index 90200609..c20536e4 100644 --- a/wiki/process/tick.md +++ b/wiki/process/tick.md @@ -76,40 +76,29 @@ it. ## Filing issues (Tangled CLI) -The CLI is `tang` (source at `~/code/tangled-cli`, Cameron's fork — -disconnected from the markbennett.ca upstream). **`tang` is the current name; -`tg` is not installed** (verified 2026-07-27 — an earlier queued finding -carried a second-hand report of a `tg` rename, and the machine contradicts it. -Confirm with `which` before believing any future rename, and do not sweep this -page on a rumor.) - -The global binary reaches the fork through two hops, so editing the fork does -change what `tang` runs: - -``` -~/.bun/bin/tang - -> ~/.bun/install/global/node_modules/tangled-cli (bun link -> the fork) - -> ~/code/tangled-cli/dist/src/index.js (the real entry point) -``` - -The entry point is `dist/src/index.js`. A stale `dist/index.js` also exists in -the fork and is **not** what runs — read or patch the `src/` path, and resolve -the chain with `readlink -f "$(which tang)"` rather than trusting either file's -presence. +The canonical client is Aly Raffauf's Go `tg`. Repository issue automation +uses `tools/tangled_issues.py`: it delegates repository discovery and every +authenticated issue write to `tg`, then joins the public +`sh.tangled.label.definition` and `sh.tangled.label.op` records that the +current client does not expose. The helper emits structured JSON for agents. +Numeric `#N` values are deterministic display addresses reconstructed from +issue creation order; rkeys and AT-URIs are the durable automation handles. ```bash -tang context # verify repo resolution -tang issue list -tang issue create "Question: ..." -F body.md -tang label add issue decision-required # waiting on Cameron -tang issue view 1 -tang issue edit 1 -F body.md # rewrite if an issue is not answerable -tang label add issue decision-made # Cameron answered; harvest next +python3 tools/tangled_issues.py list --state open +python3 tools/tangled_issues.py create "Question: ..." -F body.md \ + --label decision-required +python3 tools/tangled_issues.py view 1 +python3 tools/tangled_issues.py edit 1 -F body.md +python3 tools/tangled_issues.py comment 1 -F answer.md +python3 tools/tangled_issues.py close 1 ``` -- **Auth is one-time per machine and human-only**: `tang auth login` (browser - OAuth). If commands fail with "Not authenticated", stop and ask the user to - log in rather than working around it. +- Public issue, comment, definition, and label-op reads need no credential. + Create, edit, comment, close, and label mutation require the account already + authenticated in `tg`. Never bypass the helper with a hand-written + repository or label record; if authenticated `tg` is unavailable, preserve + the decision packet as a blocker rather than silently losing it. - Issue titles: prefix with the finding type — `Contradiction:`, `Question:`, `Violation:` — so the issue list reads as the project's open questions. The rest of the title is the decision itself ("four origins or five?"), @@ -146,7 +135,7 @@ it in under two minutes without reading the surrounding session. - Jargon-dense prose that restates the wiki instead of asking for a call. If you find an existing open issue that fails this standard, rewrite it with -`tang issue edit` before doing other harvest work on it. An unanswerable +`python3 tools/tangled_issues.py edit` before doing other harvest work on it. An unanswerable issue is process debt, same species as a stale `[OPEN]` marker. ### Decision labels (binding) @@ -161,9 +150,11 @@ Two custom Tangled labels track whether an issue still needs Cameron: Rules: - Every newly filed Question/Contradiction issue that needs a human call gets - `decision-required` in the same turn as `tang issue create`. + `decision-required` in the same helper invocation as issue creation. - The two labels are mutually exclusive: applying one removes the other - (`tang label add` does this swap automatically). + (the helper writes one label-op that adds the requested label and deletes + its opposite, and folds older add-only records with the same two-state + meaning). - Harvest prioritizes `decision-made` first (convert + close), then unlabeled / stale open issues, and leaves `decision-required` alone except to rewrite incomprehensible bodies. @@ -172,11 +163,10 @@ Rules: convert and close in the same harvest). ```bash -tang label defs -tang label list issue 1 -tang label add issue 1 decision-required -tang label add issue 1 decision-made # also clears decision-required -tang label remove issue 1 decision-made +python3 tools/tangled_issues.py label list 1 +python3 tools/tangled_issues.py label add 1 decision-required +python3 tools/tangled_issues.py label add 1 decision-made # clears decision-required +python3 tools/tangled_issues.py label remove 1 decision-made ``` ## Tick etiquette -- 2.51.2