diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 14202c7..dcf19e2 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -44,11 +44,30 @@ jobs: - name: Verify plan outputs env: MISSING: ${{ steps.plan.outputs.plan-missing }} + MEMORY: ${{ steps.plan.outputs.plan-memory-exact }} + MEMORY_DEPRECATED: ${{ steps.plan.outputs.plan-tm-exact }} TOKENS: ${{ steps.plan.outputs.plan-token-estimate }} run: | - echo "plan: missing=${MISSING} tokens=${TOKENS}" + echo "plan: missing=${MISSING} memory=${MEMORY} tokens=${TOKENS}" test "${MISSING}" -gt 0 test "${TOKENS}" -gt 0 + test "${MEMORY}" -ge 0 + # plan-tm-exact is the deprecated name of plan-memory-exact. + test "${MEMORY_DEPRECATED}" = "${MEMORY}" + + test-plan-outputs: + name: "up --plan outputs for kapi 1.2 and 1.3 (kapi stub)" + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v6 + + # kapi 1.3 reports the exact content-memory count as memoryExact, and + # kapi 1.2 as tmExact. The harness runs the plan step on each and checks + # the outputs, the job summary and the PR comment. + - name: Run the plan harness + run: | + yq --version + test/plan-outputs.sh test-check-gate: name: "check --ship (exit-3 contract)" @@ -383,8 +402,8 @@ jobs: test -f test/fixture/content/en_qps.json test "${STATUS}" = "no-changes" - # The README sends callers to setup-kapi's cache-tm for caching rather than a - # cache step of their own. The save job runs the action and finds kapi's parse + # The README sends callers to setup-kapi's parse cache rather than a cache + # step of their own. The save job runs the action and finds kapi's parse # cache where setup-kapi saves it, and nothing at the retired .kapi/cache path; # the restore job must find that cache in place before the action runs again. test-parse-cache-save: diff --git a/README.md b/README.md index 290c3bc..3e9b7b4 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # Kapi Action -A GitHub Action that runs [kapi](https://github.com/neokapi/neokapi) commands — catch up translations, gate content quality, plan cost — and delivers the results — as a commit, a pull request, or a report on the PR that caused the work. +A GitHub Action that runs [kapi](https://github.com/neokapi/neokapi) commands that bring a project's content and its translations up to date, gate content quality and plan the cost of a change. It reports what changed to a delivery step you own (a commit or a pull request), and can post a report on the pull request that caused the work. ## Prerequisites @@ -67,9 +67,9 @@ Parked is the interesting one: partial progress is real progress, so the default `kapi up` treats the recipe as the desired state — the languages the project targets, and the ship gates that define *shippable* — and reconciles the content toward it. Each pass, for every language behind its gate: -1. **Reuse** — exact translation-memory matches fill first, for free. -2. **Translate** — the configured AI provider fills what remains, with the project's terminology and brand context. -3. **Check** — deterministic checks run over what was produced (placeholder integrity, inline tags, do-not-translate terms, untranslated text). A unit with a failing finding counts as *drafted*, not translated — it cannot clear a gate until fixed. +1. **Reuse**: exact content-memory matches fill first, for free. +2. **Translate**: the configured AI provider fills what remains, with the project's terminology and voice context. +3. **Check**: deterministic checks run over what was produced (placeholder integrity, inline tags, do-not-translate terms, untranslated text). A unit with a failing finding still counts as *translated*, and the finding holds its locale out of shipping until it is fixed. Passes repeat until every language clears its gate, a pass makes no progress, or the pass cap is reached. @@ -77,16 +77,16 @@ Passes repeat until every language clears its gate, a pass makes no progress, or flowchart LR S[source changes] --> U[kapi up] subgraph PASS ["each pass, per language behind its gate"] - TM["1 · reuse
TM exact matches"] --> AI["2 · translate
AI + terminology"] --> CK["3 · check
placeholders · terms · tags"] + MEM["1 · reuse
content memory exact matches"] --> AI["2 · translate
AI + terminology"] --> CK["3 · check
placeholders · terms · tags"] end U --> PASS CK -->|every gate met| CV["up to date
changes ready to deliver"] CK -->|needs a person| PK["parked
the review queue"] - PK --> RV["review & approve
committed under .kapi/state"] + PK --> RV["review & approve
recorded in the project's context"] RV -.->|next run sees it| U ``` -**Parked work is the review queue, not an error.** What the machine couldn't decide waits for a person: review the wording, approve or fix it, and `kapi commit` records the decision under `.kapi/state/`, or the connected server records it. Approvals raise the `reviewed` coverage the ship gate measures, so the next run and the next gate see them. `kapi check --ship` (see [Gate pull requests](#gate-pull-requests-on-content-quality)) is what enforces the bar at release time. +**Parked work is the review queue, not an error.** What the machine couldn't decide waits for a person: review the wording, approve or fix it, and the approval is recorded in the project's context (with `kapi apply` or the Review page of Kapi Desktop), or the connected server records it. Push it with `kapi context push`, and a run with `context-sync` takes it in. Approvals raise the `established` coverage the ship gate measures, so the next run and the next gate see them. `kapi check --ship` (see [Gate pull requests](#gate-pull-requests-on-content-quality)) is what enforces the bar at release time. The kapi up report (outcome, passes, parked locales) is always written to the job summary. Under the hood the Action runs `kapi up --json`, an NDJSON stream — one convergence event per line, closed by a single `{"type":"result", ...}` record. That record is the contract; the events are the log. It becomes the `outcome`, `passes`, and `parked-locales` outputs. @@ -134,7 +134,7 @@ App token instead (both delivery actions above accept a `token:` input). ### Plan mode: the cost of a change, on its PR -`plan: "true"` dry-runs the kapi loop — pending work, TM leverage, and a token estimate, with no writes and no provider calls (so it needs no API keys). With `pr-comment: "true"` on a pull-request event, the plan lands as one sticky comment that re-runs update in place: +`plan: "true"` dry-runs the kapi loop and reports pending work, content-memory leverage and a token estimate, with no writes and no provider calls (so it needs no API keys). With `pr-comment: "true"` on a pull-request event, the plan lands as one sticky comment that re-runs update in place: ```yaml name: Translation plan @@ -157,7 +157,7 @@ jobs: pr-comment: "true" ``` -> "This change leaves **42 unit(s)** of pending translation work: 30 recoverable from TM, 12 for AI (~450 tokens estimated)." +> "This change leaves **42 unit(s)** of pending translation work: 30 recoverable from content memory, 12 for AI (~450 tokens estimated)." ### Gate pull requests on content quality @@ -287,9 +287,9 @@ context, settles, and pushes the rules it established. ### Caching -`neokapi/setup-kapi@v1` carries kapi's parse cache (`.kapi/work/cache/docs`) between runs. Its `cache-tm` input is on by default for a project with a `kapi.yaml`, so this Action needs no cache step of its own. Set setup-kapi's `project-dir` when the recipe is not at the repository root. +`neokapi/setup-kapi@v1` carries kapi's parse cache (`.kapi/work/cache/docs`) between runs. Its `parse-cache` input is on by default for a project with a `kapi.yaml`, so this Action needs no cache step of its own. Older setup-kapi releases name the input `cache-tm`, and newer ones still accept that name with a deprecation warning. Set setup-kapi's `project-dir` when the recipe is not at the repository root. -Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets and review state a run produced, and a restored copy changes what `kapi status`, `kapi check --ship` and `kapi up` report. +Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets a run produced, and a restored copy changes what `kapi status`, `kapi check --ship` and `kapi up` report. ## Inputs @@ -298,7 +298,7 @@ Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets an | `command` | `up` | Kapi subcommand to execute | | `args` | | Additional arguments | | `project` | | Path to the `kapi.yaml` recipe (`-p` flag) | -| `plan` | `false` | With `command: up`: dry run — pending work, TM leverage, token estimate; no writes, no provider calls | +| `plan` | `false` | With `command: up`: a dry run reporting pending work, content-memory leverage and a token estimate; no writes, no provider calls | | `fail-on-parked` | `false` | With `command: up`, fail the workflow when the run parks instead of reporting partial progress | | `pr-comment` | `false` | Sticky report comment on pull-request events, including for a failed gate or a check that did not run | | `token` | `${{ github.token }}` | Token for the sticky PR comment | @@ -316,7 +316,8 @@ Leave the rest of `.kapi/work/` out of any cache. Its store holds the targets an | `gate` | With `command: check`: `pass` or `fail` (empty when the check did not run or errored) | | `result` | With `command: check`: `passed` (exit 0), `failed` (exit 3), `did_not_run` (exit 4), or `error` (any other exit code) | | `did-not-run-cause` | With `command: check`, when `result` is `did_not_run`: `checker_invalid`, `nothing_to_check`, `content_not_checked`, or `unknown` | -| `plan-missing` / `plan-tm-exact` / `plan-ai-remaining` / `plan-token-estimate` | With `plan: true`: the plan totals | +| `plan-missing` / `plan-memory-exact` / `plan-ai-remaining` / `plan-token-estimate` | With `plan: true`: the plan totals; `plan-memory-exact` counts the units recoverable from exact content-memory matches | +| `plan-tm-exact` | Deprecated: use `plan-memory-exact`, which holds the same value | | `has-changes` | Whether the run left changes in the working tree for your delivery step | | `changed-files` | Newline-separated paths the run changed | diff --git a/action.yml b/action.yml index 7a602bf..3259b4d 100644 --- a/action.yml +++ b/action.yml @@ -1,5 +1,5 @@ name: "Kapi Run" -description: "Run kapi commands — catch up translations, gate content quality, plan cost — and report what changed. Delivery (commit, push, PR) is your workflow's own step" +description: "Run kapi commands that bring a project's content and its translations up to date, gate content quality and plan cost, and report what changed. Delivery (commit, push, PR) is your workflow's own step" branding: icon: "refresh-cw" color: "blue" @@ -19,8 +19,9 @@ inputs: default: "" plan: description: >- - With `command: up`, dry-run instead: report pending work, TM leverage, - and a token estimate without writing anything or calling a provider. + With `command: up`, dry-run instead: report pending work, content-memory + leverage, and a token estimate without writing anything or calling a + provider. Pairs with `pr-comment` to post the cost of a change on its PR. required: false default: "false" @@ -87,9 +88,12 @@ outputs: plan-missing: description: "With `plan: true`: pending units across all locales" value: ${{ steps.run-kapi.outputs.plan-missing }} + plan-memory-exact: + description: "With `plan: true`: units recoverable from exact content-memory matches" + value: ${{ steps.run-kapi.outputs.plan-memory-exact }} plan-tm-exact: - description: "With `plan: true`: units recoverable from TM exact hits" - value: ${{ steps.run-kapi.outputs.plan-tm-exact }} + description: "Deprecated: use `plan-memory-exact`, which holds the same value" + value: ${{ steps.run-kapi.outputs.plan-memory-exact }} plan-ai-remaining: description: "With `plan: true`: units left for AI translation" value: ${{ steps.run-kapi.outputs.plan-ai-remaining }} @@ -146,28 +150,30 @@ runs: printf 'Running:'; printf ' %q' "${cmd[@]}"; echo "${cmd[@]}" > "${RUNNER_TEMP}/kapi-plan.json" MISSING=$(jq -r '.totals.missingTarget // 0' "${RUNNER_TEMP}/kapi-plan.json") - TM=$(jq -r '.totals.tmExact // 0' "${RUNNER_TEMP}/kapi-plan.json") + # kapi 1.3 names the exact content-memory count memoryExact; kapi 1.2 + # wrote the same number as tmExact. + MEMORY=$(jq -r '.totals.memoryExact // .totals.tmExact // 0' "${RUNNER_TEMP}/kapi-plan.json") AI=$(jq -r '.totals.aiRemaining // 0' "${RUNNER_TEMP}/kapi-plan.json") TOKENS=$(jq -r '.totals.tokenEstimate // 0' "${RUNNER_TEMP}/kapi-plan.json") { echo "mode=plan" echo "plan-missing=${MISSING}" - echo "plan-tm-exact=${TM}" + echo "plan-memory-exact=${MEMORY}" echo "plan-ai-remaining=${AI}" echo "plan-token-estimate=${TOKENS}" } >> "$GITHUB_OUTPUT" { echo "## kapi up — plan" echo - echo "This change leaves **${MISSING} unit(s)** of pending translation work: ${TM} recoverable from TM, ${AI} for AI (~${TOKENS} tokens estimated)." + echo "This change leaves **${MISSING} unit(s)** of pending translation work: ${MEMORY} recoverable from content memory, ${AI} for AI (~${TOKENS} tokens estimated)." echo - echo "| Locale | Pending | TM exact | AI remaining | Token estimate |" + echo "| Locale | Pending | Content memory exact | AI remaining | Token estimate |" echo "|---|---|---|---|---|" - jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.tmExact) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" + jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.memoryExact // .tmExact // 0) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" echo echo "_Estimate is source-chars/4; no provider calls were made._" } >> "$GITHUB_STEP_SUMMARY" - echo "Plan: ${MISSING} unit(s) pending, ${TM} from TM, ${AI} for AI (~${TOKENS} tokens)." + echo "Plan: ${MISSING} unit(s) pending, ${MEMORY} from content memory, ${AI} for AI (~${TOKENS} tokens)." exit 0 fi @@ -197,7 +203,7 @@ runs: { echo "## kapi check" echo - echo "❌ **Gate: unmet** — a brand, terminology, QA, or coverage gate failed. See the findings in the job log." + echo "❌ **Gate: unmet**. A voice, terminology, QA or coverage gate failed. See the findings in the job log." } >> "$GITHUB_STEP_SUMMARY" echo "::error::kapi gate unmet (exit 3) — content quality gates failed; read the findings and fix them. This is 'not on-spec yet', not a crash." exit 3 @@ -380,7 +386,7 @@ runs: if [ -z "$PATHS" ]; then # No paths specified — check the entire working tree. `kapi init` # gitignores the .kapi/ state directory, so what shows up here is the - # localized content the run produced, including brand-new locale files + # target-language content the run produced, including brand-new locale files # (which a tracked-files-only diff would miss). DIRT="$(git status --porcelain)" else @@ -426,7 +432,7 @@ runs: CAUSE: ${{ steps.run-kapi.outputs.did-not-run-cause }} DID_NOT_RUN_SUMMARY: ${{ steps.run-kapi.outputs.did-not-run-summary }} PLAN_MISSING: ${{ steps.run-kapi.outputs.plan-missing }} - PLAN_TM: ${{ steps.run-kapi.outputs.plan-tm-exact }} + PLAN_MEMORY: ${{ steps.run-kapi.outputs.plan-memory-exact }} PLAN_AI: ${{ steps.run-kapi.outputs.plan-ai-remaining }} PLAN_TOKENS: ${{ steps.run-kapi.outputs.plan-token-estimate }} RUN_URL: ${{ github.server_url }}/${{ github.repository }}/actions/runs/${{ github.run_id }} @@ -435,13 +441,13 @@ runs: MARKER="" if [ "$MODE" = "plan" ]; then - TABLE=$(jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.tmExact) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" 2>/dev/null || true) + TABLE=$(jq -r '.scopes[] | "| \(.locale) | \(.missingTarget) | \(.memoryExact // .tmExact // 0) | \(.aiRemaining) | \(.tokenEstimate) |"' "${RUNNER_TEMP}/kapi-plan.json" 2>/dev/null || true) BODY="${MARKER} ## kapi up — plan - This change leaves **${PLAN_MISSING:-0} unit(s)** of pending translation work: ${PLAN_TM:-0} recoverable from TM, ${PLAN_AI:-0} for AI (~${PLAN_TOKENS:-0} tokens estimated). + This change leaves **${PLAN_MISSING:-0} unit(s)** of pending translation work: ${PLAN_MEMORY:-0} recoverable from content memory, ${PLAN_AI:-0} for AI (~${PLAN_TOKENS:-0} tokens estimated). - | Locale | Pending | TM exact | AI remaining | Token estimate | + | Locale | Pending | Content memory exact | AI remaining | Token estimate | |---|---|---|---|---| ${TABLE} diff --git a/test/check-outcomes.sh b/test/check-outcomes.sh index 8d46a5f..db72b29 100755 --- a/test/check-outcomes.sh +++ b/test/check-outcomes.sh @@ -115,7 +115,7 @@ comment_case() { GATE="$(output_value gate "$out")" RESULT="$(output_value result "$out")" \ CAUSE="$(output_value did-not-run-cause "$out")" \ DID_NOT_RUN_SUMMARY="$(output_value did-not-run-summary "$out")" \ - PLAN_MISSING="" PLAN_TM="" PLAN_AI="" PLAN_TOKENS="" \ + PLAN_MISSING="" PLAN_MEMORY="" PLAN_AI="" PLAN_TOKENS="" \ RUNNER_TEMP="$dir/tmp" \ bash "$work/comment.sh" > "$dir/comment.stdout" 2>&1 || rc=$? diff --git a/test/plan-output/kapi-1.2.json b/test/plan-output/kapi-1.2.json new file mode 100644 index 0000000..1d94935 --- /dev/null +++ b/test/plan-output/kapi-1.2.json @@ -0,0 +1,30 @@ +{ + "flow": "default (built-in)", + "flowDrafts": true, + "flowCallsProvider": true, + "scopes": [ + { + "locale": "de", + "collection": "app", + "missingTarget": 3, + "tmExact": 1, + "aiRemaining": 2, + "tokenEstimate": 7 + }, + { + "locale": "fr", + "collection": "app", + "missingTarget": 3, + "tmExact": 2, + "aiRemaining": 1, + "tokenEstimate": 4 + } + ], + "totals": { + "missingTarget": 6, + "tmExact": 3, + "aiRemaining": 3, + "tokenEstimate": 11 + }, + "note": "content-memory leverage counts exact-hash hits only; token estimate is source chars / 4 for the remaining units (no tokenizer, no provider calls)." +} diff --git a/test/plan-output/kapi-1.3.json b/test/plan-output/kapi-1.3.json new file mode 100644 index 0000000..dfc9c5e --- /dev/null +++ b/test/plan-output/kapi-1.3.json @@ -0,0 +1,30 @@ +{ + "flow": "default (built-in)", + "flowDrafts": true, + "flowCallsProvider": true, + "scopes": [ + { + "locale": "de", + "collection": "app", + "missingTarget": 3, + "memoryExact": 1, + "aiRemaining": 2, + "tokenEstimate": 7 + }, + { + "locale": "fr", + "collection": "app", + "missingTarget": 3, + "memoryExact": 2, + "aiRemaining": 1, + "tokenEstimate": 4 + } + ], + "totals": { + "missingTarget": 6, + "memoryExact": 3, + "aiRemaining": 3, + "tokenEstimate": 11 + }, + "note": "content-memory leverage counts exact-hash hits only; token estimate is source chars / 4 for the remaining units (no tokenizer, no provider calls)." +} diff --git a/test/plan-outputs.sh b/test/plan-outputs.sh new file mode 100755 index 0000000..9803a7b --- /dev/null +++ b/test/plan-outputs.sh @@ -0,0 +1,115 @@ +#!/usr/bin/env bash +# Runs the action's "Run kapi" step in plan mode against the kapi stub in +# test/stub, once with the plan kapi 1.3 prints (memoryExact) and once with the +# plan kapi 1.2 printed (tmExact), and asserts the outputs, the job summary and +# the sticky PR comment each produces. The step scripts are read out of +# action.yml, so the code under test is the code the action ships. Needs yq +# (v4) and jq. +set -euo pipefail + +here="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" +root="$(dirname "$here")" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +yq '.runs.steps[] | select(.id == "run-kapi") | .run' "$root/action.yml" > "$work/step.sh" +if ! grep -q 'kapi-plan.json' "$work/step.sh"; then + echo "could not read the run-kapi step out of action.yml" >&2 + exit 1 +fi +yq '.runs.steps[] | select(.id == "pr-comment") | .run' "$root/action.yml" > "$work/comment.sh" +if ! grep -q 'kapi-report' "$work/comment.sh"; then + echo "could not read the pr-comment step out of action.yml" >&2 + exit 1 +fi + +failures=0 + +output_value() { + sed -n "s/^$1=//p" "$2" | tail -n 1 +} + +fail() { + echo "FAIL $1: $2" + failures=$((failures + 1)) +} + +# contains NAME FILE TEXT asserts that FILE contains TEXT. +contains() { + grep -qF -- "$3" "$2" || fail "$1" "$(basename "$2") lacks '$3'" +} + +# plan_case NAME OUTPUT runs the plan step with the stub printing +# test/plan-output/OUTPUT, then the sticky PR comment step with what it wrote. +plan_case() { + local name="$1" output="$2" + local dir="$work/cases/$name" + mkdir -p "$dir/tmp" + : > "$dir/output" + : > "$dir/summary" + local before="$failures" + + local rc=0 + env \ + PATH="$here/stub:$PATH" \ + COMMAND=up ARGS="" PROJECT="" PLAN=true FAIL_ON_PARKED=false \ + RUNNER_TEMP="$dir/tmp" GITHUB_OUTPUT="$dir/output" GITHUB_STEP_SUMMARY="$dir/summary" \ + KAPI_STUB_OUTPUT="$here/plan-output/$output" KAPI_STUB_EXIT=0 KAPI_STUB_ARGV="$dir/argv" \ + bash "$work/step.sh" > "$dir/stdout" 2>&1 || rc=$? + [ "$rc" = 0 ] || fail "$name" "exit code: got '$rc', want '0'" + + local got + for pair in mode=plan plan-missing=6 plan-memory-exact=3 plan-ai-remaining=3 plan-token-estimate=11; do + got="$(output_value "${pair%%=*}" "$dir/output")" + [ "$got" = "${pair#*=}" ] || fail "$name" "${pair%%=*}: got '$got', want '${pair#*=}'" + done + got="$(tr '\n' ' ' < "$dir/argv")" + [ "$got" = "up --plan --json " ] || fail "$name" "kapi argv: got '$got', want 'up --plan --json '" + + contains "$name" "$dir/summary" "3 recoverable from content memory, 3 for AI (~11 tokens estimated)." + contains "$name" "$dir/summary" "| Locale | Pending | Content memory exact | AI remaining | Token estimate |" + contains "$name" "$dir/summary" "| de | 3 | 1 | 2 | 7 |" + contains "$name" "$dir/summary" "| fr | 3 | 2 | 1 | 4 |" + contains "$name" "$dir/stdout" "Plan: 6 unit(s) pending, 3 from content memory, 3 for AI (~11 tokens)." + + rc=0 + env \ + PATH="$here/stub:$PATH" GH_STUB_LOG="$dir/gh" \ + GH_TOKEN=unused REPO=neokapi/kapi-action PR_NUMBER=1 \ + RUN_URL="https://github.com/neokapi/kapi-action/actions/runs/1" \ + MODE="$(output_value mode "$dir/output")" OUTCOME="" PASSES="" PARKED="" \ + GATE="" RESULT="" CAUSE="" DID_NOT_RUN_SUMMARY="" \ + PLAN_MISSING="$(output_value plan-missing "$dir/output")" \ + PLAN_MEMORY="$(output_value plan-memory-exact "$dir/output")" \ + PLAN_AI="$(output_value plan-ai-remaining "$dir/output")" \ + PLAN_TOKENS="$(output_value plan-token-estimate "$dir/output")" \ + RUNNER_TEMP="$dir/tmp" \ + bash "$work/comment.sh" > "$dir/comment.stdout" 2>&1 || rc=$? + [ "$rc" = 0 ] || fail "$name comment" "exit code: got '$rc', want '0'" + if [ ! -f "$dir/gh/body.md" ]; then + fail "$name comment" "posted no comment" + else + contains "$name comment" "$dir/gh/body.md" "This change leaves **6 unit(s)** of pending translation work: 3 recoverable from content memory, 3 for AI (~11 tokens estimated)." + contains "$name comment" "$dir/gh/body.md" "| Locale | Pending | Content memory exact | AI remaining | Token estimate |" + contains "$name comment" "$dir/gh/body.md" "| de | 3 | 1 | 2 | 7 |" + contains "$name comment" "$dir/gh/body.md" "| fr | 3 | 2 | 1 | 4 |" + fi + + if [ "$failures" = "$before" ]; then + echo "ok $name" + else + sed 's/^/ | /' "$dir/stdout" "$dir/summary" + if [ -f "$dir/gh/body.md" ]; then + sed 's/^/ > /' "$dir/gh/body.md" + fi + fi +} + +plan_case kapi-1.3 kapi-1.3.json +plan_case kapi-1.2 kapi-1.2.json + +if [ "$failures" -gt 0 ]; then + echo "$failures assertion(s) failed" + exit 1 +fi +echo "all plan outputs passed"