@delorenj/pjangler 1.4.5 → 1.5.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (83) hide show
  1. package/README.md +41 -520
  2. package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
  3. package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
  4. package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
  5. package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
  6. package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
  7. package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
  8. package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
  9. package/dist/index.js +5747 -13483
  10. package/dist/mcp-server.js +6853 -14059
  11. package/dist/notebook/journal-service-main.js +5682 -0
  12. package/dist/project-registry-service.js +748 -0
  13. package/dist/prompt.js +2 -1
  14. package/package.json +15 -10
  15. package/scripts/install-project-registry.mjs +34 -0
  16. package/scripts/migrate-project-registry.mjs +117 -0
  17. package/src/project/registryClient.ts +62 -0
  18. package/templates/commonproject/copier.yml +16 -19
  19. package/templates/commonproject/template/.agents/hooks/README.md +10 -4
  20. package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
  21. package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
  22. package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
  23. package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
  24. package/templates/commonproject/template/.agents/skills.json +0 -1
  25. package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
  26. package/templates/commonproject/template/.project.json.jinja +1 -1
  27. package/templates/commonproject/template/mise.toml.jinja +11 -21
  28. package/contracts/fleet-contract.yaml +0 -513
  29. package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
  30. package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
  31. package/templates/hermes-agent/copier.yml +0 -186
  32. package/templates/hermes-agent/template/.gitignore.jinja +0 -11
  33. package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
  34. package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
  35. package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
  36. package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
  37. package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
  38. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
  39. package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
  40. package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
  41. package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
  42. package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
  43. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
  44. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
  45. package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
  46. package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
  47. package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
  48. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
  49. package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
  50. package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
  51. package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
  52. package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
  53. package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
  54. package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
  55. package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
  56. package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
  57. package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
  58. package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
  59. package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
  60. package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
  61. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
  62. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
  63. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
  64. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
  65. package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
  66. package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
  67. package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
  68. package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
  69. package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
  70. package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
  71. package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
  72. package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
  73. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
  74. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
  75. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
  76. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
  77. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
  78. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
  79. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
  80. package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
  81. package/templates/hermes-agent/template/hermes.jinja +0 -109
  82. package/templates/hermes-agent/template/momo.jinja +0 -177
  83. package/templates/hermes-agent/template/role.yaml.jinja +0 -112
@@ -1,137 +0,0 @@
1
- #!/usr/bin/env python3
2
- """momo-wip-lock — shared WIP=1 driver lease so interactive Momo and the Hermes
3
- sentinel never double-drive one board (momo E2/S2.3, the coexistence gate).
4
-
5
- Both drivers acquire the SAME advisory lease (a JSON file, conventionally
6
- <runtime>/wip-driver.lock) before a board-driving pass. The lease PERSISTS across
7
- processes — it is deliberately NOT a held flock, because a Momo pass spans many
8
- separate tool-call/bash invocations over minutes. flock is used only to serialize
9
- the check-and-set so two acquirers can't race. A lease is respected while its
10
- heartbeat is fresh (now - heartbeat_at < ttl); a stale lease (holder died without
11
- releasing) can be stolen after it expires.
12
-
13
- Owners are free strings by convention: "momo" (interactive) or
14
- "hermes:<agent_id>" (the sentinel). WIP=1 is per BOARD == per runtime, so there
15
- is one lease file per runtime.
16
-
17
- Protocol:
18
- * Driver start: acquire <lock> <me> -> exit 0 → drive; exit 1 → HELD, back off.
19
- * While driving: refresh <lock> <me> periodically (< ttl) so the lease stays fresh.
20
- * Driver end: release <lock> <me>.
21
- * A holder that crashed leaves a lease that expires after `ttl`; the next driver
22
- acquires it normally (or `--steal` to take an explicitly stale one immediately).
23
-
24
- Commands (exit 0 = you hold it; 1 = someone else holds it fresh; 2 = usage/error):
25
- acquire <lockfile> <owner> [--ttl S] [--steal]
26
- refresh <lockfile> <owner>
27
- release <lockfile> <owner>
28
- status <lockfile> # always exit 0
29
- """
30
- from __future__ import annotations
31
- import argparse, fcntl, json, os, socket, sys, tempfile, time
32
-
33
-
34
- def _read(path: str):
35
- try:
36
- with open(path) as f:
37
- return json.load(f)
38
- except Exception:
39
- return None
40
-
41
-
42
- def _fresh(lease: dict | None, now: float) -> bool:
43
- return bool(lease) and (now - lease.get("heartbeat_at", 0)) < lease.get("ttl", 300)
44
-
45
-
46
- def _write_atomic(path: str, data: dict) -> None:
47
- d = os.path.dirname(os.path.abspath(path)) or "."
48
- fd, tmp = tempfile.mkstemp(dir=d)
49
- with os.fdopen(fd, "w") as f:
50
- json.dump(data, f)
51
- os.replace(tmp, path)
52
-
53
-
54
- def _guard(lockfile: str):
55
- """Sibling .flock file held only for the read-modify-write critical section."""
56
- g = open(lockfile + ".flock", "a")
57
- fcntl.flock(g, fcntl.LOCK_EX)
58
- return g
59
-
60
-
61
- def acquire(lockfile: str, owner: str, ttl: int, steal: bool) -> int:
62
- now = time.time(); g = _guard(lockfile)
63
- try:
64
- cur = _read(lockfile)
65
- if cur and cur.get("owner") != owner and _fresh(cur, now) and not steal:
66
- print(f"HELD by {cur['owner']} (fresh, {int(now - cur['heartbeat_at'])}s ago) — back off")
67
- return 1
68
- started = cur["started_at"] if (cur and cur.get("owner") == owner and "started_at" in cur) else now
69
- _write_atomic(lockfile, {
70
- "owner": owner, "pid": os.getpid(), "host": socket.gethostname(),
71
- "started_at": started, "heartbeat_at": now, "ttl": ttl,
72
- })
73
- print(f"ACQUIRED by {owner}" + (" (stole stale lease)" if (cur and cur.get('owner') != owner) else ""))
74
- return 0
75
- finally:
76
- fcntl.flock(g, fcntl.LOCK_UN); g.close()
77
-
78
-
79
- def refresh(lockfile: str, owner: str) -> int:
80
- now = time.time(); g = _guard(lockfile)
81
- try:
82
- cur = _read(lockfile)
83
- if not cur or cur.get("owner") != owner:
84
- print(f"NOT OWNER (held by {cur.get('owner') if cur else 'nobody'}) — cannot refresh")
85
- return 1
86
- cur["heartbeat_at"] = now
87
- _write_atomic(lockfile, cur); print("REFRESHED"); return 0
88
- finally:
89
- fcntl.flock(g, fcntl.LOCK_UN); g.close()
90
-
91
-
92
- def release(lockfile: str, owner: str) -> int:
93
- g = _guard(lockfile)
94
- try:
95
- cur = _read(lockfile)
96
- if cur and cur.get("owner") != owner:
97
- print(f"NOT OWNER (held by {cur['owner']}) — not releasing"); return 1
98
- try:
99
- os.remove(lockfile)
100
- except FileNotFoundError:
101
- pass
102
- print("RELEASED"); return 0
103
- finally:
104
- fcntl.flock(g, fcntl.LOCK_UN); g.close()
105
-
106
-
107
- def status(lockfile: str) -> int:
108
- now = time.time(); cur = _read(lockfile)
109
- if not cur:
110
- print("FREE (no lease)"); return 0
111
- state = "fresh" if _fresh(cur, now) else "STALE"
112
- print(f"{cur['owner']} — {state} (heartbeat {int(now - cur.get('heartbeat_at', 0))}s ago, "
113
- f"ttl {cur.get('ttl')}s, pid {cur.get('pid')}@{cur.get('host')})")
114
- return 0
115
-
116
-
117
- def main() -> int:
118
- ap = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter)
119
- ap.add_argument("cmd", choices=["acquire", "refresh", "release", "status"])
120
- ap.add_argument("lockfile")
121
- ap.add_argument("owner", nargs="?")
122
- ap.add_argument("--ttl", type=int, default=300, help="freshness window in seconds (default 300)")
123
- ap.add_argument("--steal", action="store_true", help="take an explicitly stale lease immediately")
124
- a = ap.parse_args()
125
- if a.cmd in ("acquire", "refresh", "release") and not a.owner:
126
- print("owner required", file=sys.stderr); return 2
127
- if a.cmd == "acquire":
128
- return acquire(a.lockfile, a.owner, a.ttl, a.steal)
129
- if a.cmd == "refresh":
130
- return refresh(a.lockfile, a.owner)
131
- if a.cmd == "release":
132
- return release(a.lockfile, a.owner)
133
- return status(a.lockfile)
134
-
135
-
136
- if __name__ == "__main__":
137
- raise SystemExit(main())
@@ -1,329 +0,0 @@
1
- #!/usr/bin/env sh
2
- # Linear ticket-provider adapter (reference implementation).
3
- #
4
- # Credentials: LINEAR_API_KEY
5
- # Board binding (role.yaml `ticket_provider:`):
6
- # name: linear
7
- # team: <TEAM_KEY> e.g. DEL
8
- # project: "<Project name>" optional; scopes milestone/issue queries
9
- # state_map: { in_review: "In Review", completed: "Done",
10
- # cancelled: "Canceled" } optional overrides
11
- #
12
- # Implements the contract in lib/ticket-provider.sh. All Linear access goes
13
- # through GraphQL so the same envelope works in unattended runs.
14
- # LINEAR_MAX_PAGES bounds issue pagination (default 1000).
15
- # GraphQL variable references are intentionally single-quoted shell literals.
16
- # shellcheck disable=SC2016
17
- set -eu
18
-
19
- OP="${1:-}"; shift 2>/dev/null || true
20
- ROLE_DIR="$(cd "$(dirname "$0")/../.." && pwd)"
21
- ROLE_YAML="$ROLE_DIR/role.yaml"
22
- GRAPHQL_URL="${LINEAR_GRAPHQL_URL:-https://api.linear.app/graphql}"
23
-
24
- die() { echo "linear: $*" >&2; exit 1; }
25
- need_key() { [ -n "${LINEAR_API_KEY:-}" ] || die "LINEAR_API_KEY is not set"; }
26
-
27
- validated_uint() {
28
- setting="$1"; value="$2"; minimum="$3"; maximum="$4"
29
- case "$value" in ''|*[!0-9]*) die "$setting must be an integer from $minimum through $maximum" ;; esac
30
- [ "$value" -ge "$minimum" ] && [ "$value" -le "$maximum" ] \
31
- || die "$setting must be an integer from $minimum through $maximum"
32
- printf '%s' "$value"
33
- }
34
-
35
- # tp_cfg KEY — read ticket_provider.<KEY> from role.yaml (best-effort, flat).
36
- tp_cfg() {
37
- [ -f "$ROLE_YAML" ] || return 0
38
- python3 - "$ROLE_YAML" "$1" <<'PY'
39
- import sys, re, pathlib
40
- text = pathlib.Path(sys.argv[1]).read_text()
41
- m = re.search(r'(?ms)^ticket_provider:\s*$(.*?)(?=^\S)', text + "\n\x00")
42
- block = m.group(1) if m else ""
43
- key = sys.argv[2]
44
- mm = re.search(rf'(?m)^\s*{re.escape(key)}:\s*"?([^"\n]*)"?\s*$', block)
45
- print(mm.group(1).strip() if mm else "")
46
- PY
47
- }
48
-
49
- # pj_cfg KEY — read ticket_provider.<KEY> from the repo-root .project.json (the
50
- # SOT), walking up from the role dir. Preferred over role.yaml.
51
- pj_cfg() {
52
- python3 - "$ROLE_DIR" "$1" <<'PY'
53
- import sys, json, pathlib
54
- start = pathlib.Path(sys.argv[1]).resolve(); key = sys.argv[2]
55
- for parent in [start, *start.parents]:
56
- f = parent / ".project.json"
57
- if f.is_file():
58
- try: tp = (json.loads(f.read_text()).get("ticket_provider") or {})
59
- except Exception: tp = {}
60
- print(tp.get(key, "") if isinstance(tp, dict) else ""); break
61
- else:
62
- print("")
63
- PY
64
- }
65
-
66
- # gql QUERY [VARS_JSON] — POST a GraphQL request, print data JSON, fail on errors.
67
- gql() {
68
- need_key
69
- _vars="${2:-}"; [ -n "$_vars" ] || _vars='{}'
70
- python3 - "$1" "$_vars" "$GRAPHQL_URL" <<'PY'
71
- import json, os, sys, urllib.request, urllib.error
72
- q, variables = sys.argv[1], json.loads(sys.argv[2])
73
- req = urllib.request.Request(
74
- sys.argv[3],
75
- data=json.dumps({"query": q, "variables": variables}).encode(),
76
- headers={"Authorization": os.environ["LINEAR_API_KEY"],
77
- "Content-Type": "application/json"},
78
- method="POST")
79
- try:
80
- body = json.loads(urllib.request.urlopen(req, timeout=30).read())
81
- except urllib.error.HTTPError as e:
82
- body = json.loads(e.read() or "{}")
83
- except urllib.error.URLError as e:
84
- print(f"linear request failed: {e}", file=sys.stderr); sys.exit(1)
85
- if body.get("errors"):
86
- print(json.dumps(body["errors"]), file=sys.stderr); sys.exit(1)
87
- print(json.dumps(body.get("data") or {}))
88
- PY
89
- }
90
-
91
- TEAM="$(pj_cfg team)"; [ -n "$TEAM" ] || TEAM="$(tp_cfg team)"
92
- PROJECT="$(pj_cfg project)"; [ -n "$PROJECT" ] || PROJECT="$(tp_cfg project)"
93
- SM_IN_REVIEW="$(tp_cfg in_review)"; SM_IN_REVIEW="${SM_IN_REVIEW:-In Review}"
94
- SM_DONE="$(tp_cfg completed)"; SM_DONE="${SM_DONE:-Done}"
95
- SM_CANCELLED="$(tp_cfg cancelled)"; SM_CANCELLED="${SM_CANCELLED:-Canceled}"
96
- SM_STARTED="$(tp_cfg started)"
97
- SM_UNSTARTED="$(tp_cfg unstarted)"
98
- SM_BACKLOG="$(tp_cfg backlog)"
99
- MAX_PAGES="$(validated_uint LINEAR_MAX_PAGES "${LINEAR_MAX_PAGES:-1000}" 1 1000)"
100
-
101
- # All Linear ops require the API key; fail fast and clean before any pipe.
102
- need_key
103
-
104
- case "$OP" in
105
- resolve)
106
- [ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set in role.yaml"
107
- gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id key name}} }' \
108
- "$(printf '{"k":"%s"}' "$TEAM")" \
109
- | python3 -c 'import sys,json; d=json.load(sys.stdin); t=(d.get("teams",{}).get("nodes") or [{}])[0]; print(json.dumps({"provider":"linear","board_id":t.get("id",""),"board_url":"https://linear.app/team/"+t.get("key",""),"identifier":t.get("key","")}))'
110
- ;;
111
-
112
- active_milestone)
113
- # Linear project milestones; pick the first non-completed milestone in the project.
114
- gql 'query($p:String){ projects(filter:{name:{eq:$p}}){nodes{ id name projectMilestones{nodes{id name targetDate}} state }} }' \
115
- "$(printf '{"p":"%s"}' "$PROJECT")" \
116
- | python3 -c 'import sys,json
117
- d=json.load(sys.stdin); ps=d.get("projects",{}).get("nodes") or []
118
- p=ps[0] if ps else {}
119
- ms=(p.get("projectMilestones",{}) or {}).get("nodes") or []
120
- m=ms[0] if ms else {"id":p.get("id",""),"name":p.get("name","")}
121
- print(json.dumps({"id":m.get("id",""),"name":m.get("name",""),"state":p.get("state","")}))'
122
- ;;
123
-
124
- list_issues)
125
- [ -n "$TEAM" ] || die "ticket_provider.team not set"
126
- ISSUE_PAGE_DIR="$(mktemp -d "${TMPDIR:-/tmp}/linear-issues.XXXXXX")" || die "could not create pagination scratch directory"
127
- ISSUE_PAGE_ROWS="$ISSUE_PAGE_DIR/rows"
128
- ISSUE_PAGE_CURSORS="$ISSUE_PAGE_DIR/cursors"
129
- : > "$ISSUE_PAGE_ROWS"
130
- : > "$ISSUE_PAGE_CURSORS"
131
- cleanup_issue_pages() {
132
- rm -f "$ISSUE_PAGE_ROWS" "$ISSUE_PAGE_CURSORS"
133
- rmdir "$ISSUE_PAGE_DIR" 2>/dev/null || true
134
- }
135
- trap cleanup_issue_pages 0
136
- trap 'cleanup_issue_pages; exit 129' HUP
137
- trap 'cleanup_issue_pages; exit 130' INT
138
- trap 'cleanup_issue_pages; exit 143' TERM
139
- AFTER=""
140
- PAGE_COUNT=0
141
- while :; do
142
- PAGE_COUNT=$((PAGE_COUNT + 1))
143
- [ "$PAGE_COUNT" -le "$MAX_PAGES" ] \
144
- || die "pagination exceeded LINEAR_MAX_PAGES=$MAX_PAGES"
145
- VARS="$(python3 -c 'import json,sys; print(json.dumps({"k":sys.argv[1],"after":sys.argv[2] or None}))' "$TEAM" "$AFTER")"
146
- PAGE="$(gql 'query($k:String!,$after:String){ issues(first:100, after:$after, filter:{team:{key:{eq:$k}}}){nodes{ id identifier title updatedAt url state{name type} assignee{name} } pageInfo{hasNextPage endCursor}} }' "$VARS")"
147
- printf '%s' "$PAGE" | python3 -c 'import sys,json
148
- d=json.load(sys.stdin)
149
- for n in (d.get("issues") or {}).get("nodes") or []:
150
- st=n.get("state") or {}
151
- print(json.dumps({"id":n["id"],"key":n.get("identifier",""),"title":n.get("title",""),
152
- "state":st.get("name",""),"state_type":st.get("type",""),
153
- "updated_at":n.get("updatedAt",""),
154
- "assignee":(n.get("assignee") or {}).get("name",""),"url":n.get("url","")}))' \
155
- >> "$ISSUE_PAGE_ROWS"
156
- HAS_NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print("true" if ((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("hasNextPage") else "false")')"
157
- [ "$HAS_NEXT" = "true" ] || break
158
- NEXT="$(printf '%s' "$PAGE" | python3 -c 'import sys,json; print(str(((json.load(sys.stdin).get("issues") or {}).get("pageInfo") or {}).get("endCursor") or ""))')"
159
- [ -n "$NEXT" ] || die "Linear pagination reported another page without an end cursor"
160
- CURSOR_FINGERPRINT="$(python3 -c 'import hashlib,sys; print(hashlib.sha256(sys.argv[1].encode()).hexdigest())' "$NEXT")"
161
- if grep -Fqx "$CURSOR_FINGERPRINT" "$ISSUE_PAGE_CURSORS"; then
162
- die "Linear pagination cursor repeated"
163
- fi
164
- printf '%s\n' "$CURSOR_FINGERPRINT" >> "$ISSUE_PAGE_CURSORS"
165
- AFTER="$NEXT"
166
- done
167
- python3 - "$ISSUE_PAGE_ROWS" <<'PY'
168
- import json
169
- import pathlib
170
- import sys
171
-
172
- rows = [json.loads(line) for line in pathlib.Path(sys.argv[1]).read_text().splitlines() if line]
173
- print(json.dumps(rows))
174
- PY
175
- cleanup_issue_pages
176
- trap - 0 HUP INT TERM
177
- ;;
178
-
179
- get_issue)
180
- ID="${1:?usage: get_issue <id>}"
181
- gql 'query($id:String!){ issue(id:$id){ id identifier title description state{name type} comments{nodes{id body user{name}}} } }' \
182
- "$(printf '{"id":"%s"}' "$ID")" \
183
- | python3 -c 'import sys,json
184
- d=json.load(sys.stdin); i=d.get("issue") or {}
185
- st=i.get("state") or {}
186
- cs=[{"id":c["id"],"body":c.get("body",""),"author":(c.get("user") or {}).get("name","")} for c in (i.get("comments",{}) or {}).get("nodes") or []]
187
- print(json.dumps({"id":i.get("id",""),"key":i.get("identifier",""),"title":i.get("title",""),
188
- "description":i.get("description",""),"acceptance":i.get("description",""),
189
- "state":st.get("name",""),"state_type":st.get("type",""),"comments":cs}))'
190
- ;;
191
-
192
- comment)
193
- ID="${1:?usage: comment <id> <body>}"; BODY="${2:?usage: comment <id> <body>}"
194
- gql 'mutation($id:String!,$b:String!){ commentCreate(input:{issueId:$id,body:$b}){ comment{id issue{id}} success } }' \
195
- "$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"b":sys.argv[2]}))' "$ID" "$BODY")" \
196
- | EXPECTED_ID="$ID" python3 -c 'import sys,json,os
197
- document=json.load(sys.stdin)
198
- created=document.get("commentCreate") if isinstance(document,dict) else None
199
- if not isinstance(created,dict) or created.get("success") is not True:
200
- raise SystemExit("linear: commentCreate did not report success")
201
- comment=created.get("comment")
202
- if not isinstance(comment,dict):
203
- raise SystemExit("linear: commentCreate response omitted its comment object")
204
- comment_id=comment.get("id")
205
- if not isinstance(comment_id,str) or not comment_id.strip():
206
- raise SystemExit("linear: commentCreate response omitted its comment id")
207
- issue=comment.get("issue")
208
- if not isinstance(issue,dict) or str(issue.get("id") or "").strip()!=os.environ["EXPECTED_ID"]:
209
- raise SystemExit("linear: commentCreate response did not identify the requested issue")
210
- print(comment_id.strip())'
211
- ;;
212
-
213
- transition)
214
- ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
215
- # Map normalized -> a concrete Linear state name, then resolve its id on the team.
216
- case "$TARGET" in
217
- completed) WANT_TYPE=completed; WANT_NAME="$SM_DONE" ;;
218
- cancelled) WANT_TYPE=canceled; WANT_NAME="$SM_CANCELLED" ;;
219
- in_review) WANT_TYPE=started; WANT_NAME="$SM_IN_REVIEW" ;;
220
- started) WANT_TYPE=started; WANT_NAME="$SM_STARTED" ;;
221
- unstarted) WANT_TYPE=unstarted; WANT_NAME="$SM_UNSTARTED" ;;
222
- backlog) WANT_TYPE=backlog; WANT_NAME="$SM_BACKLOG" ;;
223
- *) die "invalid normalized state: $TARGET" ;;
224
- esac
225
- STATE_ID="$(gql 'query($id:String!){ issue(id:$id){ team{ states{nodes{id name type}} } } }' \
226
- "$(printf '{"id":"%s"}' "$ID")" \
227
- | WANT_TYPE="$WANT_TYPE" WANT_NAME="$WANT_NAME" python3 -c 'import sys,json,os
228
- d=json.load(sys.stdin)
229
- states=((d.get("issue") or {}).get("team") or {}).get("states",{}).get("nodes") or []
230
- want_t=os.environ["WANT_TYPE"]; want_n=os.environ.get("WANT_NAME","")
231
- if want_n:
232
- candidates=[s for s in states if str(s.get("name") or "").strip().casefold()==want_n.strip().casefold()]
233
- basis=f"configured name {want_n!r}"
234
- else:
235
- candidates=[s for s in states if s.get("type")==want_t]
236
- basis=f"workflow type {want_t!r}"
237
- if len(candidates) != 1:
238
- raise SystemExit(f"linear: {basis} resolved {len(candidates)} states; exactly one is required")
239
- pick=candidates[0]
240
- if pick.get("type") != want_t:
241
- raise SystemExit(f"linear: configured state {want_n!r} has type {pick.get('type')!r}, expected {want_t!r}")
242
- state_id=str(pick.get("id") or "")
243
- if not state_id:
244
- raise SystemExit("linear: resolved state omitted its id")
245
- print(state_id)')"
246
- [ -n "$STATE_ID" ] || die "no Linear state for normalized '$TARGET'"
247
- gql 'mutation($id:String!,$s:String!){ issueUpdate(id:$id,input:{stateId:$s}){ success issue{id identifier state{id name type}} } }' \
248
- "$(python3 -c 'import json,sys; print(json.dumps({"id":sys.argv[1],"s":sys.argv[2]}))' "$ID" "$STATE_ID")" \
249
- | EXPECTED_ID="$ID" EXPECTED_STATE_ID="$STATE_ID" python3 -c 'import sys,json,os
250
- u=json.load(sys.stdin).get("issueUpdate") or {}
251
- if u.get("success") is not True:
252
- raise SystemExit("linear: issueUpdate did not report success")
253
- issue=u.get("issue") or {}
254
- if str(issue.get("id") or "") != os.environ["EXPECTED_ID"]:
255
- raise SystemExit("linear: issueUpdate read-back did not identify the requested issue")
256
- state=issue.get("state") or {}
257
- if str(state.get("id") or "") != os.environ["EXPECTED_STATE_ID"]:
258
- raise SystemExit("linear: issueUpdate read-back did not confirm the exact target state")
259
- print("ok " + str(issue.get("identifier") or issue.get("id") or ""))'
260
- ;;
261
-
262
- describe_board)
263
- # Read-only team lookup by id, against an EXPLICIT workspace argument so no
264
- # ambient binding can answer for the wrong org. Linear's team KEY is a real
265
- # authoritative identifier — it prefixes every issue — so it is read back
266
- # from Linear itself and never proposed locally.
267
- DWS="${1:?usage: describe_board <workspace> <board_id>}"
268
- DBID="${2:?usage: describe_board <workspace> <board_id>}"
269
- gql 'query($id:String!){ team(id:$id){ id key name } organization{ urlKey } }' \
270
- "$(printf '{"id":"%s"}' "$DBID")" \
271
- | WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
272
- d = json.load(sys.stdin)
273
- t = d.get("team") or {}
274
- ident = str(t.get("key") or "")
275
- if not ident:
276
- raise SystemExit("linear: team %s reported no authoritative key" % os.environ["BID"])
277
- print(json.dumps({
278
- "board_id": str(t.get("id") or os.environ["BID"]),
279
- "identifier": ident,
280
- "workspace": str((d.get("organization") or {}).get("urlKey") or os.environ["WS"]),
281
- "name": str(t.get("name") or ""),
282
- }))'
283
- ;;
284
-
285
- create_board)
286
- # Linear teams/projects are created by humans; the adapter resolves, not creates.
287
- echo "linear: create_board is a no-op (Linear team/project created via Linear UI); using resolve" >&2
288
- exec sh "$0" resolve
289
- ;;
290
-
291
- create_issue)
292
- # File a new issue on the bound team. Team comes from resolved config, never
293
- # from an argument. Deliberately NOT idempotent by default: two issues may
294
- # legitimately share a title. Pass --if-absent to reuse an issue whose title
295
- # already matches instead.
296
- IF_ABSENT=0
297
- case "${1:-}" in --if-absent) IF_ABSENT=1; shift ;; esac
298
- TITLE="${1:?usage: create_issue [--if-absent] <title> [description]}"; DESC="${2:-}"
299
- [ -n "$TEAM" ] || die "ticket_provider.team (Linear team key) not set"
300
- ISSUE=""; CREATED=true
301
- if [ "$IF_ABSENT" = 1 ]; then
302
- ISSUE="$(gql 'query($k:String!,$t:String!){ issues(first:1, filter:{team:{key:{eq:$k}}, title:{eq:$t}}){nodes{id identifier url}} }' \
303
- "$(python3 -c 'import json,sys; print(json.dumps({"k":sys.argv[1],"t":sys.argv[2]}))' "$TEAM" "$TITLE")" \
304
- | python3 -c 'import sys,json
305
- ns=((json.load(sys.stdin).get("issues") or {}).get("nodes")) or []
306
- print(json.dumps(ns[0]) if ns else "")')"
307
- [ -z "$ISSUE" ] || CREATED=false
308
- fi
309
- if [ -z "$ISSUE" ]; then
310
- TEAM_ID="$(gql 'query($k:String!){ teams(filter:{key:{eq:$k}}){nodes{id}} }' "$(printf '{"k":"%s"}' "$TEAM")" \
311
- | python3 -c 'import sys,json
312
- ns=((json.load(sys.stdin).get("teams") or {}).get("nodes")) or []
313
- print(ns[0].get("id","") if ns else "")')"
314
- [ -n "$TEAM_ID" ] || die "no Linear team with key '$TEAM'"
315
- ISSUE="$(gql 'mutation($team:String!,$t:String!,$d:String){ issueCreate(input:{teamId:$team,title:$t,description:$d}){ success issue{id identifier url} } }' \
316
- "$(python3 -c 'import json,sys; print(json.dumps({"team":sys.argv[1],"t":sys.argv[2],"d":sys.argv[3] or None}))' "$TEAM_ID" "$TITLE" "$DESC")" \
317
- | python3 -c 'import sys,json
318
- r=json.load(sys.stdin).get("issueCreate") or {}
319
- print(json.dumps(r["issue"]) if r.get("success") and r.get("issue") else "")')"
320
- fi
321
- [ -n "$ISSUE" ] || die "create_issue failed"
322
- printf '%s' "$ISSUE" | CREATED="$CREATED" python3 -c 'import sys,json,os
323
- i=json.load(sys.stdin)
324
- print(json.dumps({"issue_id":i.get("id",""),"key":i.get("identifier",""),
325
- "issue_url":i.get("url",""),"created":os.environ["CREATED"]=="true"}))'
326
- ;;
327
-
328
- *) die "unknown op: $OP" ;;
329
- esac