@ikon85/agent-workflow-kit 0.10.0
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.
- package/.agents/skills/ask-matt/SKILL.md +139 -0
- package/.agents/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/audit-skills/SKILL.md +145 -0
- package/.agents/skills/board-to-waves/SKILL.md +135 -0
- package/.agents/skills/code-review/SKILL.md +64 -0
- package/.agents/skills/codebase-design/DEEPENING.md +37 -0
- package/.agents/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.agents/skills/codebase-design/SKILL.md +114 -0
- package/.agents/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/codex-adapter-sync/SKILL.md +151 -0
- package/.agents/skills/decision-gate/SKILL.md +67 -0
- package/.agents/skills/diagnose/SKILL.md +136 -0
- package/.agents/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/.agents/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.agents/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/domain-modeling/SKILL.md +76 -0
- package/.agents/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/git-worktree-recover/SKILL.md +176 -0
- package/.agents/skills/grill-me/SKILL.md +54 -0
- package/.agents/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/.agents/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/grill-with-docs/SKILL.md +137 -0
- package/.agents/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/implement/SKILL.md +15 -0
- package/.agents/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
- package/.agents/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
- package/.agents/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/.agents/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.agents/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
- package/.agents/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/.agents/skills/improve-codebase-architecture/SKILL.md +88 -0
- package/.agents/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/kit-release/SKILL.md +56 -0
- package/.agents/skills/local-ci/SKILL.md +94 -0
- package/.agents/skills/orchestrate-wave/SKILL.md +261 -0
- package/.agents/skills/orchestrate-wave/references/builder-contract.md +67 -0
- package/.agents/skills/prototype/LOGIC.md +79 -0
- package/.agents/skills/prototype/SKILL.md +30 -0
- package/.agents/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/prototype/UI.md +112 -0
- package/.agents/skills/research/SKILL.md +12 -0
- package/.agents/skills/research/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/resolving-merge-conflicts/SKILL.md +14 -0
- package/.agents/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/retro/SKILL.md +209 -0
- package/.agents/skills/scale-check/SKILL.md +141 -0
- package/.agents/skills/security-audit/SKILL.md +67 -0
- package/.agents/skills/setup-workflow/SKILL.md +187 -0
- package/.agents/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/setup-workflow/board-sync.md +111 -0
- package/.agents/skills/setup-workflow/code-review.md +26 -0
- package/.agents/skills/setup-workflow/domain.md +51 -0
- package/.agents/skills/setup-workflow/issue-tracker-github.md +35 -0
- package/.agents/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
- package/.agents/skills/setup-workflow/issue-tracker-local.md +30 -0
- package/.agents/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
- package/.agents/skills/setup-workflow/spec-completeness-seed.md +44 -0
- package/.agents/skills/setup-workflow/spec-self-critique-seed.md +43 -0
- package/.agents/skills/setup-workflow/triage-labels.md +18 -0
- package/.agents/skills/setup-workflow/workflow-overview.md +33 -0
- package/.agents/skills/spec-self-critique/SKILL.md +152 -0
- package/.agents/skills/spec-self-critique/scenarios.md +82 -0
- package/.agents/skills/tdd/SKILL.md +120 -0
- package/.agents/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/tdd/deep-modules.md +33 -0
- package/.agents/skills/tdd/interface-design.md +31 -0
- package/.agents/skills/tdd/mocking.md +59 -0
- package/.agents/skills/tdd/refactoring.md +10 -0
- package/.agents/skills/tdd/tests.md +61 -0
- package/.agents/skills/to-issues/SKILL.md +325 -0
- package/.agents/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
- package/.agents/skills/to-prd/SKILL.md +180 -0
- package/.agents/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/to-waves/SKILL.md +260 -0
- package/.agents/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
- package/.agents/skills/triage/AGENT-BRIEF.md +168 -0
- package/.agents/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/.agents/skills/triage/SKILL.md +122 -0
- package/.agents/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/verify-spike/SKILL.md +39 -0
- package/.agents/skills/wayfinder/SKILL.md +127 -0
- package/.agents/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
- package/.agents/skills/wrapup/SKILL.md +71 -0
- package/.agents/skills/wrapup/SOURCES.txt +6 -0
- package/.agents/skills/zoom-out/SKILL.md +7 -0
- package/.agents/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/hooks/_hook_utils.py +184 -0
- package/.claude/hooks/drift-guard.py +176 -0
- package/.claude/hooks/skill-drift-hint.py +122 -0
- package/.claude/hooks/sync-board-status.py +191 -0
- package/.claude/skills/ask-matt/SKILL.md +139 -0
- package/.claude/skills/ask-matt/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/audit-skills/SKILL.md +145 -0
- package/.claude/skills/board-to-waves/SKILL.md +148 -0
- package/.claude/skills/code-review/SKILL.md +64 -0
- package/.claude/skills/codebase-design/DEEPENING.md +37 -0
- package/.claude/skills/codebase-design/DESIGN-IT-TWICE.md +44 -0
- package/.claude/skills/codebase-design/SKILL.md +114 -0
- package/.claude/skills/codebase-design/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/codex-build/SKILL.md +126 -0
- package/.claude/skills/codex-build/THIRD-PARTY-NOTICES.md +35 -0
- package/.claude/skills/codex-review/SKILL.md +145 -0
- package/.claude/skills/codex-review/THIRD-PARTY-NOTICES.md +31 -0
- package/.claude/skills/decision-gate/SKILL.md +67 -0
- package/.claude/skills/diagnose/SKILL.md +136 -0
- package/.claude/skills/diagnose/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/diagnose/scripts/hitl-loop.template.sh +41 -0
- package/.claude/skills/domain-modeling/ADR-FORMAT.md +47 -0
- package/.claude/skills/domain-modeling/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/domain-modeling/SKILL.md +76 -0
- package/.claude/skills/domain-modeling/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/git-guardrails-claude-code/SKILL.md +96 -0
- package/.claude/skills/git-guardrails-claude-code/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/git-guardrails-claude-code/scripts/block-dangerous-git.sh +28 -0
- package/.claude/skills/git-worktree-recover/SKILL.md +182 -0
- package/.claude/skills/grill-me/SKILL.md +54 -0
- package/.claude/skills/grill-me/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/grill-me-codex/SKILL.md +158 -0
- package/.claude/skills/grill-me-codex/THIRD-PARTY-NOTICES.md +32 -0
- package/.claude/skills/grill-with-docs/ADR-FORMAT.md +47 -0
- package/.claude/skills/grill-with-docs/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/grill-with-docs/SKILL.md +137 -0
- package/.claude/skills/grill-with-docs/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/grill-with-docs-codex/ADR-FORMAT.md +47 -0
- package/.claude/skills/grill-with-docs-codex/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/grill-with-docs-codex/SKILL.md +233 -0
- package/.claude/skills/grill-with-docs-codex/THIRD-PARTY-NOTICES.md +33 -0
- package/.claude/skills/implement/SKILL.md +15 -0
- package/.claude/skills/implement/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/improve-codebase-architecture/ADR-FORMAT.md +47 -0
- package/.claude/skills/improve-codebase-architecture/CONTEXT-FORMAT.md +60 -0
- package/.claude/skills/improve-codebase-architecture/DEEPENING.md +37 -0
- package/.claude/skills/improve-codebase-architecture/HTML-REPORT.md +123 -0
- package/.claude/skills/improve-codebase-architecture/INTERFACE-DESIGN.md +46 -0
- package/.claude/skills/improve-codebase-architecture/LANGUAGE.md +53 -0
- package/.claude/skills/improve-codebase-architecture/SKILL.md +88 -0
- package/.claude/skills/improve-codebase-architecture/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/kit-release/SKILL.md +56 -0
- package/.claude/skills/local-ci/SKILL.md +94 -0
- package/.claude/skills/orchestrate-wave/SKILL.md +269 -0
- package/.claude/skills/orchestrate-wave/references/builder-contract.md +67 -0
- package/.claude/skills/prototype/LOGIC.md +79 -0
- package/.claude/skills/prototype/SKILL.md +30 -0
- package/.claude/skills/prototype/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/prototype/UI.md +112 -0
- package/.claude/skills/research/SKILL.md +12 -0
- package/.claude/skills/research/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/resolving-merge-conflicts/SKILL.md +14 -0
- package/.claude/skills/resolving-merge-conflicts/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/retro/SKILL.md +209 -0
- package/.claude/skills/scale-check/SKILL.md +141 -0
- package/.claude/skills/security-audit/SKILL.md +67 -0
- package/.claude/skills/setup-pre-commit/SKILL.md +104 -0
- package/.claude/skills/setup-pre-commit/scripts/pre-commit.template.sh +35 -0
- package/.claude/skills/setup-workflow/SKILL.md +187 -0
- package/.claude/skills/setup-workflow/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/setup-workflow/board-sync.md +111 -0
- package/.claude/skills/setup-workflow/code-review.md +26 -0
- package/.claude/skills/setup-workflow/domain.md +51 -0
- package/.claude/skills/setup-workflow/issue-tracker-github.md +35 -0
- package/.claude/skills/setup-workflow/issue-tracker-gitlab.md +34 -0
- package/.claude/skills/setup-workflow/issue-tracker-local.md +30 -0
- package/.claude/skills/setup-workflow/orchestrate-wave-seed.md +44 -0
- package/.claude/skills/setup-workflow/spec-completeness-seed.md +44 -0
- package/.claude/skills/setup-workflow/spec-self-critique-seed.md +43 -0
- package/.claude/skills/setup-workflow/triage-labels.md +18 -0
- package/.claude/skills/setup-workflow/workflow-overview.md +33 -0
- package/.claude/skills/skill-manifest.json +389 -0
- package/.claude/skills/spec-self-critique/SKILL.md +152 -0
- package/.claude/skills/spec-self-critique/scenarios.md +82 -0
- package/.claude/skills/tdd/SKILL.md +120 -0
- package/.claude/skills/tdd/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/tdd/deep-modules.md +33 -0
- package/.claude/skills/tdd/interface-design.md +31 -0
- package/.claude/skills/tdd/mocking.md +59 -0
- package/.claude/skills/tdd/refactoring.md +10 -0
- package/.claude/skills/tdd/tests.md +61 -0
- package/.claude/skills/to-issues/SKILL.md +325 -0
- package/.claude/skills/to-issues/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/to-prd/PROGRAM-PRD-FORMAT.md +162 -0
- package/.claude/skills/to-prd/SKILL.md +180 -0
- package/.claude/skills/to-prd/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/to-waves/SKILL.md +260 -0
- package/.claude/skills/to-waves/SLICE-METADATA-FORMAT.md +74 -0
- package/.claude/skills/triage/AGENT-BRIEF.md +168 -0
- package/.claude/skills/triage/OUT-OF-SCOPE.md +101 -0
- package/.claude/skills/triage/SKILL.md +122 -0
- package/.claude/skills/triage/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/verify-spike/SKILL.md +39 -0
- package/.claude/skills/wayfinder/SKILL.md +127 -0
- package/.claude/skills/wayfinder/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/wrapup/SKILL.md +81 -0
- package/.claude/skills/wrapup/SOURCES.txt +6 -0
- package/.claude/skills/write-a-skill/GLOSSARY.md +201 -0
- package/.claude/skills/write-a-skill/SKILL.md +88 -0
- package/.claude/skills/write-a-skill/THIRD-PARTY-NOTICES.md +29 -0
- package/.claude/skills/zoom-out/SKILL.md +7 -0
- package/.claude/skills/zoom-out/THIRD-PARTY-NOTICES.md +29 -0
- package/LICENSE +28 -0
- package/PROVENANCE.md +39 -0
- package/README.md +619 -0
- package/agent-workflow-kit.package.json +1939 -0
- package/docs/agents/board-sync.md +71 -0
- package/docs/agents/code-review.md +14 -0
- package/docs/agents/domain.md +7 -0
- package/docs/agents/issue-tracker.md +12 -0
- package/docs/agents/security-audit-runbook-template.md +205 -0
- package/docs/agents/skills/orchestrate-wave.md +13 -0
- package/docs/agents/skills/spec-self-critique.md +18 -0
- package/docs/agents/triage-labels.md +10 -0
- package/docs/agents/wave-anchor-template.md +88 -0
- package/docs/conventions/spec-completeness.md +23 -0
- package/docs/index.html +56 -0
- package/docs/methodology.html +488 -0
- package/docs/methodology.svg +93 -0
- package/docs/workflow.png +0 -0
- package/package.json +40 -0
- package/scripts/anchor_table.py +343 -0
- package/scripts/board-sync.py +1232 -0
- package/scripts/board_config.py +147 -0
- package/scripts/board_fields.py +192 -0
- package/scripts/build-kit.mjs +116 -0
- package/scripts/build-kit.test.mjs +59 -0
- package/scripts/check-kit-staleness.mjs +45 -0
- package/scripts/check-kit-staleness.test.mjs +19 -0
- package/scripts/execute-ready-check.py +516 -0
- package/scripts/issue_deps.py +79 -0
- package/scripts/kit-release.mjs +100 -0
- package/scripts/kit-release.test.mjs +67 -0
- package/scripts/lib/audit-refs.mjs +146 -0
- package/scripts/lib/scrub.mjs +113 -0
- package/scripts/lib/scrub.test.mjs +187 -0
- package/scripts/loc_offender_core.py +196 -0
- package/scripts/loc_offender_gate.py +182 -0
- package/scripts/node_kind.py +68 -0
- package/scripts/portability_profile_scan.py +53 -0
- package/scripts/pr-body-check.py +263 -0
- package/scripts/program_graph.py +125 -0
- package/scripts/program_graph_parse.py +275 -0
- package/scripts/program_graph_validate.py +256 -0
- package/scripts/program_sync.py +160 -0
- package/scripts/release-delta-guard.mjs +96 -0
- package/scripts/release-delta-guard.test.mjs +53 -0
- package/scripts/test_board_sync_create_idempotency.py +104 -0
- package/scripts/test_dist_kit_smoke.py +199 -0
- package/scripts/test_kit_docs_language_census.py +178 -0
- package/scripts/test_skill_code_review_seed.py +89 -0
- package/scripts/test_skill_frontmatter_lint.py +226 -0
- package/scripts/test_skill_gh_lint.py +124 -0
- package/scripts/test_skill_language_census.py +254 -0
- package/scripts/test_skill_portability_lint.py +773 -0
- package/scripts/test_skill_publish_audit.py +300 -0
- package/scripts/test_skill_selfcontainment_lint.py +255 -0
- package/scripts/test_skill_setup_workflow_seeds.py +200 -0
- package/scripts/test_skill_stale_name_lint.py +126 -0
- package/scripts/test_skill_surface_refs.py +300 -0
- package/scripts/test_skill_trailing_artifact_lint.py +71 -0
- package/scripts/wrapup-land.py +573 -0
- package/src/cli.mjs +70 -0
- package/src/commands/diff.mjs +6 -0
- package/src/commands/init.mjs +59 -0
- package/src/commands/uninstall.mjs +43 -0
- package/src/commands/update.mjs +95 -0
- package/src/lib/atomicWrite.mjs +49 -0
- package/src/lib/bundle.mjs +133 -0
- package/src/lib/hash.mjs +13 -0
- package/src/lib/manifest.mjs +58 -0
- package/src/lib/sentinel.mjs +20 -0
- package/src/lib/settings.mjs +23 -0
|
@@ -0,0 +1,1232 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""board-sync.py — single GitHub Projects-v2 board-sync / sub-issue helper.
|
|
3
|
+
|
|
4
|
+
Encapsulates the five board mechanics the planning skills used to inline as bare
|
|
5
|
+
`gh` snippets (board-to-waves, to-prd, to-issues):
|
|
6
|
+
|
|
7
|
+
1. GraphQL-Link — native parent↔child sub-issue link (`link`)
|
|
8
|
+
2. one-parent-check — an issue may have only one parent (`parent-of`, folded into `link`)
|
|
9
|
+
3. preview-header — `GraphQL-Features: sub_issues` on every sub-issue call
|
|
10
|
+
4. Wave-Stempel — stamp the Wave (number) field (`--wave` on `create`/`add`)
|
|
11
|
+
5. board-sync — add an issue to the board + set Status/Wave/Cluster/Path fields
|
|
12
|
+
|
|
13
|
+
Board-specific values (field IDs, status names, labels) are NOT inlined here —
|
|
14
|
+
board_config reads them from the `board-sync:profile` block in
|
|
15
|
+
docs/agents/board-sync.md, so the published script carries no project-private
|
|
16
|
+
constants. Re-verify the IDs against a changed board with
|
|
17
|
+
`gh project field-list <n> --owner <owner> --format json`.
|
|
18
|
+
|
|
19
|
+
All write commands accept --dry-run (prints the gh argv it would run, no network).
|
|
20
|
+
The single `_gh` subprocess seam is monkeypatched in scripts/test_board_sync.py so
|
|
21
|
+
tests never touch the real API.
|
|
22
|
+
"""
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import argparse
|
|
26
|
+
import datetime
|
|
27
|
+
import json
|
|
28
|
+
import re
|
|
29
|
+
import subprocess
|
|
30
|
+
import sys
|
|
31
|
+
import tempfile
|
|
32
|
+
from pathlib import Path
|
|
33
|
+
from typing import Optional
|
|
34
|
+
|
|
35
|
+
sys.path.insert(0, str(Path(__file__).resolve().parent))
|
|
36
|
+
from board_config import ( # noqa: E402
|
|
37
|
+
ConfigError, load_board_config, phase_field_id, program_type_label,
|
|
38
|
+
status_roles, STATUS_ROLE_KEYS,
|
|
39
|
+
)
|
|
40
|
+
# stamp-batch / field-value / promote-guards — pure logic, see module docstring.
|
|
41
|
+
from board_fields import ( # noqa: E402
|
|
42
|
+
build_stamps, chunk_stamps, build_stamp_mutation, parse_batch_response, repair_command,
|
|
43
|
+
extract_field_value, wave_mismatch_guard, program_prd_refusal,
|
|
44
|
+
)
|
|
45
|
+
# Node-kind classifier — `promote`'s Program-PRD refusal + `program-sync`'s
|
|
46
|
+
# node-kind dispatch guard both need to tell a Program-PRD apart from a Welle-Anker.
|
|
47
|
+
from node_kind import classify_node, PROGRAM # noqa: E402
|
|
48
|
+
# Blocked-by body mirror — pure render/splice/parse + the shared
|
|
49
|
+
# "open blocker" predicate, shared with the execute-ready drift check;
|
|
50
|
+
# native dependencies (API) are the truth.
|
|
51
|
+
from issue_deps import open_blocker_numbers, splice_blocked_by_section # noqa: E402
|
|
52
|
+
|
|
53
|
+
# --- Board profile (SSOT: docs/agents/board-sync.md `board-sync:profile`) ----
|
|
54
|
+
# No inline board IDs: board_config reads them from the profile so the published
|
|
55
|
+
# kit carries no project-private constants (a consumer's profile is seeded by
|
|
56
|
+
# /setup-workflow). A bad/missing profile fails clean here (one-line stderr, exit
|
|
57
|
+
# 1) instead of a raw ConfigError traceback.
|
|
58
|
+
try:
|
|
59
|
+
_CFG = load_board_config()
|
|
60
|
+
except ConfigError as exc:
|
|
61
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
62
|
+
sys.exit(1)
|
|
63
|
+
REPO = _CFG["repo"]
|
|
64
|
+
REPO_NAME = REPO.split("/", 1)[1]
|
|
65
|
+
PROJECT_NUMBER = _CFG["project"]["number"]
|
|
66
|
+
PROJECT_OWNER = _CFG["project"]["owner"]
|
|
67
|
+
PROJECT_NODE_ID = _CFG["project"]["nodeId"]
|
|
68
|
+
STATUS_FIELD_ID = _CFG["fields"]["status"]["id"]
|
|
69
|
+
STATUS_OPTIONS = _CFG["fields"]["status"]["options"]
|
|
70
|
+
WAVE_FIELD_ID = _CFG["fields"]["wave"]
|
|
71
|
+
CLUSTER_FIELD_ID = _CFG["fields"]["cluster"]
|
|
72
|
+
SPEC_PATH_FIELD_ID = _CFG["fields"]["specPath"]
|
|
73
|
+
PLAN_PATH_FIELD_ID = _CFG["fields"]["planPath"]
|
|
74
|
+
READY_FOR_AGENT = _CFG["labels"]["readyForAgent"]
|
|
75
|
+
TYPE_PREFIX = _CFG["labels"]["typePrefix"]
|
|
76
|
+
CLUSTER_TYPE_LABEL = _CFG["labels"]["clusterType"]
|
|
77
|
+
WAVE_STUB_LABEL = _CFG["labels"]["waveStub"]
|
|
78
|
+
# Optional Programm-Flughöhe key — literal-default getter, so
|
|
79
|
+
# an existing profile without `labels.programType` keeps working unchanged.
|
|
80
|
+
PROGRAM_TYPE_LABEL = program_type_label(_CFG)
|
|
81
|
+
# Optional semantic role map — empty dict when the profile has no
|
|
82
|
+
# `fields.status.roles` yet; role-consuming paths degrade visibly (see
|
|
83
|
+
# `_status_roles_or_hint` / `resolve_status_role`), never crash.
|
|
84
|
+
STATUS_ROLES = status_roles(_CFG)
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def _status_roles_or_hint(context: str) -> dict:
|
|
88
|
+
"""STATUS_ROLES, with a one-line stderr hint when the profile has no role
|
|
89
|
+
map — status-name-based signals then read as 'open' (⬜) while PR-based
|
|
90
|
+
signals keep working; the sync itself proceeds."""
|
|
91
|
+
if not STATUS_ROLES:
|
|
92
|
+
print(f"hint: {context}: no fields.status.roles in the board profile — "
|
|
93
|
+
"board-status names can't be interpreted (PR signals still work); "
|
|
94
|
+
"add the roles map (see docs/agents/board-sync.md)", file=sys.stderr)
|
|
95
|
+
return STATUS_ROLES
|
|
96
|
+
PROJECT_ITEM_LIST_LIMIT = 2000
|
|
97
|
+
GH_TIMEOUT_SECONDS = 15 # a hanging gh prompt must not block a session indefinitely
|
|
98
|
+
|
|
99
|
+
# Sub-issues GraphQL API is behind a preview feature gate per account.
|
|
100
|
+
SUB_ISSUES_HEADER = "GraphQL-Features: sub_issues"
|
|
101
|
+
|
|
102
|
+
ADD_SUBISSUE_MUTATION = (
|
|
103
|
+
"mutation($parent:ID!,$child:ID!){"
|
|
104
|
+
"addSubIssue(input:{issueId:$parent,subIssueId:$child}){"
|
|
105
|
+
"issue{number} subIssue{number}}}"
|
|
106
|
+
)
|
|
107
|
+
REMOVE_SUBISSUE_MUTATION = (
|
|
108
|
+
"mutation($parent:ID!,$child:ID!){"
|
|
109
|
+
"removeSubIssue(input:{issueId:$parent,subIssueId:$child}){"
|
|
110
|
+
"issue{number} subIssue{number}}}"
|
|
111
|
+
)
|
|
112
|
+
PARENT_QUERY = (
|
|
113
|
+
"query($owner:String!,$repo:String!,$num:Int!){"
|
|
114
|
+
"repository(owner:$owner,name:$repo){issue(number:$num){parent{number title}}}}"
|
|
115
|
+
)
|
|
116
|
+
CHILDREN_QUERY = (
|
|
117
|
+
"query($owner:String!,$repo:String!,$num:Int!){"
|
|
118
|
+
"repository(owner:$owner,name:$repo){issue(number:$num){"
|
|
119
|
+
"subIssues(first:100){nodes{number}}}}}"
|
|
120
|
+
)
|
|
121
|
+
|
|
122
|
+
|
|
123
|
+
class GhError(RuntimeError):
|
|
124
|
+
"""Raised when the gh CLI exits non-zero."""
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
# --- subprocess seam (the only thing tests monkeypatch) ----------------------
|
|
128
|
+
def _gh(args: list[str]) -> str:
|
|
129
|
+
"""Run `gh <args>`, return stdout. Raise GhError on non-zero exit or timeout."""
|
|
130
|
+
try:
|
|
131
|
+
result = subprocess.run(["gh", *args], capture_output=True, text=True,
|
|
132
|
+
timeout=GH_TIMEOUT_SECONDS)
|
|
133
|
+
except subprocess.TimeoutExpired as exc:
|
|
134
|
+
raise GhError(f"gh {' '.join(args)} timed out after {GH_TIMEOUT_SECONDS}s") from exc
|
|
135
|
+
if result.returncode != 0:
|
|
136
|
+
# `gh api graphql` exits non-zero when the response body carries GraphQL
|
|
137
|
+
# `errors` even alongside a partial `data` (stamp-batch's per-alias
|
|
138
|
+
# failure case) — attach the raw stdout to the exception so a
|
|
139
|
+
# caller that WANTS that partial body (stamp-batch) can still parse it,
|
|
140
|
+
# without changing `_gh`'s raise-on-nonzero contract for every other
|
|
141
|
+
# caller that doesn't look at `.stdout`.
|
|
142
|
+
err = GhError(f"gh {' '.join(args)} failed: {result.stderr.strip()}")
|
|
143
|
+
err.stdout = result.stdout
|
|
144
|
+
raise err
|
|
145
|
+
return result.stdout
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _gh_json(args: list[str]):
|
|
149
|
+
return json.loads(_gh(args))
|
|
150
|
+
|
|
151
|
+
|
|
152
|
+
def _print_dry(args: list[str]) -> None:
|
|
153
|
+
print("[dry-run] gh " + " ".join(args))
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
# --- pure logic (directly unit-tested) ---------------------------------------
|
|
157
|
+
_PROGRAM_CREATE_IDENTITY_RE = re.compile(
|
|
158
|
+
r"<!--\s*(program-(?:stub|leaf)-source):\s*([^>]+?)\s*-->"
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
def extract_program_create_identity(body: str) -> Optional[str]:
|
|
163
|
+
"""Stable to-waves identity carried by a stub/leaf body, if present."""
|
|
164
|
+
match = _PROGRAM_CREATE_IDENTITY_RE.search(body)
|
|
165
|
+
return f"{match.group(1)}: {match.group(2).strip()}" if match else None
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _find_open_issue_by_program_identity(body: str) -> Optional[dict]:
|
|
169
|
+
"""Search-before-create for to-waves crash/retry safety.
|
|
170
|
+
|
|
171
|
+
GitHub search does not index HTML comments, so fetch the bounded open set and
|
|
172
|
+
compare the parsed marker locally. More than one match is drift: never pick a
|
|
173
|
+
winner silently.
|
|
174
|
+
"""
|
|
175
|
+
identity = extract_program_create_identity(body)
|
|
176
|
+
if identity is None:
|
|
177
|
+
return None
|
|
178
|
+
issues = _gh_json([
|
|
179
|
+
"issue", "list", "--repo", REPO, "--state", "open", "--limit", "500",
|
|
180
|
+
"--json", "number,url,body",
|
|
181
|
+
])
|
|
182
|
+
matches = [
|
|
183
|
+
issue for issue in issues
|
|
184
|
+
if extract_program_create_identity(issue.get("body") or "") == identity
|
|
185
|
+
]
|
|
186
|
+
if len(matches) > 1:
|
|
187
|
+
numbers = ", ".join(f"#{issue['number']}" for issue in matches)
|
|
188
|
+
raise ValueError(f"duplicate {identity} issues: {numbers}")
|
|
189
|
+
return matches[0] if matches else None
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def compute_next_wave(items: list[dict]) -> int:
|
|
193
|
+
"""Next monotone wave number = max(assigned wave) + 1, or 1 if none."""
|
|
194
|
+
waves = [it.get("wave") for it in items if it.get("wave") is not None]
|
|
195
|
+
return max(waves) + 1 if waves else 1
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def extract_parent_number(data: dict) -> Optional[int]:
|
|
199
|
+
"""Pull the parent issue number out of the parent-query GraphQL response."""
|
|
200
|
+
issue = (data.get("data") or {}).get("repository", {}).get("issue")
|
|
201
|
+
parent = issue.get("parent") if issue else None
|
|
202
|
+
return parent["number"] if parent else None
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def extract_children_numbers(data: dict) -> list[int]:
|
|
206
|
+
"""Pull the native sub-issue numbers out of the children-query response."""
|
|
207
|
+
issue = (data.get("data") or {}).get("repository", {}).get("issue")
|
|
208
|
+
nodes = ((issue or {}).get("subIssues") or {}).get("nodes") or []
|
|
209
|
+
return [n["number"] for n in nodes]
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
def extract_item_id(item_add_json: dict) -> str:
|
|
213
|
+
"""The project item node id returned by `gh project item-add --format json`."""
|
|
214
|
+
return item_add_json["id"]
|
|
215
|
+
|
|
216
|
+
|
|
217
|
+
def one_parent_decision(existing_parent: Optional[int], target_parent: int) -> str:
|
|
218
|
+
"""Decide what `link` should do given the child's current parent.
|
|
219
|
+
|
|
220
|
+
Returns 'link' (free), 'already' (same parent — idempotent no-op), or
|
|
221
|
+
'conflict' (foreign parent — skip, never silently re-parent).
|
|
222
|
+
"""
|
|
223
|
+
if existing_parent is None:
|
|
224
|
+
return "link"
|
|
225
|
+
if existing_parent == target_parent:
|
|
226
|
+
return "already"
|
|
227
|
+
return "conflict"
|
|
228
|
+
|
|
229
|
+
|
|
230
|
+
def hitl_guard(hitl: bool, labels: list[str]) -> None:
|
|
231
|
+
"""A HITL child is not buildable yet — it must never carry ready-for-agent.
|
|
232
|
+
|
|
233
|
+
AFK and HITL children are both Status Spec, so the status cannot discriminate;
|
|
234
|
+
only the explicit --hitl signal can. Raises ValueError on the contradiction.
|
|
235
|
+
"""
|
|
236
|
+
if hitl and READY_FOR_AGENT in (labels or []):
|
|
237
|
+
raise ValueError(
|
|
238
|
+
f"HITL issue must not carry '{READY_FOR_AGENT}' — a HITL slice is not "
|
|
239
|
+
"buildable until grilled. Drop the label or drop --hitl.")
|
|
240
|
+
|
|
241
|
+
|
|
242
|
+
def resolve_status_option(name: str) -> str:
|
|
243
|
+
if name not in STATUS_OPTIONS:
|
|
244
|
+
raise ValueError(f"unknown status {name!r}; valid: {', '.join(STATUS_OPTIONS)}")
|
|
245
|
+
return STATUS_OPTIONS[name]
|
|
246
|
+
|
|
247
|
+
|
|
248
|
+
_ROLES_MIGRATION_SNIPPET = (
|
|
249
|
+
'no fields.status.roles in the board profile — add it to the profile block '
|
|
250
|
+
'in docs/agents/board-sync.md, mapping each role to YOUR board\'s status '
|
|
251
|
+
'option names (any language), e.g.:\n'
|
|
252
|
+
' "roles": {\n'
|
|
253
|
+
' "idea": "Idea", "triaged": "Triaged", "spec": "Spec",\n'
|
|
254
|
+
' "inProgress": "In Progress", "review": "Review", "done": "Done"\n'
|
|
255
|
+
' }')
|
|
256
|
+
|
|
257
|
+
|
|
258
|
+
def resolve_status_role(role: str, roles: dict) -> str:
|
|
259
|
+
"""Option NAME for a semantic role key, via the profile's roles map.
|
|
260
|
+
Fails loud + instructive (missing map → migration snippet; unset role →
|
|
261
|
+
the configured roles) — an explicit --status-role call never guesses."""
|
|
262
|
+
if not roles:
|
|
263
|
+
raise ValueError(_ROLES_MIGRATION_SNIPPET)
|
|
264
|
+
name = roles.get(role)
|
|
265
|
+
if not name:
|
|
266
|
+
raise ValueError(
|
|
267
|
+
f"status role {role!r} not set in fields.status.roles; "
|
|
268
|
+
f"configured roles: {', '.join(sorted(roles))}")
|
|
269
|
+
return name
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
def stamp_arg_list(item_id, wave, status, cluster, spec_path, plan_path) -> list[list[str]]:
|
|
273
|
+
"""Build one `project item-edit` argv per field that was provided."""
|
|
274
|
+
base = ["project", "item-edit", "--id", item_id, "--project-id", PROJECT_NODE_ID]
|
|
275
|
+
out: list[list[str]] = []
|
|
276
|
+
if wave is not None:
|
|
277
|
+
out.append([*base, "--field-id", WAVE_FIELD_ID, "--number", str(wave)])
|
|
278
|
+
if status is not None:
|
|
279
|
+
out.append([*base, "--field-id", STATUS_FIELD_ID,
|
|
280
|
+
"--single-select-option-id", resolve_status_option(status)])
|
|
281
|
+
if cluster is not None:
|
|
282
|
+
out.append([*base, "--field-id", CLUSTER_FIELD_ID, "--text", cluster])
|
|
283
|
+
if spec_path is not None:
|
|
284
|
+
out.append([*base, "--field-id", SPEC_PATH_FIELD_ID, "--text", spec_path])
|
|
285
|
+
if plan_path is not None:
|
|
286
|
+
out.append([*base, "--field-id", PLAN_PATH_FIELD_ID, "--text", plan_path])
|
|
287
|
+
return out
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def issue_number_from_url(url: str) -> int:
|
|
291
|
+
return int(url.rstrip("/").rsplit("/", 1)[-1])
|
|
292
|
+
|
|
293
|
+
|
|
294
|
+
# --- native issue dependencies ----------------------------------------
|
|
295
|
+
# REST blocked_by endpoints (verified live 2026-07-09): the list response carries
|
|
296
|
+
# FULL issue objects and INCLUDES closed blockers — "blocked" always means
|
|
297
|
+
# "has OPEN blockers" downstream, so extraction keeps the state and the open
|
|
298
|
+
# filter is explicit. Writes take the numeric DATABASE id (`issue.id`), never
|
|
299
|
+
# `#number`/`node_id` — resolution stays encapsulated here (`_db_id`).
|
|
300
|
+
def extract_blockers(data: list) -> list[dict]:
|
|
301
|
+
"""Normalize the REST blocked_by list response to the four fields we use."""
|
|
302
|
+
return [{"number": it["number"], "id": it["id"],
|
|
303
|
+
"state": it["state"], "title": it.get("title", "")}
|
|
304
|
+
for it in (data or [])]
|
|
305
|
+
|
|
306
|
+
|
|
307
|
+
# --- Phase-1 LoC-offender marker -------------------------------------
|
|
308
|
+
# At the buildability transition (create with ready-for-agent / add --bucket afk),
|
|
309
|
+
# if the slice's `## Blast-Radius` block names a still-listed offender, plant a
|
|
310
|
+
# machine-readable marker so the build session is forewarned (Phase 1b) — the
|
|
311
|
+
# authority remains the PR-push diff gate (Phase 2). Marker-before-ready ordering
|
|
312
|
+
# is enforced by the call sites; no substantiality decision happens here.
|
|
313
|
+
_BLAST_HEADING_RE = re.compile(r"^#{2,3}\s+Blast-Radius\b", re.IGNORECASE)
|
|
314
|
+
_NEXT_HEADING_RE = re.compile(r"^#{1,6}\s")
|
|
315
|
+
_BLAST_FIELD_RE = re.compile(r"^\*\*(?:Primary|Transitive):\*\*\s*(.+)$", re.IGNORECASE)
|
|
316
|
+
_OFFENDER_MARKER_RE = re.compile(r"^<!--\s*loc-offender:.*-->\s*$", re.MULTILINE)
|
|
317
|
+
|
|
318
|
+
|
|
319
|
+
def parse_blast_radius(body: str) -> set:
|
|
320
|
+
"""Paths listed under the `## Blast-Radius` block (Primary + Transitive)."""
|
|
321
|
+
lines = (body or "").splitlines()
|
|
322
|
+
start = next((i for i, ln in enumerate(lines) if _BLAST_HEADING_RE.match(ln)), None)
|
|
323
|
+
if start is None:
|
|
324
|
+
return set()
|
|
325
|
+
paths: set = set()
|
|
326
|
+
for ln in lines[start + 1:]:
|
|
327
|
+
if _NEXT_HEADING_RE.match(ln):
|
|
328
|
+
break
|
|
329
|
+
m = _BLAST_FIELD_RE.match(ln.strip())
|
|
330
|
+
if not m:
|
|
331
|
+
continue
|
|
332
|
+
for tok in m.group(1).split(","):
|
|
333
|
+
tok = tok.strip().strip("`").strip()
|
|
334
|
+
if tok and "/" in tok:
|
|
335
|
+
paths.add(tok)
|
|
336
|
+
return paths
|
|
337
|
+
|
|
338
|
+
|
|
339
|
+
def loc_offender_hits(body: str, offenders: set) -> list:
|
|
340
|
+
"""Sorted intersection of the Blast-Radius paths with the offender baseline."""
|
|
341
|
+
return sorted(parse_blast_radius(body) & set(offenders))
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def plant_offender_marker(body: str, hits: list) -> str:
|
|
345
|
+
"""Insert/replace the `<!-- loc-offender: … -->` marker at the top. Idempotent."""
|
|
346
|
+
if not hits:
|
|
347
|
+
return body
|
|
348
|
+
body = _OFFENDER_MARKER_RE.sub("", body or "").lstrip("\n")
|
|
349
|
+
marker = f"<!-- loc-offender: {','.join(hits)} -->"
|
|
350
|
+
return f"{marker}\n{body}"
|
|
351
|
+
|
|
352
|
+
|
|
353
|
+
def read_offenders() -> set:
|
|
354
|
+
"""The offender baseline from the single SSOT (max-lines-allowlist.json)."""
|
|
355
|
+
p = Path(__file__).resolve().parent.parent / "max-lines-allowlist.json"
|
|
356
|
+
return set(json.loads(p.read_text(encoding="utf-8")).get("offenders", []))
|
|
357
|
+
|
|
358
|
+
|
|
359
|
+
def bucket_label_args(issue: int, bucket: Optional[str]) -> Optional[list[str]]:
|
|
360
|
+
"""`gh issue edit` argv for the workflow-label write of a bucket, or None.
|
|
361
|
+
|
|
362
|
+
The sanctioned route for an existing leaf's workflow label (§5a forbids a bare
|
|
363
|
+
`gh issue edit --add-label ready-for-agent`; the helper stays the owner).
|
|
364
|
+
|
|
365
|
+
afk → ADD ready-for-agent (buildable now).
|
|
366
|
+
hitl → STRIP ready-for-agent (not buildable until grilled).
|
|
367
|
+
|
|
368
|
+
Same invariant `create --hitl` enforces by *rejecting* a ready-for-agent label;
|
|
369
|
+
here enforced by *stripping* — a HITL issue can never end up carrying it.
|
|
370
|
+
"""
|
|
371
|
+
if bucket is None:
|
|
372
|
+
return None
|
|
373
|
+
base = ["issue", "edit", str(issue), "--repo", REPO]
|
|
374
|
+
# Bucketing = to-issues' atomar publish (5b): the source stub becomes a build-ready
|
|
375
|
+
# leaf, so it leaves the wave-stub planning list too. Strip is unconditional
|
|
376
|
+
# (gh exits 0 on an absent label, verified 2026-06-15).
|
|
377
|
+
if bucket == "afk":
|
|
378
|
+
return [*base, "--add-label", READY_FOR_AGENT, "--remove-label", WAVE_STUB_LABEL]
|
|
379
|
+
if bucket == "hitl":
|
|
380
|
+
return [*base, "--remove-label", READY_FOR_AGENT, "--remove-label", WAVE_STUB_LABEL]
|
|
381
|
+
raise ValueError(f"unknown bucket {bucket!r}; valid: afk, hitl")
|
|
382
|
+
|
|
383
|
+
|
|
384
|
+
def type_labels_to_strip(labels: list[str]) -> list[str]:
|
|
385
|
+
"""Non-cluster, non-program `type:*` labels to remove so exactly one
|
|
386
|
+
`type:*` remains.
|
|
387
|
+
|
|
388
|
+
Board convention is one `type:*` per issue; promotion REPLACES the prior
|
|
389
|
+
type (e.g. type:followup) with type:cluster rather than adding a second
|
|
390
|
+
. `PROGRAM_TYPE_LABEL` is excluded too — a Program-PRD's
|
|
391
|
+
type label must never be caught by this strip, regardless of caller.
|
|
392
|
+
"""
|
|
393
|
+
return [l for l in labels
|
|
394
|
+
if l.startswith(TYPE_PREFIX) and l != CLUSTER_TYPE_LABEL and l != PROGRAM_TYPE_LABEL]
|
|
395
|
+
|
|
396
|
+
|
|
397
|
+
def promote_label_args(issue: int, strip: Optional[list[str]] = None) -> list[str]:
|
|
398
|
+
"""The `gh issue edit` argv that adds type:cluster and strips prior type:* labels.
|
|
399
|
+
|
|
400
|
+
Also strips `wave-stub` and `ready-for-agent`: once promoted the issue is a
|
|
401
|
+
planned Anker, no longer a board-to-waves candidate and never a buildable leaf —
|
|
402
|
+
an Anker carries no buildability bucket (the post-promote audit invariant). A
|
|
403
|
+
clean Draft-PRD has neither label, but an atomar→promote flip source can carry
|
|
404
|
+
`ready-for-agent` (it was first wrongly published as an AFK leaf).
|
|
405
|
+
Unconditional `--remove-label` is safe — gh exits 0 when the label is absent
|
|
406
|
+
(verified 2026-06-15).
|
|
407
|
+
"""
|
|
408
|
+
args = ["issue", "edit", str(issue), "--repo", REPO,
|
|
409
|
+
"--add-label", CLUSTER_TYPE_LABEL,
|
|
410
|
+
"--remove-label", WAVE_STUB_LABEL,
|
|
411
|
+
"--remove-label", READY_FOR_AGENT]
|
|
412
|
+
for label in strip or []:
|
|
413
|
+
args += ["--remove-label", label]
|
|
414
|
+
return args
|
|
415
|
+
|
|
416
|
+
|
|
417
|
+
# Existing `Welle <N> — ` prefix (any number, em-dash/en-dash/hyphen separator) —
|
|
418
|
+
# replaced on re-promote so the wave number never doubles up.
|
|
419
|
+
_WAVE_PREFIX_RE = re.compile(r"^\s*Welle\s+\d+\s*[—–-]\s*", re.IGNORECASE)
|
|
420
|
+
# Leading conventional-commit token (`fix:`, `feat(ui):`, `chore!:`) — anchors
|
|
421
|
+
# don't carry these, so strip it when present. Matches only a lowercase token,
|
|
422
|
+
# so prose titles like "Supabase-Residuen entfernen: …" are left intact.
|
|
423
|
+
_CONVENTIONAL_PREFIX_RE = re.compile(r"^[a-z]+(\([^)]*\))?!?:\s*")
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def wave_title(current: str, wave: int) -> str:
|
|
427
|
+
"""Title for a wave anchor: `Welle <N> — <Thema>`.
|
|
428
|
+
|
|
429
|
+
Strips a leading conventional-commit prefix FIRST, then any existing
|
|
430
|
+
`Welle X — ` prefix underneath it (idempotent re-promote), then re-prefixes
|
|
431
|
+
with the given wave. Order matters: a title like `fix: Welle 7 — X` only
|
|
432
|
+
reveals its `Welle 7 — ` prefix once the conventional prefix is gone —
|
|
433
|
+
stripping Wave first leaves it intact and doubles up into
|
|
434
|
+
`Welle 29 — Welle 7 — X`.
|
|
435
|
+
"""
|
|
436
|
+
thema = _CONVENTIONAL_PREFIX_RE.sub("", current)
|
|
437
|
+
thema = _WAVE_PREFIX_RE.sub("", thema).strip()
|
|
438
|
+
return f"Welle {wave} — {thema}"
|
|
439
|
+
|
|
440
|
+
|
|
441
|
+
def title_edit_args(issue: int, title: str) -> list[str]:
|
|
442
|
+
"""The `gh issue edit` argv that sets an issue's title."""
|
|
443
|
+
return ["issue", "edit", str(issue), "--repo", REPO, "--title", title]
|
|
444
|
+
|
|
445
|
+
|
|
446
|
+
# --- gh-backed helpers -------------------------------------------------------
|
|
447
|
+
def _blocked_by_list(num: int) -> list[dict]:
|
|
448
|
+
"""Native blocked_by edges of an issue (open AND closed blockers)."""
|
|
449
|
+
data = _gh_json(["api", f"repos/{REPO}/issues/{num}/dependencies/blocked_by?per_page=100"])
|
|
450
|
+
return extract_blockers(data)
|
|
451
|
+
|
|
452
|
+
|
|
453
|
+
def _parent_of(num: int) -> Optional[int]:
|
|
454
|
+
data = _gh_json(["api", "graphql", "--header", SUB_ISSUES_HEADER,
|
|
455
|
+
"-f", f"query={PARENT_QUERY}",
|
|
456
|
+
"-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
|
|
457
|
+
"-F", f"num={num}"])
|
|
458
|
+
return extract_parent_number(data)
|
|
459
|
+
|
|
460
|
+
|
|
461
|
+
def _children_of(num: int) -> list[int]:
|
|
462
|
+
data = _gh_json(["api", "graphql", "--header", SUB_ISSUES_HEADER,
|
|
463
|
+
"-f", f"query={CHILDREN_QUERY}",
|
|
464
|
+
"-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
|
|
465
|
+
"-F", f"num={num}"])
|
|
466
|
+
return extract_children_numbers(data)
|
|
467
|
+
|
|
468
|
+
|
|
469
|
+
def _node_id(num: int) -> str:
|
|
470
|
+
return _gh(["issue", "view", str(num), "--repo", REPO, "--json", "id", "-q", ".id"]).strip()
|
|
471
|
+
|
|
472
|
+
|
|
473
|
+
def _issue_type_labels(num: int) -> list[str]:
|
|
474
|
+
"""Current label names on an issue (one per line)."""
|
|
475
|
+
out = _gh(["issue", "view", str(num), "--repo", REPO,
|
|
476
|
+
"--json", "labels", "--jq", ".labels[].name"])
|
|
477
|
+
return [l.strip() for l in out.splitlines() if l.strip()]
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _issue_title(num: int) -> str:
|
|
481
|
+
"""Current title of an issue."""
|
|
482
|
+
return _gh(["issue", "view", str(num), "--repo", REPO,
|
|
483
|
+
"--json", "title", "--jq", ".title"]).strip()
|
|
484
|
+
|
|
485
|
+
|
|
486
|
+
# --- field-value: read a Projects-v2 item's current field value ------
|
|
487
|
+
# ID-driven (matches by `fieldId`, never a field NAME) — the profile only ever
|
|
488
|
+
# carries opaque field ids, so this stays consistent with every other read/write
|
|
489
|
+
# in this file instead of introducing a new required "field name" key.
|
|
490
|
+
FIELD_VALUE_QUERY = (
|
|
491
|
+
"query($owner:String!,$repo:String!,$num:Int!){"
|
|
492
|
+
"repository(owner:$owner,name:$repo){issue(number:$num){"
|
|
493
|
+
"projectItems(first:10){nodes{id project{id} "
|
|
494
|
+
"fieldValues(first:20){nodes{"
|
|
495
|
+
"... on ProjectV2ItemFieldNumberValue{number field{... on ProjectV2FieldCommon{id}}} "
|
|
496
|
+
"... on ProjectV2ItemFieldSingleSelectValue{name optionId field{... on ProjectV2FieldCommon{id}}} "
|
|
497
|
+
"... on ProjectV2ItemFieldTextValue{text field{... on ProjectV2FieldCommon{id}}}"
|
|
498
|
+
"}}}}}}}"
|
|
499
|
+
)
|
|
500
|
+
|
|
501
|
+
|
|
502
|
+
def _field_value(issue: int, field_id: str) -> Optional[dict]:
|
|
503
|
+
data = _gh_json(["api", "graphql", "-f", f"query={FIELD_VALUE_QUERY}",
|
|
504
|
+
"-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
|
|
505
|
+
"-F", f"num={issue}"])
|
|
506
|
+
return extract_field_value(data, PROJECT_NODE_ID, field_id)
|
|
507
|
+
|
|
508
|
+
|
|
509
|
+
def _write_issue_body(issue: int, new_body: str) -> None:
|
|
510
|
+
"""Write an issue body via a temp file + `gh issue edit --body-file` — the
|
|
511
|
+
one shared write mechanic for every body-rewriting command in this file."""
|
|
512
|
+
with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False, encoding="utf-8") as f:
|
|
513
|
+
f.write(new_body)
|
|
514
|
+
tmp = f.name
|
|
515
|
+
_gh(["issue", "edit", str(issue), "--repo", REPO, "--body-file", tmp])
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def _add_and_stamp(url, wave, status, cluster, spec_path, plan_path, dry_run) -> None:
|
|
519
|
+
add_args = ["project", "item-add", str(PROJECT_NUMBER), "--owner", PROJECT_OWNER,
|
|
520
|
+
"--url", url, "--format", "json"]
|
|
521
|
+
if dry_run:
|
|
522
|
+
_print_dry(add_args)
|
|
523
|
+
for edit in stamp_arg_list("<ITEM_ID>", wave, status, cluster, spec_path, plan_path):
|
|
524
|
+
_print_dry(edit)
|
|
525
|
+
return
|
|
526
|
+
item_id = extract_item_id(_gh_json(add_args))
|
|
527
|
+
for edit in stamp_arg_list(item_id, wave, status, cluster, spec_path, plan_path):
|
|
528
|
+
_gh(edit)
|
|
529
|
+
|
|
530
|
+
|
|
531
|
+
# --- commands ----------------------------------------------------------------
|
|
532
|
+
def cmd_next_wave(_args) -> int:
|
|
533
|
+
items = _gh_json(["project", "item-list", str(PROJECT_NUMBER), "--owner", PROJECT_OWNER,
|
|
534
|
+
"--limit", str(PROJECT_ITEM_LIST_LIMIT), "--format", "json"]).get("items", [])
|
|
535
|
+
print(compute_next_wave(items))
|
|
536
|
+
return 0
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
def cmd_parent_of(args) -> int:
|
|
540
|
+
parent = _parent_of(args.issue)
|
|
541
|
+
print(parent if parent is not None else "FREI")
|
|
542
|
+
return 0
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
def cmd_children_of(args) -> int:
|
|
546
|
+
for num in _children_of(args.issue):
|
|
547
|
+
print(num)
|
|
548
|
+
return 0
|
|
549
|
+
|
|
550
|
+
|
|
551
|
+
def cmd_link(args) -> int:
|
|
552
|
+
decision = one_parent_decision(_parent_of(args.child), args.parent)
|
|
553
|
+
if decision == "already":
|
|
554
|
+
print(f"#{args.child} already a sub-issue of #{args.parent} — skip (idempotent)")
|
|
555
|
+
return 0
|
|
556
|
+
if decision == "conflict":
|
|
557
|
+
existing = _parent_of(args.child)
|
|
558
|
+
print(f"#{args.child} already has parent #{existing} — skip, not re-parenting "
|
|
559
|
+
f"(needs removeSubIssue on the old parent first)")
|
|
560
|
+
# Foreign-parent drift: non-zero for reconcile/publish; zero only in audit mode.
|
|
561
|
+
return 0 if getattr(args, "allow_drift_report", False) else 1
|
|
562
|
+
link_args = ["api", "graphql", "--header", SUB_ISSUES_HEADER,
|
|
563
|
+
"-f", f"query={ADD_SUBISSUE_MUTATION}"]
|
|
564
|
+
if args.dry_run:
|
|
565
|
+
_print_dry([*link_args, "-f", f"parent=<#{args.parent} node-id>",
|
|
566
|
+
"-f", f"child=<#{args.child} node-id>"])
|
|
567
|
+
return 0
|
|
568
|
+
_gh([*link_args, "-f", f"parent={_node_id(args.parent)}", "-f", f"child={_node_id(args.child)}"])
|
|
569
|
+
print(f"linked #{args.child} → #{args.parent}")
|
|
570
|
+
return 0
|
|
571
|
+
|
|
572
|
+
|
|
573
|
+
def cmd_unlink(args) -> int:
|
|
574
|
+
"""Remove a native sub-issue link. Parent-checked + idempotent (mirror of cmd_link):
|
|
575
|
+
only unlinks when the child's current parent IS args.parent — never touches a foreign
|
|
576
|
+
parent. The reconcile counterpart for to-issues when a promoted stub still carries
|
|
577
|
+
board-to-waves member sub-issues that are superseded by fresh slices."""
|
|
578
|
+
current = _parent_of(args.child)
|
|
579
|
+
if current is None:
|
|
580
|
+
print(f"#{args.child} has no parent — skip (idempotent)")
|
|
581
|
+
return 0
|
|
582
|
+
if current != args.parent:
|
|
583
|
+
print(f"#{args.child} parent is #{current}, not #{args.parent} — skip, "
|
|
584
|
+
f"not unlinking a foreign parent")
|
|
585
|
+
# Foreign-parent mismatch: non-zero for reconcile/publish; zero only in audit mode.
|
|
586
|
+
return 0 if getattr(args, "allow_drift_report", False) else 1
|
|
587
|
+
unlink_args = ["api", "graphql", "--header", SUB_ISSUES_HEADER,
|
|
588
|
+
"-f", f"query={REMOVE_SUBISSUE_MUTATION}"]
|
|
589
|
+
if args.dry_run:
|
|
590
|
+
_print_dry([*unlink_args, "-f", f"parent=<#{args.parent} node-id>",
|
|
591
|
+
"-f", f"child=<#{args.child} node-id>"])
|
|
592
|
+
return 0
|
|
593
|
+
_gh([*unlink_args, "-f", f"parent={_node_id(args.parent)}", "-f", f"child={_node_id(args.child)}"])
|
|
594
|
+
print(f"unlinked #{args.child} from #{args.parent}")
|
|
595
|
+
return 0
|
|
596
|
+
|
|
597
|
+
|
|
598
|
+
# --- native issue-dependency commands --------------------------------
|
|
599
|
+
def _sync_blocked_by_mirror(issue: int, blockers: list[dict]) -> None:
|
|
600
|
+
"""Rewrite the issue's `## Blocked by` body mirror from the given edge list.
|
|
601
|
+
No-op when already in sync (anchor-sync's no-drift contract)."""
|
|
602
|
+
body = _gh(["issue", "view", str(issue), "--repo", REPO, "--json", "body", "-q", ".body"])
|
|
603
|
+
new_body = splice_blocked_by_section(body, blockers)
|
|
604
|
+
if new_body == body:
|
|
605
|
+
print(f"#{issue}: mirror in sync")
|
|
606
|
+
return
|
|
607
|
+
_write_issue_body(issue, new_body)
|
|
608
|
+
print(f"#{issue}: mirror updated")
|
|
609
|
+
|
|
610
|
+
|
|
611
|
+
def cmd_dep_add(args) -> int:
|
|
612
|
+
"""Set a native blocked-by edge + refresh the body mirror.
|
|
613
|
+
|
|
614
|
+
Idempotent AND self-healing: an existing edge skips the POST but still
|
|
615
|
+
reconciles the mirror — a failed mirror write is repaired by re-running
|
|
616
|
+
the same command. Dry-run makes NO gh calls (promote contract).
|
|
617
|
+
"""
|
|
618
|
+
path = f"repos/{REPO}/issues/{args.issue}/dependencies/blocked_by"
|
|
619
|
+
if args.dry_run:
|
|
620
|
+
_print_dry(["api", "--method", "POST", path,
|
|
621
|
+
"-F", f"issue_id=<#{args.blocked_by} database-id>"])
|
|
622
|
+
print(f"[dry-run] would refresh the ## Blocked by mirror of #{args.issue}")
|
|
623
|
+
return 0
|
|
624
|
+
existing = _blocked_by_list(args.issue)
|
|
625
|
+
if args.blocked_by in [b["number"] for b in existing]:
|
|
626
|
+
print(f"#{args.issue} already blocked by #{args.blocked_by} — skip (idempotent)")
|
|
627
|
+
_sync_blocked_by_mirror(args.issue, existing)
|
|
628
|
+
return 0
|
|
629
|
+
# The write takes the numeric DATABASE id — resolved here, never by the caller.
|
|
630
|
+
blocker = _gh_json(["api", f"repos/{REPO}/issues/{args.blocked_by}"])
|
|
631
|
+
_gh(["api", "--method", "POST", path, "-F", f"issue_id={blocker['id']}"])
|
|
632
|
+
print(f"#{args.issue} now blocked by #{args.blocked_by}")
|
|
633
|
+
_sync_blocked_by_mirror(args.issue, existing + extract_blockers([blocker]))
|
|
634
|
+
return 0
|
|
635
|
+
|
|
636
|
+
|
|
637
|
+
def cmd_dep_remove(args) -> int:
|
|
638
|
+
"""Remove a native blocked-by edge + refresh the body mirror.
|
|
639
|
+
|
|
640
|
+
Idempotent + self-healing like dep-add; the blocker's DATABASE id comes
|
|
641
|
+
straight from the edge list (path param on DELETE), no extra fetch.
|
|
642
|
+
"""
|
|
643
|
+
if args.dry_run:
|
|
644
|
+
_print_dry(["api", "--method", "DELETE",
|
|
645
|
+
f"repos/{REPO}/issues/{args.issue}/dependencies/blocked_by/"
|
|
646
|
+
f"<#{args.blocked_by} database-id>"])
|
|
647
|
+
print(f"[dry-run] would refresh the ## Blocked by mirror of #{args.issue}")
|
|
648
|
+
return 0
|
|
649
|
+
existing = _blocked_by_list(args.issue)
|
|
650
|
+
target = next((b for b in existing if b["number"] == args.blocked_by), None)
|
|
651
|
+
if target is None:
|
|
652
|
+
print(f"#{args.issue} not blocked by #{args.blocked_by} — skip (idempotent)")
|
|
653
|
+
_sync_blocked_by_mirror(args.issue, existing)
|
|
654
|
+
return 0
|
|
655
|
+
_gh(["api", "--method", "DELETE",
|
|
656
|
+
f"repos/{REPO}/issues/{args.issue}/dependencies/blocked_by/{target['id']}"])
|
|
657
|
+
print(f"#{args.issue} no longer blocked by #{args.blocked_by}")
|
|
658
|
+
_sync_blocked_by_mirror(args.issue,
|
|
659
|
+
[b for b in existing if b["number"] != args.blocked_by])
|
|
660
|
+
return 0
|
|
661
|
+
|
|
662
|
+
|
|
663
|
+
def cmd_deps(args) -> int:
|
|
664
|
+
"""Print an issue's native blocked_by edges + the open-blocker count.
|
|
665
|
+
--json emits the normalized list (execute-ready-check's machine seam)."""
|
|
666
|
+
blockers = _blocked_by_list(args.issue)
|
|
667
|
+
if getattr(args, "as_json", False):
|
|
668
|
+
print(json.dumps(blockers, ensure_ascii=False))
|
|
669
|
+
return 0
|
|
670
|
+
if not blockers:
|
|
671
|
+
print(f"#{args.issue}: no blockers")
|
|
672
|
+
return 0
|
|
673
|
+
for b in blockers:
|
|
674
|
+
print(f"- #{b['number']} ({b['state']}) — {b['title']}".rstrip())
|
|
675
|
+
print(f"open blockers: {len(open_blocker_numbers(blockers))}")
|
|
676
|
+
return 0
|
|
677
|
+
|
|
678
|
+
|
|
679
|
+
def cmd_frontier(args) -> int:
|
|
680
|
+
"""Buildable frontier of a wave anchor from native edges (orchestrate-wave's
|
|
681
|
+
reader). Per open sub-issue: FREI (0 open blockers) or BLOCKED by #…;
|
|
682
|
+
closed sub-issues print as done. ONE GraphQL round-trip — reuses the
|
|
683
|
+
anchor-sync sub-issues query (state + blockedBy per child), no per-child
|
|
684
|
+
REST fan-out."""
|
|
685
|
+
board = _anchor_board_data(args.issue)
|
|
686
|
+
if not board:
|
|
687
|
+
print(f"#{args.issue}: no native sub-issues — not a wave anchor?", file=sys.stderr)
|
|
688
|
+
return 1
|
|
689
|
+
for child, entry in board.items():
|
|
690
|
+
if entry.get("state") != "open":
|
|
691
|
+
print(f"#{child} done")
|
|
692
|
+
continue
|
|
693
|
+
blockers = open_blocker_numbers(entry.get("blocked_by") or [])
|
|
694
|
+
if blockers:
|
|
695
|
+
print(f"#{child} BLOCKED by {', '.join('#' + str(n) for n in blockers)}")
|
|
696
|
+
else:
|
|
697
|
+
print(f"#{child} FREI")
|
|
698
|
+
return 0
|
|
699
|
+
|
|
700
|
+
|
|
701
|
+
def cmd_create(args) -> int:
|
|
702
|
+
hitl_guard(getattr(args, "hitl", False), args.label or [])
|
|
703
|
+
labels = list(args.label or [])
|
|
704
|
+
body = None
|
|
705
|
+
# Phase-1: a ready-for-agent slice whose Blast-Radius names an offender
|
|
706
|
+
# gets the marker planted INTO the body-file BEFORE `gh issue create`, so the
|
|
707
|
+
# issue is never briefly ready-without-marker (no partial-ready window). Skip in
|
|
708
|
+
# dry-run — that path must read no files / make no writes (parity tests).
|
|
709
|
+
if not args.dry_run:
|
|
710
|
+
body = Path(args.body_file).read_text(encoding="utf-8")
|
|
711
|
+
if READY_FOR_AGENT in labels and body is not None:
|
|
712
|
+
hits = loc_offender_hits(body, read_offenders())
|
|
713
|
+
if hits:
|
|
714
|
+
body = plant_offender_marker(body, hits)
|
|
715
|
+
Path(args.body_file).write_text(body, encoding="utf-8")
|
|
716
|
+
create_args = ["issue", "create", "--repo", REPO, "--title", args.title,
|
|
717
|
+
"--body-file", args.body_file]
|
|
718
|
+
if getattr(args, "wave_stub", False):
|
|
719
|
+
labels.append(WAVE_STUB_LABEL)
|
|
720
|
+
for label in labels:
|
|
721
|
+
create_args += ["--label", label]
|
|
722
|
+
if args.dry_run:
|
|
723
|
+
_print_dry(create_args)
|
|
724
|
+
_add_and_stamp("<new-url>", args.wave, args.status, args.cluster, None, None, dry_run=True)
|
|
725
|
+
return 0
|
|
726
|
+
existing = _find_open_issue_by_program_identity(body or "")
|
|
727
|
+
if existing:
|
|
728
|
+
issue_no = existing["number"]
|
|
729
|
+
url = existing["url"]
|
|
730
|
+
print(f"#{issue_no} {url} (existing)")
|
|
731
|
+
else:
|
|
732
|
+
url = _gh(create_args).strip().splitlines()[-1]
|
|
733
|
+
issue_no = issue_number_from_url(url)
|
|
734
|
+
# Print the number/URL immediately — a board-sync failure below must never
|
|
735
|
+
# swallow it. A retry without this would blind-recreate a duplicate issue
|
|
736
|
+
# (the create already succeeded, only the board-sync half failed).
|
|
737
|
+
print(f"#{issue_no} {url}")
|
|
738
|
+
try:
|
|
739
|
+
_add_and_stamp(url, args.wave, args.status, args.cluster, None, None, dry_run=False)
|
|
740
|
+
except GhError as exc:
|
|
741
|
+
repair = f"python3 scripts/board-sync.py add --issue {issue_no}"
|
|
742
|
+
if args.wave is not None:
|
|
743
|
+
repair += f" --wave {args.wave}"
|
|
744
|
+
if args.status:
|
|
745
|
+
repair += f" --status {args.status}"
|
|
746
|
+
if args.cluster:
|
|
747
|
+
repair += f" --cluster {args.cluster}"
|
|
748
|
+
print(f"board-sync of #{issue_no} FAILED after create: {exc}")
|
|
749
|
+
print(f" repair (idempotent): {repair}")
|
|
750
|
+
return 1
|
|
751
|
+
return 0
|
|
752
|
+
|
|
753
|
+
|
|
754
|
+
def cmd_promote(args) -> int:
|
|
755
|
+
"""Promote a Draft-PRD to Anker: type:cluster label + Wave (+ Status), ordered.
|
|
756
|
+
|
|
757
|
+
Requires an explicit --wave (caller reads `next-wave` first) so there is no
|
|
758
|
+
in-promote race window. On a mid-transaction gh failure it prints what was
|
|
759
|
+
already set + an idempotent repair command and exits non-zero (no silent
|
|
760
|
+
half-promote).
|
|
761
|
+
|
|
762
|
+
Two guards run before any write (real path only — a dry-run makes NO
|
|
763
|
+
gh calls at all, guards included, same contract as every other dry-run
|
|
764
|
+
branch in this file): a Program-PRD is never a promote target (it is the
|
|
765
|
+
native anchor OVER Wellen, not itself a Welle), and a stub already stamped
|
|
766
|
+
with a DIFFERENT Wave than --wave refuses rather than silently overwriting.
|
|
767
|
+
"""
|
|
768
|
+
url = f"https://github.com/{REPO}/issues/{args.issue}"
|
|
769
|
+
rename = not args.no_rename
|
|
770
|
+
if args.dry_run:
|
|
771
|
+
_print_dry(promote_label_args(args.issue))
|
|
772
|
+
if rename:
|
|
773
|
+
print(f"[dry-run] gh issue edit {args.issue} --title 'Welle {args.wave} — <Thema>'")
|
|
774
|
+
_add_and_stamp(url, args.wave, args.status, None, None, None, dry_run=True)
|
|
775
|
+
return 0
|
|
776
|
+
labels = _issue_type_labels(args.issue)
|
|
777
|
+
body = _gh(["issue", "view", str(args.issue), "--repo", REPO, "--json", "body", "-q", ".body"])
|
|
778
|
+
is_program = (classify_node({"body": body, "labels": labels},
|
|
779
|
+
cluster_type_label=CLUSTER_TYPE_LABEL,
|
|
780
|
+
wave_stub_label=WAVE_STUB_LABEL) == PROGRAM
|
|
781
|
+
or PROGRAM_TYPE_LABEL in labels)
|
|
782
|
+
refusal = program_prd_refusal(is_program, args.issue)
|
|
783
|
+
if refusal:
|
|
784
|
+
print(f"error: {refusal}", file=sys.stderr)
|
|
785
|
+
return 1
|
|
786
|
+
current = _field_value(args.issue, WAVE_FIELD_ID)
|
|
787
|
+
mismatch = wave_mismatch_guard(current.get("number") if current else None, args.wave)
|
|
788
|
+
if mismatch:
|
|
789
|
+
print(f"error: {mismatch}", file=sys.stderr)
|
|
790
|
+
return 1
|
|
791
|
+
done: list[str] = []
|
|
792
|
+
try:
|
|
793
|
+
strip = type_labels_to_strip(labels)
|
|
794
|
+
_gh(promote_label_args(args.issue, strip))
|
|
795
|
+
done.append("type:cluster label"
|
|
796
|
+
+ (f" (stripped {', '.join(strip)})" if strip else ""))
|
|
797
|
+
if rename:
|
|
798
|
+
new_title = wave_title(_issue_title(args.issue), args.wave)
|
|
799
|
+
_gh(title_edit_args(args.issue, new_title))
|
|
800
|
+
done.append(f"title={new_title!r}")
|
|
801
|
+
_add_and_stamp(url, args.wave, args.status, None, None, None, dry_run=False)
|
|
802
|
+
done.append(f"Wave={args.wave}" + (f", Status={args.status}" if args.status else ""))
|
|
803
|
+
except GhError as exc:
|
|
804
|
+
repair = f"python3 scripts/board-sync.py promote --issue {args.issue} --wave {args.wave}"
|
|
805
|
+
if args.status:
|
|
806
|
+
repair += f" --status {args.status}"
|
|
807
|
+
if args.no_rename:
|
|
808
|
+
repair += " --no-rename"
|
|
809
|
+
print(f"promote of #{args.issue} FAILED mid-transaction: {exc}")
|
|
810
|
+
print(f" already set: {', '.join(done) or 'nothing'}")
|
|
811
|
+
print(f" repair (idempotent): {repair}")
|
|
812
|
+
return 1
|
|
813
|
+
print(f"promoted #{args.issue} → Anker (type:cluster, Wave={args.wave})")
|
|
814
|
+
return 0
|
|
815
|
+
|
|
816
|
+
|
|
817
|
+
def _plant_marker_on_existing(issue: int) -> None:
|
|
818
|
+
"""Fetch the live issue body; if its Blast-Radius names an offender, rewrite the
|
|
819
|
+
body with the marker via `gh issue edit --body-file`. No-op when no hit."""
|
|
820
|
+
body = _gh(["issue", "view", str(issue), "--repo", REPO, "--json", "body", "-q", ".body"])
|
|
821
|
+
hits = loc_offender_hits(body, read_offenders())
|
|
822
|
+
if not hits:
|
|
823
|
+
return
|
|
824
|
+
_write_issue_body(issue, plant_offender_marker(body, hits))
|
|
825
|
+
|
|
826
|
+
|
|
827
|
+
def cmd_add(args) -> int:
|
|
828
|
+
url = args.url or f"https://github.com/{REPO}/issues/{args.issue}"
|
|
829
|
+
issue_no = args.issue or issue_number_from_url(url)
|
|
830
|
+
_add_and_stamp(url, args.wave, args.status, args.cluster, args.spec_path, args.plan_path,
|
|
831
|
+
dry_run=args.dry_run)
|
|
832
|
+
# Phase-1: for `--bucket afk`, plant the offender marker (fetch body →
|
|
833
|
+
# edit body) BEFORE the ready-for-agent label below, so an agent never grabs a
|
|
834
|
+
# ready issue whose body lacks the marker (Codex-R1-F6 ordering). Dry-run makes
|
|
835
|
+
# no gh calls (the body fetch is a network read), so it only notes the step.
|
|
836
|
+
if getattr(args, "bucket", None) == "afk":
|
|
837
|
+
if args.dry_run:
|
|
838
|
+
print(f"[dry-run] would check #{issue_no} Blast-Radius ∩ offenders + plant marker")
|
|
839
|
+
else:
|
|
840
|
+
_plant_marker_on_existing(issue_no)
|
|
841
|
+
label_args = bucket_label_args(issue_no, getattr(args, "bucket", None))
|
|
842
|
+
if label_args is not None:
|
|
843
|
+
if args.dry_run:
|
|
844
|
+
_print_dry(label_args)
|
|
845
|
+
else:
|
|
846
|
+
_gh(label_args)
|
|
847
|
+
if not args.dry_run:
|
|
848
|
+
print(f"synced {url} to board")
|
|
849
|
+
return 0
|
|
850
|
+
|
|
851
|
+
|
|
852
|
+
# --- anchor-sync: regenerate the wave-anchor Slices-table volatile cells
|
|
853
|
+
# Pure table logic (parse/merge/refresh/render/splice) lives in anchor_table.py so
|
|
854
|
+
# it is independently testable without gh/config; this file keeps only the gh-backed
|
|
855
|
+
# board fetch + the command wiring. See anchor_table.py for the design rationale.
|
|
856
|
+
from anchor_table import ( # noqa: E402
|
|
857
|
+
SLICE_TABLE_START, SLICE_TABLE_END,
|
|
858
|
+
split_pipe_row, is_separator_row, parse_pipe_table, render_pipe_table,
|
|
859
|
+
col_index, first_subissue_num,
|
|
860
|
+
status_token_from_board, status_base, refresh_status_cell,
|
|
861
|
+
branch_from_board, refresh_branch_cell, merge_slice_rows,
|
|
862
|
+
locate_slice_table, current_slice_table, splice_slice_table,
|
|
863
|
+
extract_anchor_board_data,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
# Status fetched generically and matched by FIELD ID in the extractor
|
|
867
|
+
# — no `fieldValueByName(name:"Status")`, a consumer's field may be named anything.
|
|
868
|
+
ANCHOR_SLICES_QUERY = (
|
|
869
|
+
"query($owner:String!,$repo:String!,$num:Int!){"
|
|
870
|
+
"repository(owner:$owner,name:$repo){issue(number:$num){"
|
|
871
|
+
"subIssues(first:100){nodes{number title state "
|
|
872
|
+
"closedByPullRequestsReferences(first:10,includeClosedPrs:true)"
|
|
873
|
+
"{nodes{number state headRefName}} "
|
|
874
|
+
"blockedBy(first:20){nodes{number state}} "
|
|
875
|
+
"projectItems(first:10){nodes{fieldValues(first:20){nodes{"
|
|
876
|
+
"... on ProjectV2ItemFieldSingleSelectValue{name "
|
|
877
|
+
"field{... on ProjectV2SingleSelectField{id}}}"
|
|
878
|
+
"}}}}}}}}}"
|
|
879
|
+
)
|
|
880
|
+
|
|
881
|
+
|
|
882
|
+
def _anchor_board_data(num: int) -> dict:
|
|
883
|
+
data = _gh_json(["api", "graphql", "--header", SUB_ISSUES_HEADER,
|
|
884
|
+
"-f", f"query={ANCHOR_SLICES_QUERY}",
|
|
885
|
+
"-F", f"owner={PROJECT_OWNER}", "-F", f"repo={REPO_NAME}",
|
|
886
|
+
"-F", f"num={num}"])
|
|
887
|
+
return extract_anchor_board_data(data, STATUS_FIELD_ID)
|
|
888
|
+
|
|
889
|
+
|
|
890
|
+
def cmd_anchor_sync(args) -> int:
|
|
891
|
+
board = _anchor_board_data(args.issue)
|
|
892
|
+
body = _gh(["issue", "view", str(args.issue), "--repo", REPO, "--json", "body", "-q", ".body"])
|
|
893
|
+
headers, rows = current_slice_table(body)
|
|
894
|
+
if not headers:
|
|
895
|
+
print(f"#{args.issue}: no slice table found — nothing to sync", file=sys.stderr)
|
|
896
|
+
return 1
|
|
897
|
+
new_rows, appended = merge_slice_rows(headers, rows, board,
|
|
898
|
+
_status_roles_or_hint("anchor-sync"))
|
|
899
|
+
new_body = splice_slice_table(body, render_pipe_table(headers, new_rows))
|
|
900
|
+
appended_note = (f"; +{len(appended)} new sub-issue row(s): "
|
|
901
|
+
f"{', '.join('#' + str(s) for s in appended)}" if appended else "")
|
|
902
|
+
if new_body == body:
|
|
903
|
+
print(f"#{args.issue}: slice table already in sync (no drift)")
|
|
904
|
+
return 0
|
|
905
|
+
if args.dry_run:
|
|
906
|
+
print(f"[dry-run] would update #{args.issue} slice table{appended_note}")
|
|
907
|
+
print(render_pipe_table(headers, new_rows))
|
|
908
|
+
return 0
|
|
909
|
+
_write_issue_body(args.issue, new_body)
|
|
910
|
+
print(f"synced #{args.issue} slice table from board{appended_note}")
|
|
911
|
+
return 0
|
|
912
|
+
|
|
913
|
+
|
|
914
|
+
# --- validate-graph: counted Programm-Graph preflight for a Program-PRD
|
|
915
|
+
# All parsing/validation logic lives in program_graph.py (PURE — no gh/board_config);
|
|
916
|
+
# this handler stays thin: load config, read the PRD body + each native wave-stub's
|
|
917
|
+
# body via the existing `_gh` seam, call the module, print the counted report.
|
|
918
|
+
# Read-only by construction (only `_gh` reads, no mutating call on this path at
|
|
919
|
+
# all) — no mutation surface to guard against, regardless of read count.
|
|
920
|
+
from program_graph import ( # noqa: E402
|
|
921
|
+
validate_program_graph, render_report, parse_wellenplan_table, render_wellenplan_table,
|
|
922
|
+
)
|
|
923
|
+
|
|
924
|
+
# A wave-stub's revision marker, stamped by to-waves (Welle 52 Slice 4, §6):
|
|
925
|
+
# `<!-- program-revision: rN -->`. Read back here so validate-graph can catch a
|
|
926
|
+
# stub whose plan shape drifted from a since-bumped PRD `plan_revision`.
|
|
927
|
+
_STUB_REVISION_RE = re.compile(r"<!--\s*program-revision:\s*r(\d+)\s*-->")
|
|
928
|
+
|
|
929
|
+
|
|
930
|
+
def _fetch_stub_revisions(prd_issue: int) -> list[dict]:
|
|
931
|
+
"""The PRD's native wave-stub children, each with its parsed program-revision
|
|
932
|
+
(or None if the marker is missing) — `check_revision_coherence`'s input shape
|
|
933
|
+
(`[{"label": str, "revision": int | None}, …]`). Pre-publish (no children yet)
|
|
934
|
+
this loop never runs, so `[]` degrades revision-coherence to a no-op — the
|
|
935
|
+
unchanged green path."""
|
|
936
|
+
stubs = []
|
|
937
|
+
for child in _children_of(prd_issue):
|
|
938
|
+
body = _gh(["issue", "view", str(child), "--repo", REPO,
|
|
939
|
+
"--json", "body", "-q", ".body"])
|
|
940
|
+
m = _STUB_REVISION_RE.search(body or "")
|
|
941
|
+
stubs.append({"label": f"#{child}", "revision": int(m.group(1)) if m else None})
|
|
942
|
+
return stubs
|
|
943
|
+
|
|
944
|
+
|
|
945
|
+
def cmd_validate_graph(args) -> int:
|
|
946
|
+
body = _gh(["issue", "view", str(args.issue), "--repo", REPO,
|
|
947
|
+
"--json", "body", "-q", ".body"])
|
|
948
|
+
# Defensive read: `fields.phase` is an optional profile key (Welle 52 Slice 3
|
|
949
|
+
# adds it) — absent today, so this must degrade to a visible setup-hint finding
|
|
950
|
+
# inside program_graph, never a KeyError here (CRITICAL RECONCILIATION #2).
|
|
951
|
+
phase_options = _CFG.get("fields", {}).get("phase", {}).get("options")
|
|
952
|
+
stub_revisions = _fetch_stub_revisions(args.issue)
|
|
953
|
+
report = validate_program_graph(body, phase_options=phase_options,
|
|
954
|
+
stub_revisions=stub_revisions)
|
|
955
|
+
print(render_report(report))
|
|
956
|
+
return 1 if report.blocking else 0
|
|
957
|
+
|
|
958
|
+
|
|
959
|
+
# --- stamp-batch: alias-batched field-writes for N items (Wave/Phase) -
|
|
960
|
+
def cmd_stamp_batch(args) -> int:
|
|
961
|
+
if args.items_file:
|
|
962
|
+
items = json.loads(Path(args.items_file).read_text(encoding="utf-8"))
|
|
963
|
+
else:
|
|
964
|
+
if not args.item_id:
|
|
965
|
+
raise ValueError("stamp-batch --issue requires --item-id")
|
|
966
|
+
items = [{"issue": args.issue, "item_id": args.item_id,
|
|
967
|
+
"wave": args.wave, "phase": args.phase}]
|
|
968
|
+
phase_cfg = _CFG.get("fields", {}).get("phase")
|
|
969
|
+
stamps, skipped_phase = build_stamps(items, wave_field_id=WAVE_FIELD_ID, phase_cfg=phase_cfg)
|
|
970
|
+
if skipped_phase:
|
|
971
|
+
print(f"Phase-Feld nicht konfiguriert (`fields.phase` fehlt im Profil) — "
|
|
972
|
+
f"{skipped_phase} Phase-Stempel sichtbar übersprungen (kein stiller Verlust)")
|
|
973
|
+
if not stamps:
|
|
974
|
+
print("stamp-batch: nothing to stamp")
|
|
975
|
+
return 0
|
|
976
|
+
if args.dry_run:
|
|
977
|
+
for chunk in chunk_stamps(stamps):
|
|
978
|
+
query, _ = build_stamp_mutation(chunk, PROJECT_NODE_ID)
|
|
979
|
+
_print_dry(["api", "graphql", "-f", f"query={query}"])
|
|
980
|
+
return 0
|
|
981
|
+
succeeded: list = []
|
|
982
|
+
failed: list = []
|
|
983
|
+
for chunk in chunk_stamps(stamps):
|
|
984
|
+
query, alias_map = build_stamp_mutation(chunk, PROJECT_NODE_ID)
|
|
985
|
+
# Tolerate a non-zero exit that still carries a parseable partial
|
|
986
|
+
# response (`errors` alongside partial `data` — the batched-mutation
|
|
987
|
+
# per-alias failure case). A hard CLI/network failure (no recoverable
|
|
988
|
+
# body) re-raises, same as every other `_gh` caller in this file.
|
|
989
|
+
try:
|
|
990
|
+
out = _gh(["api", "graphql", "-f", f"query={query}"])
|
|
991
|
+
except GhError as exc:
|
|
992
|
+
out = getattr(exc, "stdout", "") or ""
|
|
993
|
+
if not out:
|
|
994
|
+
raise
|
|
995
|
+
s, f = parse_batch_response(json.loads(out), alias_map)
|
|
996
|
+
succeeded += s
|
|
997
|
+
failed += f
|
|
998
|
+
print(f"stamp-batch: {len(succeeded)} von {len(stamps)} Feld-Stempel gesetzt")
|
|
999
|
+
for stamp, msg in failed:
|
|
1000
|
+
print(f" FEHLER #{stamp.issue} {stamp.field_name}: {msg}")
|
|
1001
|
+
print(f" repair: {repair_command(stamp)}")
|
|
1002
|
+
return 1 if failed else 0
|
|
1003
|
+
|
|
1004
|
+
|
|
1005
|
+
# --- field-value: read a project item's current field value ---------
|
|
1006
|
+
def cmd_field_value(args) -> int:
|
|
1007
|
+
if args.field == "wave":
|
|
1008
|
+
field_id = WAVE_FIELD_ID
|
|
1009
|
+
else:
|
|
1010
|
+
field_id = phase_field_id(_CFG)
|
|
1011
|
+
if field_id is None:
|
|
1012
|
+
print(f"error: fields.{args.field} not configured in the board profile", file=sys.stderr)
|
|
1013
|
+
return 1
|
|
1014
|
+
value = _field_value(args.issue, field_id)
|
|
1015
|
+
if value is None:
|
|
1016
|
+
print("unset")
|
|
1017
|
+
return 0
|
|
1018
|
+
print(value.get("number", value.get("name", value.get("text"))))
|
|
1019
|
+
return 0
|
|
1020
|
+
|
|
1021
|
+
|
|
1022
|
+
# --- program-sync: Wellenplan Status-resync for a Program-PRD -------
|
|
1023
|
+
# Own grammar, own command (plan 9b(d): "program-sync statt anchor-sync-Über-
|
|
1024
|
+
# ladung") — the actual parse/render/status logic lives in program_sync.py
|
|
1025
|
+
# (pure) and program_graph.py's public Wellenplan renderer; this
|
|
1026
|
+
# handler stays thin. `_anchor_board_data` is reused as-is: a Program-PRD's
|
|
1027
|
+
# native sub-issues are its promoted Wave-Anchor stubs, fetched exactly like
|
|
1028
|
+
# an Anchor fetches its Slice sub-issues — no second query needed.
|
|
1029
|
+
from program_sync import ( # noqa: E402
|
|
1030
|
+
sync_wellenplan_status, splice_wellenplan_table, checkoff_phase_gates,
|
|
1031
|
+
)
|
|
1032
|
+
|
|
1033
|
+
|
|
1034
|
+
def cmd_program_sync(args) -> int:
|
|
1035
|
+
body = _gh(["issue", "view", str(args.issue), "--repo", REPO, "--json", "body", "-q", ".body"])
|
|
1036
|
+
labels = _issue_type_labels(args.issue)
|
|
1037
|
+
is_program = (classify_node({"body": body, "labels": labels},
|
|
1038
|
+
cluster_type_label=CLUSTER_TYPE_LABEL,
|
|
1039
|
+
wave_stub_label=WAVE_STUB_LABEL) == PROGRAM
|
|
1040
|
+
or PROGRAM_TYPE_LABEL in labels)
|
|
1041
|
+
if not is_program:
|
|
1042
|
+
print(f"error: #{args.issue} is not a Program-PRD (no `<!-- prd: program -->` "
|
|
1043
|
+
f"marker or {PROGRAM_TYPE_LABEL!r} label) — use anchor-sync for a "
|
|
1044
|
+
"Welle-Anker.", file=sys.stderr)
|
|
1045
|
+
return 1
|
|
1046
|
+
waves = parse_wellenplan_table(body)
|
|
1047
|
+
if not waves:
|
|
1048
|
+
print(f"#{args.issue}: no Wellenplan table found — nothing to sync", file=sys.stderr)
|
|
1049
|
+
return 1
|
|
1050
|
+
board = _anchor_board_data(args.issue)
|
|
1051
|
+
new_waves = sync_wellenplan_status(waves, board,
|
|
1052
|
+
_status_roles_or_hint("program-sync"))
|
|
1053
|
+
include_phase = any(w.phase is not None for w in waves)
|
|
1054
|
+
new_table = render_wellenplan_table(new_waves, include_phase=include_phase)
|
|
1055
|
+
new_body = splice_wellenplan_table(body, new_table)
|
|
1056
|
+
# upward propagation: a phase whose waves are ALL ✅ checks off its
|
|
1057
|
+
# `## Phasen-Gates` entry (monotone, stamped once — see checkoff_phase_gates).
|
|
1058
|
+
new_body, checked_gates = checkoff_phase_gates(
|
|
1059
|
+
new_body, new_waves, datetime.date.today().isoformat())
|
|
1060
|
+
checked_note = (f"; Phasen-Gate(s) abgehakt: {', '.join(checked_gates)}"
|
|
1061
|
+
if checked_gates else "")
|
|
1062
|
+
if new_body == body:
|
|
1063
|
+
print(f"#{args.issue}: Wellenplan status already in sync (no drift)")
|
|
1064
|
+
return 0
|
|
1065
|
+
if args.dry_run:
|
|
1066
|
+
print(f"[dry-run] would update #{args.issue} Wellenplan status{checked_note}")
|
|
1067
|
+
print(new_table)
|
|
1068
|
+
return 0
|
|
1069
|
+
_write_issue_body(args.issue, new_body)
|
|
1070
|
+
print(f"synced #{args.issue} Wellenplan status from board{checked_note}")
|
|
1071
|
+
return 0
|
|
1072
|
+
|
|
1073
|
+
|
|
1074
|
+
def _add_status_flags(sp) -> None:
|
|
1075
|
+
"""The shared --status / --status-role pair (mutually exclusive) on every
|
|
1076
|
+
status-writing subcommand — one wiring point, three subcommands."""
|
|
1077
|
+
g = sp.add_mutually_exclusive_group()
|
|
1078
|
+
g.add_argument("--status", choices=list(STATUS_OPTIONS))
|
|
1079
|
+
g.add_argument("--status-role", dest="status_role", choices=list(STATUS_ROLE_KEYS),
|
|
1080
|
+
help="semantic role resolved via the profile's fields.status.roles")
|
|
1081
|
+
|
|
1082
|
+
|
|
1083
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
1084
|
+
p = argparse.ArgumentParser(prog="board-sync.py", description=__doc__.splitlines()[0])
|
|
1085
|
+
sub = p.add_subparsers(dest="command", required=True)
|
|
1086
|
+
|
|
1087
|
+
sub.add_parser("next-wave", help="print the next monotone wave number").set_defaults(func=cmd_next_wave)
|
|
1088
|
+
|
|
1089
|
+
po = sub.add_parser("parent-of", help="print an issue's parent number or FREI")
|
|
1090
|
+
po.add_argument("issue", type=int)
|
|
1091
|
+
po.set_defaults(func=cmd_parent_of)
|
|
1092
|
+
|
|
1093
|
+
co = sub.add_parser("children-of", help="print an anchor's native sub-issue numbers")
|
|
1094
|
+
co.add_argument("issue", type=int)
|
|
1095
|
+
co.set_defaults(func=cmd_children_of)
|
|
1096
|
+
|
|
1097
|
+
ln = sub.add_parser("link", help="link child as sub-issue of parent (one-parent-checked)")
|
|
1098
|
+
ln.add_argument("--parent", type=int, required=True)
|
|
1099
|
+
ln.add_argument("--child", type=int, required=True)
|
|
1100
|
+
ln.add_argument("--allow-drift-report", action="store_true", dest="allow_drift_report",
|
|
1101
|
+
help="report a foreign-parent conflict but exit 0 (read-only audit)")
|
|
1102
|
+
ln.add_argument("--dry-run", action="store_true")
|
|
1103
|
+
ln.set_defaults(func=cmd_link)
|
|
1104
|
+
|
|
1105
|
+
ul = sub.add_parser("unlink", help="remove child as sub-issue of parent (parent-checked, idempotent)")
|
|
1106
|
+
ul.add_argument("--parent", type=int, required=True)
|
|
1107
|
+
ul.add_argument("--child", type=int, required=True)
|
|
1108
|
+
ul.add_argument("--allow-drift-report", action="store_true", dest="allow_drift_report",
|
|
1109
|
+
help="report a foreign-parent mismatch but exit 0 (read-only audit)")
|
|
1110
|
+
ul.add_argument("--dry-run", action="store_true")
|
|
1111
|
+
ul.set_defaults(func=cmd_unlink)
|
|
1112
|
+
|
|
1113
|
+
dp = sub.add_parser("deps", help="print an issue's native blocked_by edges")
|
|
1114
|
+
dp.add_argument("issue", type=int)
|
|
1115
|
+
dp.add_argument("--json", action="store_true", dest="as_json",
|
|
1116
|
+
help="normalized machine-readable list")
|
|
1117
|
+
dp.set_defaults(func=cmd_deps)
|
|
1118
|
+
|
|
1119
|
+
da = sub.add_parser("dep-add", help="set a native blocked-by edge + body mirror "
|
|
1120
|
+
"(idempotent, self-healing)")
|
|
1121
|
+
da.add_argument("--issue", type=int, required=True, help="the BLOCKED issue")
|
|
1122
|
+
da.add_argument("--blocked-by", type=int, required=True, dest="blocked_by",
|
|
1123
|
+
help="the BLOCKING issue")
|
|
1124
|
+
da.add_argument("--dry-run", action="store_true")
|
|
1125
|
+
da.set_defaults(func=cmd_dep_add)
|
|
1126
|
+
|
|
1127
|
+
dr = sub.add_parser("dep-remove", help="remove a native blocked-by edge + body mirror "
|
|
1128
|
+
"(idempotent)")
|
|
1129
|
+
dr.add_argument("--issue", type=int, required=True, help="the BLOCKED issue")
|
|
1130
|
+
dr.add_argument("--blocked-by", type=int, required=True, dest="blocked_by",
|
|
1131
|
+
help="the BLOCKING issue")
|
|
1132
|
+
dr.add_argument("--dry-run", action="store_true")
|
|
1133
|
+
dr.set_defaults(func=cmd_dep_remove)
|
|
1134
|
+
|
|
1135
|
+
fr = sub.add_parser("frontier", help="buildable frontier of a wave anchor from "
|
|
1136
|
+
"native edges: FREI/BLOCKED/done")
|
|
1137
|
+
fr.add_argument("issue", type=int, help="the wave-anchor issue number")
|
|
1138
|
+
fr.set_defaults(func=cmd_frontier)
|
|
1139
|
+
|
|
1140
|
+
cr = sub.add_parser("create", help="create an issue + add to board + stamp fields")
|
|
1141
|
+
cr.add_argument("--title", required=True)
|
|
1142
|
+
cr.add_argument("--body-file", required=True)
|
|
1143
|
+
cr.add_argument("--label", action="append", help="repeatable")
|
|
1144
|
+
cr.add_argument("--wave", type=int)
|
|
1145
|
+
_add_status_flags(cr)
|
|
1146
|
+
cr.add_argument("--cluster")
|
|
1147
|
+
cr.add_argument("--hitl", action="store_true",
|
|
1148
|
+
help="mark slice HITL — rejects a ready-for-agent label")
|
|
1149
|
+
cr.add_argument("--wave-stub", action="store_true", dest="wave_stub",
|
|
1150
|
+
help="tag as board-to-waves candidate stub (wave-stub label; "
|
|
1151
|
+
"stripped at promote / add --bucket)")
|
|
1152
|
+
cr.add_argument("--dry-run", action="store_true")
|
|
1153
|
+
cr.set_defaults(func=cmd_create)
|
|
1154
|
+
|
|
1155
|
+
pr = sub.add_parser("promote", help="promote a Draft-PRD to Anker (type:cluster + Wave)")
|
|
1156
|
+
pr.add_argument("--issue", type=int, required=True)
|
|
1157
|
+
pr.add_argument("--wave", type=int, required=True,
|
|
1158
|
+
help="explicit Wave (read `next-wave` first — no in-promote race)")
|
|
1159
|
+
_add_status_flags(pr)
|
|
1160
|
+
pr.add_argument("--no-rename", action="store_true",
|
|
1161
|
+
help="keep the title as-is (skip the `Welle <N> — ` prefix)")
|
|
1162
|
+
pr.add_argument("--dry-run", action="store_true")
|
|
1163
|
+
pr.set_defaults(func=cmd_promote)
|
|
1164
|
+
|
|
1165
|
+
ad = sub.add_parser("add", help="add an existing issue to the board + stamp fields")
|
|
1166
|
+
g = ad.add_mutually_exclusive_group(required=True)
|
|
1167
|
+
g.add_argument("--issue", type=int)
|
|
1168
|
+
g.add_argument("--url")
|
|
1169
|
+
ad.add_argument("--wave", type=int)
|
|
1170
|
+
_add_status_flags(ad)
|
|
1171
|
+
ad.add_argument("--cluster")
|
|
1172
|
+
ad.add_argument("--spec-path", dest="spec_path")
|
|
1173
|
+
ad.add_argument("--plan-path", dest="plan_path")
|
|
1174
|
+
ad.add_argument("--bucket", choices=["afk", "hitl"],
|
|
1175
|
+
help="workflow-label write: afk → add ready-for-agent; hitl → strip it")
|
|
1176
|
+
ad.add_argument("--dry-run", action="store_true")
|
|
1177
|
+
ad.set_defaults(func=cmd_add)
|
|
1178
|
+
|
|
1179
|
+
asy = sub.add_parser("anchor-sync",
|
|
1180
|
+
help="regenerate a wave-anchor Slices-table's volatile cells "
|
|
1181
|
+
"(Status/Branch) from the board, in place")
|
|
1182
|
+
asy.add_argument("issue", type=int, help="the wave-anchor issue number")
|
|
1183
|
+
asy.add_argument("--dry-run", action="store_true")
|
|
1184
|
+
asy.set_defaults(func=cmd_anchor_sync)
|
|
1185
|
+
|
|
1186
|
+
vg = sub.add_parser("validate-graph",
|
|
1187
|
+
help="counted Programm-Graph preflight for a Program-PRD "
|
|
1188
|
+
"(read-only)")
|
|
1189
|
+
vg.add_argument("--issue", type=int, required=True, help="the Program-PRD issue number")
|
|
1190
|
+
vg.set_defaults(func=cmd_validate_graph)
|
|
1191
|
+
|
|
1192
|
+
sb = sub.add_parser("stamp-batch",
|
|
1193
|
+
help="alias-batched GraphQL field-stamp (Wave/Phase) for N "
|
|
1194
|
+
"items, chunked ~30 aliases/request")
|
|
1195
|
+
g_sb = sb.add_mutually_exclusive_group(required=True)
|
|
1196
|
+
g_sb.add_argument("--items-file", help="JSON list of {issue,item_id,wave?,phase?}")
|
|
1197
|
+
g_sb.add_argument("--issue", type=int, help="single-item form — also the repair target")
|
|
1198
|
+
sb.add_argument("--item-id", help="required together with --issue")
|
|
1199
|
+
sb.add_argument("--wave", type=int)
|
|
1200
|
+
sb.add_argument("--phase", help="Phase name, resolved via fields.phase.options")
|
|
1201
|
+
sb.add_argument("--dry-run", action="store_true")
|
|
1202
|
+
sb.set_defaults(func=cmd_stamp_batch)
|
|
1203
|
+
|
|
1204
|
+
fv = sub.add_parser("field-value",
|
|
1205
|
+
help="read a project field's current value for an issue "
|
|
1206
|
+
"(— the promote-guard's read side)")
|
|
1207
|
+
fv.add_argument("--issue", type=int, required=True)
|
|
1208
|
+
fv.add_argument("--field", choices=["wave", "phase"], required=True)
|
|
1209
|
+
fv.set_defaults(func=cmd_field_value)
|
|
1210
|
+
|
|
1211
|
+
ps = sub.add_parser("program-sync",
|
|
1212
|
+
help="regenerate a Program-PRD's Wellenplan Status from the "
|
|
1213
|
+
"board, in place (own grammar, not anchor-sync)")
|
|
1214
|
+
ps.add_argument("issue", type=int, help="the Program-PRD issue number")
|
|
1215
|
+
ps.add_argument("--dry-run", action="store_true")
|
|
1216
|
+
ps.set_defaults(func=cmd_program_sync)
|
|
1217
|
+
return p
|
|
1218
|
+
|
|
1219
|
+
|
|
1220
|
+
def main(argv: Optional[list[str]] = None) -> int:
|
|
1221
|
+
args = build_parser().parse_args(argv)
|
|
1222
|
+
try:
|
|
1223
|
+
if getattr(args, "status_role", None):
|
|
1224
|
+
args.status = resolve_status_role(args.status_role, STATUS_ROLES)
|
|
1225
|
+
return args.func(args)
|
|
1226
|
+
except (ValueError, GhError) as exc:
|
|
1227
|
+
print(f"error: {exc}", file=sys.stderr)
|
|
1228
|
+
return 1
|
|
1229
|
+
|
|
1230
|
+
|
|
1231
|
+
if __name__ == "__main__":
|
|
1232
|
+
sys.exit(main())
|