okstra 0.107.1 → 0.107.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "okstra",
3
- "version": "0.107.1",
3
+ "version": "0.107.2",
4
4
  "description": "Multi-agent cross-verification orchestrator runtime + Claude Code skills.",
5
5
  "license": "MIT",
6
6
  "author": "devonshin",
@@ -1,5 +1,5 @@
1
1
  {
2
- "package": "0.107.1",
3
- "builtAt": "2026-07-01T07:21:31.825Z",
2
+ "package": "0.107.2",
3
+ "builtAt": "2026-07-01T09:21:00.281Z",
4
4
  "repoRoot": "/home/runner/work/okstra/okstra"
5
5
  }
@@ -12,8 +12,16 @@
12
12
  # are left for graceful shutdown). It no-ops when tmux is unavailable or nothing
13
13
  # is stale.
14
14
  #
15
- # Usage: okstra-team-reconcile.sh [--list] <team-name>
16
- # --list report what WOULD be deactivated; do not write (alias --dry-run).
15
+ # Usage:
16
+ # okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>
17
+ # okstra-team-reconcile.sh [--list] --project-root <dir> [--fallback-team <label>]
18
+ # okstra-team-reconcile.sh [--list] --session-end # reads SessionEnd JSON on stdin
19
+ #
20
+ # --project-root resolve the CURRENT live session's roster (robust to the
21
+ # phase-3 lead.sessionId snapshot drifting across resume), then
22
+ # reconcile and print `dismissible-member: <name>` lines.
23
+ # --session-end SessionEnd-hook net: reconcile the ending session's roster.
24
+ # --list report what WOULD be deactivated; do not write (alias --dry-run).
17
25
  #
18
26
  # Failures are non-fatal to the run — teardown must never block on this.
19
27
  set -u
@@ -218,7 +218,7 @@ These phases are governed by [team-contract](./team-contract.md). It is the cano
218
218
  Claude Code **v2.1.178 removed the `TeamCreate` / `TeamDelete` tools and the `Agent(...)` `team_name` parameter**. With `CLAUDE_CODE_EXPERIMENTAL_AGENT_TEAMS=1` (seeded by `okstra install` into the user's `settings.json`), every session already owns **one implicit team** from the moment it starts — there is no creation step to perform and no `team_name` to pass on dispatch. Workers spawned with `Agent(name: ...)` auto-join that implicit team. Do NOT search for, probe, or call `TeamCreate`: its absence is the expected state on every current Claude Code, NOT an "Agent Teams unavailable" signal, and is never a reason to degrade the run. There is no `select:TeamCreate` probe and no doubly-empty-select branch anymore — those keyed off a tool that no longer exists.
219
219
 
220
220
  1. Record team-state before any dispatch:
221
- - `teamName` — the verbatim name from the launch prompt's gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`, single-stage final-verification appends `-fv-s<N>`). Audit/display label only — the implicit team's on-disk directory is `session-<leadSessionId-prefix>`, which run-end teardown and reconcile resolve from `lead.sessionId`, NEVER from `teamName`.
221
+ - `teamName` — the verbatim name from the launch prompt's gate block (`okstra-<task-key>`; implementation stage runs append `-s<N>`, single-stage final-verification appends `-fv-s<N>`). Audit/display label only — the implicit team's on-disk directory is `session-<leadSessionId-prefix>`, but run-end teardown resolves it from the CURRENT live session (see `_common-contract.md` "Run-end teammate teardown"), NOT from the phase-3 `lead.sessionId` snapshot recorded here, which drifts across resume / compaction; it is never resolved from `teamName`.
222
222
  - `teamCreate: { attempted: false, status: "implicit" }` — the audit marker that this run rides the session's implicit team (no explicit creation tool exists). A concurrent-run launch prompt instead pre-records `status: "skipped", reason: "concurrent-run"`; honor whichever the gate specifies.
223
223
  2. Verify `team-state.lead.sessionId` is populated. The `okstra.sh` exec path fills it automatically (`generate_claude_session_id` → `claude --session-id ...`). The render-only / in-session takeover path (`okstra-run` skill) auto-detects via `resolve_inproc_lead_session_id`, which is best-effort and may return empty if `~/.claude/projects/<encoded-cwd>/` is unreadable or has no jsonl yet. If `lead.sessionId` is empty at this point, write the running session's id into team-state before proceeding — Phase 7 token-usage collection depends on it and will fail with `lead jsonl not found (sessionId=)` otherwise.
224
224
  3. Decide split-pane capability, record it, then emit the phase-3 checkpoint and proceed to Phase 4. Split-pane teammates require a tmux session — this is a host capability, NOT an okstra success/failure gate, and is the only thing the removed "TeamCreate" probe should ever have been deciding:
@@ -277,7 +277,9 @@ Section numbering follows `templates/reports/final-report.template.md` exactly
277
277
  - `approach` — 그래서 어떤 작업이 필요한가 (택한 방향).
278
278
  - `justification` — 왜 이게 합리적 선택인가 (대안 대비, 근거).
279
279
 
280
- Every field MUST anchor its claim with at least one evidence reference — a `path:line`, a report ID (`C-001` / `F-013` / `§5.4`), or another cited source already present in this report. When the evidence is genuinely insufficient, say so explicitly (`근거 불충분 — <이유>`) instead of inventing one. `validators/validate-run.py` → `_validate_rationale_evidence` fails any field carrying neither a citation nor an insufficiency marker, so an ungrounded or fabricated rationale is rejected at validation time. Do NOT restate the Verdict Card or the §5.4 trade-off matrix verbatim — this section is the *why*, in connected prose, that those tables compress.
280
+ Every field MUST anchor its claim with at least one evidence reference — a `path:line`, an **in-report** ID (`C-001` / `E-006` / `§5.4`), or another cited source already present in this report. When the evidence is genuinely insufficient, say so explicitly (`근거 불충분 — <이유>`) instead of inventing one. `validators/validate-run.py` → `_validate_rationale_evidence` fails any field carrying neither a citation nor an insufficiency marker, so an ungrounded or fabricated rationale is rejected at validation time.
281
+
282
+ **Reader-facing prose MUST NOT cite a bare brief/worker-internal ID that this report never surfaces** — `RC-*` (reporter confirmations, defined in the brief), `RF-*` / `F-*` (findings, defined in worker-results) have no anchor in the final report, so a reader hits an opaque token with nothing to click. Either expand it inline (`the confirmed version target 1.27.47→1.27.48`) or, for an audit trail, namespace it (`claude:F-005`). **Enforced:** `_validate_no_opaque_id_references` fails a bare `RC-*` / `RF-*` / non-namespaced `F-*` appearing in `verdictCard` / `finalVerdict` / `rationale` / `clarificationItems[].statement`. (The renderer anchors + links in-report IDs of any digit width, so a surfaced `RC-4`-style id does resolve.) Do NOT restate the Verdict Card or the §5.4 trade-off matrix verbatim — this section is the *why*, in connected prose, that those tables compress.
281
283
 
282
284
  0. **Clarification Response Carried In** — render this `## 0.` heading ONLY when `{{CLARIFICATION_RESPONSE_RELATIVE_PATH}}` is non-empty. Walk every `C-*` row of the prior report's `## 1. Clarification Items` table, reconcile against new evidence, and record the outcome (`resolved` / `obsolete`) with citation before drafting the verdict. When no carry-in path was provided, OMIT the `## 0.` heading entirely — the validator fails an empty Section 0 stub.
283
285
  1. **Cross Verification Results** — 4 categories (Full / Partial / Contested / Worker-Unique) when convergence is enabled, per `convergence`. Prepend the Round History sub-table (columns: `Round | inputQueueSize | resolvedCount | carriedForwardCount | dispatches | skippedWorkers`) plus a `round2SkippedReason: <value>` note, pulled verbatim from `convergence-<task-type>-<seq>.json`. Empty contested list renders as `- 합의 미달 항목 없음.`. Convergence-disabled runs use the legacy Consensus/Differences format and omit the round table.
@@ -55,15 +55,16 @@ profile document.
55
55
  - This step is mandatory for every phase (`requirements-discovery`, `error-analysis`, `implementation-planning`, `implementation`, `final-verification`, `release-handoff`). Whether there is anything to ask about is decided by the `--list` output, NOT by the lead's reading of `lead-pane.id`: an empty list → skip the question (above); the lead MUST NOT fabricate a synthetic pane list.
56
56
  - Run-end teammate teardown (shared — MUST run AFTER the pane disposition question and BEFORE `PROGRESS: complete`):
57
57
  - CC v2.1.178 removed `TeamCreate` / `TeamDelete`: this run rode the session's **implicit team** (Phase 3 recorded `teamCreate.status: "implicit"` plus the `teamName` audit label). There is no tool to delete a team — the implicit team ends when the session ends. What teardown CAN do is gracefully dismiss the worker teammates the lead spawned, so they stop showing as live members in the FleetView roster.
58
- - **Whether to run this step is decided by on-disk fact.** The harness names the team directory `session-<leadSessionId-prefix>` (the `teamName` label never becomes a directory name). Resolve existence from disk: find the `~/.claude/teams/session-*/config.json` whose `leadSessionId` equals team-state `lead.sessionId`.
59
- - **Matching on-disk config found, with members beyond the lead** → teammates exist; run the dismissal sequence below.
60
- - **No matching on-disk config, or split-pane was off** → nothing to dismiss → silent-skip.
58
+ - **Whether to run this step is decided by on-disk fact, resolved at teardown time by the CURRENT live session — NOT from `team-state.lead.sessionId`.** The harness names the team directory `session-<leadSessionId-prefix>` (the `teamName` label never becomes a directory name), but `team-state.lead.sessionId` is only a phase-3 snapshot: Claude Code re-issues the session id across resume / compaction mid-run, so the live roster usually sits under a DIFFERENT `session-*` dir than the snapshot names. Resolving from the snapshot is what made split-pane teardown silently miss its own live teammates. The resolver in step 1 below keys off the live session (falling back to the snapshot dir only when the live one is absent) and prints the members to dismiss.
59
+ - **Resolver printed one or more `dismissible-member: <name>` lines** → teammates exist; run the dismissal sequence below.
60
+ - **Resolver printed `no live roster for this session`, or split-pane was off** → nothing to dismiss → silent-skip.
61
61
  - The pane disposition prompt above is the only user prompt for this cleanup decision. Do NOT ask a second teammate-only question.
62
62
  - On `아니오` / `n` / `keep` → dismiss nothing. Tell the user the teammates remain visible in FleetView/Teams until the session ends, and can be removed earlier from the Teams UI.
63
63
  - On `예` / `y` / `close` → emit `PROGRESS: phase-7-teardown dismissing teammates`, then run the sequence below. Token-usage collection MUST already be complete — it reads `~/.claude/projects/` jsonls, which dismissal never touches, but the read MUST precede teardown.
64
- 1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh "<resolved-config-path>"` the on-disk `~/.claude/teams/session-<…>/config.json` resolved in the existence check above exactly once. It flips dead-pane stale-active members to inactive, and no-ops when tmux is unavailable or nothing is stale. Do NOT loop it.
65
- 2. Send each confirmed-complete member a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field. NEVER target the lead's own session, and never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address).
64
+ 1. Run `$HOME/.okstra/bin/okstra-team-reconcile.sh --project-root "<PROJECT_ROOT>" --fallback-team "session-<lead.sessionId-prefix>"` exactly once. It resolves the live session's `~/.claude/teams/session-<live>/config.json` (falling back to the snapshot `session-<lead.sessionId-prefix>` dir only if the live one is absent), flips dead-pane stale-active members to inactive (no-op when tmux is unavailable or nothing is stale), and prints one `dismissible-member: <name>` line per non-lead member. Do NOT loop it, and do NOT hand-resolve a config path from `team-state.lead.sessionId`.
65
+ 2. Send each printed `dismissible-member: <name>` **whose completion is confirmed** (its Result Path exists) a structured `SendMessage(to: <name>, message: { type: "shutdown_request" })` — the `message` MUST be the object literal shown, NEVER a JSON string stuffed into a text field. NEVER target the lead's own session, and never use `TaskStop` (teammates are not background tasks — `TaskStop` 404s on a member address).
66
66
  - Claude Code exposes no tool to delete the team or surgically remove a roster entry, so a dismissed teammate may still show as an idle pill until the session ends. Do not pretend the team was deleted. If the user wants the roster gone entirely, surface the manual action: `Teams/FleetView 에서 team <teamName> 삭제`, and if tmux panes were kept earlier also show `$HOME/.okstra/bin/okstra-trace-cleanup.sh --run-dir "<RUN_DIR>"`.
67
+ - **Enforcement / safety net.** This teardown fires after the Phase 7 validator's window, so `validate_session_conformance.py` cannot check it (it is not a required checkpoint). The net is the `SessionEnd` hook `$HOME/.okstra/bin/okstra-team-reconcile.sh --session-end` (seeded into `settings.template.json`): when the Claude session ends it reconciles that session's roster regardless of whether this step ran, so a dead-pane member never lingers as a live pill past session end.
67
68
  - Report the outcome in one short line (e.g. `worker teammate 정리 완료`, or `stale 멤버 1명 reconcile 후 정리`) and proceed to the final `PROGRESS: complete ...` line.
68
69
  - Brief handoff contract (shared — applies whenever the run consumes a task brief produced by `okstra-brief`):
69
70
  - the brief is a **pre-discovery artifact**: it converts a domain-reporter's words (non-expert *or* developer) into expert-consumable form so this and later phases can run with zero fill-in questions to the operator. The brief is **not** authoritative on solution decisions; it is authoritative on the reporter's intent.
@@ -136,10 +136,12 @@ def _yaml_inline_list(values: list[str]) -> str:
136
136
  # reference must not silently jump to the wrong row.
137
137
  _HEADING_RE = re.compile(r"^(#{1,6})[ \t]+(.*?)[ \t]*$")
138
138
  _FENCE_RE = re.compile(r"^[ \t]*(?:```|~~~)")
139
- _FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{3,})\b")
139
+ _FIRST_CELL_ID_RE = re.compile(r"^[ \t]*\*{0,2}([A-Z]{1,4}-\d{1,})\b")
140
140
  # A reference token: an ID not glued to a word char / `-` on either side and
141
141
  # not prefixed by `:` (which would make it a `<worker>:<item-id>` source ref).
142
- _REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{3,}(?![\w-])")
142
+ # `\d{1,}` (not `{3,}`): short IDs like `RC-4` are valid — the old 3-digit floor
143
+ # silently dropped them, so they could never anchor or link.
144
+ _REF_ID_RE = re.compile(r"(?<![:\w-])[A-Z]{1,4}-\d{1,}(?![\w-])")
143
145
 
144
146
 
145
147
  def _slugify(text: str) -> str:
@@ -16,6 +16,13 @@ CC v2.1.178 removed ``TeamDelete``: the implicit team ends with the session and
16
16
  run-end teardown only dismisses teammates via ``shutdown_request``. Reconcile
17
17
  keeps the roster honest so a dismissed-but-dead member does not linger as a
18
18
  live pill — see _common-contract.md "Run-end teammate teardown".
19
+
20
+ Roster resolution (``--project-root``) exists because ``team-state.lead.sessionId``
21
+ is a phase-3 snapshot: Claude Code re-issues the session id across resume /
22
+ compaction mid-run, so the live on-disk roster often sits under a DIFFERENT
23
+ ``session-*`` dir than the snapshot names. Resolving by the CURRENT live session
24
+ (the newest jsonl under the encoded-cwd projects dir) is what makes split-pane
25
+ teardown find the teammates it must dismiss.
19
26
  """
20
27
  from __future__ import annotations
21
28
 
@@ -26,6 +33,8 @@ import sys
26
33
  import tempfile
27
34
  from pathlib import Path
28
35
 
36
+ from okstra_ctl.session import resolve_inproc_lead_session_id
37
+
29
38
 
30
39
  def live_pane_ids() -> tuple[bool, set[str]]:
31
40
  """Return ``(tmux_available, live_pane_ids)``.
@@ -66,6 +75,44 @@ def reconcile_members(config: dict, live_panes: set[str]) -> list[str]:
66
75
  return flipped
67
76
 
68
77
 
78
+ def dismissible_members(config: dict) -> list[str]:
79
+ """Names of every non-lead member — the set the lead may ``shutdown_request``.
80
+
81
+ The lead further filters by confirmed-complete (result path present) before
82
+ dismissing; this just tells it which teammates the resolved roster holds.
83
+ """
84
+ lead = config.get("leadAgentId")
85
+ names: list[str] = []
86
+ for member in config.get("members", []):
87
+ if member.get("agentId") == lead:
88
+ continue
89
+ names.append(member.get("name", member.get("agentId", "?")))
90
+ return names
91
+
92
+
93
+ def _team_config_for_session(session_id: str) -> Path:
94
+ """``~/.claude/teams/session-<prefix>/config.json`` for a session id.
95
+
96
+ The harness names the team dir with the session id's first segment (the
97
+ 8-hex stem before the first dash), never the ``teamName`` label.
98
+ """
99
+ prefix = session_id.split("-", 1)[0]
100
+ return Path.home() / ".claude" / "teams" / f"session-{prefix}" / "config.json"
101
+
102
+
103
+ def _live_team_config(project_root: Path) -> Path | None:
104
+ """Resolve the CURRENT live session's team config, or None when absent.
105
+
106
+ Robust to the mid-run session-id drift that strands the phase-3
107
+ ``lead.sessionId`` snapshot: keys off the live session, not the snapshot.
108
+ """
109
+ sid = resolve_inproc_lead_session_id(project_root)
110
+ if not sid:
111
+ return None
112
+ cand = _team_config_for_session(sid)
113
+ return cand if cand.is_file() else None
114
+
115
+
69
116
  def _config_path(team_arg: str) -> Path:
70
117
  """Resolve a team name, a team dir, or a config.json path to the config file."""
71
118
  p = Path(team_arg)
@@ -92,43 +139,126 @@ def _atomic_write(path: Path, text: str) -> None:
92
139
  raise
93
140
 
94
141
 
95
- def main(argv: list[str]) -> int:
96
- dry_run = False
97
- rest: list[str] = []
98
- for arg in argv:
99
- if arg in ("--list", "--dry-run"):
100
- dry_run = True
101
- else:
102
- rest.append(arg)
103
- if len(rest) != 1:
104
- print("usage: okstra-team-reconcile.sh [--list] <team-name|team-dir|config.json>",
105
- file=sys.stderr)
106
- return 2
107
-
108
- cfg_path = _config_path(rest[0])
109
- if not cfg_path.is_file():
110
- print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
111
- return 1
142
+ def _reconcile_config(cfg_path: Path, dry_run: bool, print_members: bool) -> int:
143
+ """Read, reconcile, and (unless dry-run) write back one config.json.
112
144
 
145
+ When ``print_members`` is set, also emit one ``dismissible-member: <name>``
146
+ line per non-lead member so the lead knows whom to ``shutdown_request``.
147
+ """
113
148
  try:
114
149
  config = json.loads(cfg_path.read_text())
115
150
  except (OSError, json.JSONDecodeError) as exc:
116
151
  print(f"team-reconcile: cannot read team config at {cfg_path}: {exc}",
117
152
  file=sys.stderr)
118
153
  return 1
154
+
119
155
  available, live = live_pane_ids()
120
156
  if not available:
121
157
  print("team-reconcile: tmux unavailable — skipped (no flip)")
158
+ else:
159
+ flipped = reconcile_members(config, live)
160
+ if flipped and dry_run:
161
+ print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
162
+ elif flipped:
163
+ _atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
164
+ print(f"team-reconcile: deactivated {len(flipped)} stale member(s): "
165
+ + ", ".join(flipped))
166
+ else:
167
+ print("team-reconcile: no stale-active members")
168
+
169
+ if print_members:
170
+ for name in dismissible_members(config):
171
+ print(f"dismissible-member: {name}")
172
+ return 0
173
+
174
+
175
+ def _session_id_from_stdin() -> str:
176
+ """Extract ``session_id`` from a SessionEnd hook's JSON payload on stdin.
177
+
178
+ Returns "" when stdin is a terminal (manual invocation), empty, or not the
179
+ expected JSON — the caller then no-ops. The hook must never block or fail a
180
+ session teardown.
181
+ """
182
+ if sys.stdin.isatty():
183
+ return ""
184
+ try:
185
+ raw = sys.stdin.read()
186
+ except OSError:
187
+ return ""
188
+ if not raw.strip():
189
+ return ""
190
+ try:
191
+ data = json.loads(raw)
192
+ except json.JSONDecodeError:
193
+ return ""
194
+ sid = data.get("session_id") or data.get("sessionId") or ""
195
+ return sid if isinstance(sid, str) else ""
196
+
197
+
198
+ def _run_resolve_and_reconcile(project_root: str, fallback_team: str, dry_run: bool) -> int:
199
+ """``--project-root`` mode: resolve the live roster, reconcile, list members."""
200
+ cfg = _live_team_config(Path(project_root))
201
+ if cfg is None and fallback_team:
202
+ fb = _config_path(fallback_team)
203
+ cfg = fb if fb.is_file() else None
204
+ if cfg is None:
205
+ print("team-reconcile: no live roster for this session — nothing to dismiss")
122
206
  return 0
207
+ print(f"team-reconcile: roster resolved at {cfg}")
208
+ return _reconcile_config(cfg, dry_run, print_members=True)
209
+
123
210
 
124
- flipped = reconcile_members(config, live)
125
- if not flipped:
126
- print("team-reconcile: no stale-active members")
211
+ def _run_session_end(dry_run: bool) -> int:
212
+ """``--session-end`` mode: reconcile the ending session's roster from the
213
+ SessionEnd hook payload. Safety net so a lead that skipped run-end teardown
214
+ still leaves no dead-pane member lingering as a live pill."""
215
+ sid = _session_id_from_stdin()
216
+ if not sid:
127
217
  return 0
128
- if dry_run:
129
- print("team-reconcile (dry-run): would deactivate " + ", ".join(flipped))
218
+ cfg = _team_config_for_session(sid)
219
+ if not cfg.is_file():
130
220
  return 0
221
+ return _reconcile_config(cfg, dry_run, print_members=False)
131
222
 
132
- _atomic_write(cfg_path, json.dumps(config, indent=2, ensure_ascii=False) + "\n")
133
- print(f"team-reconcile: deactivated {len(flipped)} stale member(s): " + ", ".join(flipped))
134
- return 0
223
+
224
+ def _parse_argv(argv: list[str]) -> tuple[bool, str, str, bool, list[str]]:
225
+ dry_run = False
226
+ project_root = ""
227
+ fallback_team = ""
228
+ session_end = False
229
+ rest: list[str] = []
230
+ it = iter(argv)
231
+ for arg in it:
232
+ if arg in ("--list", "--dry-run"):
233
+ dry_run = True
234
+ elif arg == "--project-root":
235
+ project_root = next(it, "")
236
+ elif arg == "--fallback-team":
237
+ fallback_team = next(it, "")
238
+ elif arg == "--session-end":
239
+ session_end = True
240
+ else:
241
+ rest.append(arg)
242
+ return dry_run, project_root, fallback_team, session_end, rest
243
+
244
+
245
+ def main(argv: list[str]) -> int:
246
+ dry_run, project_root, fallback_team, session_end, rest = _parse_argv(argv)
247
+
248
+ if session_end:
249
+ return _run_session_end(dry_run)
250
+ if project_root:
251
+ return _run_resolve_and_reconcile(project_root, fallback_team, dry_run)
252
+
253
+ if len(rest) != 1:
254
+ print("usage: okstra-team-reconcile.sh [--list] "
255
+ "(<team-name|team-dir|config.json> | --project-root <dir> "
256
+ "[--fallback-team <label>] | --session-end)",
257
+ file=sys.stderr)
258
+ return 2
259
+
260
+ cfg_path = _config_path(rest[0])
261
+ if not cfg_path.is_file():
262
+ print(f"team-reconcile: no team config at {cfg_path}", file=sys.stderr)
263
+ return 1
264
+ return _reconcile_config(cfg_path, dry_run, print_members=False)
@@ -47,6 +47,10 @@
47
47
  {
48
48
  "type": "command",
49
49
  "command": "$HOME/.okstra/bin/okstra-trace-cleanup.sh --reap"
50
+ },
51
+ {
52
+ "type": "command",
53
+ "command": "$HOME/.okstra/bin/okstra-team-reconcile.sh --session-end"
50
54
  }
51
55
  ]
52
56
  }
@@ -1527,6 +1527,7 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1527
1527
  failures.append(f"final-report data.json: {err}")
1528
1528
 
1529
1529
  _validate_rationale_evidence(data, failures)
1530
+ _validate_no_opaque_id_references(data, failures)
1530
1531
 
1531
1532
  task_type = (data.get("header") or {}).get("taskType")
1532
1533
  if task_type == "final-verification":
@@ -1541,8 +1542,10 @@ def validate_final_report_data(report_path: Path, failures: list[str]) -> None:
1541
1542
  _validate_requirement_coverage_covered_by(data, failures)
1542
1543
 
1543
1544
 
1544
- # path:line (foo.service.ts:268), report ID (C-001 / F-013 / G-crit-002 / RC-1),
1545
- # or a section reference (§5.4) — any one satisfies "this claim is anchored".
1545
+ # path:line (foo.service.ts:268), an in-report ID (C-001 / E-006 / P-004), a
1546
+ # namespaced audit ref (claude:F-005), or a §section reference (§5.4) — any one
1547
+ # satisfies "this claim is anchored". A bare brief/worker ID (RC-*/RF-*/F-*) is
1548
+ # handled separately by `_validate_no_opaque_id_references`.
1546
1549
  _EVIDENCE_TOKEN = re.compile(
1547
1550
  r"[\w./-]+\.\w+:\d+|\b[A-Z]{1,3}(?:-[a-z]+)?-\d+\b|§\s*\d")
1548
1551
  # Explicit "I don't know" escapes — anti-fabrication's other valid answer.
@@ -1573,12 +1576,61 @@ def _validate_rationale_evidence(data: dict, failures: list[str]) -> None:
1573
1576
  continue
1574
1577
  failures.append(
1575
1578
  f"final-report data.json: rationale.{field} cites no evidence "
1576
- f"(expected a path:line, a report ID like C-001/F-013, or a §"
1579
+ f"(expected a path:line, an in-report ID like C-001, or a §"
1577
1580
  f"section reference) and gives no explicit insufficiency marker "
1578
1581
  f"(e.g. '근거 불충분'). Anchor the claim or state what is unknown."
1579
1582
  )
1580
1583
 
1581
1584
 
1585
+ # Brief/worker-internal ID families that are NOT surfaced (anchored) in the
1586
+ # final report: `RC-` reporter confirmations live in the brief, `F-`/`RF-`
1587
+ # findings live in worker-results. A reader-facing sentence citing a bare one
1588
+ # of these has no in-report target to resolve — the reader is stuck. Namespaced
1589
+ # audit citations (`claude:F-005`) are exempt via the `:` negative lookbehind.
1590
+ _OPAQUE_ID_RE = re.compile(r"(?<![:\w-])(?:RC|RF|F)-\d+(?![\w-])")
1591
+
1592
+
1593
+ def _reader_facing_narrative(data: dict) -> list[tuple[str, str]]:
1594
+ """(label, text) for every reader-facing narrative string in the report —
1595
+ the fields a human reads to act, where an unresolvable ID is most harmful."""
1596
+ out: list[tuple[str, str]] = []
1597
+ for section in ("verdictCard", "finalVerdict"):
1598
+ block = data.get(section)
1599
+ if isinstance(block, dict):
1600
+ for field in ("finalConclusion", "nextStep"):
1601
+ value = block.get(field)
1602
+ if isinstance(value, str):
1603
+ out.append((f"{section}.{field}", value))
1604
+ rationale = data.get("rationale")
1605
+ if isinstance(rationale, dict):
1606
+ for field in _RATIONALE_FIELDS:
1607
+ value = rationale.get(field)
1608
+ if isinstance(value, str):
1609
+ out.append((f"rationale.{field}", value))
1610
+ for row in data.get("clarificationItems") or []:
1611
+ if isinstance(row, dict) and isinstance(row.get("statement"), str):
1612
+ out.append((f"clarificationItems[{row.get('id') or '?'}].statement", row["statement"]))
1613
+ return out
1614
+
1615
+
1616
+ def _validate_no_opaque_id_references(data: dict, failures: list[str]) -> None:
1617
+ """Reader-facing prose must not cite a bare brief/worker-internal ID
1618
+ (`RC-*`, `RF-*`, non-namespaced `F-*`) that the report never surfaces — the
1619
+ reader has nothing to click and no definition to find. Expand it inline
1620
+ (`the confirmed version target 1.27.47→1.27.48`) or namespace an audit
1621
+ citation (`claude:F-005`)."""
1622
+ for label, text in _reader_facing_narrative(data):
1623
+ offenders = sorted({m.group(0) for m in _OPAQUE_ID_RE.finditer(text)})
1624
+ if offenders:
1625
+ failures.append(
1626
+ f"final-report data.json: {label} cites brief/worker-internal "
1627
+ f"ID(s) {', '.join(offenders)} that this report never surfaces "
1628
+ "(no anchor to resolve them to). Expand them to plain language "
1629
+ "on the reference, or namespace an audit citation like "
1630
+ "`claude:F-005` — a reader cannot resolve a bare RC-/RF-/F- token."
1631
+ )
1632
+
1633
+
1582
1634
  def _validate_implementation_planning_cross_project(data: dict, failures: list[str]) -> None:
1583
1635
  """타 프로젝트 의존을 DM 행(`kind == 'cross-project'`)으로 선언했다면
1584
1636
  `crossProjectDependencies` 에 `direction == 'upstream-precondition'` 행이