@delorenj/pjangler 1.4.6 → 1.5.2
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/README.md +41 -596
- package/dist/assets/project-notebook-skill/SHA256SUMS +5 -5
- package/dist/assets/project-notebook-skill/export-manifest.json +5 -5
- package/dist/assets/project-notebook-skill/hooks/session-end.sh +20 -1
- package/dist/assets/project-notebook-skill/hooks/session-start.sh +20 -1
- package/dist/assets/project-notebook-skill/references/configuration.md +2 -0
- package/dist/assets/project-notebook-skill/scripts/project-hooks.py +33 -3
- package/dist/assets/project-notebook-skill/tests/test_project_hooks.py +37 -0
- package/dist/index.js +9385 -18379
- package/dist/mcp-server.js +9432 -17852
- package/dist/notebook/journal-service-main.js +5682 -0
- package/dist/project-registry-service.js +748 -0
- package/dist/prompt.js +2 -1
- package/package.json +15 -10
- package/scripts/install-project-registry.mjs +34 -0
- package/scripts/migrate-project-registry.mjs +117 -0
- package/src/project/registryClient.ts +62 -0
- package/templates/commonproject/copier.yml +16 -19
- package/templates/commonproject/template/.agents/hooks/README.md +10 -4
- package/templates/commonproject/template/.agents/hooks/hermes/hindsight-hook.sh +12 -2
- package/templates/commonproject/template/.agents/hooks/hooks.master.json +10 -10
- package/templates/commonproject/template/.agents/hooks/lib/hook-guard.sh +13 -0
- package/templates/commonproject/template/.agents/hooks/sync.py +66 -17
- package/templates/commonproject/template/.agents/skills.json +0 -1
- package/templates/commonproject/template/.github/workflows/code-review.yml.jinja +7 -1
- package/templates/commonproject/template/.project.json.jinja +1 -1
- package/templates/commonproject/template/mise.toml.jinja +11 -21
- package/contracts/fleet-contract.yaml +0 -584
- package/templates/commonproject/template/.mise/scripts/provision-packs.py +0 -587
- package/templates/commonproject/template/.mise/scripts/sync-skills.py +0 -2183
- package/templates/hermes-agent/copier.yml +0 -186
- package/templates/hermes-agent/template/.gitignore.jinja +0 -11
- package/templates/hermes-agent/template/.runtime-scaffold/.gitattributes +0 -10
- package/templates/hermes-agent/template/.runtime-scaffold/.gitignore.jinja +0 -44
- package/templates/hermes-agent/template/.runtime-scaffold/README.md +0 -56
- package/templates/hermes-agent/template/.runtime-scaffold/decisions/.gitkeep +0 -2
- package/templates/hermes-agent/template/.runtime-scaffold/logs/.gitkeep +0 -1
- package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +0 -50
- package/templates/hermes-agent/template/.runtime-scaffold/memories/USER.md +0 -51
- package/templates/hermes-agent/template/.scripts/00-banner.sh +0 -21
- package/templates/hermes-agent/template/.scripts/01-config.sh +0 -60
- package/templates/hermes-agent/template/.scripts/05-fleet-env.sh +0 -42
- package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +0 -222
- package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +0 -199
- package/templates/hermes-agent/template/.scripts/30-telegram.sh +0 -363
- package/templates/hermes-agent/template/.scripts/31-slack.sh +0 -457
- package/templates/hermes-agent/template/.scripts/40-plane.sh +0 -60
- package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +0 -373
- package/templates/hermes-agent/template/.scripts/60-bloodbank.sh +0 -18
- package/templates/hermes-agent/template/.scripts/70-systemd.sh +0 -354
- package/templates/hermes-agent/template/.scripts/80-registry.sh +0 -185
- package/templates/hermes-agent/template/.scripts/99-summary.sh +0 -87
- package/templates/hermes-agent/template/.scripts/_lib.sh +0 -1138
- package/templates/hermes-agent/template/.scripts/channel-transaction.py +0 -2340
- package/templates/hermes-agent/template/.scripts/checkpoint.sh +0 -82
- package/templates/hermes-agent/template/.scripts/config.example.toml +0 -70
- package/templates/hermes-agent/template/.scripts/credential-launch.sh +0 -85
- package/templates/hermes-agent/template/.scripts/heartbeat.sh +0 -283
- package/templates/hermes-agent/template/.scripts/lib/fleet-env.sh +0 -202
- package/templates/hermes-agent/template/.scripts/lib/parse-fleet-env.py +0 -761
- package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +0 -182
- package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +0 -108
- package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +0 -199
- package/templates/hermes-agent/template/.scripts/lib/voice-config.py +0 -546
- package/templates/hermes-agent/template/.scripts/lifecycle.sh +0 -126
- package/templates/hermes-agent/template/.scripts/momo-wip-lock.py +0 -137
- package/templates/hermes-agent/template/.scripts/providers/linear.sh +0 -329
- package/templates/hermes-agent/template/.scripts/providers/plane.sh +0 -671
- package/templates/hermes-agent/template/.scripts/providers/trello.sh +0 -241
- package/templates/hermes-agent/template/.scripts/reporter-watchdog.py +0 -242
- package/templates/hermes-agent/template/.scripts/secret-scan.py +0 -134
- package/templates/hermes-agent/template/.scripts/sentinel/bin/emit-event.py +0 -106
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +0 -339
- package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +0 -179
- package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +0 -162
- package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +0 -42
- package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +0 -77
- package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +0 -116
- package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +0 -260
- package/templates/hermes-agent/template/SOUL.md.jinja +0 -197
- package/templates/hermes-agent/template/hermes.jinja +0 -109
- package/templates/hermes-agent/template/momo.jinja +0 -177
- package/templates/hermes-agent/template/role.yaml.jinja +0 -112
|
@@ -1,106 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Emit a local BloodBank-style CloudEvents envelope (provider-agnostic).
|
|
3
|
-
|
|
4
|
-
Dependency-free. Appends JSONL locally so the sentinel engine records an
|
|
5
|
-
event trail even when NATS/BloodBank is offline. Identical envelope shape to the
|
|
6
|
-
Hermes gateway; see .scripts/sentinel/docs/bloodbank-events.md.
|
|
7
|
-
"""
|
|
8
|
-
from __future__ import annotations
|
|
9
|
-
|
|
10
|
-
import argparse
|
|
11
|
-
import json
|
|
12
|
-
import os
|
|
13
|
-
import pathlib
|
|
14
|
-
import sys
|
|
15
|
-
import uuid
|
|
16
|
-
from datetime import datetime, timezone
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
def now_iso() -> str:
|
|
20
|
-
return datetime.now(timezone.utc).isoformat().replace("+00:00", "Z")
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
def parse_field(raw: str) -> tuple[str, str]:
|
|
24
|
-
if "=" not in raw:
|
|
25
|
-
raise argparse.ArgumentTypeError("--field values must use key=value")
|
|
26
|
-
key, value = raw.split("=", 1)
|
|
27
|
-
if not key:
|
|
28
|
-
raise argparse.ArgumentTypeError("--field key cannot be empty")
|
|
29
|
-
return key, value
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
def load_data(args: argparse.Namespace) -> dict:
|
|
33
|
-
data: dict[str, object] = {}
|
|
34
|
-
for key, value in args.field or []:
|
|
35
|
-
data[key] = value
|
|
36
|
-
if args.data_json:
|
|
37
|
-
loaded = json.loads(args.data_json)
|
|
38
|
-
if not isinstance(loaded, dict):
|
|
39
|
-
raise SystemExit("--data-json must be a JSON object")
|
|
40
|
-
data.update(loaded)
|
|
41
|
-
elif not sys.stdin.isatty():
|
|
42
|
-
raw = sys.stdin.read().strip()
|
|
43
|
-
if raw:
|
|
44
|
-
loaded = json.loads(raw)
|
|
45
|
-
if not isinstance(loaded, dict):
|
|
46
|
-
raise SystemExit("stdin JSON must be an object")
|
|
47
|
-
data.update(loaded)
|
|
48
|
-
return data
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
def main() -> int:
|
|
52
|
-
parser = argparse.ArgumentParser(description=__doc__)
|
|
53
|
-
parser.add_argument("event_type")
|
|
54
|
-
parser.add_argument("--root", default=os.getcwd())
|
|
55
|
-
parser.add_argument("--field", action="append", type=parse_field, default=[])
|
|
56
|
-
parser.add_argument("--data-json")
|
|
57
|
-
parser.add_argument("--source")
|
|
58
|
-
parser.add_argument("--producer", default=os.environ.get("BLOODBANK_PRODUCER", "local-script:sentinel"))
|
|
59
|
-
parser.add_argument("--service", default=os.environ.get("BLOODBANK_SERVICE", "hermes-sentinel"))
|
|
60
|
-
parser.add_argument("--actor-id", default=os.environ.get("BLOODBANK_ACTOR_ID", os.environ.get("USER", "local-agent")))
|
|
61
|
-
parser.add_argument("--actor-cli", default=os.environ.get("BLOODBANK_ACTOR_CLI", "script"))
|
|
62
|
-
parser.add_argument("--correlation-id", default=os.environ.get("BLOODBANK_CORRELATION_ID"))
|
|
63
|
-
parser.add_argument("--causation-id", default=os.environ.get("BLOODBANK_CAUSATION_ID"))
|
|
64
|
-
parser.add_argument("--kind", default="event")
|
|
65
|
-
parser.add_argument("--quiet", action="store_true")
|
|
66
|
-
args = parser.parse_args()
|
|
67
|
-
|
|
68
|
-
root = pathlib.Path(args.root).resolve()
|
|
69
|
-
log_path = pathlib.Path(
|
|
70
|
-
os.environ.get(
|
|
71
|
-
"BLOODBANK_EVENTS_LOG",
|
|
72
|
-
root / "_bmad-output" / "implementation-artifacts" / "bloodbank-events.jsonl",
|
|
73
|
-
)
|
|
74
|
-
)
|
|
75
|
-
if not log_path.is_absolute():
|
|
76
|
-
log_path = root / log_path
|
|
77
|
-
|
|
78
|
-
source = args.source or "repo://sentinel/bin/emit-event.py"
|
|
79
|
-
event = {
|
|
80
|
-
"specversion": "1.0",
|
|
81
|
-
"type": args.event_type,
|
|
82
|
-
"id": str(uuid.uuid4()),
|
|
83
|
-
"source": source,
|
|
84
|
-
"time": now_iso(),
|
|
85
|
-
"datacontenttype": "application/json",
|
|
86
|
-
"kind": args.kind,
|
|
87
|
-
"producer": args.producer,
|
|
88
|
-
"service": args.service,
|
|
89
|
-
"actor": {"type": "agent", "agent_id": args.actor_id, "cli": args.actor_cli},
|
|
90
|
-
"data": load_data(args),
|
|
91
|
-
"correlation_id": args.correlation_id or str(uuid.uuid4()),
|
|
92
|
-
"causation_id": args.causation_id,
|
|
93
|
-
}
|
|
94
|
-
|
|
95
|
-
log_path.parent.mkdir(parents=True, exist_ok=True)
|
|
96
|
-
with log_path.open("a", encoding="utf-8") as handle:
|
|
97
|
-
handle.write(json.dumps(event, sort_keys=True) + "\n")
|
|
98
|
-
|
|
99
|
-
if not args.quiet:
|
|
100
|
-
print(json.dumps(event, indent=2, sort_keys=True))
|
|
101
|
-
print(f"Appended: {log_path}", file=sys.stderr)
|
|
102
|
-
return 0
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
if __name__ == "__main__":
|
|
106
|
-
raise SystemExit(main())
|
|
@@ -1,339 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env bash
|
|
2
|
-
# Provider-agnostic autonomous adversarial-review decision gate (act, don't wait).
|
|
3
|
-
#
|
|
4
|
-
# The rigorous, INDEPENDENT ADVERSARIAL review is the normal per-pass path: an
|
|
5
|
-
# adversarial microscope that couples the close gate with an independent-reviewer
|
|
6
|
-
# drift attestation. A clean adversarial
|
|
7
|
-
# verdict is acted on AUTONOMOUSLY -- the loop treats the ticket as done and
|
|
8
|
-
# moves on; it never parks the ticket waiting on the operator for approval or
|
|
9
|
-
# sign-off. Every adversarial check stays at full strength (locked-intent
|
|
10
|
-
# baseline, drift none|minor|significant with significant -> hold, any unresolved
|
|
11
|
-
# critical/high finding -> hold, independence, and the close gate as a hard
|
|
12
|
-
# automated lock). The ONLY thing removed is the human-approval stall.
|
|
13
|
-
#
|
|
14
|
-
# A default clean run (no --close) means the loop autonomously treats the ticket
|
|
15
|
-
# as done and leaves it in the review lane (the operator's deferred-QA queue).
|
|
16
|
-
# --close is OPTIONAL (operator QA sweep): closure goes through the
|
|
17
|
-
# ticket-provider adapter (tp transition <id> completed), so the same logic works
|
|
18
|
-
# on Linear | Plane | Trello.
|
|
19
|
-
#
|
|
20
|
-
# Protocol: .scripts/sentinel/docs/autonomous-delegated-review.md
|
|
21
|
-
#
|
|
22
|
-
# Usage: issue-autonomous-review.sh ISSUE_ID REPORT_FILE [--close]
|
|
23
|
-
#
|
|
24
|
-
# Exit codes: 0 accepted (treat as done; with --close, transitioned to completed)
|
|
25
|
-
# 3 held 2 usage/missing inputs
|
|
26
|
-
set -euo pipefail
|
|
27
|
-
|
|
28
|
-
if [[ "${1:-}" == "" || "${2:-}" == "" ]]; then
|
|
29
|
-
printf 'Usage: %s ISSUE_ID REPORT_FILE [--close]\n' "$0" >&2; exit 2
|
|
30
|
-
fi
|
|
31
|
-
ISSUE="$1"; REPORT="$2"; CLOSE=0
|
|
32
|
-
[[ "${3:-}" == "--close" ]] && CLOSE=1
|
|
33
|
-
case "$ISSUE" in *[!A-Za-z0-9_-]*) printf 'Invalid issue id: %s\n' "$ISSUE" >&2; exit 2 ;; esac
|
|
34
|
-
|
|
35
|
-
BIN_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
|
36
|
-
ENGINE_DIR="$(cd "$BIN_DIR/.." && pwd)" # .scripts/sentinel
|
|
37
|
-
SCRIPTS_DIR="$(cd "$ENGINE_DIR/.." && pwd)" # .scripts
|
|
38
|
-
ROLE_DIR="$(cd "$SCRIPTS_DIR/.." && pwd)"
|
|
39
|
-
ROLE_YAML="$ROLE_DIR/role.yaml"
|
|
40
|
-
ROOT="$(git -C "$ROLE_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)"
|
|
41
|
-
cd "$ROOT"
|
|
42
|
-
|
|
43
|
-
CLOSE_GATE="$BIN_DIR/issue-close-gate.sh"
|
|
44
|
-
EVIDENCE="_bmad-output/implementation-artifacts/issue-evidence/$ISSUE.md"
|
|
45
|
-
|
|
46
|
-
role_mapping_value() {
|
|
47
|
-
python3 - "$ROLE_YAML" "$1" "$2" <<'PY'
|
|
48
|
-
import pathlib
|
|
49
|
-
import re
|
|
50
|
-
import sys
|
|
51
|
-
|
|
52
|
-
path = pathlib.Path(sys.argv[1])
|
|
53
|
-
section = sys.argv[2]
|
|
54
|
-
key = sys.argv[3]
|
|
55
|
-
text = path.read_text(encoding="utf-8") if path.is_file() else ""
|
|
56
|
-
lines = text.splitlines()
|
|
57
|
-
headers = [
|
|
58
|
-
index
|
|
59
|
-
for index, line in enumerate(lines)
|
|
60
|
-
if re.fullmatch(rf"{re.escape(section)}:\s*(?:#.*)?", line)
|
|
61
|
-
]
|
|
62
|
-
if not headers:
|
|
63
|
-
print("")
|
|
64
|
-
raise SystemExit(0)
|
|
65
|
-
if len(headers) != 1:
|
|
66
|
-
raise SystemExit(f"role config has duplicate top-level {section!r} mappings")
|
|
67
|
-
block = []
|
|
68
|
-
for line in lines[headers[0] + 1 :]:
|
|
69
|
-
if line.strip() and not line[0].isspace() and not line.lstrip().startswith("#"):
|
|
70
|
-
break
|
|
71
|
-
block.append(line)
|
|
72
|
-
content = [
|
|
73
|
-
line
|
|
74
|
-
for line in block
|
|
75
|
-
if line.strip() and not line.lstrip().startswith("#") and line[0].isspace()
|
|
76
|
-
]
|
|
77
|
-
if not content:
|
|
78
|
-
print("")
|
|
79
|
-
raise SystemExit(0)
|
|
80
|
-
direct_indent = min(len(line) - len(line.lstrip()) for line in content)
|
|
81
|
-
matches = []
|
|
82
|
-
for line in content:
|
|
83
|
-
if len(line) - len(line.lstrip()) != direct_indent:
|
|
84
|
-
continue
|
|
85
|
-
match = re.fullmatch(rf"\s{{{direct_indent}}}{re.escape(key)}:\s*(.*?)\s*", line)
|
|
86
|
-
if match:
|
|
87
|
-
matches.append(match.group(1))
|
|
88
|
-
if len(matches) > 1:
|
|
89
|
-
raise SystemExit(f"role config has duplicate {section}.{key} values")
|
|
90
|
-
value = matches[0] if matches else ""
|
|
91
|
-
value = re.sub(r"\s+#.*$", "", value).strip()
|
|
92
|
-
if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'":
|
|
93
|
-
value = value[1:-1]
|
|
94
|
-
print(value.strip())
|
|
95
|
-
PY
|
|
96
|
-
}
|
|
97
|
-
# Informational only: recorded in the ticket comment, never a blocking wait.
|
|
98
|
-
# Default 0 (no grace); an operator may set grace_hours>0 to reintroduce one.
|
|
99
|
-
ROLE_GRACE_HOURS="$(role_mapping_value reconcile grace_hours)"
|
|
100
|
-
GRACE_HOURS="${RECONCILE_GRACE_HOURS:-$ROLE_GRACE_HOURS}"
|
|
101
|
-
GRACE_HOURS="$(printf '%s' "${GRACE_HOURS:-0}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//')"
|
|
102
|
-
case "$GRACE_HOURS" in
|
|
103
|
-
''|*[!0-9]*)
|
|
104
|
-
printf "AUTONOMOUS REVIEW: CONFIG INVALID - reconcile.grace_hours must be a nonnegative integer (got '%s').\n" "$GRACE_HOURS" >&2
|
|
105
|
-
exit 3
|
|
106
|
-
;;
|
|
107
|
-
esac
|
|
108
|
-
GRACE_HOURS="$(python3 -c 'import sys; print(int(sys.argv[1]))' "$GRACE_HOURS")"
|
|
109
|
-
|
|
110
|
-
ROLE_AUTO="$(role_mapping_value reconcile auto_review)"
|
|
111
|
-
AUTO="${RECONCILE_AUTO_REVIEW:-$ROLE_AUTO}"
|
|
112
|
-
AUTO="$(printf '%s' "${AUTO:-true}" | sed 's/^[[:space:]]*//; s/[[:space:]]*$//' | tr '[:upper:]' '[:lower:]')"
|
|
113
|
-
case "$AUTO" in
|
|
114
|
-
true|on) : ;;
|
|
115
|
-
false|off)
|
|
116
|
-
printf 'Autonomous review is disabled (reconcile.auto_review=%s).\n' "$AUTO" >&2
|
|
117
|
-
exit 3
|
|
118
|
-
;;
|
|
119
|
-
*)
|
|
120
|
-
printf "AUTONOMOUS REVIEW: CONFIG INVALID - reconcile.auto_review must be true|on|false|off (got '%s').\n" "$AUTO" >&2
|
|
121
|
-
exit 3
|
|
122
|
-
;;
|
|
123
|
-
esac
|
|
124
|
-
[[ -f "$REPORT" ]] || { printf 'Missing review report file: %s\n' "$REPORT" >&2; exit 2; }
|
|
125
|
-
[[ -f "$EVIDENCE" ]] || { printf 'Missing issue evidence file: %s\n' "$EVIDENCE" >&2; exit 2; }
|
|
126
|
-
|
|
127
|
-
HOLD=""
|
|
128
|
-
hold() { HOLD="${HOLD}${HOLD:+; }$1"; }
|
|
129
|
-
|
|
130
|
-
# Structural parse of the review report (and the evidence's implementer
|
|
131
|
-
# identity): every required H2 exactly once, each authoritative field read
|
|
132
|
-
# only as a direct field of its own section. Lookalike bullets or examples in
|
|
133
|
-
# other sections can never stand in for -- or override -- the authoritative
|
|
134
|
-
# value, and duplicate/missing/blank sections or fields are rejected here,
|
|
135
|
-
# before any provider call.
|
|
136
|
-
PARSED=""
|
|
137
|
-
if ! PARSED="$(python3 - "$REPORT" "$EVIDENCE" <<'PY'
|
|
138
|
-
import re
|
|
139
|
-
import sys
|
|
140
|
-
|
|
141
|
-
report_path, evidence_path = sys.argv[1], sys.argv[2]
|
|
142
|
-
|
|
143
|
-
heading = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
def parse_sections(path):
|
|
147
|
-
with open(path, encoding="utf-8") as stream:
|
|
148
|
-
lines = stream.read().splitlines()
|
|
149
|
-
sections = {}
|
|
150
|
-
order = []
|
|
151
|
-
current = None
|
|
152
|
-
for line in lines:
|
|
153
|
-
match = heading.match(line)
|
|
154
|
-
if match and len(match.group(1)) <= 2:
|
|
155
|
-
current = match.group(2) if len(match.group(1)) == 2 else None
|
|
156
|
-
if current is not None:
|
|
157
|
-
sections.setdefault(current, [])
|
|
158
|
-
order.append(current)
|
|
159
|
-
continue
|
|
160
|
-
if current is not None:
|
|
161
|
-
sections[current].append(line)
|
|
162
|
-
return sections, order
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
def direct_fields(sections, section, *names):
|
|
166
|
-
found = []
|
|
167
|
-
for line in sections.get(section, []):
|
|
168
|
-
for name in names:
|
|
169
|
-
match = re.fullmatch(
|
|
170
|
-
r"\s*(?:[-*+]\s+)?" + re.escape(name) + r"\s*:\s*(.*?)\s*", line
|
|
171
|
-
)
|
|
172
|
-
if match:
|
|
173
|
-
found.append(match.group(1))
|
|
174
|
-
break
|
|
175
|
-
return found
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
errors = []
|
|
179
|
-
out = {}
|
|
180
|
-
|
|
181
|
-
sections, order = parse_sections(report_path)
|
|
182
|
-
for name in (
|
|
183
|
-
"Reviewer",
|
|
184
|
-
"Locked Intent Baseline",
|
|
185
|
-
"Drift Assessment",
|
|
186
|
-
"Adversarial Findings",
|
|
187
|
-
"Decision",
|
|
188
|
-
):
|
|
189
|
-
count = order.count(name)
|
|
190
|
-
if count == 0:
|
|
191
|
-
errors.append(f"report missing section {name}")
|
|
192
|
-
elif count > 1:
|
|
193
|
-
errors.append(f"report has duplicate {name} sections")
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
def report_field(section, name):
|
|
197
|
-
if order.count(section) != 1:
|
|
198
|
-
return ""
|
|
199
|
-
found = direct_fields(sections, section, name)
|
|
200
|
-
if len(found) != 1:
|
|
201
|
-
errors.append(
|
|
202
|
-
f"report section {section} must contain exactly one"
|
|
203
|
-
f" '{name}:' field (found {len(found)})"
|
|
204
|
-
)
|
|
205
|
-
return ""
|
|
206
|
-
if not found[0]:
|
|
207
|
-
errors.append(f"report field '{name}:' in section {section} is blank")
|
|
208
|
-
return ""
|
|
209
|
-
return found[0]
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def first_token(value):
|
|
213
|
-
tokens = value.split()
|
|
214
|
-
return tokens[0].lower() if tokens else ""
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
out["reviewer"] = report_field("Reviewer", "Reviewer agent")
|
|
218
|
-
out["independent"] = first_token(report_field("Reviewer", "Independent of implementer"))
|
|
219
|
-
out["drift"] = first_token(report_field("Drift Assessment", "Drift assessment"))
|
|
220
|
-
out["findings"] = first_token(report_field("Adversarial Findings", "Critical/high findings"))
|
|
221
|
-
out["decision"] = first_token(report_field("Decision", "Decision"))
|
|
222
|
-
|
|
223
|
-
evidence_sections, evidence_order = parse_sections(evidence_path)
|
|
224
|
-
implementer = ""
|
|
225
|
-
if evidence_order.count("Issue") != 1:
|
|
226
|
-
errors.append(
|
|
227
|
-
"evidence must contain exactly one Issue section carrying the"
|
|
228
|
-
" implementer identity"
|
|
229
|
-
)
|
|
230
|
-
else:
|
|
231
|
-
found = direct_fields(evidence_sections, "Issue", "Worker", "Implemented by")
|
|
232
|
-
if len(found) != 1:
|
|
233
|
-
errors.append(
|
|
234
|
-
"evidence section Issue must contain exactly one implementer"
|
|
235
|
-
" identity ('Worker:' / 'Implemented by:') field"
|
|
236
|
-
f" (found {len(found)})"
|
|
237
|
-
)
|
|
238
|
-
elif not found[0]:
|
|
239
|
-
errors.append("evidence implementer identity in section Issue is blank")
|
|
240
|
-
else:
|
|
241
|
-
implementer = found[0]
|
|
242
|
-
out["implementer"] = implementer
|
|
243
|
-
|
|
244
|
-
for key, value in out.items():
|
|
245
|
-
print("F\t{}\t{}".format(key, value.replace("\t", " ").replace("\n", " ")))
|
|
246
|
-
for error in errors:
|
|
247
|
-
print(f"E\t{error}")
|
|
248
|
-
PY
|
|
249
|
-
)"; then
|
|
250
|
-
hold "review report could not be parsed"
|
|
251
|
-
fi
|
|
252
|
-
|
|
253
|
-
REVIEWER=""; INDEP=""; DRIFT=""; FINDINGS=""; DEC=""; IMPL=""
|
|
254
|
-
while IFS="$(printf '\t')" read -r kind key value; do
|
|
255
|
-
case "$kind" in
|
|
256
|
-
E) hold "$key" ;;
|
|
257
|
-
F)
|
|
258
|
-
case "$key" in
|
|
259
|
-
reviewer) REVIEWER="$value" ;;
|
|
260
|
-
independent) INDEP="$value" ;;
|
|
261
|
-
drift) DRIFT="$value" ;;
|
|
262
|
-
findings) FINDINGS="$value" ;;
|
|
263
|
-
decision) DEC="$value" ;;
|
|
264
|
-
implementer) IMPL="$value" ;;
|
|
265
|
-
esac
|
|
266
|
-
;;
|
|
267
|
-
esac
|
|
268
|
-
done <<EOF
|
|
269
|
-
$PARSED
|
|
270
|
-
EOF
|
|
271
|
-
REVIEWER="${REVIEWER:-unknown}"
|
|
272
|
-
|
|
273
|
-
[[ "$INDEP" == "yes" ]] || hold "reviewer did not attest independence"
|
|
274
|
-
[[ -n "$IMPL" && "$IMPL" == "$REVIEWER" ]] && hold "reviewer ($REVIEWER) is the implementer"
|
|
275
|
-
|
|
276
|
-
case "$DRIFT" in
|
|
277
|
-
none|minor) : ;;
|
|
278
|
-
significant) hold "significant drift from locked intent" ;;
|
|
279
|
-
*) hold "drift assessment missing/invalid ('${DRIFT:-none-found}')"; DRIFT="${DRIFT:-unknown}" ;;
|
|
280
|
-
esac
|
|
281
|
-
[[ "$FINDINGS" == "none" ]] || hold "unresolved critical/high findings (or field missing)"
|
|
282
|
-
# Accept the keyword `accept` as clearing; tolerate the legacy `close`.
|
|
283
|
-
[[ "$DEC" == "accept" || "$DEC" == "close" ]] || hold "reviewer decision is not 'accept' (got '${DEC:-none}')"
|
|
284
|
-
|
|
285
|
-
GATE=fail
|
|
286
|
-
if sh "$CLOSE_GATE" "$ISSUE" "$ROOT" >/dev/null 2>&1 </dev/null; then GATE=pass; else hold "close gate failed"; fi
|
|
287
|
-
|
|
288
|
-
if [[ -n "$HOLD" ]]; then DECISION=held; else DECISION=accepted; fi
|
|
289
|
-
|
|
290
|
-
if [[ "$DECISION" == "held" ]]; then
|
|
291
|
-
printf 'AUTONOMOUS REVIEW: HOLD for %s\nReasons: %s\n' "$ISSUE" "$HOLD" >&2
|
|
292
|
-
exit 3
|
|
293
|
-
fi
|
|
294
|
-
|
|
295
|
-
PROV="$(role_mapping_value ticket_provider name)"; PROV="${PROV:-}"
|
|
296
|
-
if [[ "$CLOSE" -eq 1 ]]; then
|
|
297
|
-
# Optional operator QA sweep: close through the ticket-provider adapter.
|
|
298
|
-
if ! TICKET_PROVIDER="$PROV" bash -c '. "$1"; tp transition "$2" completed' _ "$SCRIPTS_DIR/lib/ticket-provider.sh" "$ISSUE" \
|
|
299
|
-
>/dev/null 2>&1; then
|
|
300
|
-
printf 'AUTONOMOUS REVIEW: CLOSE FAILED for %s - adapter transition failed; issue left open.\n' "$ISSUE" >&2
|
|
301
|
-
exit 1
|
|
302
|
-
fi
|
|
303
|
-
|
|
304
|
-
COMMENT_ID=""
|
|
305
|
-
if ! COMMENT_ID="$(TICKET_PROVIDER="$PROV" bash -c '. "$1"; tp comment "$2" "$3"' _ "$SCRIPTS_DIR/lib/ticket-provider.sh" "$ISSUE" \
|
|
306
|
-
"Autonomously accepted by $REVIEWER under the independent adversarial-review protocol (drift: $DRIFT, gate: $GATE, grace ${GRACE_HOURS}h informational). Treated as done; review report: $REPORT." 2>/dev/null)"; then
|
|
307
|
-
printf 'AUTONOMOUS REVIEW: CLOSE INCOMPLETE for %s - transition succeeded, but acceptance comment failed; issue may already be completed.\n' "$ISSUE" >&2
|
|
308
|
-
exit 1
|
|
309
|
-
fi
|
|
310
|
-
if [[ ! "$COMMENT_ID" =~ [^[:space:]] ]]; then
|
|
311
|
-
printf 'AUTONOMOUS REVIEW: CLOSE INCOMPLETE for %s - transition succeeded, but acceptance comment returned no id; comment write unproven and issue may already be completed.\n' "$ISSUE" >&2
|
|
312
|
-
exit 1
|
|
313
|
-
fi
|
|
314
|
-
|
|
315
|
-
# Acceptance is an assertion about both required writes on this path, so no
|
|
316
|
-
# acceptance-shaped output may escape until transition and comment succeed.
|
|
317
|
-
printf 'AUTONOMOUS REVIEW: ACCEPTED - treat as done (no human wait) for %s (reviewer: %s | drift: %s | gate: %s)\n' \
|
|
318
|
-
"$ISSUE" "$REVIEWER" "$DRIFT" "$GATE"
|
|
319
|
-
printf 'Ticket %s transitioned to completed via adapter.\n' "$ISSUE"
|
|
320
|
-
else
|
|
321
|
-
# Accepted: the loop autonomously treats the ticket as done and leaves it in
|
|
322
|
-
# the review lane (deferred-QA queue). Record the autonomous acceptance via the
|
|
323
|
-
# adapter -- no approval request, no "waiting on the operator".
|
|
324
|
-
COMMENT_ID=""
|
|
325
|
-
if ! COMMENT_ID="$(TICKET_PROVIDER="$PROV" bash -c '. "$1"; tp comment "$2" "$3"' _ "$SCRIPTS_DIR/lib/ticket-provider.sh" "$ISSUE" \
|
|
326
|
-
"Autonomously accepted by $REVIEWER under the independent adversarial-review protocol (drift: $DRIFT, gate: $GATE, grace ${GRACE_HOURS}h informational). Treated as done; stays in the review lane (deferred-QA queue). Review report: $REPORT." 2>/dev/null)"; then
|
|
327
|
-
printf 'AUTONOMOUS REVIEW: COMMENT FAILED for %s - acceptance comment was not recorded; issue left in review.\n' "$ISSUE" >&2
|
|
328
|
-
exit 1
|
|
329
|
-
fi
|
|
330
|
-
if [[ ! "$COMMENT_ID" =~ [^[:space:]] ]]; then
|
|
331
|
-
printf 'AUTONOMOUS REVIEW: COMMENT UNPROVEN for %s - acceptance comment returned no id; issue left in review.\n' "$ISSUE" >&2
|
|
332
|
-
exit 1
|
|
333
|
-
fi
|
|
334
|
-
printf 'AUTONOMOUS REVIEW: ACCEPTED - treat as done (no human wait) for %s (reviewer: %s | drift: %s | gate: %s)\n' \
|
|
335
|
-
"$ISSUE" "$REVIEWER" "$DRIFT" "$GATE"
|
|
336
|
-
printf 'Accepted: ticket stays in the review lane (deferred-QA queue); the loop moves on.\n'
|
|
337
|
-
printf 'Optional operator QA sweep: re-run with --close to transition %s to completed via the adapter.\n' "$ISSUE"
|
|
338
|
-
fi
|
|
339
|
-
exit 0
|
|
@@ -1,179 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env sh
|
|
2
|
-
# Provider-agnostic close gate. Verifies an issue's evidence file is complete
|
|
3
|
-
# before any closure (manual or autonomous). Pure gate: it reports PASS/FAIL on
|
|
4
|
-
# stdout/stderr and via the exit code, and publishes nothing.
|
|
5
|
-
#
|
|
6
|
-
# Usage: issue-close-gate.sh ISSUE_ID [REPO_ROOT]
|
|
7
|
-
set -eu
|
|
8
|
-
|
|
9
|
-
if [ "${1:-}" = "" ]; then
|
|
10
|
-
printf 'Usage: %s ISSUE_ID [REPO_ROOT]\n' "$0" >&2
|
|
11
|
-
exit 2
|
|
12
|
-
fi
|
|
13
|
-
ISSUE="$1"
|
|
14
|
-
case "$ISSUE" in *[!A-Za-z0-9_-]*) printf 'Invalid issue id: %s\n' "$ISSUE" >&2; exit 2 ;; esac
|
|
15
|
-
|
|
16
|
-
BIN_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
|
|
17
|
-
ROLE_YAML="$BIN_DIR/../../../role.yaml"
|
|
18
|
-
ROLE_REPO="$(sed -n 's/^repo:[[:space:]]*//p' "$ROLE_YAML" 2>/dev/null | head -n1 | tr -d '"' | tr -d '\r')"
|
|
19
|
-
ROOT="${2:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
|
|
20
|
-
cd "$ROOT"
|
|
21
|
-
ROOT="$(pwd -P)"
|
|
22
|
-
PROJECT_MANIFEST="$ROOT/.project.json"
|
|
23
|
-
if [ -e "$PROJECT_MANIFEST" ] || [ -L "$PROJECT_MANIFEST" ]; then
|
|
24
|
-
[ -f "$PROJECT_MANIFEST" ] || {
|
|
25
|
-
printf 'Project manifest is not a regular file: %s\n' "$PROJECT_MANIFEST" >&2
|
|
26
|
-
exit 1
|
|
27
|
-
}
|
|
28
|
-
REPO_SLUG="$(python3 - "$PROJECT_MANIFEST" <<'PY'
|
|
29
|
-
import json
|
|
30
|
-
import pathlib
|
|
31
|
-
import sys
|
|
32
|
-
|
|
33
|
-
path = pathlib.Path(sys.argv[1])
|
|
34
|
-
try:
|
|
35
|
-
document = json.loads(path.read_text(encoding="utf-8"))
|
|
36
|
-
except (OSError, UnicodeError, json.JSONDecodeError) as exc:
|
|
37
|
-
raise SystemExit(f"close gate: malformed project manifest {path}: {exc}")
|
|
38
|
-
slug = document.get("project_slug") if isinstance(document, dict) else None
|
|
39
|
-
if not isinstance(slug, str) or not slug.strip():
|
|
40
|
-
raise SystemExit(f"close gate: project manifest {path} has no non-blank project_slug")
|
|
41
|
-
print(slug.strip())
|
|
42
|
-
PY
|
|
43
|
-
)" || exit 1
|
|
44
|
-
else
|
|
45
|
-
REPO_SLUG="$(basename "$ROOT")"
|
|
46
|
-
fi
|
|
47
|
-
if [ -n "$ROLE_REPO" ] && [ "$ROLE_REPO" != "$REPO_SLUG" ]; then
|
|
48
|
-
printf 'Installed role repo %s disagrees with target project slug %s.\n' "$ROLE_REPO" "$REPO_SLUG" >&2
|
|
49
|
-
exit 1
|
|
50
|
-
fi
|
|
51
|
-
|
|
52
|
-
FILE="_bmad-output/implementation-artifacts/issue-evidence/$ISSUE.md"
|
|
53
|
-
FAIL=0
|
|
54
|
-
|
|
55
|
-
if [ ! -f "$FILE" ]; then
|
|
56
|
-
printf 'Missing issue evidence file: %s\n' "$FILE" >&2
|
|
57
|
-
exit 1
|
|
58
|
-
fi
|
|
59
|
-
|
|
60
|
-
# Structural parse: every required H2 exactly once, and each authoritative
|
|
61
|
-
# field read only as a direct field of its own section. Lookalike lines in
|
|
62
|
-
# other sections can never stand in for -- or override -- the authoritative
|
|
63
|
-
# value (an authoritative `no`/`hold` always fails the gate). The implementer
|
|
64
|
-
# identity is defined as exactly one nonblank `Worker:` / `Implemented by:`
|
|
65
|
-
# field in the Issue section; duplicates, blanks, and conflicting spellings
|
|
66
|
-
# are rejected.
|
|
67
|
-
GATE_PROBLEMS=""
|
|
68
|
-
if ! GATE_PROBLEMS="$(python3 - "$FILE" <<'PY'
|
|
69
|
-
import re
|
|
70
|
-
import sys
|
|
71
|
-
|
|
72
|
-
with open(sys.argv[1], encoding="utf-8") as stream:
|
|
73
|
-
lines = stream.read().splitlines()
|
|
74
|
-
|
|
75
|
-
REQUIRED = [
|
|
76
|
-
"Issue",
|
|
77
|
-
"Acceptance Criteria",
|
|
78
|
-
"Repo Changes",
|
|
79
|
-
"Verification",
|
|
80
|
-
"Ledger Update",
|
|
81
|
-
"Known Gaps",
|
|
82
|
-
"Close Recommendation",
|
|
83
|
-
]
|
|
84
|
-
|
|
85
|
-
heading = re.compile(r"^(#{1,6})\s+(.*?)\s*$")
|
|
86
|
-
sections = {}
|
|
87
|
-
order = []
|
|
88
|
-
current = None
|
|
89
|
-
for line in lines:
|
|
90
|
-
match = heading.match(line)
|
|
91
|
-
if match and len(match.group(1)) <= 2:
|
|
92
|
-
current = match.group(2) if len(match.group(1)) == 2 else None
|
|
93
|
-
if current is not None:
|
|
94
|
-
sections.setdefault(current, [])
|
|
95
|
-
order.append(current)
|
|
96
|
-
continue
|
|
97
|
-
if current is not None:
|
|
98
|
-
sections[current].append(line)
|
|
99
|
-
|
|
100
|
-
problems = []
|
|
101
|
-
for name in REQUIRED:
|
|
102
|
-
count = order.count(name)
|
|
103
|
-
if count == 0:
|
|
104
|
-
problems.append(f"Missing required evidence: {name}")
|
|
105
|
-
elif count > 1:
|
|
106
|
-
problems.append(f"Duplicate required evidence section: {name}")
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def direct_fields(section, *names):
|
|
110
|
-
found = []
|
|
111
|
-
for line in sections.get(section, []):
|
|
112
|
-
for name in names:
|
|
113
|
-
match = re.fullmatch(
|
|
114
|
-
r"\s*(?:[-*+]\s+)?" + re.escape(name) + r"\s*:\s*(.*?)\s*", line
|
|
115
|
-
)
|
|
116
|
-
if match:
|
|
117
|
-
found.append(match.group(1))
|
|
118
|
-
break
|
|
119
|
-
return found
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
def first_token(value):
|
|
123
|
-
tokens = value.split()
|
|
124
|
-
return tokens[0].lower() if tokens else ""
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
if order.count("Ledger Update") == 1:
|
|
128
|
-
found = direct_fields("Ledger Update", "Ledger updated")
|
|
129
|
-
if len(found) != 1:
|
|
130
|
-
problems.append(
|
|
131
|
-
"Expected exactly one 'Ledger updated:' field in '## Ledger Update'"
|
|
132
|
-
f" (found {len(found)})."
|
|
133
|
-
)
|
|
134
|
-
elif first_token(found[0]) != "yes":
|
|
135
|
-
problems.append("Ledger update is not marked yes.")
|
|
136
|
-
|
|
137
|
-
if order.count("Close Recommendation") == 1:
|
|
138
|
-
found = direct_fields("Close Recommendation", "Close recommendation")
|
|
139
|
-
if len(found) != 1:
|
|
140
|
-
problems.append(
|
|
141
|
-
"Expected exactly one 'Close recommendation:' field in"
|
|
142
|
-
f" '## Close Recommendation' (found {len(found)})."
|
|
143
|
-
)
|
|
144
|
-
elif first_token(found[0]) != "ready":
|
|
145
|
-
problems.append("Close recommendation is not ready.")
|
|
146
|
-
|
|
147
|
-
if order.count("Issue") == 1:
|
|
148
|
-
found = direct_fields("Issue", "Worker", "Implemented by")
|
|
149
|
-
if len(found) != 1:
|
|
150
|
-
problems.append(
|
|
151
|
-
"Expected exactly one implementer identity ('Worker:' /"
|
|
152
|
-
f" 'Implemented by:') in '## Issue' (found {len(found)})."
|
|
153
|
-
)
|
|
154
|
-
elif not found[0]:
|
|
155
|
-
problems.append("The implementer identity in '## Issue' is blank.")
|
|
156
|
-
|
|
157
|
-
for problem in problems:
|
|
158
|
-
print(problem)
|
|
159
|
-
PY
|
|
160
|
-
)"; then
|
|
161
|
-
printf 'Evidence file could not be parsed: %s\n' "$FILE" >&2
|
|
162
|
-
exit 1
|
|
163
|
-
fi
|
|
164
|
-
if [ -n "$GATE_PROBLEMS" ]; then
|
|
165
|
-
printf '%s\n' "$GATE_PROBLEMS" >&2
|
|
166
|
-
FAIL=1
|
|
167
|
-
fi
|
|
168
|
-
|
|
169
|
-
if grep -Eiq 'TBD|TODO|not run|pending|unknown' "$FILE"; then
|
|
170
|
-
printf 'Evidence file still contains unresolved placeholders or unverified work.\n' >&2
|
|
171
|
-
FAIL=1
|
|
172
|
-
fi
|
|
173
|
-
|
|
174
|
-
if [ "$FAIL" -ne 0 ]; then
|
|
175
|
-
printf '\nCLOSE GATE: FAIL for %s\n' "$ISSUE" >&2
|
|
176
|
-
exit 1
|
|
177
|
-
fi
|
|
178
|
-
|
|
179
|
-
printf 'CLOSE GATE: PASS for %s (repo: %s)\n' "$ISSUE" "$REPO_SLUG"
|