methodology-framework 0.1.0__py3-none-any.whl

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.
@@ -0,0 +1,210 @@
1
+ """Scan story files for unpopulated agent_acus fields and backfill from Devin API."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import glob
6
+ import logging
7
+ import re
8
+ import sys
9
+ from dataclasses import dataclass
10
+
11
+ import requests
12
+
13
+ logger = logging.getLogger(__name__)
14
+
15
+ _SESSION_URL_RE = re.compile(
16
+ r"agent_session_url:\s*[\"']?https://app\.devin\.ai/sessions/([a-zA-Z0-9_-]+)[\"']?"
17
+ )
18
+ _ACUS_PENDING_RE = re.compile(r"^(\s*)agent_acus:\s*[\"']?pending[\"']?\s*$", re.MULTILINE)
19
+ _ACUS_NUMERIC_RE = re.compile(r"^\s*agent_acus:\s*[0-9]", re.MULTILINE)
20
+ _ACUS_PRESENT_RE = re.compile(r"^(\s*)agent_acus:\s*\S", re.MULTILINE)
21
+ _SESSION_URL_LINE_RE = re.compile(r"^(\s*)agent_session_url:\s*\S", re.MULTILINE)
22
+
23
+
24
+ @dataclass
25
+ class PopulateResult:
26
+ """Result of processing a single story file."""
27
+
28
+ path: str
29
+ session_id: str
30
+ acus: float
31
+ action: str # "replaced" | "inserted"
32
+
33
+
34
+ def extract_session_id(content: str) -> str | None:
35
+ """Extract the Devin session ID from a story file's agent_session_url field."""
36
+ m = _SESSION_URL_RE.search(content)
37
+ return m.group(1) if m else None
38
+
39
+
40
+ def needs_population(content: str) -> bool:
41
+ """Return True if the file has agent_acus pending or missing (with session URL present)."""
42
+ if _ACUS_PENDING_RE.search(content):
43
+ return True
44
+ has_session_url = _SESSION_URL_LINE_RE.search(content)
45
+ has_acus = _ACUS_PRESENT_RE.search(content)
46
+ return bool(has_session_url) and not bool(has_acus)
47
+
48
+
49
+ def rewrite_acus(content: str, acus_value: float) -> tuple[str, str]:
50
+ """Rewrite agent_acus in the content. Returns (new_content, action).
51
+
52
+ action is "replaced" if pending was replaced, "inserted" if the field was added.
53
+ """
54
+ m = _ACUS_PENDING_RE.search(content)
55
+ if m:
56
+ indent = m.group(1)
57
+ new_content = _ACUS_PENDING_RE.sub(f"{indent}agent_acus: {acus_value}", content)
58
+ return new_content, "replaced"
59
+
60
+ if _ACUS_NUMERIC_RE.search(content):
61
+ return content, ""
62
+
63
+ m = _SESSION_URL_LINE_RE.search(content)
64
+ if m:
65
+ indent = m.group(0).split("agent_session_url")[0]
66
+ line_end = content.find("\n", m.start())
67
+ if line_end == -1:
68
+ insert_pos = len(content)
69
+ new_content = content + f"\n{indent}agent_acus: {acus_value}"
70
+ else:
71
+ insert_pos = line_end + 1
72
+ new_content = (
73
+ content[:insert_pos] + f"{indent}agent_acus: {acus_value}\n" + content[insert_pos:]
74
+ )
75
+ return new_content, "inserted"
76
+
77
+ return content, ""
78
+
79
+
80
+ def fetch_acus_from_devin(session_id: str, api_token: str) -> float | None:
81
+ """Fetch ACU consumption from Devin API for a given session ID.
82
+
83
+ Returns the ACU value or None on error.
84
+ # Devin API v2: GET /v2/session/{session_id} returns JSON with
85
+ # "total_acus" at the top level of the response object.
86
+ """
87
+ url = f"https://api.devin.ai/v2/session/{session_id}"
88
+ headers = {"Authorization": f"Bearer {api_token}"}
89
+ try:
90
+ resp = requests.get(url, headers=headers, timeout=30)
91
+ resp.raise_for_status()
92
+ data = resp.json()
93
+ acus = data.get("total_acus")
94
+ if acus is None:
95
+ # Fallback: try nested under "status_enum" or "acus"
96
+ acus = data.get("acus")
97
+ if acus is not None:
98
+ return float(acus)
99
+ logger.warning(
100
+ "Session %s: no ACU field found in response keys: %s",
101
+ session_id,
102
+ list(data.keys()),
103
+ )
104
+ return None
105
+ except requests.HTTPError as exc:
106
+ logger.warning("Devin API error for session %s: %s", session_id, exc)
107
+ return None
108
+ except (requests.ConnectionError, requests.Timeout) as exc:
109
+ logger.warning("Devin API connection error for session %s: %s", session_id, exc)
110
+ return None
111
+
112
+
113
+ def scan_and_populate(
114
+ story_path_pattern: str,
115
+ api_token: str,
116
+ *,
117
+ dry_run: bool = False,
118
+ ) -> list[PopulateResult]:
119
+ """Scan story files and populate agent_acus from Devin API.
120
+
121
+ Returns a list of PopulateResult for each file that was updated.
122
+ """
123
+ results: list[PopulateResult] = []
124
+ files = sorted(glob.glob(story_path_pattern, recursive=True))
125
+ logger.info("Scanning %d story files matching %s", len(files), story_path_pattern)
126
+
127
+ for filepath in files:
128
+ with open(filepath, encoding="utf-8") as f:
129
+ content = f.read()
130
+
131
+ if not needs_population(content):
132
+ continue
133
+
134
+ session_id = extract_session_id(content)
135
+ if not session_id:
136
+ logger.info("Skipping %s: no agent_session_url found", filepath)
137
+ continue
138
+
139
+ acus = fetch_acus_from_devin(session_id, api_token)
140
+ if acus is None:
141
+ logger.warning(
142
+ "Skipping %s: could not fetch ACUs for session %s",
143
+ filepath,
144
+ session_id,
145
+ )
146
+ continue
147
+
148
+ new_content, action = rewrite_acus(content, acus)
149
+ if not action:
150
+ continue
151
+
152
+ if not dry_run:
153
+ with open(filepath, "w", encoding="utf-8") as f:
154
+ f.write(new_content)
155
+
156
+ results.append(
157
+ PopulateResult(
158
+ path=filepath,
159
+ session_id=session_id,
160
+ acus=acus,
161
+ action=action,
162
+ )
163
+ )
164
+ logger.info("Updated %s: agent_acus=%s (%s)", filepath, acus, action)
165
+
166
+ return results
167
+
168
+
169
+ def main() -> int:
170
+ """CLI entry point for populate-acus."""
171
+ import argparse
172
+ import os
173
+
174
+ parser = argparse.ArgumentParser(
175
+ description="Populate agent_acus in story files from Devin API",
176
+ )
177
+ parser.add_argument(
178
+ "--story-path-pattern",
179
+ default="docs/stories/**/*.md",
180
+ help="Glob pattern for story files (default: docs/stories/**/*.md)",
181
+ )
182
+ parser.add_argument(
183
+ "--dry-run",
184
+ action="store_true",
185
+ help="Print what would be changed without modifying files",
186
+ )
187
+ args = parser.parse_args()
188
+
189
+ logging.basicConfig(level=logging.INFO, format="%(levelname)s: %(message)s")
190
+
191
+ api_token = os.environ.get("DEVIN_API_TOKEN", "")
192
+ if not api_token:
193
+ logger.error("DEVIN_API_TOKEN environment variable is required")
194
+ return 1
195
+
196
+ results = scan_and_populate(args.story_path_pattern, api_token, dry_run=args.dry_run)
197
+
198
+ if not results:
199
+ print("Nothing to do — all agent_acus fields already populated.")
200
+ return 0
201
+
202
+ print(f"\nPopulated agent_acus for {len(results)} story file(s):")
203
+ for r in results:
204
+ print(f" {r.path}: {r.acus} ({r.action})")
205
+
206
+ return 0
207
+
208
+
209
+ if __name__ == "__main__":
210
+ sys.exit(main())
@@ -0,0 +1,294 @@
1
+ #!/usr/bin/env python3
2
+ """Register a rendered playbook with Devin's API and verify the registration.
3
+
4
+ Usage:
5
+ python scripts/register_playbook_with_devin.py \
6
+ --playbook-id scrum-router \
7
+ --rendered-body /path/to/rendered.md \
8
+ --macro '!scrum_pickup'
9
+
10
+ The --playbook-id value must match the ``playbook_id`` field in the source
11
+ body's YAML frontmatter. It is used as the lookup slug against Devin's API.
12
+
13
+ Environment:
14
+ DEVIN_API_TOKEN Devin API key (cog_ prefix). Required.
15
+
16
+ Exit codes:
17
+ 0 Registration succeeded and smoke-verify passed.
18
+ 1 Any failure (auth, API error, SHA256 mismatch, playbook not found).
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import argparse
24
+ import hashlib
25
+ import json
26
+ import os
27
+ import re
28
+ import sys
29
+ import urllib.error
30
+ import urllib.request
31
+ from typing import Any
32
+
33
+ MACRO_PATTERN = re.compile(r"^![A-Za-z0-9_]+$")
34
+
35
+ DEVIN_API_BASE = "https://api.devin.ai/v1"
36
+
37
+
38
+ def _request(
39
+ method: str,
40
+ path: str,
41
+ token: str,
42
+ data: dict[str, Any] | None = None,
43
+ ) -> dict[str, Any]:
44
+ """Make an authenticated request to the Devin API."""
45
+ url = f"{DEVIN_API_BASE}{path}"
46
+ body = json.dumps(data).encode("utf-8") if data else None
47
+ req = urllib.request.Request(
48
+ url,
49
+ data=body,
50
+ headers={
51
+ "Authorization": f"Bearer {token}",
52
+ "Content-Type": "application/json",
53
+ },
54
+ method=method,
55
+ )
56
+ try:
57
+ with urllib.request.urlopen(req) as resp:
58
+ result: dict[str, Any] = json.loads(resp.read().decode("utf-8"))
59
+ return result
60
+ except urllib.error.HTTPError as e:
61
+ error_body = e.read().decode("utf-8", errors="replace")
62
+ print(
63
+ f"ERROR: Devin API {method} {path} returned {e.code}: {error_body}",
64
+ file=sys.stderr,
65
+ )
66
+ sys.exit(1)
67
+
68
+
69
+ def sha256hex(text: str) -> str:
70
+ return hashlib.sha256(text.encode("utf-8")).hexdigest()
71
+
72
+
73
+ def list_playbooks(token: str) -> list[dict[str, Any]]:
74
+ """List all playbooks accessible to the token's org."""
75
+ resp: Any = _request("GET", "/playbooks", token)
76
+ # v1 returns {"playbooks": [...]} or a bare list
77
+ if isinstance(resp, list):
78
+ return resp
79
+ items: list[dict[str, Any]] = resp.get("playbooks", resp.get("data", []))
80
+ return items
81
+
82
+
83
+ def find_playbook_by_name(playbooks: list[dict[str, Any]], name: str) -> dict[str, Any] | None:
84
+ """Find a playbook by its display name (exact match)."""
85
+ for p in playbooks:
86
+ pname = p.get("name") or p.get("title") or ""
87
+ if pname == name:
88
+ return p
89
+ return None
90
+
91
+
92
+ def _build_payload(title: str, content: str, macro: str | None = None) -> dict[str, Any]:
93
+ """Build the JSON payload for CREATE or UPDATE requests.
94
+
95
+ Centralizes field-name choices (REST uses 'body', not 'content').
96
+ Includes 'macro' only when a value is provided.
97
+ """
98
+ payload: dict[str, Any] = {"title": title, "body": content}
99
+ if macro is not None:
100
+ payload["macro"] = macro
101
+ return payload
102
+
103
+
104
+ def update_playbook(
105
+ token: str, playbook_id: str, title: str, content: str, macro: str | None = None
106
+ ) -> dict[str, Any]:
107
+ """Update a playbook's title and content (full replace)."""
108
+ payload = _build_payload(title, content, macro)
109
+ return _request("PUT", f"/playbooks/{playbook_id}", token, payload)
110
+
111
+
112
+ def get_playbook(token: str, playbook_id: str) -> dict[str, Any]:
113
+ """Read back a playbook by ID."""
114
+ return _request("GET", f"/playbooks/{playbook_id}", token)
115
+
116
+
117
+ def create_playbook(
118
+ token: str, slug: str, title: str, content: str, macro: str | None = None
119
+ ) -> dict[str, Any]:
120
+ """Create a new playbook with the given slug, title, and content."""
121
+ payload = _build_payload(title, content, macro)
122
+ payload["playbook_id"] = slug
123
+ return _request("POST", "/playbooks", token, payload)
124
+
125
+
126
+ def main() -> int:
127
+ parser = argparse.ArgumentParser(description=__doc__)
128
+ parser.add_argument(
129
+ "--playbook-id",
130
+ required=True,
131
+ help="Playbook slug (from source body frontmatter playbook_id:).",
132
+ )
133
+ parser.add_argument(
134
+ "--playbook-name",
135
+ default=None,
136
+ help="Display name / title (used only when creating a new playbook).",
137
+ )
138
+ parser.add_argument(
139
+ "--rendered-body",
140
+ required=True,
141
+ help="Path to the rendered playbook markdown file.",
142
+ )
143
+ parser.add_argument(
144
+ "--macro",
145
+ default=None,
146
+ help=(
147
+ "Macro trigger name (e.g. '!scrum_pickup'). "
148
+ "Included in CREATE/UPDATE payloads when provided."
149
+ ),
150
+ )
151
+ parser.add_argument(
152
+ "--dry-run",
153
+ action="store_true",
154
+ help="List playbooks and show what would be updated, but don't write.",
155
+ )
156
+ args = parser.parse_args()
157
+
158
+ if args.macro is not None and not MACRO_PATTERN.match(args.macro):
159
+ print(
160
+ f"ERROR: --macro value must start with '!' followed by word characters "
161
+ f"(letters, digits, underscores). Got: {args.macro!r}",
162
+ file=sys.stderr,
163
+ )
164
+ return 1
165
+
166
+ token = os.environ.get("DEVIN_API_TOKEN", "")
167
+ if not token:
168
+ print("ERROR: DEVIN_API_TOKEN environment variable is not set.", file=sys.stderr)
169
+ return 1
170
+
171
+ rendered_path = args.rendered_body
172
+ try:
173
+ rendered_content = open(rendered_path, encoding="utf-8").read()
174
+ except FileNotFoundError:
175
+ print(f"ERROR: Rendered body file not found: {rendered_path}", file=sys.stderr)
176
+ return 1
177
+
178
+ rendered_sha = sha256hex(rendered_content)
179
+ print(f"Rendered body SHA256: {rendered_sha}", file=sys.stderr)
180
+ print(f"Rendered body length: {len(rendered_content)} chars", file=sys.stderr)
181
+
182
+ slug: str = args.playbook_id
183
+ name: str = args.playbook_name or slug
184
+
185
+ # Step 1: List playbooks and find the target by name
186
+ print("Listing playbooks...", file=sys.stderr)
187
+ playbooks = list_playbooks(token)
188
+ names = [p.get("name") or p.get("title") or "?" for p in playbooks]
189
+ print(f"Found {len(playbooks)} playbook(s); available names: {names}", file=sys.stderr)
190
+
191
+ matches = [p for p in playbooks if (p.get("name") or p.get("title")) == name]
192
+ if len(matches) >= 2:
193
+ ids = [p.get("id") or p.get("playbook_id") for p in matches]
194
+ print(
195
+ f"ERROR: Multiple playbooks found with name '{name}': "
196
+ f"{', '.join(str(i) for i in ids)}.\n"
197
+ f"Operator action required: delete duplicates in Devin's webapp, "
198
+ f"leaving exactly one '{name}' playbook. Then re-run.",
199
+ file=sys.stderr,
200
+ )
201
+ return 1
202
+ target = matches[0] if matches else None
203
+
204
+ if target is None:
205
+ print(
206
+ f"No existing playbook with name '{name}'. Creating new playbook...",
207
+ file=sys.stderr,
208
+ )
209
+ if args.dry_run:
210
+ print("DRY RUN — would create new playbook.", file=sys.stderr)
211
+ return 0
212
+ created = create_playbook(token, slug, name, rendered_content, args.macro)
213
+ created_id: str = created.get("playbook_id") or created.get("id") or ""
214
+ print(f"Created playbook: id={created_id}, title={name}", file=sys.stderr)
215
+
216
+ # Smoke-verify the creation
217
+ print("Smoke-verifying creation...", file=sys.stderr)
218
+ readback = get_playbook(token, created_id)
219
+ readback_content = readback.get("content") or readback.get("body") or ""
220
+ readback_sha = sha256hex(readback_content)
221
+ if readback_sha != rendered_sha:
222
+ print(
223
+ f"ERROR: Smoke-verify FAILED. "
224
+ f"Rendered SHA256: {rendered_sha}, "
225
+ f"Read-back SHA256: {readback_sha}",
226
+ file=sys.stderr,
227
+ )
228
+ return 1
229
+ print(f"Smoke-verify PASSED. SHA256 match: {rendered_sha}", file=sys.stderr)
230
+ return 0
231
+
232
+ playbook_id: str = target.get("playbook_id") or target.get("id") or ""
233
+ playbook_title: str = target.get("title") or target.get("name") or name
234
+ if not playbook_id:
235
+ print("ERROR: Could not determine playbook ID from API response.", file=sys.stderr)
236
+ return 1
237
+ print(
238
+ f"Found playbook: id={playbook_id}, title={playbook_title}; matched '{name}'; updating...",
239
+ file=sys.stderr,
240
+ )
241
+
242
+ if args.dry_run:
243
+ print("DRY RUN — skipping update and verify.", file=sys.stderr)
244
+ return 0
245
+
246
+ # Step 2: Check if content or macro has changed
247
+ existing_content: str = target.get("content") or target.get("body") or ""
248
+ existing_sha = sha256hex(existing_content)
249
+ existing_macro: str | None = target.get("macro")
250
+ body_matches = existing_sha == rendered_sha
251
+ macro_matches = existing_macro == args.macro
252
+ if body_matches and macro_matches:
253
+ print(
254
+ "Registered body already matches rendered output (SHA256 identical) "
255
+ "and macro is in sync. Skipping registration.",
256
+ file=sys.stderr,
257
+ )
258
+ return 0
259
+
260
+ reasons: list[str] = []
261
+ if not body_matches:
262
+ reasons.append(f"content differs (existing SHA256: {existing_sha})")
263
+ if not macro_matches:
264
+ reasons.append(f"macro differs (existing: {existing_macro!r}, target: {args.macro!r})")
265
+ print(f"Update needed: {'; '.join(reasons)}. Updating...", file=sys.stderr)
266
+
267
+ # Step 3: Update the playbook
268
+ update_playbook(token, playbook_id, playbook_title, rendered_content, args.macro)
269
+ print("Playbook updated successfully.", file=sys.stderr)
270
+
271
+ # Step 4: Smoke-verify — read back and compare SHA256
272
+ print("Smoke-verifying registration...", file=sys.stderr)
273
+ readback = get_playbook(token, playbook_id)
274
+ readback_content = readback.get("content") or readback.get("body") or ""
275
+ readback_sha = sha256hex(readback_content)
276
+
277
+ if readback_sha != rendered_sha:
278
+ print(
279
+ f"ERROR: Smoke-verify FAILED. "
280
+ f"Rendered SHA256: {rendered_sha}, "
281
+ f"Read-back SHA256: {readback_sha}",
282
+ file=sys.stderr,
283
+ )
284
+ return 1
285
+
286
+ print(
287
+ f"Smoke-verify PASSED. SHA256 match: {rendered_sha}",
288
+ file=sys.stderr,
289
+ )
290
+ return 0
291
+
292
+
293
+ if __name__ == "__main__":
294
+ sys.exit(main())