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"