@mc-and-his-agents/loom-installer 0.1.13 → 0.1.14

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 (22) hide show
  1. package/package.json +1 -1
  2. package/payload/manifest.json +42 -42
  3. package/payload/plugin/loom/skills/shared/scripts/loom_check.py +85 -0
  4. package/payload/plugin/loom/skills/shared/scripts/loom_flow.py +252 -1
  5. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_check.py +85 -0
  6. package/payload/skills/loom-adopt/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  7. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_check.py +85 -0
  8. package/payload/skills/loom-handoff/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  9. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_check.py +85 -0
  10. package/payload/skills/loom-init/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  11. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_check.py +85 -0
  12. package/payload/skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  13. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_check.py +85 -0
  14. package/payload/skills/loom-pre-review/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  15. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_check.py +85 -0
  16. package/payload/skills/loom-resume/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  17. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_check.py +85 -0
  18. package/payload/skills/loom-retire/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  19. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_check.py +85 -0
  20. package/payload/skills/loom-review/.loom-runtime/shared/scripts/loom_flow.py +252 -1
  21. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_check.py +85 -0
  22. package/payload/skills/loom-spec-review/.loom-runtime/shared/scripts/loom_flow.py +252 -1
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@mc-and-his-agents/loom-installer",
3
- "version": "0.1.13",
3
+ "version": "0.1.14",
4
4
  "description": "Node installer for Loom plugin and single-skill installation surfaces.",
5
5
  "type": "module",
6
6
  "bin": {
@@ -2,9 +2,9 @@
2
2
  "schema_version": "loom-installer-payload/v1",
3
3
  "loom_version": "0.4.0",
4
4
  "source_repository": "https://github.com/MC-and-his-Agents/Loom",
5
- "source_commit": "b07d54d4963a674da0ff2397f4ad244cb3028584",
5
+ "source_commit": "7f500dab49df607146abae440b849dad8f90cf27",
6
6
  "source_ref": "main",
7
- "built_at": "2026-04-25T12:04:56+08:00",
7
+ "built_at": "2026-04-25T12:49:57+08:00",
8
8
  "runtime": {
9
9
  "python_minimum": "3.10",
10
10
  "python_recommended": "3.11+"
@@ -628,13 +628,13 @@
628
628
  },
629
629
  {
630
630
  "path": "plugin/loom/skills/shared/scripts/loom_check.py",
631
- "bytes": 260856,
632
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
631
+ "bytes": 265359,
632
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
633
633
  },
634
634
  {
635
635
  "path": "plugin/loom/skills/shared/scripts/loom_flow.py",
636
- "bytes": 262117,
637
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
636
+ "bytes": 274468,
637
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
638
638
  },
639
639
  {
640
640
  "path": "plugin/loom/skills/shared/scripts/loom_init.py",
@@ -1213,13 +1213,13 @@
1213
1213
  },
1214
1214
  {
1215
1215
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_check.py",
1216
- "bytes": 260856,
1217
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
1216
+ "bytes": 265359,
1217
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
1218
1218
  },
1219
1219
  {
1220
1220
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_flow.py",
1221
- "bytes": 262117,
1222
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
1221
+ "bytes": 274468,
1222
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
1223
1223
  },
1224
1224
  {
1225
1225
  "path": "skills/loom-adopt/.loom-runtime/shared/scripts/loom_init.py",
@@ -1828,13 +1828,13 @@
1828
1828
  },
1829
1829
  {
1830
1830
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_check.py",
1831
- "bytes": 260856,
1832
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
1831
+ "bytes": 265359,
1832
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
1833
1833
  },
1834
1834
  {
1835
1835
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_flow.py",
1836
- "bytes": 262117,
1837
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
1836
+ "bytes": 274468,
1837
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
1838
1838
  },
1839
1839
  {
1840
1840
  "path": "skills/loom-handoff/.loom-runtime/shared/scripts/loom_init.py",
@@ -2443,13 +2443,13 @@
2443
2443
  },
2444
2444
  {
2445
2445
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_check.py",
2446
- "bytes": 260856,
2447
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
2446
+ "bytes": 265359,
2447
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
2448
2448
  },
2449
2449
  {
2450
2450
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_flow.py",
2451
- "bytes": 262117,
2452
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
2451
+ "bytes": 274468,
2452
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
2453
2453
  },
2454
2454
  {
2455
2455
  "path": "skills/loom-init/.loom-runtime/shared/scripts/loom_init.py",
@@ -3063,13 +3063,13 @@
3063
3063
  },
3064
3064
  {
3065
3065
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_check.py",
3066
- "bytes": 260856,
3067
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
3066
+ "bytes": 265359,
3067
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
3068
3068
  },
3069
3069
  {
3070
3070
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_flow.py",
3071
- "bytes": 262117,
3072
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
3071
+ "bytes": 274468,
3072
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
3073
3073
  },
3074
3074
  {
3075
3075
  "path": "skills/loom-merge-ready/.loom-runtime/shared/scripts/loom_init.py",
@@ -3678,13 +3678,13 @@
3678
3678
  },
3679
3679
  {
3680
3680
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_check.py",
3681
- "bytes": 260856,
3682
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
3681
+ "bytes": 265359,
3682
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
3683
3683
  },
3684
3684
  {
3685
3685
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_flow.py",
3686
- "bytes": 262117,
3687
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
3686
+ "bytes": 274468,
3687
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
3688
3688
  },
3689
3689
  {
3690
3690
  "path": "skills/loom-pre-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -4293,13 +4293,13 @@
4293
4293
  },
4294
4294
  {
4295
4295
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_check.py",
4296
- "bytes": 260856,
4297
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
4296
+ "bytes": 265359,
4297
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
4298
4298
  },
4299
4299
  {
4300
4300
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_flow.py",
4301
- "bytes": 262117,
4302
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
4301
+ "bytes": 274468,
4302
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
4303
4303
  },
4304
4304
  {
4305
4305
  "path": "skills/loom-resume/.loom-runtime/shared/scripts/loom_init.py",
@@ -4908,13 +4908,13 @@
4908
4908
  },
4909
4909
  {
4910
4910
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_check.py",
4911
- "bytes": 260856,
4912
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
4911
+ "bytes": 265359,
4912
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
4913
4913
  },
4914
4914
  {
4915
4915
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_flow.py",
4916
- "bytes": 262117,
4917
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
4916
+ "bytes": 274468,
4917
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
4918
4918
  },
4919
4919
  {
4920
4920
  "path": "skills/loom-retire/.loom-runtime/shared/scripts/loom_init.py",
@@ -5523,13 +5523,13 @@
5523
5523
  },
5524
5524
  {
5525
5525
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_check.py",
5526
- "bytes": 260856,
5527
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
5526
+ "bytes": 265359,
5527
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
5528
5528
  },
5529
5529
  {
5530
5530
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_flow.py",
5531
- "bytes": 262117,
5532
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
5531
+ "bytes": 274468,
5532
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
5533
5533
  },
5534
5534
  {
5535
5535
  "path": "skills/loom-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -6138,13 +6138,13 @@
6138
6138
  },
6139
6139
  {
6140
6140
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_check.py",
6141
- "bytes": 260856,
6142
- "sha256": "9f9b145158d659764c853f7234dfb7308c96e78031f43741e431905b91a6b7fc"
6141
+ "bytes": 265359,
6142
+ "sha256": "8d94670aca94c6166294e145d497eb9f9ba6bb1345258d131ea8722cb071d786"
6143
6143
  },
6144
6144
  {
6145
6145
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_flow.py",
6146
- "bytes": 262117,
6147
- "sha256": "ec49996bbebb9a59fee0f906d301c2c66e13e17ec548ef762f2b0e33e9abbe32"
6146
+ "bytes": 274468,
6147
+ "sha256": "95a417012fce9a5c628d1e6e6d1428c875d2a9f483174ca93a5ad9944d09c398"
6148
6148
  },
6149
6149
  {
6150
6150
  "path": "skills/loom-spec-review/.loom-runtime/shared/scripts/loom_init.py",
@@ -93,6 +93,7 @@ CORE_DOCS = (
93
93
  "docs/evidence/extraction-ledger.md",
94
94
  "docs/evidence/landing-map.md",
95
95
  "docs/evidence/validations/validation-closeout-reconciliation-blocking-gate.md",
96
+ "docs/evidence/validations/validation-github-profile-binding-orchestration.md",
96
97
  "docs/evidence/validations/validation-loom-core-runtime-parity.md",
97
98
  "docs/evidence/validations/validation-shadow-parity-blocking-gate.md",
98
99
  "docs/evidence/validations/validation-syvert-strong-governance-parity.md",
@@ -1246,6 +1247,78 @@ def require_runtime_parity_payload(
1246
1247
  failures.append(Failure(category, f"{context} check `{check.get('name')}` must include `evidence`"))
1247
1248
 
1248
1249
 
1250
+ def require_github_binding_payload(
1251
+ failures: list[Failure],
1252
+ *,
1253
+ category: str,
1254
+ context: str,
1255
+ payload: object,
1256
+ ) -> None:
1257
+ if not isinstance(payload, dict):
1258
+ failures.append(Failure(category, f"{context} must be an object"))
1259
+ return
1260
+ if payload.get("command") != "governance-profile":
1261
+ failures.append(Failure(category, f"{context} must report `command: governance-profile`"))
1262
+ if payload.get("operation") != "binding":
1263
+ failures.append(Failure(category, f"{context} must report `operation: binding`"))
1264
+ if payload.get("schema_version") != "loom-github-binding/v1":
1265
+ failures.append(Failure(category, f"{context} schema_version must be `loom-github-binding/v1`"))
1266
+ if payload.get("result") not in {"pass", "block"}:
1267
+ failures.append(Failure(category, f"{context} result must be pass/block"))
1268
+ if payload.get("fallback_to") not in {None, "github-profile-binding"}:
1269
+ failures.append(Failure(category, f"{context} fallback_to must be null or `github-profile-binding`"))
1270
+ if not isinstance(payload.get("missing_inputs"), list):
1271
+ failures.append(Failure(category, f"{context} missing_inputs must be a list"))
1272
+ binding = payload.get("binding")
1273
+ if not isinstance(binding, dict):
1274
+ failures.append(Failure(category, f"{context} must include `binding` as an object"))
1275
+ return
1276
+ if binding.get("schema_version") != "loom-github-binding/v1":
1277
+ failures.append(Failure(category, f"{context}.binding schema_version must be `loom-github-binding/v1`"))
1278
+ objects = binding.get("objects")
1279
+ expected_objects = {"phase", "fr", "work_item", "branch", "implementation_pr", "merge_commit", "target_branch"}
1280
+ if not isinstance(objects, dict) or set(objects) != expected_objects:
1281
+ failures.append(Failure(category, f"{context}.binding.objects must expose the stable GitHub binding object set"))
1282
+ chain = binding.get("chain")
1283
+ expected_chain = [
1284
+ ("phase", "fr"),
1285
+ ("fr", "work_item"),
1286
+ ("work_item", "implementation_pr"),
1287
+ ("implementation_pr", "merge_commit"),
1288
+ ("merge_commit", "target_branch"),
1289
+ ]
1290
+ if not isinstance(chain, list):
1291
+ failures.append(Failure(category, f"{context}.binding.chain must be a list"))
1292
+ else:
1293
+ actual_chain = [
1294
+ (entry.get("from"), entry.get("to"))
1295
+ for entry in chain
1296
+ if isinstance(entry, dict)
1297
+ ]
1298
+ if actual_chain != expected_chain:
1299
+ failures.append(Failure(category, f"{context}.binding.chain must preserve Phase -> FR -> Work Item -> PR -> merge commit -> target branch order"))
1300
+ for entry in chain:
1301
+ if not isinstance(entry, dict):
1302
+ failures.append(Failure(category, f"{context}.binding.chain entries must be objects"))
1303
+ continue
1304
+ if entry.get("status") not in {"present", "missing"}:
1305
+ failures.append(Failure(category, f"{context}.binding.chain statuses must be present/missing"))
1306
+ findings = binding.get("findings")
1307
+ if not isinstance(findings, list):
1308
+ failures.append(Failure(category, f"{context}.binding.findings must be a list"))
1309
+ return
1310
+ for finding in findings:
1311
+ if not isinstance(finding, dict):
1312
+ failures.append(Failure(category, f"{context}.binding.findings entries must be objects"))
1313
+ continue
1314
+ if finding.get("category") not in {"stale", "drift", "gate_failure"}:
1315
+ failures.append(Failure(category, f"{context}.binding findings must use stable taxonomy categories"))
1316
+ if finding.get("kind") != "binding_failure":
1317
+ failures.append(Failure(category, f"{context}.binding findings must use `binding_failure` for orchestration gaps"))
1318
+ if finding.get("fallback_to") != "github-profile-binding":
1319
+ failures.append(Failure(category, f"{context}.binding findings must fallback to `github-profile-binding`"))
1320
+
1321
+
1249
1322
  def require_review_record_contract(
1250
1323
  failures: list[Failure],
1251
1324
  *,
@@ -2232,6 +2305,11 @@ def check_daily_execution_cli(root: Path) -> list[Failure]:
2232
2305
  ["python3", "tools/loom_flow.py", "governance-profile", "upgrade-plan", "--target", "examples/new-project"],
2233
2306
  {"pass", "block"},
2234
2307
  ),
2308
+ (
2309
+ "governance-profile-binding",
2310
+ ["python3", "tools/loom_flow.py", "governance-profile", "binding", "--target", "."],
2311
+ {"block"},
2312
+ ),
2235
2313
  (
2236
2314
  "flow-pre-review",
2237
2315
  [
@@ -2479,6 +2557,13 @@ def check_daily_execution_cli(root: Path) -> list[Failure]:
2479
2557
  context=f"`{label}` governance_control_plane",
2480
2558
  payload=control_plane,
2481
2559
  )
2560
+ if label == "governance-profile-binding":
2561
+ require_github_binding_payload(
2562
+ failures,
2563
+ category="daily-execution-cli",
2564
+ context="`governance-profile binding`",
2565
+ payload=payload,
2566
+ )
2482
2567
  if label == "flow-pre-review":
2483
2568
  require_runtime_state_payload(
2484
2569
  failures,
@@ -12,6 +12,7 @@ import subprocess
12
12
  import sys
13
13
  from pathlib import Path
14
14
  from typing import Any
15
+ from urllib.parse import quote
15
16
 
16
17
  from fact_chain_support import (
17
18
  STATUS_FIELDS,
@@ -305,8 +306,17 @@ def parse_args(argv: list[str]) -> argparse.Namespace:
305
306
  "governance-profile",
306
307
  help="Read Loom governance maturity and upgrade requirements",
307
308
  )
308
- governance_profile.add_argument("operation", choices=("status", "upgrade-plan"))
309
+ governance_profile.add_argument("operation", choices=("status", "upgrade-plan", "binding"))
309
310
  governance_profile.add_argument("--target", required=True, help="Target repository root")
311
+ governance_profile.add_argument("--owner", help="GitHub owner; auto-detected from origin when omitted")
312
+ governance_profile.add_argument("--repo", dest="repo_name", help="GitHub repository name; auto-detected from origin when omitted")
313
+ governance_profile.add_argument("--phase", type=int, help="GitHub Phase issue number")
314
+ governance_profile.add_argument("--fr", type=int, help="GitHub FR issue number")
315
+ governance_profile.add_argument("--issue", type=int, help="GitHub Work Item issue number")
316
+ governance_profile.add_argument("--pr", type=int, help="GitHub implementation PR number")
317
+ governance_profile.add_argument("--branch", help="GitHub branch name bound to the work item")
318
+ governance_profile.add_argument("--sync", action="store_true", help="Preview host binding repairs; writes are intentionally disabled in this phase")
319
+ governance_profile.add_argument("--dry-run", action="store_true", help="Preview binding sync actions without changing GitHub state")
310
320
 
311
321
  flow = subparsers.add_parser("flow", help="Run a bundled high-frequency Loom flow")
312
322
  flow.add_argument("operation", choices=("pre-review", "review", "spec-review", "resume", "handoff", "merge-ready"))
@@ -837,6 +847,7 @@ def normalize_rest_issue(payload: dict[str, Any]) -> dict[str, Any]:
837
847
  "number": payload.get("number"),
838
848
  "state": github_issue_state(payload.get("state")),
839
849
  "title": payload.get("title"),
850
+ "body": payload.get("body"),
840
851
  "url": payload.get("html_url"),
841
852
  }
842
853
 
@@ -849,6 +860,7 @@ def normalize_rest_pr(payload: dict[str, Any]) -> dict[str, Any]:
849
860
  "number": payload.get("number"),
850
861
  "state": github_pr_state(payload),
851
862
  "title": payload.get("title"),
863
+ "body": payload.get("body"),
852
864
  "url": payload.get("html_url"),
853
865
  "isDraft": bool(payload.get("draft")),
854
866
  "mergedAt": payload.get("merged_at"),
@@ -873,6 +885,18 @@ def github_pr_payload(root: Path, owner: str, repo_name: str, pr_number: int) ->
873
885
  return normalize_rest_pr(payload), []
874
886
 
875
887
 
888
+ def github_branch_payload(root: Path, owner: str, repo_name: str, branch_name: str) -> tuple[dict[str, Any] | None, list[str]]:
889
+ payload, errors = gh_rest_json(root, f"repos/{owner}/{repo_name}/branches/{quote(branch_name, safe='')}")
890
+ if errors or payload is None:
891
+ return None, errors
892
+ commit = payload.get("commit") if isinstance(payload.get("commit"), dict) else {}
893
+ return {
894
+ "name": payload.get("name") or branch_name,
895
+ "protected": bool(payload.get("protected")),
896
+ "commit": {"sha": commit.get("sha")} if isinstance(commit.get("sha"), str) else None,
897
+ }, []
898
+
899
+
876
900
  def gh_json_list(root: Path, args: list[str], key: str) -> tuple[list[dict[str, Any]], list[str]]:
877
901
  payload, errors = gh_json(root, args)
878
902
  if errors or payload is None:
@@ -3528,8 +3552,235 @@ def governance_profile_payload(target_root: Path, operation: str) -> dict[str, A
3528
3552
  }
3529
3553
 
3530
3554
 
3555
+ def issue_binding_entry(role: str, number: int | None, payload: dict[str, Any] | None, errors: list[str]) -> dict[str, Any]:
3556
+ status = "present" if payload is not None else "missing"
3557
+ if errors:
3558
+ status = "unreadable"
3559
+ return {
3560
+ "role": role,
3561
+ "number": number,
3562
+ "status": status,
3563
+ "state": payload.get("state") if payload else None,
3564
+ "title": payload.get("title") if payload else None,
3565
+ "url": payload.get("url") if payload else None,
3566
+ "errors": errors,
3567
+ }
3568
+
3569
+
3570
+ def text_mentions_issue(text: object, issue_number: int) -> bool:
3571
+ if not isinstance(text, str):
3572
+ return False
3573
+ pattern = re.compile(rf"(?i)(?:close[sd]?|fix(?:e[sd])?|resolve[sd]?|refs?|related)\s+#?{issue_number}\b|#{issue_number}\b")
3574
+ return bool(pattern.search(text))
3575
+
3576
+
3577
+ def github_binding_payload(
3578
+ *,
3579
+ target_root: Path,
3580
+ owner: str | None,
3581
+ repo_name: str | None,
3582
+ phase_number: int | None,
3583
+ fr_number: int | None,
3584
+ issue_number: int | None,
3585
+ pr_number: int | None,
3586
+ branch_name: str | None,
3587
+ sync: bool,
3588
+ dry_run: bool,
3589
+ ) -> dict[str, Any]:
3590
+ detected_owner, detected_repo = detect_github_repo(target_root)
3591
+ owner = owner or detected_owner
3592
+ repo_name = repo_name or detected_repo
3593
+ missing_inputs: list[str] = []
3594
+ findings: list[dict[str, Any]] = []
3595
+ repair_plan: list[dict[str, Any]] = []
3596
+
3597
+ if not owner or not repo_name:
3598
+ missing_inputs.append("owner/repo")
3599
+ if issue_number is None:
3600
+ missing_inputs.append("work_item issue")
3601
+ if sync and not dry_run:
3602
+ missing_inputs.append("dry-run")
3603
+ findings.append(
3604
+ {
3605
+ "category": "gate_failure",
3606
+ "kind": "binding_failure",
3607
+ "severity": "block",
3608
+ "subject": "governance-profile binding sync",
3609
+ "why_blocking": "binding sync is read-only in this phase unless --dry-run is set.",
3610
+ "fallback_to": "github-profile-binding",
3611
+ "evidence": {"sync": sync, "dry_run": dry_run},
3612
+ }
3613
+ )
3614
+
3615
+ phase_payload: dict[str, Any] | None = None
3616
+ fr_payload: dict[str, Any] | None = None
3617
+ issue_payload: dict[str, Any] | None = None
3618
+ pr_payload: dict[str, Any] | None = None
3619
+ branch_payload: dict[str, Any] | None = None
3620
+ phase_errors: list[str] = []
3621
+ fr_errors: list[str] = []
3622
+ issue_errors: list[str] = []
3623
+ pr_errors: list[str] = []
3624
+ branch_errors: list[str] = []
3625
+
3626
+ if owner and repo_name:
3627
+ if phase_number is not None:
3628
+ phase_payload, phase_errors = github_issue_payload(target_root, owner, repo_name, phase_number)
3629
+ missing_inputs.extend(f"phase: {message}" for message in phase_errors)
3630
+ if fr_number is not None:
3631
+ fr_payload, fr_errors = github_issue_payload(target_root, owner, repo_name, fr_number)
3632
+ missing_inputs.extend(f"fr: {message}" for message in fr_errors)
3633
+ if issue_number is not None:
3634
+ issue_payload, issue_errors = github_issue_payload(target_root, owner, repo_name, issue_number)
3635
+ missing_inputs.extend(f"work_item: {message}" for message in issue_errors)
3636
+ if pr_number is not None:
3637
+ pr_payload, pr_errors = github_pr_payload(target_root, owner, repo_name, pr_number)
3638
+ missing_inputs.extend(f"pr: {message}" for message in pr_errors)
3639
+
3640
+ inferred_branch = branch_name
3641
+ if inferred_branch is None and pr_payload is not None and isinstance(pr_payload.get("headRefName"), str):
3642
+ inferred_branch = pr_payload.get("headRefName")
3643
+ if owner and repo_name and inferred_branch:
3644
+ branch_payload, branch_errors = github_branch_payload(target_root, owner, repo_name, inferred_branch)
3645
+ missing_inputs.extend(f"branch: {message}" for message in branch_errors)
3646
+
3647
+ if issue_payload is not None and pr_payload is not None:
3648
+ pr_body = pr_payload.get("body")
3649
+ if not text_mentions_issue(pr_body, int(issue_payload.get("number") or issue_number or 0)):
3650
+ findings.append(
3651
+ {
3652
+ "category": "gate_failure",
3653
+ "kind": "binding_failure",
3654
+ "severity": "block",
3655
+ "subject": f"PR #{pr_number} -> Work Item #{issue_number}",
3656
+ "why_blocking": "implementation PR body does not mention the Work Item issue.",
3657
+ "fallback_to": "github-profile-binding",
3658
+ "evidence": {
3659
+ "pr_number": pr_number,
3660
+ "issue_number": issue_number,
3661
+ "expected_reference": f"#{issue_number}",
3662
+ },
3663
+ }
3664
+ )
3665
+ repair_plan.append(
3666
+ {
3667
+ "action": "update_pr_body",
3668
+ "subject": f"PR #{pr_number}",
3669
+ "body_append": f"\n\nRelated Work\n\n- Closes #{issue_number}\n",
3670
+ "mode": "dry-run" if dry_run else "not-applied",
3671
+ }
3672
+ )
3673
+ if issue_payload is not None and fr_payload is not None and not text_mentions_issue(issue_payload.get("body"), int(fr_number or 0)):
3674
+ findings.append(
3675
+ {
3676
+ "category": "gate_failure",
3677
+ "kind": "binding_failure",
3678
+ "severity": "block",
3679
+ "subject": f"Work Item #{issue_number} -> FR #{fr_number}",
3680
+ "why_blocking": "Work Item issue body does not mention the FR issue.",
3681
+ "fallback_to": "github-profile-binding",
3682
+ "evidence": {"work_item": issue_number, "fr": fr_number, "expected_reference": f"#{fr_number}"},
3683
+ }
3684
+ )
3685
+ if fr_payload is not None and phase_payload is not None and not text_mentions_issue(fr_payload.get("body"), int(phase_number or 0)):
3686
+ findings.append(
3687
+ {
3688
+ "category": "gate_failure",
3689
+ "kind": "binding_failure",
3690
+ "severity": "block",
3691
+ "subject": f"FR #{fr_number} -> Phase #{phase_number}",
3692
+ "why_blocking": "FR issue body does not mention the Phase issue.",
3693
+ "fallback_to": "github-profile-binding",
3694
+ "evidence": {"fr": fr_number, "phase": phase_number, "expected_reference": f"#{phase_number}"},
3695
+ }
3696
+ )
3697
+
3698
+ merge_commit = pr_payload.get("mergeCommit") if isinstance(pr_payload, dict) else None
3699
+ merge_commit_sha = merge_commit.get("oid") if isinstance(merge_commit, dict) else None
3700
+ target_branch = pr_payload.get("baseRefName") if isinstance(pr_payload, dict) else None
3701
+ binding = {
3702
+ "schema_version": "loom-github-binding/v1",
3703
+ "repository": {"owner": owner, "name": repo_name},
3704
+ "objects": {
3705
+ "phase": issue_binding_entry("phase", phase_number, phase_payload, phase_errors),
3706
+ "fr": issue_binding_entry("fr", fr_number, fr_payload, fr_errors),
3707
+ "work_item": issue_binding_entry("work_item", issue_number, issue_payload, issue_errors),
3708
+ "branch": {
3709
+ "role": "branch",
3710
+ "name": inferred_branch,
3711
+ "status": "present" if branch_payload is not None else ("unreadable" if branch_errors else "missing"),
3712
+ "head_sha": branch_payload.get("commit", {}).get("sha") if isinstance(branch_payload, dict) and isinstance(branch_payload.get("commit"), dict) else None,
3713
+ "errors": branch_errors,
3714
+ },
3715
+ "implementation_pr": {
3716
+ "role": "implementation_pr",
3717
+ "number": pr_number,
3718
+ "status": "present" if pr_payload is not None else ("unreadable" if pr_errors else "missing"),
3719
+ "state": pr_payload.get("state") if pr_payload else None,
3720
+ "isDraft": pr_payload.get("isDraft") if pr_payload else None,
3721
+ "headRefName": pr_payload.get("headRefName") if pr_payload else None,
3722
+ "baseRefName": pr_payload.get("baseRefName") if pr_payload else None,
3723
+ "url": pr_payload.get("url") if pr_payload else None,
3724
+ "errors": pr_errors,
3725
+ },
3726
+ "merge_commit": {
3727
+ "role": "merge_commit",
3728
+ "sha": merge_commit_sha,
3729
+ "status": "present" if merge_commit_sha else "missing",
3730
+ },
3731
+ "target_branch": {
3732
+ "role": "target_branch",
3733
+ "name": target_branch,
3734
+ "status": "present" if target_branch else "missing",
3735
+ },
3736
+ },
3737
+ "chain": [
3738
+ {"from": "phase", "to": "fr", "status": "present" if phase_payload and fr_payload else "missing"},
3739
+ {"from": "fr", "to": "work_item", "status": "present" if fr_payload and issue_payload else "missing"},
3740
+ {"from": "work_item", "to": "implementation_pr", "status": "present" if issue_payload and pr_payload else "missing"},
3741
+ {"from": "implementation_pr", "to": "merge_commit", "status": "present" if merge_commit_sha else "missing"},
3742
+ {"from": "merge_commit", "to": "target_branch", "status": "present" if merge_commit_sha and target_branch else "missing"},
3743
+ ],
3744
+ "findings": findings,
3745
+ "repair_plan": repair_plan if sync or dry_run else [],
3746
+ }
3747
+ chain_complete = all(entry.get("status") == "present" for entry in binding["chain"])
3748
+ if not chain_complete and "binding_chain" not in missing_inputs:
3749
+ missing_inputs.append("binding_chain")
3750
+ result = "pass" if not missing_inputs and not findings and chain_complete else "block"
3751
+ return {
3752
+ "command": "governance-profile",
3753
+ "operation": "binding",
3754
+ "schema_version": "loom-github-binding/v1",
3755
+ "result": result,
3756
+ "summary": (
3757
+ "GitHub profile binding chain is readable."
3758
+ if result == "pass"
3759
+ else "GitHub profile binding chain is incomplete or inconsistent."
3760
+ ),
3761
+ "missing_inputs": missing_inputs,
3762
+ "fallback_to": None if result == "pass" else "github-profile-binding",
3763
+ "binding": binding,
3764
+ }
3765
+
3766
+
3531
3767
  def handle_governance_profile(args: argparse.Namespace) -> int:
3532
3768
  target_root = Path(args.target).expanduser().resolve()
3769
+ if args.operation == "binding":
3770
+ return emit(
3771
+ github_binding_payload(
3772
+ target_root=target_root,
3773
+ owner=args.owner,
3774
+ repo_name=args.repo_name,
3775
+ phase_number=args.phase,
3776
+ fr_number=args.fr,
3777
+ issue_number=args.issue,
3778
+ pr_number=args.pr,
3779
+ branch_name=args.branch,
3780
+ sync=args.sync,
3781
+ dry_run=args.dry_run,
3782
+ )
3783
+ )
3533
3784
  return emit(governance_profile_payload(target_root, args.operation))
3534
3785
 
3535
3786