-
Notifications
You must be signed in to change notification settings - Fork 0
feat(planning): draft-goal-condition skill — docs-conformant /goal shape + mechanical length gate #592
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
feat(planning): draft-goal-condition skill — docs-conformant /goal shape + mechanical length gate #592
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
cb4fd54
feat(planning): add draft-goal-condition skill with mechanical /goal …
kyle-sexton 927a6e7
chore(planning): set executable bit on goal-condition-length scripts
kyle-sexton afef2f0
fix(planning): guard goal-condition counter failure; harden usage + docs
kyle-sexton 112b080
fix(planning): require live condition shape on goal doc-fetch failure
kyle-sexton File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,119 @@ | ||
| #!/usr/bin/env bash | ||
| # Mechanical character-length gate for a drafted /goal completion condition. | ||
| # | ||
| # A language model cannot reliably count characters, so conformance to the | ||
| # /goal condition character limit is decided here — deterministically, with NO | ||
| # model involvement. The limit is NOT baked in: the caller passes the value it | ||
| # read from the current official docs at authoring time (--limit), so this gate | ||
| # never rots when the documented limit changes between Claude Code versions. | ||
| # | ||
| # Exit 0 = condition length is within the limit (count <= limit) | ||
| # Exit 1 = condition exceeds the limit (count > limit) | ||
| # Exit 2 = usage or environment error (bad/missing --limit, empty condition, ...) | ||
| # | ||
| # Usage: | ||
| # bash goal-condition-length.sh --limit <N> [--file <path>] # else reads stdin | ||
| # bash goal-condition-length.sh --help | ||
| # | ||
| # Counting: Unicode code points ("characters"), locale-independent via perl when | ||
| # present, falling back to `wc -m`. A trailing newline (as a file editor adds) | ||
| # is not part of the pasted condition and is stripped before counting. | ||
| # | ||
| # Output (stdout, greppable): `chars=<n> limit=<N> status=<ok|over>` | ||
|
|
||
| set -uo pipefail | ||
|
|
||
| usage() { | ||
| # Sentinel range (not fixed line numbers) so the printed usage never silently | ||
| # truncates when the header grows or shrinks on a future edit. | ||
| sed -n '/^# Mechanical/,/^# Output/p' "${BASH_SOURCE[0]}" | sed 's/^# \{0,1\}//' | ||
| } | ||
|
|
||
| limit="" | ||
| file="" | ||
|
|
||
| while [[ $# -gt 0 ]]; do | ||
| case "$1" in | ||
| --help | -h) | ||
| usage | ||
| exit 0 | ||
| ;; | ||
| --limit) | ||
| limit="${2-}" | ||
| shift 2 || { | ||
| echo "error: --limit needs a value" >&2 | ||
| exit 2 | ||
| } | ||
| ;; | ||
| --limit=*) | ||
| limit="${1#*=}" | ||
| shift | ||
| ;; | ||
| --file) | ||
| file="${2-}" | ||
| shift 2 || { | ||
| echo "error: --file needs a value" >&2 | ||
| exit 2 | ||
| } | ||
| ;; | ||
| --file=*) | ||
| file="${1#*=}" | ||
| shift | ||
| ;; | ||
| *) | ||
| echo "error: unknown argument: $1" >&2 | ||
| exit 2 | ||
| ;; | ||
| esac | ||
| done | ||
|
|
||
| # The limit is supplied by the caller (read live from the official docs); this | ||
| # gate deliberately has no default so a stale number can never be baked in. | ||
| if [[ -z "$limit" ]]; then | ||
| echo "error: --limit <N> is required (pass the current limit read from the official /goal docs)" >&2 | ||
| exit 2 | ||
| fi | ||
| if ! [[ "$limit" =~ ^[0-9]+$ ]] || [[ "$limit" -eq 0 ]]; then | ||
| echo "error: --limit must be a positive integer, got: $limit" >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Read the condition. Command substitution strips trailing newlines, which is | ||
| # the desired normalization: a pasted /goal condition carries none. | ||
| if [[ -n "$file" ]]; then | ||
| if [[ ! -f "$file" ]]; then | ||
| echo "error: --file not found: $file" >&2 | ||
| exit 2 | ||
| fi | ||
| condition="$(cat -- "$file")" | ||
| else | ||
| condition="$(cat)" | ||
| fi | ||
|
|
||
| if [[ -z "$condition" ]]; then | ||
| echo "error: empty condition (nothing to measure)" >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| # Count characters (Unicode code points), not bytes. | ||
| if command -v perl >/dev/null 2>&1; then | ||
| chars="$(printf '%s' "$condition" | perl -CSAD -e 'my $c = do { local $/; <STDIN> }; print length $c;')" | ||
| else | ||
| chars="$(printf '%s' "$condition" | wc -m | tr -d '[:space:]')" | ||
| fi | ||
|
|
||
| # Without set -e, a crashed counter would leave $chars empty and the -gt test | ||
| # below would error-and-fall-through to a false "status=ok". Fail loudly instead: | ||
| # a length gate that silently passes when its own counter broke is worse than useless. | ||
| if ! [[ "$chars" =~ ^[0-9]+$ ]]; then | ||
| echo "error: character count failed (counter returned: '${chars:-<empty>}')" >&2 | ||
| exit 2 | ||
| fi | ||
|
|
||
| if [[ "$chars" -gt "$limit" ]]; then | ||
| echo "chars=$chars limit=$limit status=over" | ||
| exit 1 | ||
| fi | ||
|
|
||
| echo "chars=$chars limit=$limit status=ok" | ||
| exit 0 |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,103 @@ | ||
| #!/usr/bin/env bash | ||
| # Black-box contract test for goal-condition-length.sh. | ||
| # | ||
| # Self-contained and cwd-independent; mutates only its own mktemp dir. Fixture | ||
| # limits are small, arbitrary numbers — never the documented /goal limit — so | ||
| # the tool and its test stay grep-clean of any baked-in doc value. | ||
| set -uo pipefail | ||
|
|
||
| SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)" | ||
| SUT="$SCRIPT_DIR/goal-condition-length.sh" | ||
|
|
||
| fails=0 | ||
| pass() { printf 'ok - %s\n' "$1"; } | ||
| fail() { | ||
| printf 'FAIL - %s\n' "$1" >&2 | ||
| fails=$((fails + 1)) | ||
| } | ||
|
|
||
| TMP="$(mktemp -d)" | ||
| trap 'rm -rf "$TMP"' EXIT | ||
|
|
||
| # Assert an exit code from running the SUT with stdin `input` and given args. | ||
| # Usage: expect_exit <label> <want_code> <input> <args...> | ||
| expect_exit() { | ||
| local label="$1" want="$2" input="$3" | ||
| shift 3 | ||
| local got | ||
| printf '%s' "$input" | bash "$SUT" "$@" >/dev/null 2>&1 | ||
| got=$? | ||
| if [[ "$got" -eq "$want" ]]; then pass "$label"; else fail "$label (want exit $want, got $got)"; fi | ||
| } | ||
|
|
||
| # 1. --help exits 0. | ||
| if bash "$SUT" --help >/dev/null 2>&1; then pass "--help exits 0"; else fail "--help should exit 0"; fi | ||
|
|
||
| # 2. Under limit -> exit 0. | ||
| expect_exit "under limit -> 0" 0 "hello" --limit 50 | ||
|
|
||
| # 3. Exactly at limit -> exit 0 (inclusive boundary: 'up to N characters'). | ||
| expect_exit "at limit (boundary) -> 0" 0 "abcde" --limit 5 | ||
|
|
||
| # 4. Over limit -> exit 1. | ||
| expect_exit "over limit -> 1" 1 "abcdef" --limit 5 | ||
|
|
||
| # 5. Missing --limit -> exit 2. | ||
| expect_exit "missing --limit -> 2" 2 "hello" | ||
|
|
||
| # 6. Non-integer limit -> exit 2. | ||
| expect_exit "non-integer limit -> 2" 2 "hello" --limit abc | ||
|
|
||
| # 7. Zero limit -> exit 2. | ||
| expect_exit "zero limit -> 2" 2 "hello" --limit 0 | ||
|
|
||
| # 8. Empty condition -> exit 2. | ||
| expect_exit "empty condition -> 2" 2 "" --limit 50 | ||
|
|
||
| # 9. Trailing newline is stripped (5 chars, not 6) -> at-limit passes. | ||
| expect_exit "trailing newline stripped -> 0" 0 $'abcde\n' --limit 5 | ||
|
|
||
| # 10. Reads condition from --file. | ||
| printf 'abcdef' >"$TMP/cond.txt" | ||
| bash "$SUT" --limit 5 --file "$TMP/cond.txt" >/dev/null 2>&1 | ||
| rc=$? | ||
| if [[ $rc -eq 1 ]]; then pass "--file over limit -> 1"; else fail "--file over limit -> wrong code ($rc)"; fi | ||
|
|
||
| # 11. Missing --file target -> exit 2. | ||
| bash "$SUT" --limit 5 --file "$TMP/nope.txt" >/dev/null 2>&1 | ||
| rc=$? | ||
| if [[ $rc -eq 2 ]]; then pass "missing --file -> 2"; else fail "missing --file -> wrong code ($rc)"; fi | ||
|
|
||
| # 12. stdout is greppable and reports the count and status. | ||
| out="$(printf 'abcdef' | bash "$SUT" --limit 5 2>/dev/null)" | ||
| if [[ "$out" == "chars=6 limit=5 status=over" ]]; then pass "stdout reports chars/limit/status"; else fail "stdout wrong: '$out'"; fi | ||
|
|
||
| # 13. Multibyte char counts as one code point, not its byte length. | ||
| # Skipped (optional-tool SKIP convention) when perl is absent and `wc -m` | ||
| # would fall back to a byte count in a non-UTF-8 locale. | ||
| if command -v perl >/dev/null 2>&1; then | ||
| # 'héllo' = 5 code points; passes a limit of 5. | ||
| expect_exit "multibyte counts as 1 code point -> 0" 0 $'h\xc3\xa9llo' --limit 5 | ||
| else | ||
| printf 'SKIP - multibyte code-point count (perl absent)\n' | ||
| fi | ||
|
|
||
| # 14. Counter failure is caught, not silently passed. Shadow BOTH counting | ||
| # backends (perl and wc) with stubs that emit nothing, so whichever branch | ||
| # the script takes yields an empty count. The guard must then exit 2 rather | ||
| # than fall through to a false status=ok on a crashed counter. | ||
| fake_bin="$TMP/fakebin" | ||
| mkdir -p "$fake_bin" | ||
| for stub in perl wc; do | ||
| printf '#!/usr/bin/env bash\ncat >/dev/null 2>&1\nexit 0\n' >"$fake_bin/$stub" | ||
| chmod +x "$fake_bin/$stub" | ||
| done | ||
| rc=0 | ||
| printf 'hello' | PATH="$fake_bin:$PATH" bash "$SUT" --limit 50 >/dev/null 2>&1 || rc=$? | ||
| if [[ $rc -eq 2 ]]; then pass "counter failure -> 2 (no false pass)"; else fail "counter failure -> wrong code ($rc)"; fi | ||
|
|
||
| if [[ "$fails" -ne 0 ]]; then | ||
| printf '\n%d test(s) failed.\n' "$fails" >&2 | ||
| exit 1 | ||
| fi | ||
| printf '\nAll goal-condition-length.sh tests passed.\n' |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,83 @@ | ||
| --- | ||
| name: draft-goal-condition | ||
| description: "Craft a paste-ready /goal completion condition from a stated intent — the autonomous-goal / keep-working-toward-a-goal field Claude Code evaluates after every turn. Reads the current official /goal docs live for the condition shape and character limit (never hardcodes them), drafts a transcript-demonstrable condition (measurable end state + stated check + constraints + optional turn/time bound), and proves it fits the limit with a deterministic character counter instead of model guesswork. Use for 'craft a /goal', 'write a goal condition', 'set up an autonomous goal', 'make Claude keep working until X', 'my /goal is too long / over the limit', 'turn this into a completion condition'; skip and route elsewhere when the work is interval-shaped (/loop), cloud/sessionless (routines, /schedule), or a one-shot prompt." | ||
| argument-hint: "[intent]" | ||
| user-invocable: true | ||
| disable-model-invocation: false | ||
| --- | ||
|
|
||
| ## Variables | ||
|
|
||
| Arguments: `$ARGUMENTS` — the natural-language intent for the autonomous run, plus any constraints or bounds the user volunteers. | ||
|
|
||
| ## Purpose | ||
|
|
||
| Turn a stated intent into a paste-ready `/goal <condition>` string that conforms to the shape the official Claude Code docs prescribe and provably fits the documented character limit. A language model cannot reliably count characters, so length conformance is decided by a deterministic counter, not by estimation. | ||
|
|
||
| The `/goal` contract — its condition shape and its character limit — can change between Claude Code versions. This skill therefore reads the **current** official docs at authoring time and never bakes those values into its own text. The number you see in a doc today is not a constant to memorize. | ||
|
|
||
| ## Step 0 — Lever fit (is `/goal` even the right tool?) | ||
|
|
||
| `/goal` starts the next turn when the previous one finishes and stops when a fresh evaluator model confirms a completion condition holds. Before authoring, confirm that fits the intent. If it does not, route instead of drafting: | ||
|
|
||
| - **Interval-driven** ("every 5 minutes", "poll until") → `/loop` (a time interval starts each turn), not `/goal`. | ||
| - **Cloud / sessionless / scheduled** ("nightly", "each morning", runs with no session open) → routines / `/schedule`. | ||
| - **Custom per-turn logic across all sessions** (deterministic script check, settings-scoped) → a prompt-based Stop hook. | ||
| - **One-shot** (a single prompt with no across-turn continuation) → just prompt; no goal. | ||
|
|
||
| Confirm the current comparison semantics against the live docs (below) rather than this summary — the routing table can drift. Only proceed when the intent genuinely wants "keep working until this condition is met." | ||
|
|
||
| ## Step 1 — Read the live contract | ||
|
|
||
| Fetch the current official `/goal` documentation and extract, from the page itself: | ||
|
|
||
| 1. the **effective-condition shape** it prescribes, and | ||
| 2. the **maximum character limit** for a condition. | ||
|
|
||
| Primary source: `https://code.claude.com/docs/en/goal`. Cross-check the scheduling comparison via the pages that doc links (`/en/scheduled-tasks`, routines) if Step 0 routing is in question. | ||
|
|
||
| **Doc-fetch failure is not silent and never guessed.** If the page cannot be fetched or its structure has shifted so the limit or shape cannot be located, stop and tell the user exactly that, citing the URL. Do not fall back to a remembered number or shape — a stale limit or condition shape baked in here is precisely the drift this skill exists to avoid. Offer the user two ways forward: paste the current condition shape and character limit from that page — the shape drives the Step 2 draft, the limit drives the Step 3 counter — or defer until the docs are reachable. Never finalize a draft on a shape or limit that was not sourced live. | ||
|
|
||
| ## Step 2 — Draft the condition | ||
|
|
||
| The evaluator judges the condition against **what Claude has already surfaced in the transcript** — it does not run commands or read files. Draft accordingly: every claim in the condition must be something Claude's own output can demonstrate. | ||
|
|
||
| Structure the draft to the doc-sourced shape. As of the contract this skill targets, that is: | ||
|
|
||
| - **One measurable end state** — a test result, a build exit code, a file count, an empty queue. | ||
| - **A stated check** — how Claude proves it (e.g. "`npm test` exits 0", "`git status` is clean"). | ||
| - **Constraints that must not change** on the way there (e.g. "no other test file is modified"). | ||
| - **An optional turn/time bound** — e.g. "or stop after 20 turns" — to cap runaway loops. | ||
|
|
||
| Avoid conditions the transcript cannot show (subjective quality, external state Claude never surfaces). | ||
|
|
||
| ## Step 3 — Mechanical length check | ||
|
|
||
| Validate the draft's character count against the **live limit from Step 1** with the deterministic counter (no model estimation). Write the draft to a temp file and pass `--file` — this is the robust path, immune to a condition that contains a single quote, backtick, or `$` that would otherwise mangle a piped string: | ||
|
|
||
| ```shell | ||
| bash "${CLAUDE_PLUGIN_ROOT}/scripts/goal-condition-length.sh" --limit <LIMIT_FROM_STEP_1> --file <path-to-draft> | ||
| ``` | ||
|
|
||
| For a simple condition with no shell-special characters, stdin also works: `printf '%s' "<condition>" | bash "${CLAUDE_PLUGIN_ROOT}/scripts/goal-condition-length.sh" --limit <LIMIT_FROM_STEP_1>`. | ||
|
|
||
| Exit `0` = within limit, `1` = over, `2` = usage/env error (including a counter that failed to produce a number); stdout reports `chars=<n> limit=<N> status=<ok|over>`. | ||
|
|
||
| **On `status=over`:** tighten and re-run until it passes — shorten prose, fold the stated check into the end state, drop redundant constraints — **without dropping the measurable end state, the stated check, or the load-bearing constraints**. If the intent genuinely cannot compress into one provable condition under the limit, say so rather than silently shedding a constraint; splitting a goal into sequential per-phase goals is not documented doctrine and is out of scope here. | ||
|
|
||
| ## Step 4 — Output | ||
|
|
||
| Emit the final, counter-passed condition as a paste-ready invocation: | ||
|
|
||
| ```text | ||
| /goal <condition> | ||
| ``` | ||
|
|
||
| Note for the user: `/goal` holds for the current session only. A goal survives `--resume` / `--continue` (though its turn count, timer, and token baseline reset), but running `/clear` removes it — so the goal must be re-set after any `/clear`. | ||
|
|
||
| ## Gotchas | ||
|
|
||
| - **The limit is characters, not tokens.** The counter counts Unicode code points; do not substitute a token estimate. | ||
| - **Never hardcode the limit or the shape** into a draft, this file, or the script. They are read live each run; that is the whole point. | ||
| - **Over-limit submission behavior is undocumented** — there is no stated truncation or rejection semantics, so the pre-submission counter is the only guard. Do not assume the app will trim for you. | ||
| - **A goal does not change permissions.** If the stated check runs a command, the user still gets asked unless auto mode or their settings already allow it — worth flagging when the check is a shell command. | ||
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.