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.
Files changed (37) hide show
  1. {methodology_framework-0.4.1/src/methodology_framework.egg-info → methodology_framework-0.4.3}/PKG-INFO +3 -2
  2. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/README.md +2 -1
  3. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/pyproject.toml +1 -1
  4. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/build_playbook.py +3 -3
  5. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_discovery.py +124 -27
  6. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/playbooks/bindings/meth.yaml +1 -0
  7. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/playbooks/bindings/scrum.yaml +1 -0
  8. 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
  9. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/populate_acus.py +153 -22
  10. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/register_playbook_with_devin.py +84 -37
  11. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/sync_stories_to_jira.py +6 -3
  12. {methodology_framework-0.4.1 → methodology_framework-0.4.3/src/methodology_framework.egg-info}/PKG-INFO +3 -2
  13. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/SOURCES.txt +1 -1
  14. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_jira_discovery.py +18 -0
  15. methodology_framework-0.4.3/tests/test_populate_acus.py +498 -0
  16. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_register_playbook_with_devin.py +15 -10
  17. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_workflows.py +78 -0
  18. methodology_framework-0.4.1/tests/test_populate_acus.py +0 -245
  19. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/LICENSE +0 -0
  20. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/setup.cfg +0 -0
  21. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/__init__.py +0 -0
  22. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/__main__.py +0 -0
  23. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/bootstrap_jira.py +0 -0
  24. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/__init__.py +0 -0
  25. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/automation_rules.yaml +0 -0
  26. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/custom_fields.yaml +0 -0
  27. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/jira_shapes/workflow.yaml +0 -0
  28. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/specs/devin-story-format.md +0 -0
  29. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/templates/github_workflows/populate-story-acus-caller.yml +0 -0
  30. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework/templates/story.md +0 -0
  31. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/dependency_links.txt +0 -0
  32. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/requires.txt +0 -0
  33. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/src/methodology_framework.egg-info/top_level.txt +0 -0
  34. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_bootstrap_jira.py +0 -0
  35. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_build_playbook.py +0 -0
  36. {methodology_framework-0.4.1 → methodology_framework-0.4.3}/tests/test_populate_acus_integration.py +0 -0
  37. {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.1
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
+ [![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.
@@ -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/scrum-router.body.md"
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
+ [![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.
@@ -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/scrum-router.body.md"
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.1"
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/scrum-router.body.md
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/scrum-router.body.md and
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" / "scrum-router.body.md"
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 discover_transitions(
224
- session: Any,
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
- Instead, we create a name→ID map from the workflow spec (which has
239
- transition_id values) and verify they're consistent with what's live.
240
- The verifier already checked these exist; we trust the spec IDs.
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
- return result
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
- Raises DiscoveryError if "Story" or "Sub-task" is missing from the
296
- project's issue type list.
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
- 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)
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
 
@@ -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,9 @@
1
1
  ---
2
- playbook_id: scrum-router
3
- version: 1.4.0
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: scrum-router@{{VERSION}} (binding: {{PROJECT_KEY}})`. This makes the active playbook version unambiguous in audit logs.
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: scrum-router@{{VERSION}} (binding: {{PROJECT_KEY}})
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 > 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.
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
- _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