methodology-framework 0.4.1__tar.gz → 0.4.2__tar.gz

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 (37) hide show
  1. {methodology_framework-0.4.1/src/methodology_framework.egg-info → methodology_framework-0.4.2}/PKG-INFO +2 -1
  2. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/README.md +1 -0
  3. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/pyproject.toml +1 -1
  4. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_discovery.py +39 -7
  5. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/playbooks/bindings/meth.yaml +1 -0
  6. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/playbooks/bindings/scrum.yaml +1 -0
  7. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/playbooks/scrum-router.body.md +7 -5
  8. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/populate_acus.py +153 -22
  9. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/register_playbook_with_devin.py +86 -37
  10. {methodology_framework-0.4.1 → methodology_framework-0.4.2/src/methodology_framework.egg-info}/PKG-INFO +2 -1
  11. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_jira_discovery.py +18 -0
  12. methodology_framework-0.4.2/tests/test_populate_acus.py +498 -0
  13. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_register_playbook_with_devin.py +15 -10
  14. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_workflows.py +78 -0
  15. methodology_framework-0.4.1/tests/test_populate_acus.py +0 -245
  16. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/LICENSE +0 -0
  17. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/setup.cfg +0 -0
  18. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/__init__.py +0 -0
  19. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/__main__.py +0 -0
  20. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/bootstrap_jira.py +0 -0
  21. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/build_playbook.py +0 -0
  22. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/__init__.py +0 -0
  23. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
  24. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/custom_fields.yaml +0 -0
  25. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/workflow.yaml +0 -0
  26. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/specs/devin-story-format.md +0 -0
  27. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/sync_stories_to_jira.py +0 -0
  28. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
  29. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/templates/story.md +0 -0
  30. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/SOURCES.txt +0 -0
  31. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
  32. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/requires.txt +0 -0
  33. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/top_level.txt +0 -0
  34. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_bootstrap_jira.py +0 -0
  35. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_build_playbook.py +0 -0
  36. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_populate_acus_integration.py +0 -0
  37. {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_sync_stories_to_jira.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodology-framework
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Portable process tooling for agent-driven delivery — scripts, playbooks, templates, and specs.
5
5
  Author: whiteout59
6
6
  License-Expression: Apache-2.0
@@ -22,6 +22,7 @@ Requires-Dist: types-colorama<1,>=0.4; extra == "dev"
22
22
  Requires-Dist: vcrpy<9,>=7.0; extra == "dev"
23
23
  Dynamic: license-file
24
24
 
25
+ [![PyPI version](https://img.shields.io/pypi/v/methodology-framework)](https://pypi.org/project/methodology-framework/)
25
26
  # methodology-framework
26
27
 
27
28
  Portable process tooling for agent-driven delivery -- scripts, playbooks, templates, and specs.
@@ -1,3 +1,4 @@
1
+ [![PyPI version](https://img.shields.io/pypi/v/methodology-framework)](https://pypi.org/project/methodology-framework/)
1
2
  # methodology-framework
2
3
 
3
4
  Portable process tooling for agent-driven delivery -- scripts, playbooks, templates, and specs.
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "methodology-framework"
7
- version = "0.4.1"
7
+ version = "0.4.2"
8
8
  description = "Portable process tooling for agent-driven delivery — scripts, playbooks, templates, and specs."
9
9
  readme = "README.md"
10
10
  license = "Apache-2.0"
@@ -78,7 +78,16 @@ LINK_TYPE_NAME_BLOCKS = "Blocks"
78
78
 
79
79
  # Canonical issue type names
80
80
  ISSUE_TYPE_NAME_STORY = "Story"
81
- ISSUE_TYPE_NAME_SUBTASK = "Sub-task"
81
+ ISSUE_TYPE_NAME_SUBTASK = "Sub-task" # canonical (classic Atlassian spelling)
82
+
83
+ # Modern Atlassian UI defaults to "Subtask" (no hyphen) for newly-provisioned
84
+ # projects. Discovery tolerates either spelling; the matched alias gets
85
+ # canonicalized to ISSUE_TYPE_NAME_SUBTASK in the returned name→ID map so
86
+ # downstream code uses one stable key regardless of which spelling the
87
+ # adopter's Jira tenant uses. Per methodology §3.9 (No Hardcoded Identity
88
+ # Strings) and §3.7 (Portability by Construction) — tenant-spelling
89
+ # variants must not break the framework's portability claim.
90
+ ISSUE_TYPE_NAME_SUBTASK_ALIASES = ("Subtask",)
82
91
 
83
92
  # Protected/terminal status names
84
93
  PROTECTED_STATUS_NAMES = frozenset(
@@ -291,9 +300,14 @@ def discover_issue_types(
291
300
  is unsafe (it returns types across all projects and dict-overwrite
292
301
  picks the wrong project's ID when names collide).
293
302
 
294
- Returns mapping from issue type name to ID string.
295
- Raises DiscoveryError if "Story" or "Sub-task" is missing from the
296
- project's issue type list.
303
+ Returns mapping from issue type name to ID string. The Subtask issue
304
+ type is tolerated under either the classic spelling ``"Sub-task"`` or
305
+ the modern Atlassian default ``"Subtask"`` — whichever the project
306
+ actually has gets canonicalized to ``ISSUE_TYPE_NAME_SUBTASK`` in the
307
+ returned map so downstream code uses one stable key.
308
+
309
+ Raises DiscoveryError if ``"Story"`` is missing OR if neither of the
310
+ Subtask spellings is present in the project's issue type list.
297
311
  """
298
312
  resp = session.get(f"{base_url}/rest/api/3/project/{project_key}")
299
313
  resp.raise_for_status()
@@ -304,9 +318,27 @@ def discover_issue_types(
304
318
  for it in project_issue_types:
305
319
  name_to_id[it["name"]] = it["id"]
306
320
 
307
- for required_name in (ISSUE_TYPE_NAME_STORY, ISSUE_TYPE_NAME_SUBTASK):
308
- if required_name not in name_to_id:
309
- raise _fail("issue type", required_name, project_key)
321
+ if ISSUE_TYPE_NAME_STORY not in name_to_id:
322
+ raise _fail("issue type", ISSUE_TYPE_NAME_STORY, project_key)
323
+
324
+ # Subtask tolerance: accept canonical spelling OR any registered alias.
325
+ # If matched via an alias, add a synthetic entry under the canonical
326
+ # name so downstream lookups have a stable key.
327
+ if ISSUE_TYPE_NAME_SUBTASK not in name_to_id:
328
+ matched_alias = next(
329
+ (a for a in ISSUE_TYPE_NAME_SUBTASK_ALIASES if a in name_to_id),
330
+ None,
331
+ )
332
+ if matched_alias is None:
333
+ accepted_aliases = ", ".join(ISSUE_TYPE_NAME_SUBTASK_ALIASES)
334
+ error_name = f"{ISSUE_TYPE_NAME_SUBTASK} (also accepted: {accepted_aliases})"
335
+ raise _fail("issue type", error_name, project_key)
336
+ name_to_id[ISSUE_TYPE_NAME_SUBTASK] = name_to_id[matched_alias]
337
+ logger.info(
338
+ "Discovered Subtask issue type under alias '%s'; canonicalized as '%s'",
339
+ matched_alias,
340
+ ISSUE_TYPE_NAME_SUBTASK,
341
+ )
310
342
 
311
343
  return name_to_id
312
344
 
@@ -8,3 +8,4 @@ playbook_bindings:
8
8
  REQ_DOC_PATH: "docs/requirements.md"
9
9
  CONFIG_DOC_PATH: "docs/jira-pickup-config.md"
10
10
  STORY_PATH_PATTERN: "docs/stories/**/*.md"
11
+ PLAYBOOK_ID: "meth-router"
@@ -9,3 +9,4 @@ playbook_bindings:
9
9
  REQ_DOC_PATH: "docs/use-cases.md"
10
10
  CONFIG_DOC_PATH: "docs/jira-pickup-config.md"
11
11
  STORY_PATH_PATTERN: "docs/stories/{phase1,phase2}/**/*.md"
12
+ PLAYBOOK_ID: "scrum-router"
@@ -1,7 +1,8 @@
1
1
  ---
2
- playbook_id: scrum-router
3
- version: 1.4.0
2
+ playbook_id: {{PLAYBOOK_ID}}
3
+ version: 1.5.0
4
4
  changelog:
5
+ - "1.5.0 (2026-06-07): Parameterize all self-references via new {{PLAYBOOK_ID}} substitution token (previously hardcoded as `scrum-router` in 4 places — frontmatter, the audit-banner step, the comment template, and the webapp settings-path reference). Bindings (scrum.yaml, meth.yaml) now declare PLAYBOOK_ID. Rendered METH playbook now correctly emits `meth-router` in the audit banner instead of `scrum-router` (surfaced by the post-extraction smoke #71 / METH-5). MINOR bump per SemVer (new substitution token added; no breaking change since all bindings updated simultaneously). Closes #92."
5
6
  - "1.4.0 (2026-06-02): Added METH as a second bound project alongside SCRUM. Surface expansion only; no body changes. MINOR bump per SemVer (binding surface expands without breaking existing callers)."
6
7
  - "1.3.0 (2026-05-29): New convention — cross-repo writeback discipline (story file in repo A, implementation in repo B → two-PR pattern with writeback PR in repo A). New convention — agent populates agent_session_url field in post-execution notes block. New convention — agent_acus field added to post-exec block (default \"pending\"; populated asynchronously by post-merge automation since agents cannot self-report ACU consumption mid-session). MINOR bump per SemVer (three additive new conventions, no breaking changes for single-repo work)."
7
8
  - "1.2.0 (2026-05-27): New convention — agent writes pre-execution scoping into the story file's new ## Scoping notes (pre-execution) section as the first commit of the implementation branch. New convention — agent populates the ## Post-execution notes block before opening PR; empty-default stubs are forbidden. MINOR bump per SemVer (additive new conventions)."
@@ -31,6 +32,7 @@ Consuming projects must provide bindings for the following placeholders. The bui
31
32
  | `{{REQ_DOC_PATH}}` | Path to the project's requirements / use-case registry doc. | `docs/use-cases.md` |
32
33
  | `{{CONFIG_DOC_PATH}}` | Path to the project's Jira pickup config doc. | `docs/jira-pickup-config.md` |
33
34
  | `{{STORY_PATH_PATTERN}}` | Glob-style base path to the story directories (include trailing `/`). | `docs/stories/{phase1,phase2}/` |
35
+ | `{{PLAYBOOK_ID}}` | Slug under which the rendered playbook is registered in Devin's webapp; bound per project so multi-binding deployments don't overwrite each other. Echoed in the audit banner and the comment template. | `scrum-router` (SCRUM) / `meth-router` (METH) |
34
36
  | `{{VERSION}}` | Playbook version from frontmatter; injected automatically by the build script. | `1.3.0` |
35
37
 
36
38
  # {{PROJECT_KEY}} ticket pickup (router)
@@ -63,7 +65,7 @@ The matched story file ideally contains an `Execution recipe` section (now speci
63
65
 
64
66
  ## Procedure
65
67
 
66
- 1. Clone the repo and check out `main`. First line of your substantive output (scoping comment OR implementation log) MUST be: `Playbook: scrum-router@{{VERSION}} (binding: {{PROJECT_KEY}})`. This makes the active playbook version unambiguous in audit logs.
68
+ 1. Clone the repo and check out `main`. First line of your substantive output (scoping comment OR implementation log) MUST be: `Playbook: {{PLAYBOOK_ID}}@{{VERSION}} (binding: {{PROJECT_KEY}})`. This makes the active playbook version unambiguous in audit logs.
67
69
  2. Read `CLAUDE.md`, then the four files listed in Overview, in order.
68
70
  3. Identify the Jira ticket key from the trigger context.
69
71
  4. Locate the story file under `{{STORY_PATH_PATTERN}}` using the lookup chain from "What's Needed From User":
@@ -100,7 +102,7 @@ The matched story file ideally contains an `Execution recipe` section (now speci
100
102
  Comment template (rendered into ADF):
101
103
 
102
104
  ```
103
- Playbook: scrum-router@{{VERSION}} (binding: {{PROJECT_KEY}})
105
+ Playbook: {{PLAYBOOK_ID}}@{{VERSION}} (binding: {{PROJECT_KEY}})
104
106
 
105
107
  ### Scoping for <TICKET-KEY> — <story title>
106
108
 
@@ -143,7 +145,7 @@ The matched story file ideally contains an `Execution recipe` section (now speci
143
145
 
144
146
  a. **If `Confidence` is `LOW`**, fire the `ambiguity_escalation` drift trigger: transition the ticket to `BLOCKED`, reassign to the ticket owner (assignee at time of pickup; fall back to reporter if no assignee), and post a comment naming the trigger and what specifically is blocking (e.g., "ambiguity_escalation: depends_on `simulator-payments-api-happy-pair-scenario` not on main"). Stop the session. *This applies in scoping-only mode too — do not wait for the implementation phase to escalate a scoping-time blocker.*
145
147
 
146
- b. **If the Jira integration is in scoping-only mode**, stop the session here without further action. The scoping comment is posted; a human will click "Start session" to spawn implementation when ready. **Source of truth for the mode is the webapp's automation-trigger settings (`app.devin.ai > Settings > Integrations > Jira > Automation triggers > scrum-router > Session mode`), not any in-repo document** — check the webapp setting if the mode is ambiguous from context. The consuming project's `{{CONFIG_DOC_PATH}}` § 5 may describe the intended mode but is best-effort, not authoritative.
148
+ b. **If the Jira integration is in scoping-only mode**, stop the session here without further action. The scoping comment is posted; a human will click "Start session" to spawn implementation when ready. **Source of truth for the mode is the webapp's automation-trigger settings (`app.devin.ai > Settings > Integrations > Jira > Automation triggers > {{PLAYBOOK_ID}} > Session mode`), not any in-repo document** — check the webapp setting if the mode is ambiguous from context. The consuming project's `{{CONFIG_DOC_PATH}}` § 5 may describe the intended mode but is best-effort, not authoritative.
147
149
 
148
150
  c. **If the Jira integration is in direct mode, or a human has started the session from a scoping comment**, proceed to step 12 (implementation).
149
151
 
@@ -15,10 +15,40 @@ logger = logging.getLogger(__name__)
15
15
  _SESSION_URL_RE = re.compile(
16
16
  r"agent_session_url:\s*[\"']?https://app\.devin\.ai/sessions/([a-zA-Z0-9_-]+)[\"']?"
17
17
  )
18
- _ACUS_PENDING_RE = re.compile(r"^(\s*)agent_acus:\s*[\"']?pending[\"']?\s*$", re.MULTILINE)
18
+ # The optional `(#.*)?` group absorbs trailing YAML inline comments (e.g.
19
+ # `agent_acus: pending # filled by populator`). Without this, operator-added
20
+ # annotations on the pending line silently defeat the regex and the populator
21
+ # skips the file — same fail-silent class as the v2/v3 URL bug (task #74).
22
+ # The comment is dropped on replacement; once the value is numeric, the
23
+ # "pending" annotation is no longer truthful.
24
+ _ACUS_PENDING_RE = re.compile(
25
+ r"^(\s*)agent_acus:\s*[\"']?pending[\"']?\s*(#.*)?$",
26
+ re.MULTILINE,
27
+ )
19
28
  _ACUS_NUMERIC_RE = re.compile(r"^\s*agent_acus:\s*[0-9]", re.MULTILINE)
20
29
  _ACUS_PRESENT_RE = re.compile(r"^(\s*)agent_acus:\s*\S", re.MULTILINE)
21
30
  _SESSION_URL_LINE_RE = re.compile(r"^(\s*)agent_session_url:\s*\S", re.MULTILINE)
31
+ _ACUS_LINE_RE = re.compile(r"^(\s*)agent_acus:\s*\S.*$", re.MULTILINE)
32
+
33
+ # agent_session_status: Devin's session lifecycle state at population time
34
+ # (e.g. "exit", "finished", "running"). Vendor-neutral name mirroring the
35
+ # agent_acus / agent_session_url pattern. The field is populator-only: agents
36
+ # don't emit "pending" at PR-open time because they can't predict their own
37
+ # session's terminal status. The populator captures whatever the v3 response's
38
+ # `status` field holds at populate time and writes it alongside agent_acus.
39
+ _STATUS_LINE_RE = re.compile(r"^(\s*)agent_session_status:\s*\S.*$", re.MULTILINE)
40
+
41
+
42
+ @dataclass
43
+ class SessionData:
44
+ """Subset of Devin v3 session response that the populator persists.
45
+
46
+ acus is required (the populator's primary purpose); status is informational
47
+ audit metadata and may be absent from legacy / mid-lifecycle responses.
48
+ """
49
+
50
+ acus: float
51
+ status: str | None
22
52
 
23
53
 
24
54
  @dataclass
@@ -29,6 +59,8 @@ class PopulateResult:
29
59
  session_id: str
30
60
  acus: float
31
61
  action: str # "replaced" | "inserted"
62
+ status: str | None = None # Devin session status if captured (task #87)
63
+ status_action: str = "" # "replaced" | "inserted" | "" (no-op or absent)
32
64
 
33
65
 
34
66
  def extract_session_id(content: str) -> str | None:
@@ -77,19 +109,72 @@ def rewrite_acus(content: str, acus_value: float) -> tuple[str, str]:
77
109
  return content, ""
78
110
 
79
111
 
80
- def fetch_acus_from_devin(session_id: str, api_token: str, org_id: str) -> float | None:
81
- """Fetch ACU consumption from Devin API for a given session ID.
112
+ def rewrite_status(content: str, status_value: str) -> tuple[str, str]:
113
+ """Insert or replace agent_session_status in the content.
82
114
 
83
- Returns the ACU value or None on error.
115
+ Returns (new_content, action) where action is:
116
+ - "replaced" if an existing agent_session_status line was overwritten
117
+ - "inserted" if a new line was added after agent_acus / agent_session_url
118
+ - "" if neither anchor was found (the caller has nowhere to anchor the
119
+ insert — should not happen in practice because needs_population requires
120
+ agent_session_url to exist)
121
+
122
+ The field is inserted AFTER agent_acus when present (since acus is the
123
+ primary populator field and status is supplementary metadata); falls back
124
+ to inserting after agent_session_url for files that have a session URL but
125
+ no acus line yet.
126
+ """
127
+ # Replace path
128
+ m = _STATUS_LINE_RE.search(content)
129
+ if m:
130
+ indent = m.group(1)
131
+ new_content = _STATUS_LINE_RE.sub(
132
+ f"{indent}agent_session_status: {status_value}",
133
+ content,
134
+ count=1,
135
+ )
136
+ return new_content, "replaced"
137
+
138
+ # Insert path — anchor preferentially on agent_acus, fall back to session URL
139
+ anchor = _ACUS_LINE_RE.search(content) or _SESSION_URL_LINE_RE.search(content)
140
+ if anchor is None:
141
+ return content, ""
142
+
143
+ indent = anchor.group(1)
144
+ line_end = content.find("\n", anchor.start())
145
+ insert_line = f"{indent}agent_session_status: {status_value}"
146
+ if line_end == -1:
147
+ new_content = content + f"\n{insert_line}"
148
+ else:
149
+ insert_pos = line_end + 1
150
+ new_content = content[:insert_pos] + f"{insert_line}\n" + content[insert_pos:]
151
+ return new_content, "inserted"
152
+
153
+
154
+ def fetch_session_data_from_devin(
155
+ session_id: str, api_token: str, org_id: str
156
+ ) -> SessionData | None:
157
+ """Fetch ACU consumption and session status from Devin API for a session.
158
+
159
+ Returns a SessionData (acus + optional status) or None on error / when no
160
+ ACU field is present in the response.
84
161
 
85
162
  Devin API v3 (org-scoped, service-user auth):
86
163
  GET /v3/organizations/{org_id}/sessions/{devin_id}
87
- Returns JSON with ``acus_consumed`` (float) at the top level.
164
+ Returns JSON with ``acus_consumed`` (float) and ``status`` (string) at
165
+ the top level among other fields. The populator persists both: ACU into
166
+ ``agent_acus`` (the primary audit value) and status into
167
+ ``agent_session_status`` (informational lifecycle metadata, task #87).
88
168
 
89
169
  The session ID extracted from story file URLs (e.g.
90
170
  ``https://app.devin.ai/sessions/abc123``) is the raw ID without prefix.
91
171
  The v3 path parameter expects the ``devin-`` prefix, so this function
92
172
  prepends it when constructing the URL.
173
+
174
+ Status is best-effort: if the response omits it (legacy sessions, partial
175
+ payloads), the SessionData carries None and the rewriter skips inserting
176
+ agent_session_status. acus is required — None response means populator
177
+ skips the file entirely (preserves existing fail-loud behavior).
93
178
  """
94
179
  devin_id = f"devin-{session_id}" if not session_id.startswith("devin-") else session_id
95
180
  url = f"https://api.devin.ai/v3/organizations/{org_id}/sessions/{devin_id}"
@@ -98,17 +183,6 @@ def fetch_acus_from_devin(session_id: str, api_token: str, org_id: str) -> float
98
183
  resp = requests.get(url, headers=headers, timeout=30)
99
184
  resp.raise_for_status()
100
185
  data = resp.json()
101
- # v3 field name is "acus_consumed"; keep legacy fallbacks for robustness.
102
- for field in ("acus_consumed", "total_acus", "acus"):
103
- acus = data.get(field)
104
- if acus is not None:
105
- return float(acus)
106
- logger.warning(
107
- "Session %s: no ACU field found in response keys: %s",
108
- session_id,
109
- list(data.keys()),
110
- )
111
- return None
112
186
  except requests.HTTPError as exc:
113
187
  logger.warning("Devin API error for session %s: %s", session_id, exc)
114
188
  return None
@@ -116,6 +190,36 @@ def fetch_acus_from_devin(session_id: str, api_token: str, org_id: str) -> float
116
190
  logger.warning("Devin API connection error for session %s: %s", session_id, exc)
117
191
  return None
118
192
 
193
+ # v3 field name is "acus_consumed"; keep legacy fallbacks for robustness.
194
+ acus: float | None = None
195
+ for field in ("acus_consumed", "total_acus", "acus"):
196
+ v = data.get(field)
197
+ if v is not None:
198
+ acus = float(v)
199
+ break
200
+ if acus is None:
201
+ logger.warning(
202
+ "Session %s: no ACU field found in response keys: %s",
203
+ session_id,
204
+ list(data.keys()),
205
+ )
206
+ return None
207
+
208
+ status_raw = data.get("status")
209
+ status = str(status_raw) if status_raw is not None else None
210
+ return SessionData(acus=acus, status=status)
211
+
212
+
213
+ def fetch_acus_from_devin(session_id: str, api_token: str, org_id: str) -> float | None:
214
+ """Backward-compatible shim returning only the ACU value.
215
+
216
+ Preserved so existing callers and contract-level integration tests keep
217
+ working unchanged. New code SHOULD call ``fetch_session_data_from_devin``
218
+ directly to also capture the session status (task #87).
219
+ """
220
+ data = fetch_session_data_from_devin(session_id, api_token, org_id)
221
+ return data.acus if data is not None else None
222
+
119
223
 
120
224
  def scan_and_populate(
121
225
  story_path_pattern: str,
@@ -144,8 +248,8 @@ def scan_and_populate(
144
248
  logger.info("Skipping %s: no agent_session_url found", filepath)
145
249
  continue
146
250
 
147
- acus = fetch_acus_from_devin(session_id, api_token, org_id)
148
- if acus is None:
251
+ session_data = fetch_session_data_from_devin(session_id, api_token, org_id)
252
+ if session_data is None:
149
253
  logger.warning(
150
254
  "Skipping %s: could not fetch ACUs for session %s",
151
255
  filepath,
@@ -153,10 +257,17 @@ def scan_and_populate(
153
257
  )
154
258
  continue
155
259
 
156
- new_content, action = rewrite_acus(content, acus)
260
+ new_content, action = rewrite_acus(content, session_data.acus)
157
261
  if not action:
158
262
  continue
159
263
 
264
+ # Best-effort status capture (task #87). Status is informational; if the
265
+ # API response omits it (legacy sessions, partial payloads) we still
266
+ # write the ACU value and skip the status field rather than failing.
267
+ status_action = ""
268
+ if session_data.status is not None:
269
+ new_content, status_action = rewrite_status(new_content, session_data.status)
270
+
160
271
  if not dry_run:
161
272
  with open(filepath, "w", encoding="utf-8") as f:
162
273
  f.write(new_content)
@@ -165,11 +276,28 @@ def scan_and_populate(
165
276
  PopulateResult(
166
277
  path=filepath,
167
278
  session_id=session_id,
168
- acus=acus,
279
+ acus=session_data.acus,
169
280
  action=action,
281
+ status=session_data.status,
282
+ status_action=status_action,
170
283
  )
171
284
  )
172
- logger.info("Updated %s: agent_acus=%s (%s)", filepath, acus, action)
285
+ if session_data.status is not None:
286
+ logger.info(
287
+ "Updated %s: agent_acus=%s (%s), agent_session_status=%s (%s)",
288
+ filepath,
289
+ session_data.acus,
290
+ action,
291
+ session_data.status,
292
+ status_action or "no-op",
293
+ )
294
+ else:
295
+ logger.info(
296
+ "Updated %s: agent_acus=%s (%s); no status field in v3 response",
297
+ filepath,
298
+ session_data.acus,
299
+ action,
300
+ )
173
301
 
174
302
  return results
175
303
 
@@ -218,7 +346,10 @@ def main() -> int:
218
346
 
219
347
  print(f"\nPopulated agent_acus for {len(results)} story file(s):")
220
348
  for r in results:
221
- print(f" {r.path}: {r.acus} ({r.action})")
349
+ if r.status is not None:
350
+ print(f" {r.path}: acus={r.acus} ({r.action}), status={r.status} ({r.status_action})")
351
+ else:
352
+ print(f" {r.path}: acus={r.acus} ({r.action}), status=<absent>")
222
353
 
223
354
  return 0
224
355
 
@@ -12,6 +12,16 @@ body's YAML frontmatter. It is used as the lookup slug against Devin's API.
12
12
 
13
13
  Environment:
14
14
  DEVIN_API_TOKEN Devin API key (cog_ prefix). Required.
15
+ DEVIN_ORG_ID Devin organization ID (v3 API path is org-scoped). Required.
16
+
17
+ Devin API v3 (org-scoped, service-user auth):
18
+ GET /v3/organizations/{org_id}/playbooks — list
19
+ GET /v3/organizations/{org_id}/playbooks/{id} — read
20
+ POST /v3/organizations/{org_id}/playbooks — create
21
+ PUT /v3/organizations/{org_id}/playbooks/{id} — update
22
+
23
+ Migrated from v1 in task #107 — same pattern as the populator's v3 migration
24
+ (#74). v1 was rejecting cog_* service-user tokens with 403 Unauthorized.
15
25
 
16
26
  Exit codes:
17
27
  0 Registration succeeded and smoke-verify passed.
@@ -22,45 +32,60 @@ from __future__ import annotations
22
32
 
23
33
  import argparse
24
34
  import hashlib
25
- import json
26
35
  import os
27
36
  import re
28
37
  import sys
29
- import urllib.error
30
- import urllib.request
31
38
  from typing import Any
32
39
 
40
+ import requests
41
+
33
42
  MACRO_PATTERN = re.compile(r"^![A-Za-z0-9_]+$")
34
43
 
35
- DEVIN_API_BASE = "https://api.devin.ai/v1"
44
+ DEVIN_API_BASE = "https://api.devin.ai/v3"
36
45
 
37
46
 
38
47
  def _request(
39
48
  method: str,
40
49
  path: str,
41
50
  token: str,
51
+ org_id: str,
42
52
  data: dict[str, Any] | None = None,
43
53
  ) -> 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
- )
54
+ """Make an authenticated request to the Devin API v3 org-scoped endpoint.
55
+
56
+ `path` is the trailing path component (e.g., '/playbooks' or
57
+ '/playbooks/{id}'); this function prepends the v3 org-scoped prefix
58
+ `/organizations/{org_id}` automatically.
59
+
60
+ Uses `requests` (not urllib) — Devin's v3 API rejects urllib's default
61
+ User-Agent ('Python-urllib/3.12') with 403 Unauthorized while accepting
62
+ requests' default ('python-requests/X.Y.Z'). Switched 2026-06-07 after
63
+ #108 diagnostic isolated the failure mode: curl + requests both return
64
+ 200; urllib returns 403 against identical token/org. Aligns this script
65
+ with the populator (populate_acus.py) which has always used requests
66
+ successfully against v3.
67
+ """
68
+ url = f"{DEVIN_API_BASE}/organizations/{org_id}{path}"
69
+ headers = {
70
+ "Authorization": f"Bearer {token}",
71
+ "Content-Type": "application/json",
72
+ }
56
73
  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")
74
+ resp = requests.request(method, url, headers=headers, json=data, timeout=30)
75
+ resp.raise_for_status()
76
+ result: dict[str, Any] = resp.json()
77
+ return result
78
+ except requests.HTTPError as e:
79
+ error_body = e.response.text if e.response is not None else "<no response body>"
80
+ status = e.response.status_code if e.response is not None else "???"
81
+ print(
82
+ f"ERROR: Devin API {method} {path} returned {status}: {error_body}",
83
+ file=sys.stderr,
84
+ )
85
+ sys.exit(1)
86
+ except (requests.ConnectionError, requests.Timeout) as e:
62
87
  print(
63
- f"ERROR: Devin API {method} {path} returned {e.code}: {error_body}",
88
+ f"ERROR: Devin API connection error for {method} {path}: {e}",
64
89
  file=sys.stderr,
65
90
  )
66
91
  sys.exit(1)
@@ -70,13 +95,17 @@ def sha256hex(text: str) -> str:
70
95
  return hashlib.sha256(text.encode("utf-8")).hexdigest()
71
96
 
72
97
 
73
- def list_playbooks(token: str) -> list[dict[str, Any]]:
98
+ def list_playbooks(token: str, org_id: str) -> list[dict[str, Any]]:
74
99
  """List all playbooks accessible to the token's org."""
75
- resp: Any = _request("GET", "/playbooks", token)
76
- # v1 returns {"playbooks": [...]} or a bare list
100
+ resp: Any = _request("GET", "/playbooks", token, org_id)
101
+ # v3 confirmed 2026-06-07 via #108 diagnostic: returns {"items": [...]}.
102
+ # Tolerate the legacy "playbooks" / "data" wrappers + bare list for
103
+ # forward-compat across API shape changes.
77
104
  if isinstance(resp, list):
78
105
  return resp
79
- items: list[dict[str, Any]] = resp.get("playbooks", resp.get("data", []))
106
+ items: list[dict[str, Any]] = resp.get(
107
+ "items", resp.get("playbooks", resp.get("data", []))
108
+ )
80
109
  return items
81
110
 
82
111
 
@@ -102,25 +131,35 @@ def _build_payload(title: str, content: str, macro: str | None = None) -> dict[s
102
131
 
103
132
 
104
133
  def update_playbook(
105
- token: str, playbook_id: str, title: str, content: str, macro: str | None = None
134
+ token: str,
135
+ org_id: str,
136
+ playbook_id: str,
137
+ title: str,
138
+ content: str,
139
+ macro: str | None = None,
106
140
  ) -> dict[str, Any]:
107
141
  """Update a playbook's title and content (full replace)."""
108
142
  payload = _build_payload(title, content, macro)
109
- return _request("PUT", f"/playbooks/{playbook_id}", token, payload)
143
+ return _request("PUT", f"/playbooks/{playbook_id}", token, org_id, payload)
110
144
 
111
145
 
112
- def get_playbook(token: str, playbook_id: str) -> dict[str, Any]:
146
+ def get_playbook(token: str, org_id: str, playbook_id: str) -> dict[str, Any]:
113
147
  """Read back a playbook by ID."""
114
- return _request("GET", f"/playbooks/{playbook_id}", token)
148
+ return _request("GET", f"/playbooks/{playbook_id}", token, org_id)
115
149
 
116
150
 
117
151
  def create_playbook(
118
- token: str, slug: str, title: str, content: str, macro: str | None = None
152
+ token: str,
153
+ org_id: str,
154
+ slug: str,
155
+ title: str,
156
+ content: str,
157
+ macro: str | None = None,
119
158
  ) -> dict[str, Any]:
120
159
  """Create a new playbook with the given slug, title, and content."""
121
160
  payload = _build_payload(title, content, macro)
122
161
  payload["playbook_id"] = slug
123
- return _request("POST", "/playbooks", token, payload)
162
+ return _request("POST", "/playbooks", token, org_id, payload)
124
163
 
125
164
 
126
165
  def main() -> int:
@@ -168,6 +207,16 @@ def main() -> int:
168
207
  print("ERROR: DEVIN_API_TOKEN environment variable is not set.", file=sys.stderr)
169
208
  return 1
170
209
 
210
+ org_id = os.environ.get("DEVIN_ORG_ID", "")
211
+ if not org_id:
212
+ print(
213
+ "ERROR: DEVIN_ORG_ID environment variable is required "
214
+ "(the Devin organization ID; obtain from your service user setup "
215
+ "or the Devin webapp settings).",
216
+ file=sys.stderr,
217
+ )
218
+ return 1
219
+
171
220
  rendered_path = args.rendered_body
172
221
  try:
173
222
  rendered_content = open(rendered_path, encoding="utf-8").read()
@@ -184,7 +233,7 @@ def main() -> int:
184
233
 
185
234
  # Step 1: List playbooks and find the target by name
186
235
  print("Listing playbooks...", file=sys.stderr)
187
- playbooks = list_playbooks(token)
236
+ playbooks = list_playbooks(token, org_id)
188
237
  names = [p.get("name") or p.get("title") or "?" for p in playbooks]
189
238
  print(f"Found {len(playbooks)} playbook(s); available names: {names}", file=sys.stderr)
190
239
 
@@ -209,13 +258,13 @@ def main() -> int:
209
258
  if args.dry_run:
210
259
  print("DRY RUN — would create new playbook.", file=sys.stderr)
211
260
  return 0
212
- created = create_playbook(token, slug, name, rendered_content, args.macro)
261
+ created = create_playbook(token, org_id, slug, name, rendered_content, args.macro)
213
262
  created_id: str = created.get("playbook_id") or created.get("id") or ""
214
263
  print(f"Created playbook: id={created_id}, title={name}", file=sys.stderr)
215
264
 
216
265
  # Smoke-verify the creation
217
266
  print("Smoke-verifying creation...", file=sys.stderr)
218
- readback = get_playbook(token, created_id)
267
+ readback = get_playbook(token, org_id, created_id)
219
268
  readback_content = readback.get("content") or readback.get("body") or ""
220
269
  readback_sha = sha256hex(readback_content)
221
270
  if readback_sha != rendered_sha:
@@ -265,12 +314,12 @@ def main() -> int:
265
314
  print(f"Update needed: {'; '.join(reasons)}. Updating...", file=sys.stderr)
266
315
 
267
316
  # Step 3: Update the playbook
268
- update_playbook(token, playbook_id, playbook_title, rendered_content, args.macro)
317
+ update_playbook(token, org_id, playbook_id, playbook_title, rendered_content, args.macro)
269
318
  print("Playbook updated successfully.", file=sys.stderr)
270
319
 
271
320
  # Step 4: Smoke-verify — read back and compare SHA256
272
321
  print("Smoke-verifying registration...", file=sys.stderr)
273
- readback = get_playbook(token, playbook_id)
322
+ readback = get_playbook(token, org_id, playbook_id)
274
323
  readback_content = readback.get("content") or readback.get("body") or ""
275
324
  readback_sha = sha256hex(readback_content)
276
325
 
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: methodology-framework
3
- Version: 0.4.1
3
+ Version: 0.4.2
4
4
  Summary: Portable process tooling for agent-driven delivery — scripts, playbooks, templates, and specs.
5
5
  Author: whiteout59
6
6
  License-Expression: Apache-2.0
@@ -22,6 +22,7 @@ Requires-Dist: types-colorama<1,>=0.4; extra == "dev"
22
22
  Requires-Dist: vcrpy<9,>=7.0; extra == "dev"
23
23
  Dynamic: license-file
24
24
 
25
+ [![PyPI version](https://img.shields.io/pypi/v/methodology-framework)](https://pypi.org/project/methodology-framework/)
25
26
  # methodology-framework
26
27
 
27
28
  Portable process tooling for agent-driven delivery -- scripts, playbooks, templates, and specs.