Files
msd-core/gsd-core/workflows/graduation.md
Tom Boucher b6e6a22fce fix(#2402): honor response_language across orchestrator output + UAT checkpoint renderer (#2457)
* fix(#2402): honor response_language across orchestrator output + UAT checkpoint renderer

Replays the in-flight bot branch fix/2402-response-language-orchestrator-coverage
(seven commits, never pushed) onto current origin/next as a single squashed commit.
The original work was substantial and correct; this commit preserves its full scope,
trimmed where rebase conflicts + workflow size budgets required it.

Three independent layers where response_language was being dropped are closed:

Layer 1 — orchestrator-facing directives across workflows. Adds the strong
"All user-facing output in this workflow MUST be presented in {response_language};
technical terms, code, paths, and subagent prompts stay in English" directive
to ~40 workflows that previously either lacked it entirely (verify-work,
new-project, new-milestone, quick, manager, and ~35 more) or carried only
the weak subagent-prompt-only form (plan-phase, execute-phase). The directive
covers narration between tool calls and banner output, not just the
AskUserQuestion prompts.

Layer 2 — UAT checkpoint renderer (src/uat.cts). buildCheckpoint now accepts
an optional responseLanguage parameter and renders the frame strings
("CHECKPOINT: Verification Required", "Type `pass` or describe what's wrong.")
in any of 9 languages (English/Spanish/French/German/Portuguese/Japanese/
Chinese/Korean/Italian) with an alias table covering ~30 input variants
(en, es, español, ja, 日本語, etc.). cmdRenderCheckpoint reads
config.response_language via loadConfig(cwd) and passes it through, so the
byte-for-byte block verify-work.md reprints verbatim is already localized
when written — preserving the anti-injection hygiene rule at verify-work.md
(the model is forbidden to translate after the fact). CJK display width is
computed by East Asian Width property ranges (W/F) so the right ║ border of
the banner stays aligned for full-width characters. English fallback is
byte-identical to the pre-fix behavior when response_language is unset or
unrecognized.

Layer 3 — literal English report templates in execute-phase. The top-of-
workflow directive covers all template sites (templates are a structural
source, not literal output). Inline render-language notes that previously
sat at each template site were removed during the squash because they
pushed execute-phase.md over its frozen pre-phase-6 byte ceiling
(93600 — ADR-857 Phase 6 capstone). The single top directive covers the
same surface with fewer bytes.

Also extends src/docs.cts and src/init.cts to propagate response_language
into the init JSON bundle of the additional workflows so the directive can
read it.

Tests added:
- tests/uat.test.cjs: buildCheckpoint with unset/unrecognized language falls
  back to English default; recognized language swaps only the two frame
  strings while structural lines stay untouched; CJK display-width regression
  (independent recomputation of East Asian Width W/F ranges).
- tests/workspace.test.cjs, tests/docs-update.test.cjs: response_language
  wiring through docs.cts/init.cts.

References: #2402; reporter's three-layer triage + Layer-4 follow-up; the
byte-for-byte anti-injection hygiene rule at verify-work.md (the reason
Layer 2 must be renderer-side, not model-translated).

This is a squash of the in-flight bot branch — seven commits representing
the original implementation plus its subsequent fix/CJK-padding/test/
changeset/regen cycles, none of which were ever pushed or PR'd. The squash
captures the final coherent state.

* chore(#2402): backfill pr:2457 in .changeset/2402-response-language-orchestrator-coverage.md

* chore(#2402): regen golden + size baseline after rebase against #2315 (PR #2451)

Rebase conflicts were entirely in generated artifacts (golden-install-parity
fixtures + workflow-size-baseline.json). After taking theirs during rebase,
regenerated cleanly against the merged source tree.
2026-07-20 14:22:27 -04:00

12 KiB
Raw Blame History

graduation.md — LEARNINGS.md Cross-Phase Graduation Helper

Invoked by: transition.md step graduation_scan. Never invoked directly by users.

This workflow clusters recurring items across the last N phases' LEARNINGS.md files and surfaces promotion candidates to the developer via HITL. No item is promoted without explicit developer approval.


Configuration

Read from project config (config.json):

Key Default Description
features.graduation true Master on/off switch. false skips silently.
features.graduation_window 5 How many prior phases to scan
features.graduation_threshold 3 Minimum cluster size to surface

Step 1: Guard Checks

_GSD_SHIM_NAME="gsd-tools.cjs"; _GSD_RUNTIME_ROOT="${RUNTIME_DIR:-$(git rev-parse --show-toplevel 2>/dev/null || pwd)}"; GSD_TOOLS="${_GSD_RUNTIME_ROOT}/gsd-core/bin/${_GSD_SHIM_NAME}"; if [ -f "$GSD_TOOLS" ]; then gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.claude/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${_GSD_RUNTIME_ROOT}/.codex/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif command -v gsd-tools >/dev/null 2>&1; then GSD_TOOLS="$(command -v gsd-tools)"; gsd_run() { "$GSD_TOOLS" "$@"; }; elif [ -f "${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLAUDE_CONFIG_DIR:-$HOME/.claude}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${HERMES_HOME:-$HOME/.hermes}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CURSOR_CONFIG_DIR:-$HOME/.cursor}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEX_HOME:-$HOME/.codex}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GEMINI_CONFIG_DIR:-$HOME/.gemini}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${COPILOT_CONFIG_DIR:-$HOME/.copilot}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${WINDSURF_CONFIG_DIR:-$HOME/.codeium/windsurf}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${AUGMENT_CONFIG_DIR:-$HOME/.augment}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${TRAE_CONFIG_DIR:-$HOME/.trae}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${QWEN_CONFIG_DIR:-$HOME/.qwen}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CODEBUDDY_CONFIG_DIR:-$HOME/.codebuddy}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${CLINE_CONFIG_DIR:-$HOME/.cline}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${GROK_AGENTS_HOME:-$HOME/.agents}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${ANTIGRAVITY_CONFIG_DIR:-$HOME/.gemini/antigravity}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${OPENCODE_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/opencode}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; elif [ -f "${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}" ]; then GSD_TOOLS="${KILO_CONFIG_DIR:-${XDG_CONFIG_HOME:-$HOME/.config}/kilo}/gsd-core/bin/${_GSD_SHIM_NAME}"; gsd_run() { node "$GSD_TOOLS" "$@"; }; else echo "ERROR: gsd-tools.cjs not found at $GSD_TOOLS and gsd-tools is not on PATH. Run: npx -y @opengsd/gsd-core@latest --claude --local" >&2; exit 1; fi; if [ -n "${CLAUDE_ENV_FILE:-}" ] && [ -n "${GSD_TOOLS:-}" ]; then printf "export PATH='%s':\"\$PATH\"\n" "${GSD_TOOLS%/*}" >> "$CLAUDE_ENV_FILE" 2>/dev/null || true; fi
RESPONSE_LANGUAGE=$(gsd_run query config-get response_language --default "" 2>/dev/null || echo "")
GRADUATION_ENABLED=$(gsd_run query config-get features.graduation 2>/dev/null || echo "true")
GRADUATION_WINDOW=$(gsd_run query config-get features.graduation_window 2>/dev/null || echo "5")
GRADUATION_THRESHOLD=$(gsd_run query config-get features.graduation_threshold 2>/dev/null || echo "3")

If response_language is set: All user-facing questions, prompts, and explanations in this workflow MUST be presented in {response_language}. Technical terms, code, file paths, and subagent prompts stay in English — only user-facing output is translated.

Skip silently (print nothing) if:

  • features.graduation is false
  • Fewer than graduation_threshold completed prior phases exist (not enough data)

Skip silently (print nothing) if total items across all LEARNINGS.md files in the window is fewer than 5.


Step 2: Collect LEARNINGS.md Files

Find LEARNINGS.md files from the last N completed phases (excluding the phase currently completing):

find .planning/phases -name "*-LEARNINGS.md" | sort | tail -n "$GRADUATION_WINDOW"

For each file found:

  1. Parse the four category sections: ## Decisions, ## Lessons, ## Patterns, ## Surprises
  2. Extract each ### Item Title + body as a single item record: { category, title, body, source_phase, source_file }
  3. Skip items that already contain **Graduated:** — they have been promoted and must not re-surface

Step 3: Cluster by Lexical Similarity

For each category independently, cluster items using Jaccard similarity on tokenized title+body:

Tokenization: lowercase, strip punctuation, split on whitespace, remove stop words (a, an, the, is, was, in, on, at, to, for, of, and, or, but, with, from, that, this, by, as).

Jaccard similarity: |A ∩ B| / |A ∪ B| where A and B are token sets. Two items are in the same cluster if similarity ≥ 0.25.

Clustering algorithm: single-pass greedy — process items in phase order; add to the first cluster whose centroid (union of all cluster tokens) has similarity ≥ 0.25 with the new item; otherwise start a new cluster.

Cluster size filter: only surface clusters with distinct source phases ≥ graduation_threshold (not just total items — same item repeated in one phase still counts as 1 distinct phase).


Step 4: Check graduation_backlog in STATE.md

Read .planning/STATE.md graduation_backlog section (if present). Format:

graduation_backlog:
  - cluster_id: "{sha256-of-cluster-title}"
    status: "dismissed"   # or "deferred"
    deferred_until: "phase-N"  # only for deferred entries
    cluster_title: "{representative title}"

Skip any cluster whose cluster_id matches a dismissed entry.

Skip any cluster whose cluster_id matches a deferred entry where deferred_until phase has not yet completed.


Step 5: Surface Promotion Candidates

For each qualifying cluster, determine the suggested target file:

Category Suggested Target
decisions PROJECT.md — append under ## Validated Decisions (create section if absent)
patterns PATTERNS.md — append under the appropriate category section (create file if absent)
lessons PROJECT.md — append under ## Invariants (create section if absent)
surprises Flag for human review — if genuinely surprising 3+ times, something structural is wrong

Print the graduation report:

📚 Graduation scan across phases {M}–{N}:

  HIGH RECURRENCE ({K}/{WINDOW} phases)
  ├─ Cluster: "{representative title}"
  ├─ Category: {category}
  ├─ Sources: {list of NN-LEARNINGS filenames}
  └─ Suggested target: {target file} § {section}

  [repeat for each qualifying cluster, ordered HIGH→LOW recurrence]

For each cluster above, choose an action:
  P = Promote now   D = Defer (re-surface next transition)   X = Dismiss (never re-surface)   A = Defer all remaining

Step 6: HITL — Process Each Cluster

For each cluster (in order from Step 5), ask the developer:

Cluster: "{title}" [{category}, {K} phases] → {target}
Action [P/D/X/A]:

Use AskUserQuestion (or equivalent HITL primitive for the current runtime). If TEXT_MODE is true, display the cluster question as plain text and accept typed input. Accept single-character input: P, D, X, A (case-insensitive).

On P (Promote now):

  1. Read the target file (or create it with a standard header if absent)
  2. Append the cluster entry under the suggested section:
    ### {Cluster representative title}
    {Merged body — combine unique sentences across cluster items}
    
    **Sources:** Phase {A}, Phase {B}, Phase {C}
    **Promoted:** {ISO_DATE}
    
  3. For each source LEARNINGS.md item in the cluster, append **Graduated:** {target-file}:{ISO_DATE} after its last existing field
  4. Commit both the target file and all annotated LEARNINGS.md files in a single atomic commit: docs(learnings): graduate "{cluster title}" to {target-file}

On D (Defer):

Write to .planning/STATE.md under graduation_backlog:

- cluster_id: "{sha256}"
  status: "deferred"
  deferred_until: "phase-{NEXT_PHASE_NUMBER}"
  cluster_title: "{title}"

On X (Dismiss):

Write to .planning/STATE.md under graduation_backlog:

- cluster_id: "{sha256}"
  status: "dismissed"
  cluster_title: "{title}"

On A (Defer all):

Defer the current cluster (same as D) and skip all remaining clusters for this run, deferring each to the next transition. Print:

[graduation: deferred all remaining clusters to next transition]

Then proceed directly to Step 7.


Step 7: Completion Report

After processing all clusters, print:

Graduation complete: {promoted} promoted, {deferred} deferred, {dismissed} dismissed.

If no clusters qualified (all filtered by backlog or threshold), print:

[graduation: no qualifying clusters in phases {M}–{N}]

First-Run Behaviour

On the first transition after upgrading to a version that includes this workflow, all extant LEARNINGS.md files may produce a large batch of candidates at once. A [Defer all] shorthand is available: if the developer enters A at any cluster prompt, all remaining clusters for this run are deferred to the next transition.


No-Op Conditions (silent skip)

  • features.graduation = false
  • Fewer than graduation_threshold prior phases with LEARNINGS.md
  • Total items < 5 across the window
  • All qualifying clusters are in graduation_backlog as dismissed