@walwal-harness/cli 7.1.57 → 7.1.58

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.
@@ -2,11 +2,11 @@
2
2
  # harness-company-complete.sh — mark the current v7 company mission complete.
3
3
  #
4
4
  # Usage:
5
- # bash scripts/harness-company-complete.sh <project-root> [reason]
5
+ # bash scripts/harness-company-complete.sh <project-root> [reason] [mission-rel]
6
6
  #
7
7
  # This is the explicit running -> idle/done transition used by dashboard,
8
8
  # runner, hooks, and final CEO handoff paths. It intentionally only updates
9
- # runtime state; it does not archive or rewrite mission documents.
9
+ # runtime state and mission lifecycle; it does not archive mission documents.
10
10
 
11
11
  set -uo pipefail
12
12
 
@@ -26,44 +26,6 @@ command -v jq >/dev/null 2>&1 || {
26
26
  exit 1
27
27
  }
28
28
 
29
- # Lessons-before-plan gate (AGENTS.md Hard Rule 20). The Stop hook cannot see
30
- # this path: once this script sets conductor.state=completed, harness-stop.sh
31
- # short-circuits at the top, so a mission could complete having never read the
32
- # corpus simply by firing the transition. Refuse the terminal transition here —
33
- # this is the transition P6 names, and it is the last point at which refusing
34
- # still means anything.
35
- #
36
- # Safe against deadlock: the gate is scoped to the latest ACTIVE mission, so the
37
- # stop-hook backstop (which fires only when no mission is active) always passes.
38
- # Opt out per project with .harness/config.json behavior.lessons_gate=false, or
39
- # override a single call with HARNESS_SKIP_LESSONS_GATE=1.
40
- if [ "${HARNESS_SKIP_LESSONS_GATE:-0}" != "1" ] && [ -x "$SCRIPT_DIR/harness-lessons-gate.sh" ]; then
41
- if ! gate_out="$(bash "$SCRIPT_DIR/harness-lessons-gate.sh" "$PROJECT_ROOT" text latest-active 2>/dev/null)"; then
42
- {
43
- echo "[company-complete] REFUSED: the active mission has not recorded what it read before planning."
44
- echo "$gate_out"
45
- echo " Add the two sections to each role document, then re-run this transition."
46
- echo " (override: HARNESS_SKIP_LESSONS_GATE=1, or behavior.lessons_gate=false in .harness/config.json)"
47
- } >&2
48
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | company-complete | refused | lessons-gate (Hard Rule 20)" >> "$PROJECT_ROOT/.harness/progress.log" 2>/dev/null || true
49
- exit 1
50
- fi
51
- fi
52
-
53
- # Corpus reachability (Hard Rule 11) and spec pins (Hard Rule 4) are re-checked
54
- # at the same point, for the same reason: both are promises that decay silently
55
- # between when they are made and when the mission claims to be done.
56
- if [ "${HARNESS_SKIP_LESSONS_GATE:-0}" != "1" ] && [ -x "$SCRIPT_DIR/harness-corpus-reachability.sh" ]; then
57
- if ! reach_out="$(bash "$SCRIPT_DIR/harness-corpus-reachability.sh" "$PROJECT_ROOT" text 2>/dev/null)"; then
58
- {
59
- echo "[company-complete] REFUSED: corpus entries are unreachable by roles they name as an audience."
60
- echo "$reach_out"
61
- } >&2
62
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | company-complete | refused | corpus-reachability (Hard Rule 11)" >> "$PROJECT_ROOT/.harness/progress.log" 2>/dev/null || true
63
- exit 1
64
- fi
65
- fi
66
-
67
29
  state_mtime() {
68
30
  stat -f %m "$1" 2>/dev/null || stat -c %Y "$1" 2>/dev/null || echo 0
69
31
  }
@@ -87,24 +49,117 @@ pick_transition_mission_state() {
87
49
  [ -n "$best" ] && printf '%s\n' "$best"
88
50
  }
89
51
 
52
+ refuse() {
53
+ echo "[company-complete] REFUSED: $1" >&2
54
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | company-complete | refused | $1" >> "$PROJECT_ROOT/.harness/progress.log"
55
+ if [ -n "${target_state:-}" ] && jq -e '.lifecycle == "complete" or .lifecycle == "completed"' "$target_state" >/dev/null; then
56
+ echo 'Restore active:true and lifecycle:active, then retry.' >&2
57
+ fi
58
+ exit 1
59
+ }
60
+
61
+ target_state=""
62
+ if [ "$#" -ge 3 ]; then
63
+ mission_rel="$3"
64
+ case "$mission_rel" in ""|/*|*..*) refuse invalid-mission-path ;; esac
65
+ target_dir="$(cd "$DOCS/$mission_rel" 2>/dev/null && pwd -P)" || refuse invalid-mission-path
66
+ case "$target_dir/" in "$(cd "$DOCS" && pwd -P)/"*) ;; *) refuse invalid-mission-path ;; esac
67
+ target_state="$target_dir/mission-state.json"
68
+ [ -f "$target_state" ] || refuse missing-mission-state
69
+ else
70
+ target_state="$(pick_transition_mission_state)"
71
+ fi
72
+ ended=false
73
+ tiered=false
74
+ gate_scope=latest-active
75
+ # Explicit targets scope the existing lessons gate even for legacy missions.
76
+ if [ "$#" -ge 3 ]; then gate_scope="mission:$3"; fi
77
+ if [ -n "$target_state" ]; then
78
+ lifecycle=$(jq -r '.lifecycle // .status // "unknown"' "$target_state") || refuse invalid-mission-state
79
+ case "$lifecycle" in cancelled|superseded|closed) ended=true ;; esac
80
+ if jq -e 'has("tier")' "$target_state" >/dev/null; then
81
+ tiered=true
82
+ mission_rel="${target_state#"$DOCS"/}"
83
+ # Explicit targets were canonicalized; retain the caller's relative path.
84
+ if [ "$#" -ge 3 ]; then mission_rel="$3/mission-state.json"; fi
85
+ mission_rel="${mission_rel%/mission-state.json}"
86
+ gate_scope="mission:$mission_rel"
87
+ fi
88
+ fi
89
+
90
+ if [ "$ended" = false ]; then
91
+ if [ "${HARNESS_SKIP_LESSONS_GATE:-0}" != 1 ] && [ -x "$SCRIPT_DIR/harness-lessons-gate.sh" ]; then
92
+ gate_out=$(bash "$SCRIPT_DIR/harness-lessons-gate.sh" "$PROJECT_ROOT" text "$gate_scope") || refuse "lessons-gate: $gate_out"
93
+ fi
94
+ if [ "$tiered" = true ]; then
95
+ evidence=$(bash "$SCRIPT_DIR/harness-worker-evidence-validate.sh" "$PROJECT_ROOT" text "$gate_scope") || refuse "worker-evidence: $evidence"
96
+ mission_dir="$(dirname "$target_state")"
97
+ [ -f "$mission_dir/cqo.md" ] || refuse missing-verdict
98
+ verdict=$(awk '
99
+ /^[[:space:]]*>?[[:space:]]*##[[:space:]]+CQO Verdict([[:space:]]+\([^)]*\))?[[:space:]]*$/ { inb=1; next }
100
+ /^[[:space:]]*>?[[:space:]]*#{1,2}[[:space:]]/ { inb=0 }
101
+ inb { candidate=$0; sub(/^[[:space:]>*_-]*/, "", candidate)
102
+ if (tolower(candidate) ~ /^verdict[*_[:space:]]*:/) last=$0 }
103
+ END {
104
+ if (last == "") print "missing"
105
+ else if (last ~ /^[[:space:]]*>?[[:space:]]*Verdict:[[:space:]]*(PASS|ACCEPTED|FAIL|REJECTED|BLOCKED)[[:space:]]*$/) {
106
+ sub(/^[[:space:]]*>?[[:space:]]*Verdict:[[:space:]]*/, "", last)
107
+ sub(/[[:space:]]*$/, "", last); print last
108
+ } else print "invalid"
109
+ }
110
+ ' "$mission_dir/cqo.md") || refuse invalid-final-verdict
111
+ case "$verdict" in
112
+ PASS|ACCEPTED) ;;
113
+ missing) refuse missing-verdict ;;
114
+ invalid) refuse invalid-final-verdict ;;
115
+ *) refuse verdict-not-pass ;;
116
+ esac
117
+ tier=$(jq -r 'def r: if . == "S" then 0 elif . == "M" then 1 else 2 end;
118
+ [.tier, (.tier_history[]? | .from, .to)] | map(r) | max' "$target_state") || refuse invalid-tier-state
119
+ if [ -f "$PROJECT_ROOT/.harness/config.json" ]; then
120
+ enabled=$(jq -r 'if .behavior.mission_tiers == null then true else .behavior.mission_tiers end' "$PROJECT_ROOT/.harness/config.json") || refuse invalid-config
121
+ [ "$enabled" != false ] || tier=2
122
+ fi
123
+ if [ "$tier" -eq 0 ]; then
124
+ grep -Eq '^[[:space:]]*>?[[:space:]]*##[[:space:]]+Direct Work[[:space:]]*$' "$mission_dir/cto.md" || refuse missing-direct-work
125
+ grep -Eq '^[[:space:]]*>?[[:space:]]*##[[:space:]]+Verification Commands[[:space:]]*$' "$mission_dir/cqo.md" || refuse missing-verification-commands
126
+ session=$(sed -nE 's/^[[:space:]]*Verification Session: (separate|same-session)[[:space:]]*$/\1/p' "$mission_dir/cqo.md" | tail -1)
127
+ [ -n "$session" ] || refuse missing-verification-session
128
+ if [ "$session" = same-session ]; then
129
+ echo '[company-complete] WARNING: same-session-verification; disclose to Owner.' >&2
130
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | company-complete | warn | same-session-verification" >> "$PROJECT_ROOT/.harness/progress.log"
131
+ fi
132
+ fi
133
+ fi
134
+ fi
135
+
136
+ # Corpus reachability (Hard Rule 11) and spec pins (Hard Rule 4) are re-checked
137
+ # at the same point, for the same reason: both are promises that decay silently
138
+ # between when they are made and when the mission claims to be done.
139
+ if [ "${HARNESS_SKIP_LESSONS_GATE:-0}" != "1" ] && [ -x "$SCRIPT_DIR/harness-corpus-reachability.sh" ]; then
140
+ if ! reach_out="$(bash "$SCRIPT_DIR/harness-corpus-reachability.sh" "$PROJECT_ROOT" text 2>/dev/null)"; then
141
+ refuse "corpus-reachability: $reach_out"
142
+ fi
143
+ fi
144
+
90
145
  # Spec pins are verified against the mission this transition would close. This
91
146
  # sits BEFORE the runtime transition on purpose: a refusal that runs after
92
147
  # progress.json is already `completed` has refused nothing.
93
- if [ "${HARNESS_SKIP_LESSONS_GATE:-0}" != "1" ] && [ -x "$SCRIPT_DIR/harness-spec-pin.sh" ] && [ -d "$DOCS" ]; then
94
- pin_target="$(pick_transition_mission_state)"
148
+ if [ "$ended" = false ] && [ "${HARNESS_SKIP_LESSONS_GATE:-0}" != "1" ] && [ -x "$SCRIPT_DIR/harness-spec-pin.sh" ] && [ -d "$DOCS" ]; then
149
+ pin_target="$target_state"
95
150
  if [ -n "$pin_target" ]; then
96
151
  mission_rel="${pin_target#"$DOCS"/}"; mission_rel="${mission_rel%/mission-state.json}"
152
+ if [ "$#" -ge 3 ]; then mission_rel="$3"; fi
97
153
  if ! pin_out="$(bash "$SCRIPT_DIR/harness-spec-pin.sh" "$PROJECT_ROOT" "$mission_rel" verify text 2>/dev/null)"; then
98
- {
99
- echo "[company-complete] REFUSED: this mission was built against a spec that has since moved."
100
- echo "$pin_out"
101
- } >&2
102
- echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | company-complete | refused | spec-pin drift (Hard Rule 4)" >> "$PROJECT_ROOT/.harness/progress.log" 2>/dev/null || true
103
- exit 1
154
+ refuse "spec-pin drift: $pin_out"
104
155
  fi
105
156
  fi
106
157
  fi
107
158
 
159
+ if [ "$ended" = true ]; then
160
+ echo "$(date -u +%Y-%m-%dT%H:%M:%SZ) | company-complete | ended-without-acceptance | $lifecycle" >> "$PROJECT_ROOT/.harness/progress.log"
161
+ fi
162
+
108
163
  if ! bash "$SCRIPT_DIR/harness-progress-set.sh" "$PROJECT_ROOT" \
109
164
  '.company_state.state = "idle" |
110
165
  .company_state.active_workers = 0 |
@@ -143,11 +198,10 @@ if [ -f "$TODOS" ]; then
143
198
  fi
144
199
 
145
200
  if [ -d "$DOCS" ]; then
146
- target_state="$(pick_transition_mission_state)"
147
201
  if [ -n "$target_state" ]; then
148
202
  lifecycle="$(jq -r '.lifecycle // .status // "unknown"' "$target_state" 2>/dev/null || echo unknown)"
149
203
  case "$lifecycle" in
150
- closed|cancelled|superseded|complete|completed) ;;
204
+ closed|cancelled|superseded) ;;
151
205
  *)
152
206
  tmp="$(mktemp)"
153
207
  jq '.lifecycle = "complete" | .active = false | .completed_at = (now | todate) | del(.blocked_reason) | del(.blocked_at)' "$target_state" > "$tmp" && mv "$tmp" "$target_state"
@@ -11,17 +11,19 @@
11
11
  # ## Lessons Preflight — which convention/gotcha items apply, and why
12
12
  # ## Lessons Tally — one line: which of them actually fired ("0 fired" is valid)
13
13
  #
14
- # Usage: harness-lessons-gate.sh <project-root> [text|json] [all|latest-active]
14
+ # Usage: harness-lessons-gate.sh <project-root> [text|json] [all|latest-active|mission:<rel>]
15
15
  set -euo pipefail
16
16
 
17
17
  PROJECT_ROOT="${1:-.}"
18
18
  DOC_ROOT="$PROJECT_ROOT/.harness/documents"
19
19
  CONFIG="$PROJECT_ROOT/.harness/config.json"
20
20
 
21
- [ -d "$DOC_ROOT" ] || exit 0
22
-
23
21
  mode="${2:-text}"
24
22
  scope="${3:-all}"
23
+ if [ ! -d "$DOC_ROOT" ]; then
24
+ [[ "$scope" != mission:* && "$mode" != direct-work-sha ]] || exit 1
25
+ exit 0
26
+ fi
25
27
 
26
28
  # Opt-out for projects that have not adopted the sections yet.
27
29
  if command -v jq >/dev/null 2>&1 && [ -f "$CONFIG" ]; then
@@ -57,7 +59,10 @@ mission_mtime() {
57
59
  stat -f '%m' "$1" 2>/dev/null || stat -c '%Y' "$1" 2>/dev/null || echo 0
58
60
  }
59
61
 
60
- mission_dirs=$(find "$DOC_ROOT" -type f \( -name 'ceo.md' -o -name 'coo.md' -o -name 'cdo.md' -o -name 'cto.md' -o -name 'cqo.md' -o -name 'ops.md' \) -exec dirname {} \; 2>/dev/null | sort -u)
62
+ mission_dirs=""
63
+ if [[ "$scope" != mission:* ]]; then
64
+ mission_dirs=$(find "$DOC_ROOT" -type f \( -name 'ceo.md' -o -name 'coo.md' -o -name 'cdo.md' -o -name 'cto.md' -o -name 'cqo.md' -o -name 'ops.md' \) -exec dirname {} \; 2>/dev/null | sort -u)
65
+ fi
61
66
 
62
67
  if [ "$scope" = "latest-active" ]; then
63
68
  latest_active_mission=""
@@ -83,14 +88,37 @@ EOF
83
88
  mission_dirs="$latest_active_mission"
84
89
  fi
85
90
 
91
+ if [[ "$scope" == mission:* ]]; then
92
+ mission_rel="${scope#mission:}"
93
+ case "$mission_rel" in ""|/*|*..*) echo "invalid mission path" >&2; exit 1 ;; esac
94
+ mission_dirs="$(cd "$DOC_ROOT/$mission_rel" 2>/dev/null && pwd -P)" || exit 1
95
+ case "$mission_dirs/" in "$(cd "$DOC_ROOT" && pwd -P)/"*) ;; *) exit 1 ;; esac
96
+ [ -f "$mission_dirs/mission-state.json" ] || exit 1
97
+ fi
98
+
86
99
  while IFS= read -r mission_dir; do
87
100
  [ -n "$mission_dir" ] || continue
88
101
  [ -d "$mission_dir" ] || continue
89
102
  mission_name="${mission_dir#"$DOC_ROOT"/}"
103
+ tier=2
104
+ if [ -f "$mission_dir/mission-state.json" ]; then
105
+ tier=$(jq -r 'def r: if . == "S" then 0 elif . == "M" then 1 else 2 end;
106
+ [.tier, (.tier_history[]? | .from, .to)] | map(r) | max' "$mission_dir/mission-state.json")
107
+ fi
108
+ if [ -f "$PROJECT_ROOT/.harness/config.json" ]; then
109
+ enabled=$(jq -r 'if .behavior.mission_tiers == null then true else .behavior.mission_tiers end' "$PROJECT_ROOT/.harness/config.json")
110
+ [ "$enabled" != false ] || tier=2
111
+ fi
90
112
 
91
113
  for role in ceo coo cdo cto cqo ops; do
92
114
  role_path="$mission_dir/$role.md"
93
115
  [ -s "$role_path" ] || continue
116
+ if [[ "$role" == coo || "$role" == cdo || "$role" == ops ]] && has_section "$role_path" "Not Applicable"; then
117
+ continue
118
+ fi
119
+ if [ "$tier" -lt 2 ] && has_section "$role_path" "Lessons"; then
120
+ continue
121
+ fi
94
122
  if ! has_section "$role_path" "Lessons Preflight"; then
95
123
  violations+=("$mission_name:$role.md-missing-lessons-preflight")
96
124
  fi
@@ -116,12 +144,13 @@ if [ "$mode" = "json" ]; then
116
144
  | {ok:false, violations:.}
117
145
  '
118
146
  else
119
- echo "Lessons-before-plan violation (AGENTS.md Hard Rule 20):"
147
+ echo "Lessons-before-plan violation (AGENTS.md Hard Rule 20; unknown tier → L):"
120
148
  for violation in "${violations[@]}"; do
121
149
  mission="${violation%%:*}"
122
150
  docs="${violation#*:}"
123
151
  echo "- mission: $mission"
124
152
  echo " issue: $docs"
153
+ echo " S/M role documents may instead use ## Lessons (Preflight/Fired)."
125
154
  echo " required: '## Lessons Preflight' (which convention/gotcha items apply, and why)"
126
155
  echo " '## Lessons Tally' (one line: which fired; '0 fired' is valid and must be stated)"
127
156
  done
@@ -0,0 +1,29 @@
1
+ #!/bin/bash
2
+ # Pass variable names, never secret values, on the command line. Do not use bash -x.
3
+ set -o pipefail
4
+ fail() { echo "[secret-scan] $*" >&2; exit 2; }
5
+ VALS=()
6
+ while [ "$#" -gt 0 ] && [ "$1" != -- ]; do
7
+ [[ "$1" =~ ^[a-zA-Z_][a-zA-Z0-9_]*$ ]] || fail "invalid variable name"
8
+ [ -n "${!1}" ] || fail "unset or empty variable: $1"
9
+ VALS+=("${!1}")
10
+ shift
11
+ done
12
+ [ "${#VALS[@]}" -gt 0 ] && [ "${1:-}" = -- ] || fail "expected VAR_NAME... -- PATH..."
13
+ shift
14
+ [ "$#" -gt 0 ] || fail "no paths"
15
+ for path in "$@"; do [ -e "$path" ] || fail "missing path"; done
16
+ OUT=$(grep -rlF -f <(printf '%s\n' "${VALS[@]}") -- "$@")
17
+ RC=$?
18
+ COUNT=0
19
+ if [ -n "$OUT" ]; then
20
+ while IFS= read -r path; do
21
+ printf 'leak: %s\n' "$path"
22
+ COUNT=$((COUNT+1))
23
+ done <<< "$OUT"
24
+ fi
25
+ case "$RC" in
26
+ 0) printf 'leaks=%s\n' "$COUNT"; exit 1 ;;
27
+ 1) echo 'leaks=0'; exit 0 ;;
28
+ *) echo "scan incomplete (grep rc=$RC)" >&2; exit 2 ;;
29
+ esac
@@ -160,10 +160,10 @@ if [ -x "$SCRIPT_DIR/harness-worker-evidence-validate.sh" ]; then
160
160
  WORKER_EVIDENCE_OK=$(echo "$WORKER_EVIDENCE_JSON" | jq -r 'if has("ok") then .ok else true end' 2>/dev/null || echo true)
161
161
  if [ "$WORKER_EVIDENCE_OK" != "true" ]; then
162
162
  REASON=$(echo "$WORKER_EVIDENCE_JSON" | jq -r '
163
- "CXX 직접 실행 차단: " +
164
- ([.violations[] | "\(.mission) has CXX docs without worker reports: \(.docs | join(","))"] | join("; ")) +
165
- ". harness-hiring/resource-manager로 전문 worker를 고용 또는 배정하고 .harness/documents/{goal-or-child-mission}/{owning-cxx}/workers/{worker-name}.md 를 남긴 뒤 계속하라."
166
- ' 2>/dev/null || echo "CXX 직접 실행 차단: worker report가 없는 active mission이 있습니다. hired worker 보고서를 먼저 생성하세요.")
163
+ "등급별 worker/문서 증거 차단: " +
164
+ ([.violations[] | "\(.mission) has evidence violations: \(.docs | join(","))"] | join("; ")) +
165
+ ". S의 CTO/CQO만 worker 면제이며 S/M 역할 Notes는 축약 가능하다. 나머지는 harness-hiring/resource-manager로 전문 worker를 고용 또는 배정하고 .harness/documents/{goal-or-child-mission}/{owning-cxx}/workers/{worker-name}.md 를 남긴 뒤 계속하라."
166
+ ' 2>/dev/null || echo "등급별 worker/문서 증거 차단: worker report가 없는 active mission이 있습니다. hired worker 보고서를 먼저 생성하세요.")
167
167
  jq -nc --arg reason "$REASON" '{decision:"block", reason:$reason}'
168
168
  exit 0
169
169
  fi
@@ -184,7 +184,7 @@ if [ -x "$SCRIPT_DIR/harness-lessons-gate.sh" ]; then
184
184
  ([.violations[] | "\(.mission) → \(.docs | join(","))"] | join("; ")) +
185
185
  ". 각 role 문서에 `## Lessons Preflight`(이 미션에 적용되는 conventions/gotchas 항목과 이유)와 " +
186
186
  "그 아래 `## Implementation Notes` 바로 앞에 한 줄짜리 `## Lessons Tally`(실제로 발동한 항목; `0 fired` 도 유효하며 생략은 불가)를 " +
187
- "추가한 뒤 계속하라. 코퍼스를 다시 요약한 별도 체크리스트 파일을 만들지 말 것 — 읽는 순서를 고치는 규칙이다."
187
+ "추가한 뒤 계속하라. S/M 역할 문서는 ## Lessons (Preflight/Fired) 한 섹션으로 대체 가능하다. 코퍼스를 다시 요약한 별도 체크리스트 파일을 만들지 말 것 — 읽는 순서를 고치는 규칙이다."
188
188
  ' 2>/dev/null || echo "교훈 선행 게이트(Hard Rule 20): active mission 의 role 문서에 ## Lessons Preflight / ## Lessons Tally 가 없습니다. 먼저 추가하세요.")
189
189
  jq -nc --arg reason "$REASON" '{decision:"block", reason:$reason}'
190
190
  exit 0
@@ -0,0 +1,71 @@
1
+ #!/bin/bash
2
+ # Source identity is necessary, not sufficient: see CQO Evidence Reuse.
3
+ set -o pipefail
4
+ fail() { echo "[verify-fingerprint] $*" >&2; exit 2; }
5
+ CMD=fingerprint
6
+ ROOT=.
7
+ LOG=
8
+ INCLUDES=()
9
+ [ "${1:-}" != run ] || { CMD=run; shift; }
10
+ SEPARATOR=false
11
+ while [ "$#" -gt 0 ]; do
12
+ case "$1" in
13
+ --root|--include|--log)
14
+ [ "$#" -ge 2 ] && [ -n "$2" ] || fail "missing option value"
15
+ case "$1" in
16
+ --root) ROOT=$2 ;;
17
+ --include) INCLUDES+=("$2") ;;
18
+ --log) LOG=$2 ;;
19
+ esac
20
+ shift 2 ;;
21
+ --) SEPARATOR=true; shift; break ;;
22
+ *) fail "unknown option" ;;
23
+ esac
24
+ done
25
+ if [ "$CMD" = run ]; then
26
+ [ -n "$LOG" ] && [ "$SEPARATOR" = true ] && [ "$#" -gt 0 ] || fail "run requires --log PATH and -- CMD..."
27
+ else
28
+ [ "$SEPARATOR" = false ] && [ -z "$LOG" ] || fail "unexpected run options"
29
+ fi
30
+ cd -- "$ROOT" 2>/dev/null || fail "invalid root"
31
+ ROOT=$(git rev-parse --show-toplevel 2>/dev/null) || fail "not a git repository"
32
+ cd -- "$ROOT" || fail "invalid repository root"
33
+ if command -v shasum >/dev/null 2>&1; then HASH=(shasum -a 256)
34
+ elif command -v sha256sum >/dev/null 2>&1; then HASH=(sha256sum)
35
+ else fail "SHA-256 tool required"; fi
36
+ hash() { "${HASH[@]}" | awk '{print $1}'; }
37
+ PATHS=(. ':(top,exclude).harness' ':(top,exclude).myterm')
38
+ file_input() {
39
+ printf '%s\0' "$1"
40
+ if [ -e "$1" ] || [ -L "$1" ]; then
41
+ [ -f "$1" ] || return 2
42
+ hash < "$1" || return 2
43
+ else printf 'MISSING\n'; fi
44
+ }
45
+ fingerprint() {
46
+ {
47
+ git rev-parse --verify HEAD || return 2
48
+ git diff HEAD --binary --no-ext-diff --no-textconv -- "${PATHS[@]}" || return 2
49
+ git ls-files --others --exclude-standard -z -- "${PATHS[@]}" |
50
+ while IFS= read -r -d '' file; do file_input "$file" || exit 2; done || return 2
51
+ for file in "${INCLUDES[@]}"; do file_input "$file" || return 2; done
52
+ } | hash
53
+ }
54
+ BEFORE=$(fingerprint) || fail "cannot fingerprint inputs"
55
+ case "$CMD" in
56
+ fingerprint) printf '%s\n' "$BEFORE" ;;
57
+ run)
58
+ # ponytail: before/after misses ABA edits; wait for editors or use a fixed worktree.
59
+ # Relative log/include paths and the command are resolved from the repository root.
60
+ (umask 077; : > "$LOG") || fail "cannot open log"
61
+ "$@" > "$LOG" 2>&1
62
+ RC=$?
63
+ AFTER=$(fingerprint) || fail "cannot fingerprint inputs after run"
64
+ [ "$BEFORE" = "$AFTER" ] || { echo 'tree changed during run' >&2; exit 3; }
65
+ printf 'Baseline: cmd='
66
+ printf '%q ' "$@"
67
+ printf 'exit=%s log=%q fingerprint=%s includes=' "$RC" "$LOG" "$BEFORE"
68
+ printf '%q ' "${INCLUDES[@]}"
69
+ printf '\n'
70
+ exit "$RC" ;;
71
+ esac
@@ -1,14 +1,17 @@
1
1
  #!/bin/bash
2
- # harness-worker-evidence-validate.sh — block CXX direct execution without worker reports
2
+ # harness-worker-evidence-validate.sh — enforce tier-scoped role and worker evidence
3
+ # Usage: <root> [text|json|direct-work-sha] [all|latest-active|mission:<rel>]
3
4
  set -euo pipefail
4
5
 
5
6
  PROJECT_ROOT="${1:-.}"
6
7
  DOC_ROOT="$PROJECT_ROOT/.harness/documents"
7
8
 
8
- [ -d "$DOC_ROOT" ] || exit 0
9
-
10
9
  mode="${2:-text}"
11
10
  scope="${3:-all}"
11
+ if [ ! -d "$DOC_ROOT" ]; then
12
+ [[ "$scope" != mission:* && "$mode" != direct-work-sha ]] || exit 1
13
+ exit 0
14
+ fi
12
15
  violations=()
13
16
 
14
17
  # Section-scoped reading (conventions/shared.md): match `^>?\s*#{1,6}` and take
@@ -28,6 +31,29 @@ has_implementation_notes() {
28
31
  grep -Eq "${HEADING3}Open Questions[[:space:]]*$" "$file"
29
32
  }
30
33
 
34
+ has_role_notes() {
35
+ if [ "$tier" -lt 2 ]; then
36
+ grep -Eq "${HEADING2}Implementation Notes[[:space:]]*$" "$1"
37
+ else
38
+ has_implementation_notes "$1"
39
+ fi
40
+ }
41
+
42
+ # Preserve the exact body bytes, including trailing spaces and final newline.
43
+ direct_work_sha() {
44
+ node - "$1" <<'JS'
45
+ const fs = require('fs'), crypto = require('crypto');
46
+ const text = fs.readFileSync(process.argv[2], 'utf8');
47
+ const heading = /^[ \t]*>?[ \t]*##[ \t]+Direct Work[ \t]*\r?$/m.exec(text);
48
+ if (!heading) process.exit(1);
49
+ let start = heading.index + heading[0].length;
50
+ if (text[start] === '\n') start++;
51
+ const rest = text.slice(start);
52
+ const next = /^[ \t]*>?[ \t]*#/m.exec(rest);
53
+ process.stdout.write(crypto.createHash('sha256').update(rest.slice(0, next ? next.index : rest.length)).digest('hex') + '\n');
54
+ JS
55
+ }
56
+
31
57
  has_worker_report() {
32
58
  local mission_dir="$1"
33
59
  local owner="${2:-}"
@@ -43,7 +69,10 @@ has_legacy_flat_worker_report() {
43
69
  find "$mission_dir/workers" -maxdepth 1 -type f -name '*.md' 2>/dev/null | grep -q .
44
70
  }
45
71
 
46
- mission_dirs=$(find "$DOC_ROOT" -type f \( -name 'ceo.md' -o -name 'coo.md' -o -name 'cdo.md' -o -name 'cto.md' -o -name 'cqo.md' -o -name 'ops.md' \) -exec dirname {} \; 2>/dev/null | sort -u)
72
+ mission_dirs=""
73
+ if [[ "$scope" != mission:* ]]; then
74
+ mission_dirs=$(find "$DOC_ROOT" -type f \( -name 'ceo.md' -o -name 'coo.md' -o -name 'cdo.md' -o -name 'cto.md' -o -name 'cqo.md' -o -name 'ops.md' \) -exec dirname {} \; 2>/dev/null | sort -u)
75
+ fi
47
76
 
48
77
  mission_mtime() {
49
78
  stat -f '%m' "$1" 2>/dev/null || stat -c '%Y' "$1" 2>/dev/null || echo 0
@@ -79,13 +108,36 @@ EOF
79
108
  mission_dirs="$latest_active_mission"
80
109
  fi
81
110
 
111
+ if [[ "$scope" == mission:* ]]; then
112
+ mission_rel="${scope#mission:}"
113
+ case "$mission_rel" in ""|/*|*..*) echo "invalid mission path" >&2; exit 1 ;; esac
114
+ mission_dirs="$(cd "$DOC_ROOT/$mission_rel" 2>/dev/null && pwd -P)" || exit 1
115
+ case "$mission_dirs/" in "$(cd "$DOC_ROOT" && pwd -P)/"*) ;; *) exit 1 ;; esac
116
+ [ -f "$mission_dirs/mission-state.json" ] || exit 1
117
+ fi
118
+
119
+ if [ "$mode" = direct-work-sha ]; then
120
+ [[ "$scope" == mission:* ]] || exit 1
121
+ direct_work_sha "$mission_dirs/cto.md"
122
+ exit $?
123
+ fi
124
+
82
125
  while IFS= read -r mission_dir; do
83
126
  [ -n "$mission_dir" ] || continue
84
127
  [ -d "$mission_dir" ] || continue
85
128
  mission_name="${mission_dir#$DOC_ROOT/}"
129
+ tier=2
130
+ if [ -f "$mission_dir/mission-state.json" ]; then
131
+ tier=$(jq -r 'def r: if . == "S" then 0 elif . == "M" then 1 else 2 end;
132
+ [.tier, (.tier_history[]? | .from, .to)] | map(r) | max' "$mission_dir/mission-state.json")
133
+ fi
134
+ if [ -f "$PROJECT_ROOT/.harness/config.json" ]; then
135
+ enabled=$(jq -r 'if .behavior.mission_tiers == null then true else .behavior.mission_tiers end' "$PROJECT_ROOT/.harness/config.json")
136
+ [ "$enabled" != false ] || tier=2
137
+ fi
86
138
 
87
139
  ceo_path="$mission_dir/ceo.md"
88
- if [ -s "$ceo_path" ] && ! has_implementation_notes "$ceo_path"; then
140
+ if [ -s "$ceo_path" ] && ! has_role_notes "$ceo_path"; then
89
141
  violations+=("$mission_name:ceo.md-missing-implementation-notes")
90
142
  fi
91
143
 
@@ -96,10 +148,41 @@ while IFS= read -r mission_dir; do
96
148
  for cxx in coo cdo cto cqo ops; do
97
149
  cxx_path="$mission_dir/$cxx.md"
98
150
  [ -s "$cxx_path" ] || continue
99
- if ! has_implementation_notes "$cxx_path"; then
151
+ # Summoned but nothing to do: a one-reason N/A report completes the role.
152
+ if [[ "$cxx" == coo || "$cxx" == cdo || "$cxx" == ops ]] && grep -Eq "${HEADING2}Not Applicable[[:space:]]*$" "$cxx_path"; then
153
+ continue
154
+ fi
155
+ if ! has_role_notes "$cxx_path"; then
100
156
  violations+=("$mission_name:$cxx.md-missing-implementation-notes")
101
157
  fi
102
- if ! has_worker_report "$mission_dir" "$cxx"; then
158
+ exempt=false
159
+ if [ "$tier" -eq 0 ] && [[ "$cxx" == cto || "$cxx" == cqo ]]; then
160
+ exempt=true
161
+ elif [ "$tier" -lt 2 ] && [ "$cxx" = ops ]; then
162
+ # S/M OPS observes directly; workers only for recovery or monitoring setup.
163
+ exempt=true
164
+ elif [ "$cxx" = cto ] && [ -f "$mission_dir/mission-state.json" ] && jq -e 'any(.tier_history[]?; .from == "S" and (.to == "M" or .to == "L"))' "$mission_dir/mission-state.json" >/dev/null; then
165
+ frozen=$(jq -r '[.tier_history[]? | select(.from == "S" and (.to == "M" or .to == "L"))][0].direct_work_sha256 // empty' "$mission_dir/mission-state.json")
166
+ if [ -n "$frozen" ]; then
167
+ current=$(direct_work_sha "$cxx_path") || current=""
168
+ if [ "$current" != "$frozen" ]; then
169
+ violations+=("$mission_name:cto-direct-work-modified-after-upgrade")
170
+ else
171
+ post=$(awk '
172
+ /^[[:space:]]*>?[[:space:]]*##[[:space:]]+Post-Upgrade Work[[:space:]]*$/ { inb=1; next }
173
+ inb && /^[[:space:]]*>?[[:space:]]*#/ { exit }
174
+ inb && /[^[:space:]]/ { sub(/^[[:space:]]*/, ""); print; exit }
175
+ ' "$cxx_path")
176
+ # ponytail: trust the declaration; add git-diff enforcement if abuse is observed.
177
+ if [[ "$post" == none* ]] || { [ -n "$post" ] && has_worker_report "$mission_dir" cto; }; then
178
+ exempt=true
179
+ else
180
+ violations+=("$mission_name:cto-post-upgrade-work-without-worker")
181
+ fi
182
+ fi
183
+ fi
184
+ fi
185
+ if [ "$exempt" != true ] && ! has_worker_report "$mission_dir" "$cxx"; then
103
186
  violations+=("$mission_name:$cxx.md")
104
187
  fi
105
188
  workers_dir="$mission_dir/$cxx/workers"
@@ -113,7 +196,7 @@ while IFS= read -r mission_dir; do
113
196
  done
114
197
 
115
198
  cqo_path="$mission_dir/cqo.md"
116
- if [ -s "$cqo_path" ] && grep -Eq '\b(ACCEPTED|REJECTED|PASS|FAIL)\b' "$cqo_path" && ! has_worker_report "$mission_dir" "cqo"; then
199
+ if [ "$tier" -ne 0 ] && [ -s "$cqo_path" ] && grep -Eq '\b(ACCEPTED|REJECTED|PASS|FAIL)\b' "$cqo_path" && ! has_worker_report "$mission_dir" "cqo"; then
117
200
  violations+=("$mission_name:cqo-verdict-without-evaluator")
118
201
  fi
119
202
  done <<EOF
@@ -131,6 +214,10 @@ if [ -f "$PROGRESS_FILE" ]; then
131
214
  report_abs="$wreport"
132
215
  case "$wreport" in /*) ;; *) report_abs="$PROJECT_ROOT/$wreport" ;; esac
133
216
  [ -f "$report_abs" ] || continue
217
+ if [[ "$scope" == mission:* ]]; then
218
+ report_abs="$(cd "$(dirname "$report_abs")" && pwd -P)/$(basename "$report_abs")"
219
+ case "$report_abs" in "$mission_dirs/"*) ;; *) continue ;; esac
220
+ fi
134
221
  # Section-scoped reading: the Status body may be quoted or indented.
135
222
  if awk '
136
223
  /^[[:space:]]*>?[[:space:]]*##[[:space:]]+Status[[:space:]]*$/ { inb=1; next }
@@ -160,7 +247,7 @@ if [ "$mode" = "json" ]; then
160
247
  | {ok:false, violations:.}
161
248
  '
162
249
  else
163
- echo "CXX worker evidence violation:"
250
+ echo "CXX worker evidence violation (unknown tier → L):"
164
251
  for violation in "${violations[@]}"; do
165
252
  mission="${violation%%:*}"
166
253
  docs="${violation#*:}"