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.
- {methodology_framework-0.4.1/src/methodology_framework.egg-info → methodology_framework-0.4.2}/PKG-INFO +2 -1
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/README.md +1 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/pyproject.toml +1 -1
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_discovery.py +39 -7
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/playbooks/bindings/meth.yaml +1 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/playbooks/bindings/scrum.yaml +1 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/playbooks/scrum-router.body.md +7 -5
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/populate_acus.py +153 -22
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/register_playbook_with_devin.py +86 -37
- {methodology_framework-0.4.1 → methodology_framework-0.4.2/src/methodology_framework.egg-info}/PKG-INFO +2 -1
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_jira_discovery.py +18 -0
- methodology_framework-0.4.2/tests/test_populate_acus.py +498 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_register_playbook_with_devin.py +15 -10
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_workflows.py +78 -0
- methodology_framework-0.4.1/tests/test_populate_acus.py +0 -245
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/LICENSE +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/setup.cfg +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/__init__.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/__main__.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/bootstrap_jira.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/build_playbook.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/__init__.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/custom_fields.yaml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/jira_shapes/workflow.yaml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/specs/devin-story-format.md +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/sync_stories_to_jira.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework/templates/story.md +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/SOURCES.txt +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/requires.txt +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/src/methodology_framework.egg-info/top_level.txt +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_bootstrap_jira.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_build_playbook.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.2}/tests/test_populate_acus_integration.py +0 -0
- {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.
|
|
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
|
+
[](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.
|
|
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|
|
4
4
|
|
|
5
5
|
[project]
|
|
6
6
|
name = "methodology-framework"
|
|
7
|
-
version = "0.4.
|
|
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
|
-
|
|
296
|
-
|
|
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
|
-
|
|
308
|
-
|
|
309
|
-
|
|
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
|
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
---
|
|
2
|
-
playbook_id:
|
|
3
|
-
version: 1.
|
|
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:
|
|
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:
|
|
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 >
|
|
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
|
-
|
|
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
|
|
81
|
-
"""
|
|
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
|
|
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)
|
|
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
|
-
|
|
148
|
-
if
|
|
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
|
-
|
|
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
|
-
|
|
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/
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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}
|
|
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
|
-
#
|
|
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(
|
|
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,
|
|
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,
|
|
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.
|
|
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
|
+
[](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.
|