@delorenj/pjangler 1.4.2 → 1.4.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +528 -0
  2. package/contracts/fleet-contract.yaml +513 -0
  3. package/dist/index.js +9333 -1509
  4. package/dist/mcp-server.js +6634 -1096
  5. package/dist/prompt.js +2 -1
  6. package/package.json +10 -4
  7. package/templates/hermes-agent/copier.yml +16 -3
  8. package/templates/hermes-agent/template/.runtime-scaffold/memories/MEMORY.md +7 -4
  9. package/templates/hermes-agent/template/.scripts/10-hermes-profile.sh +88 -94
  10. package/templates/hermes-agent/template/.scripts/20-runtime-repo.sh +44 -21
  11. package/templates/hermes-agent/template/.scripts/30-telegram.sh +182 -171
  12. package/templates/hermes-agent/template/.scripts/31-slack.sh +260 -165
  13. package/templates/hermes-agent/template/.scripts/40-plane.sh +45 -36
  14. package/templates/hermes-agent/template/.scripts/42-ticket-provider.sh +210 -41
  15. package/templates/hermes-agent/template/.scripts/70-systemd.sh +129 -15
  16. package/templates/hermes-agent/template/.scripts/80-registry.sh +42 -6
  17. package/templates/hermes-agent/template/.scripts/99-summary.sh +69 -16
  18. package/templates/hermes-agent/template/.scripts/_lib.sh +773 -0
  19. package/templates/hermes-agent/template/.scripts/channel-transaction.py +2340 -0
  20. package/templates/hermes-agent/template/.scripts/config.example.toml +8 -2
  21. package/templates/hermes-agent/template/.scripts/credential-launch.sh +5 -1
  22. package/templates/hermes-agent/template/.scripts/heartbeat.sh +2 -3
  23. package/templates/hermes-agent/template/.scripts/lib/profile-config-lock.py +182 -0
  24. package/templates/hermes-agent/template/.scripts/lib/profile-config-seed.py +108 -0
  25. package/templates/hermes-agent/template/.scripts/lib/ticket-provider.sh +93 -4
  26. package/templates/hermes-agent/template/.scripts/lib/voice-config.py +546 -0
  27. package/templates/hermes-agent/template/.scripts/providers/linear.sh +138 -25
  28. package/templates/hermes-agent/template/.scripts/providers/plane.sh +408 -51
  29. package/templates/hermes-agent/template/.scripts/providers/trello.sh +54 -6
  30. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-autonomous-review.sh +257 -43
  31. package/templates/hermes-agent/template/.scripts/sentinel/bin/issue-close-gate.sh +142 -25
  32. package/templates/hermes-agent/template/.scripts/sentinel/docs/autonomous-delegated-review.md +13 -19
  33. package/templates/hermes-agent/template/.scripts/sentinel/docs/bloodbank-events.md +29 -36
  34. package/templates/hermes-agent/template/.scripts/sentinel/docs/continuous-ticket-orchestration.md +3 -1
  35. package/templates/hermes-agent/template/.scripts/sentinel.prompt.md.jinja +7 -8
  36. package/templates/hermes-agent/template/.scripts/store-onepassword-secret.py +260 -0
  37. package/templates/hermes-agent/template/SOUL.md.jinja +14 -16
  38. package/templates/hermes-agent/template/hermes.jinja +1 -1
  39. package/templates/hermes-agent/template/role.yaml.jinja +15 -4
@@ -81,8 +81,14 @@ list_id_for() {
81
81
  [ -n "$BOARD" ] || die "ticket_provider.board not set"
82
82
  want="$(list_name_for "$1")"
83
83
  api GET "boards/$BOARD/lists" | NM="$want" python3 -c 'import sys,json,os
84
- rows=json.load(sys.stdin); nm=os.environ["NM"].lower()
85
- print(next((l["id"] for l in rows if l.get("name","").lower()==nm), ""))'
84
+ rows=json.load(sys.stdin); nm=os.environ["NM"].strip().casefold()
85
+ matches=[row for row in rows if str(row.get("name") or "").strip().casefold()==nm]
86
+ if len(matches) != 1:
87
+ raise SystemExit("trello: exact list name %r resolved %d lists; exactly one is required" % (os.environ["NM"], len(matches)))
88
+ list_id=str(matches[0].get("id") or "")
89
+ if not list_id:
90
+ raise SystemExit("trello: resolved list omitted its id")
91
+ print(list_id)'
86
92
  }
87
93
 
88
94
  # All Trello ops require credentials; fail fast and clean before any pipe.
@@ -134,14 +140,52 @@ print(json.dumps({"id":c.get("id",""),"key":c.get("id",""),"title":c.get("name",
134
140
  comment)
135
141
  ID="${1:?usage: comment <id> <body>}"; BODY="${2:?}"
136
142
  api POST "cards/$ID/actions/comments" "text=$(python3 -c 'import urllib.parse,sys; print(urllib.parse.quote(sys.argv[1]))' "$BODY")" \
137
- | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))'
143
+ | EXPECTED_ID="$ID" python3 -c 'import sys,json,os
144
+ action=json.load(sys.stdin)
145
+ if not isinstance(action,dict):
146
+ raise SystemExit("trello: comment response was not an action object")
147
+ action_id=action.get("id")
148
+ if not isinstance(action_id,str) or not action_id.strip():
149
+ raise SystemExit("trello: comment response omitted its action id")
150
+ data=action.get("data")
151
+ card=data.get("card") if isinstance(data,dict) else None
152
+ if not isinstance(card,dict) or str(card.get("id") or "").strip()!=os.environ["EXPECTED_ID"]:
153
+ raise SystemExit("trello: comment response did not identify the requested card")
154
+ print(action_id.strip())'
138
155
  ;;
139
156
 
140
157
  transition)
141
158
  ID="${1:?usage: transition <id> <normalized-state>}"; TARGET="${2:?}"
142
159
  LID="$(list_id_for "$TARGET")"
143
160
  [ -n "$LID" ] || die "no Trello list mapped for normalized '$TARGET' (check state_map)"
144
- api PUT "cards/$ID" "idList=$LID" | python3 -c 'import sys,json; c=json.load(sys.stdin); print("ok "+c.get("id",""))'
161
+ api PUT "cards/$ID" "idList=$LID" >/dev/null
162
+ api GET "cards/$ID" "fields=id,idList" \
163
+ | EXPECTED_ID="$ID" EXPECTED_LIST_ID="$LID" python3 -c 'import sys,json,os
164
+ card=json.load(sys.stdin)
165
+ if str(card.get("id") or "") != os.environ["EXPECTED_ID"]:
166
+ raise SystemExit("trello: transition read-back did not identify the requested card")
167
+ if str(card.get("idList") or "") != os.environ["EXPECTED_LIST_ID"]:
168
+ raise SystemExit("trello: transition read-back did not confirm the exact target list")
169
+ print("ok " + str(card.get("id") or ""))'
170
+ ;;
171
+
172
+ describe_board)
173
+ # Read-only board lookup against an EXPLICIT workspace argument, mirroring
174
+ # the Plane op, so no ambient binding can send the query somewhere else.
175
+ # Trello mints NO project key, so `identifier` is ALWAYS empty here: an
176
+ # empty identifier is this provider's authoritative answer, not a lookup
177
+ # failure, and callers must never promote it to a confirmed value.
178
+ DWS="${1:?usage: describe_board <workspace> <board_id>}"
179
+ DBID="${2:?usage: describe_board <workspace> <board_id>}"
180
+ api GET "boards/$DBID" "fields=id,name,idOrganization" \
181
+ | WS="$DWS" BID="$DBID" python3 -c 'import sys, json, os
182
+ b = json.load(sys.stdin)
183
+ print(json.dumps({
184
+ "board_id": str(b.get("id") or os.environ["BID"]),
185
+ "identifier": "",
186
+ "workspace": str(b.get("idOrganization") or os.environ["WS"]),
187
+ "name": str(b.get("name") or ""),
188
+ }))'
145
189
  ;;
146
190
 
147
191
  create_board)
@@ -154,8 +198,12 @@ print(next((b["id"] for b in rows if b.get("name","").lower()==nm), ""))')"
154
198
  | python3 -c 'import sys,json; print(json.load(sys.stdin).get("id",""))')"
155
199
  fi
156
200
  [ -n "$BID" ] || die "create_board failed"
157
- api GET "boards/$BID" "fields=url" | BID="$BID" python3 -c 'import sys,json,os
158
- b=json.load(sys.stdin); print(json.dumps({"board_id":os.environ["BID"],"board_url":b.get("url","")}))'
201
+ # Trello assigns no project key, so the caller's prefix IS the identity and
202
+ # this adapter is the only thing that can confirm it. Echo it back: the
203
+ # create_board envelope always carries the identifier the board is bound
204
+ # under, so no caller ever has to invent one.
205
+ api GET "boards/$BID" "fields=url" | BID="$BID" IDENT="${2:-}" python3 -c 'import sys,json,os
206
+ b=json.load(sys.stdin); print(json.dumps({"board_id":os.environ["BID"],"board_url":b.get("url",""),"identifier":os.environ.get("IDENT","")}))'
159
207
  ;;
160
208
 
161
209
  create_issue)
@@ -2,8 +2,8 @@
2
2
  # Provider-agnostic autonomous adversarial-review decision gate (act, don't wait).
3
3
  #
4
4
  # The rigorous, INDEPENDENT ADVERSARIAL review is the normal per-pass path: an
5
- # adversarial microscope that couples the close gate, an independent-reviewer
6
- # drift attestation, and the BloodBank decision event. A clean adversarial
5
+ # adversarial microscope that couples the close gate with an independent-reviewer
6
+ # drift attestation. A clean adversarial
7
7
  # verdict is acted on AUTONOMOUSLY -- the loop treats the ticket as done and
8
8
  # moves on; it never parks the ticket waiting on the operator for approval or
9
9
  # sign-off. Every adversarial check stays at full strength (locked-intent
@@ -40,44 +40,246 @@ ROLE_YAML="$ROLE_DIR/role.yaml"
40
40
  ROOT="$(git -C "$ROLE_DIR" rev-parse --show-toplevel 2>/dev/null || pwd)"
41
41
  cd "$ROOT"
42
42
 
43
- EMIT="$BIN_DIR/emit-event.py"
44
43
  CLOSE_GATE="$BIN_DIR/issue-close-gate.sh"
45
44
  EVIDENCE="_bmad-output/implementation-artifacts/issue-evidence/$ISSUE.md"
46
45
 
47
- yget() { sed -n "s/^[[:space:]]*$1:[[:space:]]*//p" "$ROLE_YAML" 2>/dev/null | head -n1 | tr -d '"' | tr -d '\r'; }
48
- REPO="$(yget repo)"; REPO="${REPO:-unknown}"
49
- # Informational only: recorded in the decision event/comment, never a blocking
50
- # wait. Default 0 (no grace); an operator may set grace_hours>0 to reintroduce one.
51
- GRACE_HOURS="${RECONCILE_GRACE_HOURS:-$(yget grace_hours)}"; GRACE_HOURS="${GRACE_HOURS:-0}"
52
- AUTO="$(yget auto_review)"; AUTO="${AUTO:-true}"
53
- EVT="bloodbank.v1.repo.$REPO.issue.autonomous_review.decided"
46
+ role_mapping_value() {
47
+ python3 - "$ROLE_YAML" "$1" "$2" <<'PY'
48
+ import pathlib
49
+ import re
50
+ import sys
54
51
 
55
- if [[ "${RECONCILE_AUTO_REVIEW:-$AUTO}" == "false" || "${RECONCILE_AUTO_REVIEW:-}" == "off" ]]; then
56
- printf 'Autonomous review is disabled (reconcile.auto_review=false).\n' >&2; exit 3
57
- fi
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
58
124
  [[ -f "$REPORT" ]] || { printf 'Missing review report file: %s\n' "$REPORT" >&2; exit 2; }
59
125
  [[ -f "$EVIDENCE" ]] || { printf 'Missing issue evidence file: %s\n' "$EVIDENCE" >&2; exit 2; }
60
126
 
61
127
  HOLD=""
62
128
  hold() { HOLD="${HOLD}${HOLD:+; }$1"; }
63
129
 
64
- for s in '^## Reviewer' '^## Locked Intent Baseline' '^## Drift Assessment' '^## Adversarial Findings' '^## Decision'; do
65
- grep -q "$s" "$REPORT" || hold "report missing section ${s#^## }"
66
- done
67
- grep -qi '^- *Independent of implementer: *yes' "$REPORT" || hold "reviewer did not attest independence"
68
- REVIEWER="$(sed -n 's/^- *Reviewer agent: *//p' "$REPORT" | head -n1 | tr -d '\r')"; REVIEWER="${REVIEWER:-unknown}"
69
- IMPL="$(sed -n 's/^- *\(Worker\|Implemented by\): *//p' "$EVIDENCE" | head -n1 | tr -d '\r')"
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"
70
274
  [[ -n "$IMPL" && "$IMPL" == "$REVIEWER" ]] && hold "reviewer ($REVIEWER) is the implementer"
71
275
 
72
- DRIFT="$(sed -n 's/^- *Drift assessment: *//p' "$REPORT" | head -n1 | tr -d '\r' | tr 'A-Z' 'a-z' | awk '{print $1}')"
73
276
  case "$DRIFT" in
74
277
  none|minor) : ;;
75
278
  significant) hold "significant drift from locked intent" ;;
76
279
  *) hold "drift assessment missing/invalid ('${DRIFT:-none-found}')"; DRIFT="${DRIFT:-unknown}" ;;
77
280
  esac
78
- grep -qi '^- *Critical/high findings: *none' "$REPORT" || hold "unresolved critical/high findings (or line missing)"
281
+ [[ "$FINDINGS" == "none" ]] || hold "unresolved critical/high findings (or field missing)"
79
282
  # Accept the keyword `accept` as clearing; tolerate the legacy `close`.
80
- DEC="$(sed -n 's/^- *Decision: *//p' "$REPORT" | head -n1 | tr -d '\r' | tr 'A-Z' 'a-z' | awk '{print $1}')"
81
283
  [[ "$DEC" == "accept" || "$DEC" == "close" ]] || hold "reviewer decision is not 'accept' (got '${DEC:-none}')"
82
284
 
83
285
  GATE=fail
@@ -85,40 +287,52 @@ if sh "$CLOSE_GATE" "$ISSUE" "$ROOT" >/dev/null 2>&1 </dev/null; then GATE=pass;
85
287
 
86
288
  if [[ -n "$HOLD" ]]; then DECISION=held; else DECISION=accepted; fi
87
289
 
88
- python3 "$EMIT" "$EVT" --root "$ROOT" \
89
- --source "repo://sentinel/bin/issue-autonomous-review.sh" --actor-id "$REVIEWER" \
90
- --field issue="$ISSUE" --field decision="$DECISION" --field drift="$DRIFT" \
91
- --field close_gate="$GATE" --field reviewer_agent="$REVIEWER" \
92
- --field evidence_file="$EVIDENCE" --field report_file="$REPORT" \
93
- --field grace_hours="$GRACE_HOURS" --field hold_reasons="${HOLD:-none}" \
94
- --quiet </dev/null || printf 'WARN: decision event emission failed; event trail incomplete.\n' >&2
95
-
96
290
  if [[ "$DECISION" == "held" ]]; then
97
291
  printf 'AUTONOMOUS REVIEW: HOLD for %s\nReasons: %s\n' "$ISSUE" "$HOLD" >&2
98
292
  exit 3
99
293
  fi
100
294
 
101
- printf 'AUTONOMOUS REVIEW: ACCEPTED - treat as done (no human wait) for %s (reviewer: %s | drift: %s | gate: %s)\n' \
102
- "$ISSUE" "$REVIEWER" "$DRIFT" "$GATE"
103
-
295
+ PROV="$(role_mapping_value ticket_provider name)"; PROV="${PROV:-}"
104
296
  if [[ "$CLOSE" -eq 1 ]]; then
105
297
  # Optional operator QA sweep: close through the ticket-provider adapter.
106
- PROV="$(yget name)"; PROV="${PROV:-}"
107
- if TICKET_PROVIDER="$PROV" bash -c '. "$1"; tp transition "$2" completed' _ "$SCRIPTS_DIR/lib/ticket-provider.sh" "$ISSUE"; then
108
- printf 'Ticket %s transitioned to completed via adapter.\n' "$ISSUE"
109
- TICKET_PROVIDER="$PROV" bash -c '. "$1"; tp comment "$2" "$3"' _ "$SCRIPTS_DIR/lib/ticket-provider.sh" "$ISSUE" \
110
- "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." >/dev/null 2>&1 || true
111
- else
112
- printf 'Adapter transition failed; decision event recorded, issue left open.\n' >&2
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
113
308
  exit 1
114
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"
115
320
  else
116
321
  # Accepted: the loop autonomously treats the ticket as done and leaves it in
117
322
  # the review lane (deferred-QA queue). Record the autonomous acceptance via the
118
323
  # adapter -- no approval request, no "waiting on the operator".
119
- PROV="$(yget name)"; PROV="${PROV:-}"
120
- TICKET_PROVIDER="$PROV" bash -c '. "$1"; tp comment "$2" "$3"' _ "$SCRIPTS_DIR/lib/ticket-provider.sh" "$ISSUE" \
121
- "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." >/dev/null 2>&1 || true
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"
122
336
  printf 'Accepted: ticket stays in the review lane (deferred-QA queue); the loop moves on.\n'
123
337
  printf 'Optional operator QA sweep: re-run with --close to transition %s to completed via the adapter.\n' "$ISSUE"
124
338
  fi
@@ -1,7 +1,7 @@
1
1
  #!/usr/bin/env sh
2
2
  # Provider-agnostic close gate. Verifies an issue's evidence file is complete
3
- # before any closure (manual or autonomous). Repo name is read from role.yaml so
4
- # event types carry the right repo lane.
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
5
  #
6
6
  # Usage: issue-close-gate.sh ISSUE_ID [REPO_ROOT]
7
7
  set -eu
@@ -14,49 +14,166 @@ ISSUE="$1"
14
14
  case "$ISSUE" in *[!A-Za-z0-9_-]*) printf 'Invalid issue id: %s\n' "$ISSUE" >&2; exit 2 ;; esac
15
15
 
16
16
  BIN_DIR="$(CDPATH= cd -- "$(dirname -- "$0")" && pwd)"
17
- ROLE_DIR="$(cd "$BIN_DIR/../../.." && pwd)"
18
- ROLE_YAML="$ROLE_DIR/role.yaml"
19
- EMIT="$BIN_DIR/emit-event.py"
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')"
20
19
  ROOT="${2:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"
21
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
22
32
 
23
- REPO="$(sed -n 's/^repo:[[:space:]]*//p' "$ROLE_YAML" 2>/dev/null | head -n1 | tr -d '"' | tr -d '\r')"
24
- REPO="${REPO:-unknown}"
25
- EVT_PREFIX="bloodbank.v1.repo.$REPO.issue"
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
26
51
 
27
52
  FILE="_bmad-output/implementation-artifacts/issue-evidence/$ISSUE.md"
28
53
  FAIL=0
29
- check() { grep -q "$1" "$FILE" || { printf 'Missing required evidence: %s\n' "$2" >&2; FAIL=1; }; }
30
54
 
31
55
  if [ ! -f "$FILE" ]; then
32
56
  printf 'Missing issue evidence file: %s\n' "$FILE" >&2
33
57
  exit 1
34
58
  fi
35
59
 
36
- check '^## Issue' 'Issue'
37
- check '^## Acceptance Criteria' 'Acceptance Criteria'
38
- check '^## Repo Changes' 'Repo Changes'
39
- check '^## Verification' 'Verification'
40
- check '^## Ledger Update' 'Ledger Update'
41
- check '^## Known Gaps' 'Known Gaps'
42
- check '^## Close Recommendation' 'Close Recommendation'
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
43
168
 
44
169
  if grep -Eiq 'TBD|TODO|not run|pending|unknown' "$FILE"; then
45
170
  printf 'Evidence file still contains unresolved placeholders or unverified work.\n' >&2
46
171
  FAIL=1
47
172
  fi
48
- grep -q 'Ledger updated: yes' "$FILE" || { printf 'Ledger update is not marked yes.\n' >&2; FAIL=1; }
49
- grep -q 'Close recommendation: ready' "$FILE" || { printf 'Close recommendation is not ready.\n' >&2; FAIL=1; }
50
173
 
51
174
  if [ "$FAIL" -ne 0 ]; then
52
- python3 "$EMIT" "$EVT_PREFIX.gate.failed" --root "$ROOT" \
53
- --source "repo://sentinel/bin/issue-close-gate.sh" \
54
- --field issue="$ISSUE" --field evidence_file="$FILE" --quiet </dev/null || true
55
175
  printf '\nCLOSE GATE: FAIL for %s\n' "$ISSUE" >&2
56
176
  exit 1
57
177
  fi
58
178
 
59
- python3 "$EMIT" "$EVT_PREFIX.gate.passed" --root "$ROOT" \
60
- --source "repo://sentinel/bin/issue-close-gate.sh" \
61
- --field issue="$ISSUE" --field evidence_file="$FILE" --quiet </dev/null || true
62
- printf 'CLOSE GATE: PASS for %s\n' "$ISSUE"
179
+ printf 'CLOSE GATE: PASS for %s (repo: %s)\n' "$ISSUE" "$REPO_SLUG"