@ccoalm/ccl-skills 0.15.0 → 0.15.1

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 (32) hide show
  1. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/ccl-skills.ts +80 -4
  2. package/dist/assets/marketplace/plugins/ccl-skills/packages/opencode-plugin/commands/ccl-install-skills.md +16 -4
  3. package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/owner-dispatch.sh +13 -2
  4. package/dist/assets/marketplace/plugins/ccl-skills/scripts/owner-dispatch/test.sh +53 -0
  5. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/review_gate.py +7 -1
  6. package/dist/assets/marketplace/plugins/ccl-skills/skills/code-review/scripts/test_review_gate.sh +65 -0
  7. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/SKILL.md +4 -4
  8. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-observability/references/alerting-and-on-call.md +8 -0
  9. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/SKILL.md +14 -14
  10. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/dual-sidecar-and-traffic-config-center.md +1 -1
  11. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/grpc-authority-workaround.md +40 -83
  12. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/mesh-architecture.md +2 -2
  13. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/retry-timeout-circuit-breaker.md +44 -37
  14. package/dist/assets/marketplace/plugins/ccl-skills/skills/platform-service-connectivity/references/service-discovery-recipe.md +1 -1
  15. package/dist/assets/marketplace/plugins/ccl-skills/skills/product-rd-workflow/references/delivery-lifecycle.md +1 -1
  16. package/dist/assets/marketplace/plugins/ccl-skills/skills/requirement-scope/SKILL.md +8 -5
  17. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/external-practice-controls.md +3 -3
  18. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/references/source-register.md +13 -0
  19. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/eval-golden-trace.rb +31 -7
  20. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/impact-chain-gate.rb +74 -3
  21. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/skill-behavior-eval.py +103 -21
  22. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_check_ccl_impact_chain_refscripts.sh +74 -1
  23. package/dist/assets/marketplace/plugins/ccl-skills/skills/skill-extraction-workflow/scripts/test_eval_runtime.py +428 -0
  24. package/dist/assets/release.json +45 -30
  25. package/dist/claude-adapter.js +14 -7
  26. package/dist/codex-host.d.ts +1 -3
  27. package/dist/codex-host.js +6 -9
  28. package/dist/host-probe.d.ts +11 -0
  29. package/dist/host-probe.js +27 -0
  30. package/dist/opencode-adapter.js +24 -19
  31. package/dist/unified.js +11 -9
  32. package/package.json +1 -1
@@ -6,6 +6,7 @@ import {
6
6
  mkdirSync,
7
7
  mkdtempSync,
8
8
  readFileSync,
9
+ readdirSync,
9
10
  realpathSync,
10
11
  rmSync,
11
12
  statSync,
@@ -337,6 +338,7 @@ export const CclSkills = async (context: {
337
338
  const hooksRoot = runtimeRoot()
338
339
  const parentSessions = new Map<string, string>()
339
340
  const idleInFlight = new Set<string>()
341
+ const pendingSkills = new Map<string, string>()
340
342
  let stateRoot: string | null = null
341
343
 
342
344
  function ensureStateRoot() {
@@ -390,9 +392,67 @@ export const CclSkills = async (context: {
390
392
  return typeof command === "string" && /\b(?:git\s+(?:push|merge)|gh\b[^\n;&|]*\bpr\s+merge|glab\b[^\n;&|]*\bmr\s+(?:merge|accept)|curl|wget)\b/i.test(command)
391
393
  }
392
394
 
393
- function safeTranscriptInput(tool: string, args: Record<string, unknown>) {
394
- if (tool === "skill") return typeof args.skill === "string" ? { skill: args.skill } : {}
395
- return {}
395
+ function sameSkillDirectory(loaded: string, active: string) {
396
+ if (loaded === realpathSync(active)) return true
397
+ const pending = [[loaded, active]]
398
+ while (pending.length) {
399
+ const [copy, owner] = pending.pop()!
400
+ const copyStat = lstatSync(copy), ownerStat = lstatSync(owner)
401
+ if (copyStat.isSymbolicLink() || ownerStat.isSymbolicLink()) return false
402
+ if (copyStat.isDirectory() && ownerStat.isDirectory()) {
403
+ const copyNames = readdirSync(copy).sort(), ownerNames = readdirSync(owner).sort()
404
+ if (copyNames.length !== ownerNames.length || copyNames.some((name, index) => name !== ownerNames[index])) return false
405
+ for (const name of ownerNames) pending.push([join(copy, name), join(owner, name)])
406
+ } else if (!copyStat.isFile() || !ownerStat.isFile() || copyStat.size !== ownerStat.size || !readFileSync(copy).equals(readFileSync(owner))) return false
407
+ }
408
+ return true
409
+ }
410
+
411
+ function completedSkill(name: string | undefined, metadata: unknown) {
412
+ if (!name || !hooksRoot || !metadata || typeof metadata !== "object") return null
413
+ const result = metadata as { name?: unknown; dir?: unknown }
414
+ if (result.name !== name || typeof result.dir !== "string") return null
415
+ // The native tool reports the loaded source directory. A same-named skill
416
+ // elsewhere must never be promoted to a CCL owner by its caller's argument.
417
+ try {
418
+ const loaded = realpathSync(result.dir)
419
+ const entry = join(loaded, "SKILL.md")
420
+ if (!lstatSync(entry).isFile() || lstatSync(entry).isSymbolicLink()) return null
421
+ const content = readFileSync(entry, "utf8")
422
+ if (!content.startsWith(`---\nname: ${name}\n`)) return null
423
+ const activeRoot = existsSync(join(hooksRoot, "skills")) ? hooksRoot : resolve(hooksRoot, "../..")
424
+ // Bind the full skill to the running runtime: progressive disclosure loads
425
+ // references/scripts relative to this directory. Active source/project edits
426
+ // remain valid; inactive copies must match current files, without a stale cache.
427
+ const activeSkill = join(activeRoot, "skills", name)
428
+ const activeEntry = join(activeSkill, "SKILL.md")
429
+ if (!existsSync(activeEntry) || !lstatSync(activeEntry).isFile() || lstatSync(activeEntry).isSymbolicLink() || !sameSkillDirectory(loaded, activeSkill)) return null
430
+ const nativeRoots = [activeRoot, join(HOST_HOME, ".config/opencode"), join(directory, ".opencode"), join(context.worktree ?? directory, ".opencode")]
431
+ for (const root of nativeRoots) {
432
+ const expected = join(root, "skills", name)
433
+ if (existsSync(expected) && realpathSync(expected) === loaded && (root === activeRoot || existsSync(join(root, "ccl-skills/runtime/hooks/hooks.json")))) return `ccl-skills:${name}`
434
+ }
435
+ // Explicit skills.paths can select a CCL source checkout while a global
436
+ // adapter supplies the runtime. Require both its layout and the active bytes.
437
+ const sourceRoot = resolve(loaded, "../..")
438
+ const manifestPath = join(sourceRoot, ".claude-plugin/plugin.json")
439
+ if (loaded === join(sourceRoot, "skills", name) && existsSync(manifestPath)) {
440
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf8"))
441
+ if (manifest?.name === "ccl-skills" && manifest?.skills === "./skills/") return `ccl-skills:${name}`
442
+ }
443
+ // The source installer also supports ~/.agents/skills. Require its existing
444
+ // receipt and the current native skill's bytes, not just a same-named file.
445
+ const compatibility = join(HOST_HOME, ".agents/skills", name)
446
+ const native = join(HOST_HOME, ".config/opencode/skills", name, "SKILL.md")
447
+ const receipt = join(HOST_HOME, ".config/opencode/ccl-skills/install-manifest.json")
448
+ if (existsSync(compatibility) && realpathSync(compatibility) === loaded && existsSync(receipt) && existsSync(native)) {
449
+ const manifest = JSON.parse(readFileSync(receipt, "utf8"))
450
+ if (manifest?.installer === "scripts/install-opencode.sh" && manifest?.install_mode === "global" && readFileSync(native, "utf8") === content) return `ccl-skills:${name}`
451
+ }
452
+ return null
453
+ } catch {
454
+ return null
455
+ }
396
456
  }
397
457
 
398
458
  async function resumeForStop(sessionID: string, reasons: string[]) {
@@ -446,6 +506,9 @@ export const CclSkills = async (context: {
446
506
  output.args = args
447
507
  const targets = editPaths(tool, args, directory)
448
508
  const toolName = claudeToolName(tool)
509
+ if (tool === "skill" && typeof args.name === "string" && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(args.name) && args.name.length <= 64) {
510
+ pendingSkills.set(`${sessionID}\0${callID}`, args.name)
511
+ }
449
512
  if (targets.length) {
450
513
  targets.forEach((filePath, index) => appendTranscript(sessionID, {
451
514
  type: "assistant",
@@ -454,7 +517,7 @@ export const CclSkills = async (context: {
454
517
  } else {
455
518
  appendTranscript(sessionID, {
456
519
  type: "assistant",
457
- message: { content: [{ type: "tool_use", id: callID, name: toolName, input: safeTranscriptInput(tool, args) }] },
520
+ message: { content: [{ type: "tool_use", id: callID, name: toolName, input: {} }] },
458
521
  })
459
522
  }
460
523
 
@@ -507,6 +570,15 @@ export const CclSkills = async (context: {
507
570
  const tool = input.tool.toLowerCase()
508
571
  const sessionID = input.sessionID ?? `pid-${process.ppid}`
509
572
  const callID = input.callID ?? "unknown-call"
573
+ if (tool === "skill") {
574
+ const key = `${sessionID}\0${callID}`
575
+ const owner = completedSkill(pendingSkills.get(key), output.metadata)
576
+ pendingSkills.delete(key)
577
+ if (owner) appendTranscript(sessionID, {
578
+ type: "assistant",
579
+ message: { content: [{ type: "tool_use", id: callID, name: "Skill", input: { skill: owner } }] },
580
+ })
581
+ }
510
582
  appendTranscript(sessionID, {
511
583
  type: "user",
512
584
  message: { content: [{ type: "tool_result", tool_use_id: callID, content: "" }] },
@@ -536,6 +608,9 @@ export const CclSkills = async (context: {
536
608
  ? (properties.info as { id: string }).id
537
609
  : ""
538
610
  if (!sessionID) return
611
+ if (event.type === "session.deleted" || event.type === "session.idle" || (properties.status as { type?: string } | undefined)?.type === "idle") {
612
+ for (const key of pendingSkills.keys()) if (key.startsWith(`${sessionID}\0`)) pendingSkills.delete(key)
613
+ }
539
614
  if (event.type === "session.deleted") {
540
615
  const path = transcriptPath(sessionID)
541
616
  if (path) rmSync(path, { force: true })
@@ -562,6 +637,7 @@ export const CclSkills = async (context: {
562
637
  },
563
638
 
564
639
  dispose: async () => {
640
+ pendingSkills.clear()
565
641
  if (stateRoot) rmSync(stateRoot, { recursive: true, force: true })
566
642
  stateRoot = null
567
643
  },
@@ -3,12 +3,24 @@ description: Install or refresh CCL skills for OpenCode
3
3
  argument-hint: "[--project]"
4
4
  ---
5
5
 
6
- Run the CCL OpenCode installer from the repository root.
6
+ Install or reapply the CCL assets using the current installation mode. Check the existing CCL installation record and available entrypoint before running a command.
7
7
 
8
- Use `--project` when the user wants project-local `.opencode/skills`, `.opencode/commands`, and `.opencode/plugins` copied into this repository.
8
+ For an npm installation, reapply the invoked package's OpenCode assets:
9
9
 
10
10
  ```bash
11
- bash scripts/install-opencode.sh $ARGUMENTS
11
+ ccl-skills install --host opencode
12
12
  ```
13
13
 
14
- After installation, tell the user to restart OpenCode or open a new session.
14
+ For a newer package version, follow `/ccl-update-skills`. The npm package does not include the source installer or support `--project`; do not forward that flag to the npm CLI.
15
+
16
+ For a source checkout, locate the actual CCL repository and verify its installer exists. By default, from that checkout run:
17
+
18
+ ```bash
19
+ bash scripts/install-opencode.sh --no-agent $ARGUMENTS
20
+ ```
21
+
22
+ Use `--project` only for source mode when the user wants `.opencode/skills`, `.opencode/commands`, and `.opencode/plugins` copied into that checkout. Do not run a relative source-installer path in an unrelated project.
23
+
24
+ For a global source installation, omit `--no-agent` when the user also needs `~/.agents/skills` for another tool. To sync only that compatibility path, run `bash scripts/install-opencode.sh --only-agent`; this mode does not install OpenCode assets and cannot be combined with `--project` or `--no-agent`.
25
+
26
+ After OpenCode assets change, restart OpenCode or open a new session. After a compatibility-only sync, restart the tool that reads `~/.agents/skills`.
@@ -1059,7 +1059,15 @@ def main(argv):
1059
1059
  head_enabled = enabled(head_cfg)
1060
1060
  base_enabled = enabled(base_cfg)
1061
1061
 
1062
- diff = run_git(["diff", "-z", "--name-only", base, "HEAD"], cwd=repo, text=False)
1062
+ # --no-renames so a file moved OUT of a gated directory is still seen at its old
1063
+ # (gated) path; rename detection would only report the exempt destination.
1064
+ # --ignore-submodules=none so repository config (diff.ignoreSubmodules=all) cannot
1065
+ # suppress a gitlink move out of the gated tree, which would leave only .gitmodules.
1066
+ diff = run_git(
1067
+ ["diff", "-z", "--name-only", "--no-renames", "--ignore-submodules=none", base, "HEAD"],
1068
+ cwd=repo,
1069
+ text=False,
1070
+ )
1063
1071
  if diff.returncode != 0:
1064
1072
  return fail("owner-dispatch ci: diff failed — FAIL-CLOSED (2)")
1065
1073
  changed = [p.decode("utf-8", "surrogateescape") for p in diff.stdout.split(b"\0") if p]
@@ -1183,7 +1191,10 @@ cmd_ci() {
1183
1191
  # file (NUL can't survive a shell var) so paths with spaces/newlines classify exactly.
1184
1192
  local CHANGED=() difftmp rel
1185
1193
  difftmp=$(mktemp 2>/dev/null) || { echo "owner-dispatch ci: mktemp failed — FAIL-CLOSED (2)" >&2; return 2; }
1186
- git diff -z --name-only "$base" HEAD > "$difftmp" 2>/dev/null || { rm -f "$difftmp"; echo "owner-dispatch ci: diff failed — FAIL-CLOSED (2)" >&2; return 2; }
1194
+ # -C "$repo" so diff.relative cannot truncate the set when ci runs from a subdirectory;
1195
+ # --no-renames and --ignore-submodules=none: see the Python path above. Both paths must
1196
+ # classify the same file set regardless of repository diff configuration or cwd.
1197
+ git -C "$repo" diff -z --name-only --no-renames --ignore-submodules=none "$base" HEAD > "$difftmp" 2>/dev/null || { rm -f "$difftmp"; echo "owner-dispatch ci: diff failed — FAIL-CLOSED (2)" >&2; return 2; }
1187
1198
  while IFS= read -r -d '' rel; do CHANGED+=("$rel"); done < "$difftmp"
1188
1199
  rm -f "$difftmp"
1189
1200
 
@@ -523,6 +523,59 @@ echo "// z" >> "$REPO/src/foo.go"; gc more
523
523
  # 10i. CI fails CLOSED when it cannot determine a base (no --base, no upstream).
524
524
  if ( cd "$REPO" && bash "$ENGINE" ci >/dev/null 2>&1 ); then bad "ci no-base should fail-closed"; else ok "ci: no base => fail-closed (non-zero)"; fi
525
525
 
526
+ # 10j. a gated file moved OUT of the gated tree is still a gated change. Two ways the diff
527
+ # can hide it: rename detection resolves a pure move to the exempt destination only,
528
+ # and diff.ignoreSubmodules=all drops a moved gitlink entirely. Both ci paths (jq and
529
+ # the python fallback) must classify the same set, so each case runs on both.
530
+ command -v jq >/dev/null 2>&1 || bad "ci rename-out: jq absent" "the jq lane would silently be a second python run"
531
+ [ -z "$(PATH="$NOJQBIN" command -v jq || true)" ] || bad "ci rename-out: NOJQBIN still resolves jq" "the python lane would silently be a second jq run"
532
+ printf '%s\n' "$EN" | rawcfg
533
+ mkdir -p "$REPO/design" "$REPO/exempt"; echo "owners: x" > "$REPO/design/map.md"
534
+ echo "package x" > "$REPO/src/moved.go"; gc renamebase
535
+ rnbase=$(git -C "$REPO" rev-parse HEAD)
536
+ git -C "$REPO" config diff.renames true # else the base engine reports the source anyway
537
+ git -C "$REPO" mv src/moved.go exempt/moved.go; gc renameout
538
+ jq_rc=0; jq_out=$( cd "$REPO" && bash "$ENGINE" ci --base "$rnbase" 2>&1 ) || jq_rc=$?
539
+ py_rc=0; py_out=$( cd "$REPO" && PATH="$NOJQBIN" bash "$ENGINE" ci --base "$rnbase" 2>&1 ) || py_rc=$?
540
+ [ "$jq_rc" = 1 ] && [ "$py_rc" = 1 ] && ok "ci: gated file renamed out of scope, stale map => fail on both lanes" || bad "ci rename-out rc" "jq=$jq_rc py=$py_rc"
541
+ case "$jq_out$py_out" in *src/moved.go*src/moved.go*) ok "ci rename-out: both lanes name the gated source path" ;; *) bad "ci rename-out diagnostic" "$jq_out | $py_out" ;; esac
542
+ # cwd must not change the verdict: with diff.relative=true a diff run from a subdirectory
543
+ # would drop paths outside it, and the two lanes would disagree.
544
+ git -C "$REPO" config diff.relative true
545
+ rel_jq=0; ( cd "$REPO/exempt" && bash "$ENGINE" ci --base "$rnbase" >/dev/null 2>&1 ) || rel_jq=$?
546
+ rel_py=0; ( cd "$REPO/exempt" && PATH="$NOJQBIN" bash "$ENGINE" ci --base "$rnbase" >/dev/null 2>&1 ) || rel_py=$?
547
+ [ "$rel_jq" = 1 ] && [ "$rel_py" = 1 ] && ok "ci: run from a subdirectory under diff.relative=true => still fail on both lanes" || bad "ci rename-out relative rc" "jq=$rel_jq py=$rel_py"
548
+ git -C "$REPO" config --unset diff.relative
549
+ echo "owners: y" >> "$REPO/design/map.md"; gc renameoutmap
550
+ ok_jq=0; ( cd "$REPO" && bash "$ENGINE" ci --base "$rnbase" >/dev/null 2>&1 ) || ok_jq=$?
551
+ ok_py=0; ( cd "$REPO" && PATH="$NOJQBIN" bash "$ENGINE" ci --base "$rnbase" >/dev/null 2>&1 ) || ok_py=$?
552
+ [ "$ok_jq" = 0 ] && [ "$ok_py" = 0 ] && ok "ci: renamed-out gated file with updated map => ok on both lanes" || bad "ci rename-out with map" "jq=$ok_jq py=$ok_py"
553
+ # an option-like base must be rejected before any diff runs, on both lanes
554
+ for badbase in "--output=$WORK/pwned" "-z"; do
555
+ b_jq=0; ( cd "$REPO" && bash "$ENGINE" ci --base "$badbase" >/dev/null 2>&1 ) || b_jq=$?
556
+ b_py=0; ( cd "$REPO" && PATH="$NOJQBIN" bash "$ENGINE" ci --base "$badbase" >/dev/null 2>&1 ) || b_py=$?
557
+ [ "$b_jq" = 2 ] && [ "$b_py" = 2 ] && ok "ci: option-like base '$badbase' => fail-closed(2) on both lanes" || bad "ci option-base rc" "jq=$b_jq py=$b_py"
558
+ done
559
+ [ ! -e "$WORK/pwned" ] && ok "ci: option-like base created no file" || bad "ci option-base wrote a file"
560
+
561
+ # 10j2. the same bypass through a submodule, which repository config can hide outright.
562
+ SUBSRC="$WORK/subsrc"; mkdir -p "$SUBSRC"
563
+ git -C "$SUBSRC" init -q; git -C "$SUBSRC" config user.email t@t; git -C "$SUBSRC" config user.name t
564
+ echo hi > "$SUBSRC/f.txt"; git -C "$SUBSRC" add -A; git -C "$SUBSRC" commit -qm sub
565
+ sub_err=$(git -C "$REPO" -c protocol.file.allow=always submodule add -q "$SUBSRC" src/vendor 2>&1) || true
566
+ if [ -e "$REPO/src/vendor/f.txt" ]; then
567
+ gc subbase
568
+ sub_base=$(git -C "$REPO" rev-parse HEAD)
569
+ git -C "$REPO" config diff.ignoreSubmodules all
570
+ git -C "$REPO" mv src/vendor exempt/vendor; gc submove
571
+ sm_jq=0; sm_jq_out=$( cd "$REPO" && bash "$ENGINE" ci --base "$sub_base" 2>&1 ) || sm_jq=$?
572
+ sm_py=0; sm_py_out=$( cd "$REPO" && PATH="$NOJQBIN" bash "$ENGINE" ci --base "$sub_base" 2>&1 ) || sm_py=$?
573
+ [ "$sm_jq" = 1 ] && [ "$sm_py" = 1 ] && ok "ci: submodule moved out of scope under diff.ignoreSubmodules=all => fail on both lanes" || bad "ci submodule-move rc" "jq=$sm_jq py=$sm_py"
574
+ git -C "$REPO" config --unset diff.ignoreSubmodules
575
+ else
576
+ bad "ci submodule-move: could not create the submodule fixture" "$sub_err"
577
+ fi
578
+
526
579
  # ---- 11. SubagentStop: agent_id-scoped enforcement (the same engine handles Stop +
527
580
  # SubagentStop; agent_id is present only for the latter and scopes markers/cap). ----
528
581
  rm -rf "$BDIR"
@@ -2736,6 +2736,7 @@ def freeze_review_profile(
2736
2736
  required_self_review_fields = {"concern", "conclusion", "evidence_refs"}
2737
2737
  self_review: list[dict[str, Any]] = []
2738
2738
  seen_concerns: set[str] = set()
2739
+ seen_owner_concerns: set[tuple[str, str]] = set()
2739
2740
  for index, item in enumerate(self_review_raw):
2740
2741
  item_fields = set(item) if isinstance(item, dict) else set()
2741
2742
  if item_fields not in (
@@ -2761,7 +2762,6 @@ def freeze_review_profile(
2761
2762
  len(normalized_conclusion) < 20
2762
2763
  or placeholder_key in PLACEHOLDER_TEXT
2763
2764
  or concern not in known_concern_ids
2764
- or concern in seen_concerns
2765
2765
  or not isinstance(references, list)
2766
2766
  or not references
2767
2767
  or any(
@@ -2781,6 +2781,12 @@ def freeze_review_profile(
2781
2781
  )
2782
2782
  except GateError as exc:
2783
2783
  raise GateError(exc.reason, "self_review_incomplete") from exc
2784
+ if (concern, skill) in seen_owner_concerns:
2785
+ raise GateError(
2786
+ "self_review repeats a concern for the same skill",
2787
+ "self_review_incomplete",
2788
+ )
2789
+ seen_owner_concerns.add((concern, skill))
2784
2790
  seen_concerns.add(concern)
2785
2791
  self_review.append(
2786
2792
  {
@@ -1233,6 +1233,71 @@ out="$(run_gate --review-plan-file "$WORK/owner-review-plan.json" --allow-fallba
1233
1233
  check "owner-aware success without a wrapper binding receipt fails closed" \
1234
1234
  '[ "$rc" = 2 ] && json_fields "$out" reason_code=binding_mismatch next_action=stop_reviewer_lane && [ "$(printf "%s" "$out" | python3 -c "import json,sys; print(len(json.load(sys.stdin).get(\"reviewed_skills\", [])))")" = 0 ]'
1235
1235
 
1236
+ # Owner count is independent of concern count: a cross-skill candidate can have
1237
+ # more owners than the eight known concerns without changing the review scope.
1238
+ python3 - "$WORK" <<'PY'
1239
+ import copy
1240
+ import json
1241
+ from pathlib import Path
1242
+ import sys
1243
+
1244
+ work = Path(sys.argv[1])
1245
+ owners = (
1246
+ "requirement-scope", "product-rd-workflow", "platform-service-connectivity",
1247
+ "platform-observability", "skill-extraction-workflow", "python-service-dev",
1248
+ "web-react-dev", "testing-strategy", "terminal-cli-dev",
1249
+ )
1250
+ plan = json.loads((work / "review-plan.json").read_text())
1251
+ patches = []
1252
+ for owner in owners:
1253
+ root = work / owner
1254
+ root.mkdir(exist_ok=True)
1255
+ entrypoint = root / "SKILL.md"
1256
+ if not entrypoint.exists():
1257
+ entrypoint.write_text(f"# Synthetic {owner} owner\n")
1258
+ plan["self_review"].append({
1259
+ "concern": "correctness", "skill": owner,
1260
+ "conclusion": f"The {owner} boundary preserves its stated acceptance behavior.",
1261
+ "evidence_refs": ["e1"],
1262
+ })
1263
+ path = f"skills/{owner}/SKILL.md"
1264
+ patches.append(f"diff --git a/{path} b/{path}\n--- a/{path}\n+++ b/{path}\n@@ -1 +1 @@\n-old\n+new\n")
1265
+ (work / "many-owner-diff.patch").write_text("".join(patches))
1266
+ plans = {"many-owner": plan}
1267
+ missing_owner = copy.deepcopy(plan)
1268
+ missing_owner["self_review"].pop()
1269
+ plans["many-owner-missing-owner"] = missing_owner
1270
+ missing_concern = copy.deepcopy(plan)
1271
+ missing_concern["self_review"] = [row for row in missing_concern["self_review"] if row["concern"] != "safety"]
1272
+ plans["many-owner-missing-concern"] = missing_concern
1273
+ duplicate = copy.deepcopy(plan)
1274
+ duplicate["self_review"].append(copy.deepcopy(duplicate["self_review"][-1]))
1275
+ plans["many-owner-duplicate"] = duplicate
1276
+ default_duplicate = copy.deepcopy(plan)
1277
+ default_duplicate["self_review"].append({**default_duplicate["self_review"][0], "skill": "code-review"})
1278
+ plans["many-owner-default-duplicate"] = default_duplicate
1279
+ for name, value in plans.items():
1280
+ (work / f"{name}-plan.json").write_text(json.dumps(value))
1281
+ PY
1282
+
1283
+ reset_case passed unavailable unavailable
1284
+ out="$(run_gate --diff-file "$WORK/many-owner-diff.patch" --review-plan-file "$WORK/many-owner-plan.json")"; rc=$?
1285
+ check "nine derived owners can assess shared concerns in one explicit plan" \
1286
+ '[ "$rc" = 0 ] && json_fields "$out" status=passed review_plan_source=implementer-supplied owner_selection_source=controller-derived+implementer-declared && [ "$(printf %s "$out" | python3 -c "import json,sys; p=json.load(sys.stdin); print(len(p[\"selected_skills\"]), len(p[\"reviewed_skills\"]))")" = "10 9" ]'
1287
+ printf '%s\n' "$out" >"$WORK/many-owner-review.json"
1288
+
1289
+ reset_case passed unavailable unavailable
1290
+ out="$(run_completion_gate --diff-file "$WORK/many-owner-diff.patch" --review-plan-file "$WORK/many-owner-plan.json" --completion-review-result-file "$WORK/many-owner-review.json")"; rc=$?
1291
+ check "nine-owner explicit self-review closes the exact-candidate completion checkpoint" \
1292
+ '[ "$rc" = 0 ] && [ ! -e "$WORK/state/client_sequence" ] && json_fields "$out" mode=complete status=passed completion_gated=false next_action=complete'
1293
+
1294
+ for invalid_plan in missing-owner missing-concern duplicate default-duplicate; do
1295
+ reset_case passed unavailable unavailable
1296
+ out="$(run_gate --diff-file "$WORK/many-owner-diff.patch" --review-plan-file "$WORK/many-owner-$invalid_plan-plan.json")"; rc=$?
1297
+ check "shared-concern owner plans reject $invalid_plan before provider execution" \
1298
+ '[ "$rc" = 2 ] && [ ! -e "$WORK/state/client_sequence" ] && json_fields "$out" reason_code=self_review_incomplete next_action=deep_self_review'
1299
+ done
1300
+
1236
1301
  printf 'diff --git a/skills/testing-strategy/SKILL.md b/skills/testing-strategy/SKILL.md\n--- a/skills/testing-strategy/SKILL.md\n+++ b/skills/testing-strategy/SKILL.md\n@@ -1 +1 @@\n-old\n+new\n' >"$WORK/diff.patch"
1237
1302
  reset_case passed unavailable unavailable
1238
1303
  out="$(run_gate --allow-fallback-egress)"; rc=$?
@@ -30,7 +30,7 @@ Observability is the chain that turns one user action into searchable, joinable,
30
30
  2. **Instrumentation** — every service emits structured logs, metrics, and spans through the **framework default**, not ad-hoc code. A service whose middleware chain does not include `ctx_inject + metrics + recovery + tracing` is unobservable by design.
31
31
  3. **Transport** — logs go stdout → file collector (DaemonSet) → log pipeline → search index; metrics + traces go SDK → OTLP collector → metric store + trace store. Both transports must survive collector restarts and back-pressure.
32
32
  4. **Storage + display** — logs in a searchable index keyed by log-id and trace-id; metrics in a long-term store separate from scraper; traces queryable by trace-id; one dashboard tool joins all three.
33
- 5. **Evidence consumption** — SLIs are queries against (3) + (4); alerts evaluate SLIs and route to on-call; runbooks live in a wiki that on-call can reach in under one minute.
33
+ 5. **Evidence consumption** — SLIs query (3) + (4); actionable alerts route to on-call; runbooks live in a wiki that on-call can reach in under one minute.
34
34
 
35
35
  A new service must satisfy all five before it is allowed in production. A release must produce evidence at all five before it is promoted.
36
36
 
@@ -106,9 +106,9 @@ Add domain fields with a prefix (e.g. `app_*`, `biz_*`) to avoid colliding with
106
106
 
107
107
  **Retrofit / migration contract** (the rules above are otherwise greenfield-framed): when a schema is introduced over EXISTING services, field names, metric label names, and identity env-var names are a **migration contract** — a blind rename breaks every deployed dashboard, alert, and saved query that keys on the old name. Retrofitting MUST alias or dual-write old→new and migrate consumers before retiring the old name; never rename in place. The cheap time to fix a name is before services adopt it.
108
108
 
109
- ### R7 — Alerts are SLI-driven and route to a human
109
+ ### R7 — Alerts are actionable and route to an owner
110
110
 
111
- - Alerts evaluate against SLIs (query the long-term metric store), not raw metrics.
111
+ - Use SLIs for SLO paging; actionable capacity or impending-failure alerts may use internal measurements. See `references/alerting-and-on-call.md`.
112
112
  - Severity levels: P0 (page on-call now), P1 (notify channel, ack within work hours), P2 (digest).
113
113
  - Every alert MUST link to a runbook entry. If no runbook exists, the alert is not allowed to be P0.
114
114
  - An alert-backed metric is a coverage contract, and the trigger is mechanical, not prose: any new or changed alert rule, SLO, dashboard alert annotation, or metric referenced by an alert policy fires this check (a written "alert on any increase" note also counts, but its absence is not an exemption). Enumerate every site that should feed the metric and verify each is actually instrumented — derive the site list from a static registry or lint where possible; the easiest site to miss is often the riskiest (e.g. the panic counter in a stream reader parsing untrusted bytes). Ship the site checklist with the alert.
@@ -208,7 +208,7 @@ If any phase has missing evidence, the work is not done.
208
208
  - **"Sample everything vs head-sample 1%"** → head-sample low (1–10%) for cost; retaining errors/slow requests is **tail** sampling at the Collector and requires near-full SDK export to it (head-dropped spans never arrive) — pick one model per service, do not claim both. Sampling-by-route is acceptable for known noisy paths.
209
209
  - **"Metrics egress: scrape vs push"** → each platform picks ONE canonical egress and every process uses it. A Prometheus-native platform may default to **scrape** (pull, with `up`/target-health + service discovery); an OTel-first platform, or workers/jobs/runtimes that can't be scraped, may default to **OTLP push** to a collector. Neither is universally better — don't overturn a working pull setup just to push. One egress per process for a given metric; a migration window may dual-write only with isolated pipelines / distinct metric names / a dedup plan. Long-term store stays separate from the short-term scrape/collect layer (R5).
210
210
  - **"Add a new label to a metric"** → answer the cardinality question first. If max distinct values × series count > 1e6, refuse and use an exemplar trace instead.
211
- - **"Alert on this symptom or that cause"** → alert on user-visible symptom; cause-based alerts produce paging spam.
211
+ - **"Alert on this symptom or that cause"** → prefer user-visible symptoms; capacity or impending-failure warnings follow R7.
212
212
 
213
213
  ## Sanitization and Provenance
214
214
 
@@ -1,5 +1,13 @@
1
1
  # Alerting and On-Call
2
2
 
3
+ ## Choose an actionable signal
4
+
5
+ - **User symptoms and SLOs:** use service-level signals for user-symptom paging and the corresponding SLIs for SLO burn-rate alerts. Query the declared metric store; diagnostic counters do not become availability or latency SLIs merely because an alert uses them.
6
+ - **Capacity and impending failures:** white-box measurements may warn before user SLIs degrade, such as a credible forecast of disk exhaustion. Record the expected failure, the evidence for the threshold or forecast, the action, and the responsible owner. Choose urgency from impact and time left to act; a high internal metric alone is not a reason to page.
7
+ - **Diagnostic signals:** keep measurements with no actionable condition in dashboards or queries. Do not alert on every possible cause.
8
+
9
+ Verify new or changed alert rules against relevant failure, healthy, and recovery cases. Preserve the severity, ownership, runbook, and delivery requirements below for both symptom and impending-failure alerts. [Google SRE's Monitoring Distributed Systems](https://sre.google/sre-book/monitoring-distributed-systems/) describes both user symptoms and imminent saturation as alerting inputs.
10
+
3
11
  ## Two patterns
4
12
 
5
13
  ### Pattern A — Prometheus AlertManager
@@ -100,10 +100,11 @@ Confusing these is the source of most "why doesn't this work" connectivity bugs.
100
100
 
101
101
  ### R4 — Default retry/timeout/circuit-breaker live at mesh, app overrides for business reasons
102
102
 
103
- - Mesh DestinationRule provides default per-callee policy: retries (1-2 attempts on 5xx/connect-fail), connect timeout, request timeout ceiling, outlier detection (5xx-percent → eject).
104
- - Framework client SDK provides per-call override: business timeout (always ≤ mesh ceiling), retry policy for idempotent calls, hedging.
103
+ - On Istio HTTP/gRPC paths, `VirtualService` HTTP routes own request `timeout` and `retries`; `DestinationRule` owns connection-pool settings and `outlierDetection`. Other transports use their platform-owned equivalents.
104
+ - Before configuring retries or timers, read `references/retry-timeout-circuit-breaker.md` for field paths, single-owner retry selection, and idempotency checks. Both mesh and SDK follow them; a 5xx or missing response alone never proves replay safe.
105
+ - Framework client SDK may provide method-specific retry/hedging; its total call budget must fit the caller's remaining duration and any platform cap. A longer mesh timeout is a backstop, not an extended caller deadline.
105
106
  - App code MAY override per-RPC. App code MUST NOT silently disable mesh-level outlier detection.
106
- - Budget rule: total upstream timeout = caller deadline minus a safety margin (e.g. 100ms). Cascading timeouts must shrink down the call chain.
107
+ - Budget rule: downstream work, attempts, and backoff fit the remaining duration minus a safety margin (e.g. 100ms). Propagate deadline and cancellation; never reset the full budget at each hop.
107
108
 
108
109
  ### R5 — mTLS is mesh-default, app cannot disable
109
110
 
@@ -151,14 +152,13 @@ The client middleware fills this from ctx automatically; the server middleware e
151
152
 
152
153
  For non-protobuf or metadata-only transports, an equivalent header set is compliant only when it cites a resolvable platform owner record, such as a repo/path, document URL, registry id, gateway policy, or owner-suite id. The owner record must enumerate the concrete header names. In diff-only review without resolver tooling, the diff must cite a stable owner-record locator and list the concrete header names inline; with resolver tooling, the reviewer or owner-suite may resolve the locator to those names instead. The enumerated header names must then be checked against the actual propagated and exposed headers: caller-supplied identity headers are absent unless positive authenticated-caller evidence exists. An unresolvable, non-enumerating, uncited, service-local, or unchecked header set is an open gap, not a permitted alternative. For pure HTTP (no RPC base), the equivalent is a stable owner-recorded header set, also filled by middleware.
153
154
 
154
- ### R8 — gRPC `:authority` and DNS-label hyphenation
155
+ ### R8 — gRPC `:authority` compatibility follows the actual request path
155
156
 
156
- If the platform allows service names with underscores (`<owner>.<class>.<env>` containing `_`), gRPC will reject them in the HTTP/2 `:authority` pseudo-header because it must be a valid DNS label (no `_`). Two acceptable fixes:
157
+ HTTP/2 `:authority` carries URI authority, not a single DNS label. An underscore alone does not prove a gRPC protocol violation. DNS hostnames, certificate identity checks, SDK validation, and proxy routing can impose different constraints; preserve the constraints of the deployed path.
157
158
 
158
- 1. **Disallow underscores in new service names**; enforce in registry registration and CI.
159
- 2. **Mesh-level rewrite**: an EnvoyFilter Lua snippet replaces `_` with `-` in `:authority` for gRPC requests, before routing.
159
+ Before renaming a service or adding a rewrite, capture the failing request's authority, the rejecting layer and version, and the relevant error or trace. A platform may require DNS-compatible service names and enforce that policy at registration and CI, but a registry name need not be the wire authority.
160
160
 
161
- Option 1 is cleaner long-term; option 2 is the live-system workaround. Document which the platform uses; new services should follow option 1.
161
+ A proxy rewrite is an option only when the request reaches that filter before the rejecting layer. It cannot fix a client rejection before transmission or an inbound parser rejection before the filter runs. Scope any verified rewrite to the affected traffic and explicit authority mappings; retain route, TLS identity, and authorization checks. Diagnosis, migration, and verification: `references/grpc-authority-workaround.md`.
162
162
 
163
163
  ### R9 — Ingress and egress are explicit, not implicit
164
164
 
@@ -200,8 +200,8 @@ Option 1 is cleaner long-term; option 2 is the live-system workaround. Document
200
200
  ### Phase B — Mesh policy
201
201
 
202
202
  1. PeerAuthentication = STRICT (mTLS namespace-wide).
203
- 2. DestinationRule per critical callee: retry policy, timeouts, outlier detection.
204
- 3. VirtualService rules express lane-based routing: lane header match → lane subset.
203
+ 2. DestinationRule per critical callee: connection-pool limits, connect timeout, outlier detection.
204
+ 3. VirtualService HTTP routes: lane header match → lane subset, request timeout, and the R4 retry policy.
205
205
  4. AuthorizationPolicy expresses which services may call which (zero-trust at network level).
206
206
 
207
207
  ### Phase C — Service discovery
@@ -216,7 +216,7 @@ Option 1 is cleaner long-term; option 2 is the live-system workaround. Document
216
216
 
217
217
  1. Read the framework default client/server options module. Confirm middleware chain matches R6.
218
218
  2. Confirm dev cannot build a client/server without inheriting these.
219
- 3. Test: kill a downstream pod → verify mesh outlier-detection ejects, framework retry kicks in for idempotent calls, error propagates up with stable error-code.
219
+ 3. Test: induce the configured outlier threshold on one callee → verify ejection and recovery, the configured retry layer retries only eligible calls within its budget, and exhausted calls propagate a stable error-code.
220
220
 
221
221
  ### Phase E — Failure modes
222
222
 
@@ -234,7 +234,7 @@ Before marking work done:
234
234
 
235
235
  ## Decision Points
236
236
 
237
- - **"Add a new retry policy for service X"** → start at mesh DestinationRule. Move to framework client only if the retry depends on business idempotency knowledge.
237
+ - **"Add a new retry policy for service X"** → apply R4's replay-safety and single-owner checks. Use the matching VirtualService HTTP route for Istio retries; disable and verify route retries when the SDK owns them. DestinationRule limits concurrent retries, not per-request attempts.
238
238
  - **"Service A times out calling Service B"** → check three layers in order: app deadline (ctx timeout) → framework client timeout → mesh request timeout. Whichever is smaller wins; align them.
239
239
  - **"Switch service discovery mode"** → use `references/service-discovery-choice.md`; do not mandate registry unless the platform needs registry-specific capabilities such as per-instance drain, out-of-cluster lookup, or existing registry federation.
240
240
  - **"Need mTLS to a non-mesh external service"** → egress gateway with terminating TLS, not app-managed certs.
@@ -257,7 +257,7 @@ Reused industry patterns (PSM-style identity, `<owner>.<class>.<env>` shape, `tr
257
257
  - `references/framework-middleware.md` — Server and client middleware chain (HTTP + RPC); the canonical RPC base struct shape; verification commands.
258
258
  - `references/multi-env-routing.md` — Lane label end-to-end recipe; VirtualService patterns; queue-boundary propagation; stress/shadow tags.
259
259
  - `references/retry-timeout-circuit-breaker.md` — Mesh defaults vs SDK overrides; cascading timeout budgets; idempotency awareness; outlier detection tuning.
260
- - `references/grpc-authority-workaround.md` — The `_` → `-` rewrite quirk; when it's needed; the cleaner long-term fix.
260
+ - `references/grpc-authority-workaround.md` — Locate authority rejection; distinguish naming policy from protocol syntax; verify scoped compatibility changes.
261
261
  - `references/dual-sidecar-and-traffic-config-center.md` — Pod-level dual sidecar (mesh + platform), per-protocol mesh injection policy, per-caller-callee traffic config via config center (separate from mesh routing).
262
262
  - `references/rpc-framework-recipe.md` — Concrete kitex/hertz default suite: shared RPC base field contract, RPC base.Request full schema (8 fields incl From/To), dual-channel ctx propagation (metainfo + grpc metadata), 9-code error enum + framework error mapping table, three resolver strategies (registry / FQDN fallback / proxy), platform latency histogram buckets, CORS defaults exposing log-id header, server boot sequence with graceful shutdown.
263
263
  - `references/service-discovery-choice.md` — Decision framework: registry-based vs k8s-native SD; both support lane routing + canary + multi-env; pick by per-instance drain need, laptop access pattern, federation preference, operational burden; mixed mode (k8s east-west + thin registry for laptop) is workable; migration paths in both directions.
@@ -269,7 +269,7 @@ Reused industry patterns (PSM-style identity, `<owner>.<class>.<env>` shape, `tr
269
269
 
270
270
  1. **Static**: framework default options module includes all R6 middleware; mesh PeerAuthentication is STRICT; DestinationRule and VirtualService exist for every callee that participates in lane routing.
271
271
  2. **Live, identity**: cross-service trace shows log-id + lane consistent across all hops; mesh access log lines include both.
272
- 3. **Live, mesh policy**: kill a callee pod → outlier detection ejects within outlier-detection interval; metrics show client-side error count rise then fall.
272
+ 3. **Live, mesh policy**: trigger the configured outlier threshold with an eligible pool size; verify ejection and recovery against the effective policy and metrics. Consecutive-error checks are inline, not delayed until the periodic analysis interval.
273
273
  4. **Live, lane routing**: send request with non-default lane → confirm only matching-lane instances serve it.
274
274
  5. **Static, no leakage**: grep this skill's content — zero internal hostnames, repo names, or business terms.
275
275
 
@@ -36,7 +36,7 @@ Mesh injection is not all-or-nothing. Real platforms apply per-protocol policy:
36
36
  | App protocol | Inject Istio sidecar? | Reason |
37
37
  |---|---|---|
38
38
  | HTTP (REST) | YES | Envoy handles HTTP/1.1, HTTP/2; full feature support |
39
- | gRPC | YES | HTTP/2 + per-call routing; needs `:authority` quirk handling |
39
+ | gRPC | YES | HTTP/2 + per-call routing; verify `:authority` routing against the actual SDK/proxy path |
40
40
  | TCP (raw) | NO (often) | Envoy TCP proxy is feature-poor; routing/auth less useful at L4 |
41
41
  | Thrift (TTHeader) | NO (often) | Same — L4-ish; tooling limited |
42
42