methodology-framework 0.4.1__tar.gz → 0.4.3__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.3}/PKG-INFO +3 -2
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/README.md +2 -1
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/pyproject.toml +1 -1
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/build_playbook.py +3 -3
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_discovery.py +124 -27
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/playbooks/bindings/meth.yaml +1 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/playbooks/bindings/scrum.yaml +1 -0
- methodology_framework-0.4.1/src/methodology_framework/playbooks/scrum-router.body.md → methodology_framework-0.4.3/src/methodology_framework/playbooks/router-base.body.md +8 -5
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/populate_acus.py +153 -22
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/register_playbook_with_devin.py +84 -37
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/sync_stories_to_jira.py +6 -3
- {methodology_framework-0.4.1 → methodology_framework-0.4.3/src/methodology_framework.egg-info}/PKG-INFO +3 -2
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/SOURCES.txt +1 -1
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_jira_discovery.py +18 -0
- methodology_framework-0.4.3/tests/test_populate_acus.py +498 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_register_playbook_with_devin.py +15 -10
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/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.3}/LICENSE +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/setup.cfg +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/__init__.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/__main__.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/bootstrap_jira.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/__init__.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/custom_fields.yaml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/workflow.yaml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/specs/devin-story-format.md +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/templates/story.md +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/requires.txt +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/top_level.txt +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_bootstrap_jira.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_build_playbook.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_populate_acus_integration.py +0 -0
- {methodology_framework-0.4.1 → methodology_framework-0.4.3}/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.3
|
|
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.
|
|
@@ -315,7 +316,7 @@ jobs:
|
|
|
315
316
|
build-and-register:
|
|
316
317
|
uses: whiteout59/methodology-framework/.github/workflows/build-and-register.yml@v0.1.0
|
|
317
318
|
with:
|
|
318
|
-
playbook_body_path: "methodology/playbooks/
|
|
319
|
+
playbook_body_path: "methodology/playbooks/router-base.body.md"
|
|
319
320
|
bindings_path: "docs/jira-pickup-config.md"
|
|
320
321
|
secrets:
|
|
321
322
|
DEVIN_API_TOKEN: ${{ secrets.DEVIN_API_TOKEN }}
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
[](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.
|
|
@@ -291,7 +292,7 @@ jobs:
|
|
|
291
292
|
build-and-register:
|
|
292
293
|
uses: whiteout59/methodology-framework/.github/workflows/build-and-register.yml@v0.1.0
|
|
293
294
|
with:
|
|
294
|
-
playbook_body_path: "methodology/playbooks/
|
|
295
|
+
playbook_body_path: "methodology/playbooks/router-base.body.md"
|
|
295
296
|
bindings_path: "docs/jira-pickup-config.md"
|
|
296
297
|
secrets:
|
|
297
298
|
DEVIN_API_TOKEN: ${{ secrets.DEVIN_API_TOKEN }}
|
|
@@ -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.3"
|
|
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"
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env python3
|
|
2
2
|
"""Build a concrete, project-bound playbook from the parameterized body + bindings.
|
|
3
3
|
|
|
4
|
-
Reads the parameterized playbook body from methodology/playbooks/
|
|
4
|
+
Reads the parameterized playbook body from methodology/playbooks/router-base.body.md
|
|
5
5
|
and substitutes {{PLACEHOLDER}} tokens with the project-specific bindings defined in
|
|
6
6
|
docs/jira-pickup-config.md § 5.1 (the playbook_bindings YAML block).
|
|
7
7
|
|
|
@@ -23,7 +23,7 @@ Options:
|
|
|
23
23
|
Does not write output.
|
|
24
24
|
|
|
25
25
|
When --body and --bindings are omitted, the script falls back to the legacy
|
|
26
|
-
hardcoded paths (methodology/playbooks/
|
|
26
|
+
hardcoded paths (methodology/playbooks/router-base.body.md and
|
|
27
27
|
docs/jira-pickup-config.md relative to the repo root).
|
|
28
28
|
"""
|
|
29
29
|
|
|
@@ -35,7 +35,7 @@ import sys
|
|
|
35
35
|
from pathlib import Path
|
|
36
36
|
|
|
37
37
|
REPO_ROOT = Path(__file__).resolve().parent.parent
|
|
38
|
-
BODY_PATH = REPO_ROOT / "methodology" / "playbooks" / "
|
|
38
|
+
BODY_PATH = REPO_ROOT / "methodology" / "playbooks" / "router-base.body.md"
|
|
39
39
|
CONFIG_PATH = REPO_ROOT / "docs" / "jira-pickup-config.md"
|
|
40
40
|
|
|
41
41
|
PLACEHOLDER_RE = re.compile(r"\{\{(\w+)\}\}")
|
|
@@ -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(
|
|
@@ -220,24 +229,14 @@ def discover_statuses(
|
|
|
220
229
|
return result
|
|
221
230
|
|
|
222
231
|
|
|
223
|
-
def
|
|
224
|
-
|
|
225
|
-
base_url: str,
|
|
226
|
-
project_key: str,
|
|
227
|
-
) -> dict[str, str]:
|
|
228
|
-
"""Discover transition name→ID map.
|
|
229
|
-
|
|
230
|
-
Uses GET /rest/api/3/project/{key}/statuses to find a sample issue
|
|
231
|
-
type, then queries the workflow scheme. Falls back to scanning
|
|
232
|
-
transitions from each status via the workflow spec.
|
|
233
|
-
|
|
234
|
-
For transitions, the approach is: use the statuses endpoint to get
|
|
235
|
-
status names, then use GET /rest/api/3/status to get transitions.
|
|
236
|
-
But Jira Cloud doesn't expose transitions without an issue context.
|
|
232
|
+
def _spec_transition_map() -> dict[str, str]:
|
|
233
|
+
"""Return canonical transition name→ID map sourced from workflow.yaml.
|
|
237
234
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
235
|
+
Used only for dry-run sync invocations where no Jira credentials are
|
|
236
|
+
available. Returns the SCRUM-accidental IDs encoded in the spec —
|
|
237
|
+
these are stable for the SCRUM project but will NOT match a freshly-
|
|
238
|
+
provisioned team-managed project. Real sync uses :func:`discover_transitions`
|
|
239
|
+
which queries Jira directly.
|
|
241
240
|
"""
|
|
242
241
|
shape = _load_shape(_WORKFLOW_YAML)
|
|
243
242
|
result: dict[str, str] = {}
|
|
@@ -247,11 +246,86 @@ def discover_transitions(
|
|
|
247
246
|
tid = str(t["transition_id"])
|
|
248
247
|
if name not in result:
|
|
249
248
|
result[name] = tid
|
|
249
|
+
return result
|
|
250
250
|
|
|
251
|
-
if not result:
|
|
252
|
-
raise _fail("transition", "(any)", project_key)
|
|
253
251
|
|
|
254
|
-
|
|
252
|
+
def discover_transitions(
|
|
253
|
+
session: Any,
|
|
254
|
+
base_url: str,
|
|
255
|
+
project_key: str,
|
|
256
|
+
) -> dict[str, str]:
|
|
257
|
+
"""Discover transition name→ID map by querying Jira directly.
|
|
258
|
+
|
|
259
|
+
Strategy: find any existing issue in the project via JQL search, then
|
|
260
|
+
call ``GET /rest/api/3/issue/{key}/transitions`` to read the live
|
|
261
|
+
transition map for the workflow. The framework's canonical workflow
|
|
262
|
+
uses global transitions (``isGlobal: true``), so any single issue
|
|
263
|
+
exposes the complete name→ID map.
|
|
264
|
+
|
|
265
|
+
Raises :class:`DiscoveryError` if:
|
|
266
|
+
|
|
267
|
+
- The project has no issues yet (operator must create at least one
|
|
268
|
+
issue before first sync — sync can't probe an empty project's
|
|
269
|
+
workflow because Jira Cloud only exposes transitions in an issue
|
|
270
|
+
context).
|
|
271
|
+
- Any canonical transition name is missing from the live workflow
|
|
272
|
+
(operator must fix the workflow per ``bootstrap-jira --verify``).
|
|
273
|
+
|
|
274
|
+
For SCRUM the discovered IDs match :func:`_spec_transition_map` by
|
|
275
|
+
historical accident. For freshly-provisioned projects they will not —
|
|
276
|
+
that's the point of querying instead of trusting the spec.
|
|
277
|
+
"""
|
|
278
|
+
# Find any existing issue in the project via JQL.
|
|
279
|
+
resp = session.get(
|
|
280
|
+
f"{base_url}/rest/api/3/search/jql",
|
|
281
|
+
params={
|
|
282
|
+
"jql": f"project={project_key}",
|
|
283
|
+
"fields": "key",
|
|
284
|
+
"maxResults": 1,
|
|
285
|
+
},
|
|
286
|
+
)
|
|
287
|
+
resp.raise_for_status()
|
|
288
|
+
search_data: dict[str, Any] = resp.json()
|
|
289
|
+
issues = search_data.get("issues") or []
|
|
290
|
+
|
|
291
|
+
if not issues:
|
|
292
|
+
raise DiscoveryError(
|
|
293
|
+
f"Project {project_key} has no issues yet, so workflow transitions "
|
|
294
|
+
f"cannot be probed. Jira Cloud only exposes transition IDs in an "
|
|
295
|
+
f"issue context. Create at least one placeholder issue in "
|
|
296
|
+
f"{project_key} (any issue type, any state) and re-run sync. "
|
|
297
|
+
f"That issue is enough — discovery uses it as a probe to read the "
|
|
298
|
+
f"global transition map for the project's workflow."
|
|
299
|
+
)
|
|
300
|
+
|
|
301
|
+
probe_key: str = issues[0]["key"]
|
|
302
|
+
logger.info(
|
|
303
|
+
"Probing transitions on %s (any existing issue exposes the global "
|
|
304
|
+
"transition map for the project's workflow).",
|
|
305
|
+
probe_key,
|
|
306
|
+
)
|
|
307
|
+
|
|
308
|
+
resp = session.get(f"{base_url}/rest/api/3/issue/{probe_key}/transitions")
|
|
309
|
+
resp.raise_for_status()
|
|
310
|
+
transitions_data: dict[str, Any] = resp.json()
|
|
311
|
+
|
|
312
|
+
name_to_id: dict[str, str] = {}
|
|
313
|
+
for t in transitions_data.get("transitions", []):
|
|
314
|
+
name_to_id[t["name"]] = str(t["id"])
|
|
315
|
+
|
|
316
|
+
# Verify every canonical transition name resolved.
|
|
317
|
+
canonical = _canonical_transition_names()
|
|
318
|
+
missing = canonical - set(name_to_id.keys())
|
|
319
|
+
if missing:
|
|
320
|
+
raise DiscoveryError(
|
|
321
|
+
f"Project {project_key}'s workflow is missing required transition(s): "
|
|
322
|
+
f"{', '.join(sorted(missing))}. "
|
|
323
|
+
f"Run 'methodology-framework bootstrap-jira --verify "
|
|
324
|
+
f"--project-key={project_key}' to identify the gaps, then add the "
|
|
325
|
+
f"missing states/transitions in the Jira workflow editor."
|
|
326
|
+
)
|
|
327
|
+
|
|
328
|
+
return name_to_id
|
|
255
329
|
|
|
256
330
|
|
|
257
331
|
def discover_link_types(
|
|
@@ -291,9 +365,14 @@ def discover_issue_types(
|
|
|
291
365
|
is unsafe (it returns types across all projects and dict-overwrite
|
|
292
366
|
picks the wrong project's ID when names collide).
|
|
293
367
|
|
|
294
|
-
Returns mapping from issue type name to ID string.
|
|
295
|
-
|
|
296
|
-
|
|
368
|
+
Returns mapping from issue type name to ID string. The Subtask issue
|
|
369
|
+
type is tolerated under either the classic spelling ``"Sub-task"`` or
|
|
370
|
+
the modern Atlassian default ``"Subtask"`` — whichever the project
|
|
371
|
+
actually has gets canonicalized to ``ISSUE_TYPE_NAME_SUBTASK`` in the
|
|
372
|
+
returned map so downstream code uses one stable key.
|
|
373
|
+
|
|
374
|
+
Raises DiscoveryError if ``"Story"`` is missing OR if neither of the
|
|
375
|
+
Subtask spellings is present in the project's issue type list.
|
|
297
376
|
"""
|
|
298
377
|
resp = session.get(f"{base_url}/rest/api/3/project/{project_key}")
|
|
299
378
|
resp.raise_for_status()
|
|
@@ -304,9 +383,27 @@ def discover_issue_types(
|
|
|
304
383
|
for it in project_issue_types:
|
|
305
384
|
name_to_id[it["name"]] = it["id"]
|
|
306
385
|
|
|
307
|
-
|
|
308
|
-
|
|
309
|
-
|
|
386
|
+
if ISSUE_TYPE_NAME_STORY not in name_to_id:
|
|
387
|
+
raise _fail("issue type", ISSUE_TYPE_NAME_STORY, project_key)
|
|
388
|
+
|
|
389
|
+
# Subtask tolerance: accept canonical spelling OR any registered alias.
|
|
390
|
+
# If matched via an alias, add a synthetic entry under the canonical
|
|
391
|
+
# name so downstream lookups have a stable key.
|
|
392
|
+
if ISSUE_TYPE_NAME_SUBTASK not in name_to_id:
|
|
393
|
+
matched_alias = next(
|
|
394
|
+
(a for a in ISSUE_TYPE_NAME_SUBTASK_ALIASES if a in name_to_id),
|
|
395
|
+
None,
|
|
396
|
+
)
|
|
397
|
+
if matched_alias is None:
|
|
398
|
+
accepted_aliases = ", ".join(ISSUE_TYPE_NAME_SUBTASK_ALIASES)
|
|
399
|
+
error_name = f"{ISSUE_TYPE_NAME_SUBTASK} (also accepted: {accepted_aliases})"
|
|
400
|
+
raise _fail("issue type", error_name, project_key)
|
|
401
|
+
name_to_id[ISSUE_TYPE_NAME_SUBTASK] = name_to_id[matched_alias]
|
|
402
|
+
logger.info(
|
|
403
|
+
"Discovered Subtask issue type under alias '%s'; canonicalized as '%s'",
|
|
404
|
+
matched_alias,
|
|
405
|
+
ISSUE_TYPE_NAME_SUBTASK,
|
|
406
|
+
)
|
|
310
407
|
|
|
311
408
|
return name_to_id
|
|
312
409
|
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
---
|
|
2
|
-
playbook_id:
|
|
3
|
-
version: 1.
|
|
2
|
+
playbook_id: {{PLAYBOOK_ID}}
|
|
3
|
+
version: 1.6.0
|
|
4
4
|
changelog:
|
|
5
|
+
- "1.6.0 (2026-06-07): Renamed body file from `scrum-router.body.md` to `router-base.body.md`. Canonical-template naming should be project-neutral; the SCRUM prefix was a legacy artifact from when the playbook had a single SCRUM binding. The rendered outputs continue to be named per-binding via `{{PLAYBOOK_ID}}` (scrum-router, meth-router, etc.). Code + workflow references updated to the new path. Closes #113. MINOR bump per SemVer (additive: file rename + reference updates, no body content change)."
|
|
6
|
+
- "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
7
|
- "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
8
|
- "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
9
|
- "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 +33,7 @@ Consuming projects must provide bindings for the following placeholders. The bui
|
|
|
31
33
|
| `{{REQ_DOC_PATH}}` | Path to the project's requirements / use-case registry doc. | `docs/use-cases.md` |
|
|
32
34
|
| `{{CONFIG_DOC_PATH}}` | Path to the project's Jira pickup config doc. | `docs/jira-pickup-config.md` |
|
|
33
35
|
| `{{STORY_PATH_PATTERN}}` | Glob-style base path to the story directories (include trailing `/`). | `docs/stories/{phase1,phase2}/` |
|
|
36
|
+
| `{{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
37
|
| `{{VERSION}}` | Playbook version from frontmatter; injected automatically by the build script. | `1.3.0` |
|
|
35
38
|
|
|
36
39
|
# {{PROJECT_KEY}} ticket pickup (router)
|
|
@@ -63,7 +66,7 @@ The matched story file ideally contains an `Execution recipe` section (now speci
|
|
|
63
66
|
|
|
64
67
|
## Procedure
|
|
65
68
|
|
|
66
|
-
1. Clone the repo and check out `main`. First line of your substantive output (scoping comment OR implementation log) MUST be: `Playbook:
|
|
69
|
+
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
70
|
2. Read `CLAUDE.md`, then the four files listed in Overview, in order.
|
|
68
71
|
3. Identify the Jira ticket key from the trigger context.
|
|
69
72
|
4. Locate the story file under `{{STORY_PATH_PATTERN}}` using the lookup chain from "What's Needed From User":
|
|
@@ -100,7 +103,7 @@ The matched story file ideally contains an `Execution recipe` section (now speci
|
|
|
100
103
|
Comment template (rendered into ADF):
|
|
101
104
|
|
|
102
105
|
```
|
|
103
|
-
Playbook:
|
|
106
|
+
Playbook: {{PLAYBOOK_ID}}@{{VERSION}} (binding: {{PROJECT_KEY}})
|
|
104
107
|
|
|
105
108
|
### Scoping for <TICKET-KEY> — <story title>
|
|
106
109
|
|
|
@@ -143,7 +146,7 @@ The matched story file ideally contains an `Execution recipe` section (now speci
|
|
|
143
146
|
|
|
144
147
|
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
148
|
|
|
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 >
|
|
149
|
+
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
150
|
|
|
148
151
|
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
152
|
|
|
@@ -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
|
|