From c81b32e809dcb26d23773a5bf87a8c464a1dc495 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 13 Oct 2026 16:02:08 -0400 Subject: [PATCH 1/2] chore: saved Claude workflows stay LF; budgeted-autonomy README and pilot-file cleanup .gitattributes pins .claude/workflows/*.js to LF: with core.autocrlf=true they were checked out CRLF, and the Workflow tool refuses a script holding a control character, so `project-audit` could not be run by name. tests/scripts/claude-workflows.test.sh asserts the attribute and that no checked-out workflow holds a CR (compared with tr/cmp: Git for Windows' grep cannot see a CR). README: the controller takes tasks the operator adds (nothing reads cards from GitHub); the tick posts nothing to the outbox itself; the "Not in PR 3" adapter and transfer lines say what PR 5 added. Removes tools/autonomy-controller/PILOT-PR4.md, the PR 4 pilot's throwaway file (#777). Co-Authored-By: Claude Opus 5.5 --- .gitattributes | 3 +++ CHANGELOG.md | 9 +++++++++ tests/scripts/claude-workflows.test.sh | 21 ++++++++++++++++++++- tools/autonomy-controller/PILOT-PR4.md | 1 - tools/autonomy-controller/README.md | 11 +++++++---- 5 files changed, 39 insertions(+), 6 deletions(-) delete mode 100644 tools/autonomy-controller/PILOT-PR4.md diff --git a/.gitattributes b/.gitattributes index b879a5bf1..b511186c6 100644 --- a/.gitattributes +++ b/.gitattributes @@ -19,3 +19,6 @@ scripts/stop-ashlar-api-dev.sh text eol=lf # The reference consumer host is copied verbatim into a generated tree by # scripts/verify-external-product-shape.sh; CRLF here would change the bytes that gate compares. consumer-template/host/** text eol=lf +# The Workflow tool refuses a script that holds a control character, and a CR is one: a CRLF checkout of a saved +# workflow (core.autocrlf=true on Windows) cannot be run by name. tests/scripts/claude-workflows.test.sh asserts this. +.claude/workflows/*.js text eol=lf diff --git a/CHANGELOG.md b/CHANGELOG.md index bb00dc484..408ef8a6b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -17,6 +17,15 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath ## [Unreleased] +### Fixed — saved Claude workflows on a CRLF checkout + +- `.gitattributes` keeps `.claude/workflows/*.js` LF on every checkout. With `core.autocrlf=true`, the Git for + Windows default, they were checked out CRLF. The Workflow tool refuses a script that holds a control + character, so a saved workflow such as `project-audit` could not be run by name. + `tests/scripts/claude-workflows.test.sh` now asserts the attribute, and that no checked-out workflow holds a CR. + To fix a checkout made before this change, run `rm && git checkout -- `. +- `tools/autonomy-controller/PILOT-PR4.md`, the throwaway file the PR 4 pilot merged (#777), is removed. + ### Added — budgeted autonomy controller: task add --brief (card #776) - `task add --brief `: a short title, one line, not blank, at most 72 characters (anything else is a usage diff --git a/tests/scripts/claude-workflows.test.sh b/tests/scripts/claude-workflows.test.sh index 4c0c006e9..ff3cf0eaa 100644 --- a/tests/scripts/claude-workflows.test.sh +++ b/tests/scripts/claude-workflows.test.sh @@ -7,6 +7,7 @@ # The checker is proven both ways: every committed workflow must pass, and five broken fixtures (an unmatched # phase and Date.now, a meta naming a variable, a meta calling a builtin, a malformed meta literal, a body that # does not parse) must each be refused for their own reason. A checker that passed everything would fail here. +# Every saved workflow is also LF, pinned by .gitattributes: the Workflow tool refuses a script holding a CR. # # Needs node (preinstalled on the hosted ubuntu runner shell-lint uses). No node is a failure, not a skip. set -uo pipefail @@ -25,6 +26,24 @@ if [[ "${count}" -lt 2 ]]; then echo "claude-workflows.test: ERROR: expected at least 2 workflow scripts in .claude/workflows, found ${count}." >&2 exit 1 fi +# The Workflow tool refuses a script that holds a control character, and a CR is one. A CRLF checkout of a saved +# workflow (core.autocrlf=true on Windows) therefore cannot be run by name, so .gitattributes pins them to LF, and +# no checked-out copy may hold a CR. Linux CI checks the attribute; a Windows checkout made before the attribute +# landed is caught by the byte check. The byte check compares the file with itself minus every CR, because Git for +# Windows' grep drops a CR before a line end and so cannot see one. +for f in "${ROOT}"/.claude/workflows/*.js; do + rel=".claude/workflows/$(basename "${f}")" + eol="$(git -C "${ROOT}" check-attr eol -- "${rel}" | sed 's/^.*: eol: //')" + if [[ "${eol}" != "lf" ]]; then + echo "claude-workflows.test: FAIL: ${rel} is not pinned to LF in .gitattributes (eol: ${eol})." >&2 + fail=1 + fi + if ! tr -d '\r' <"${f}" | cmp -s - "${f}"; then + echo "claude-workflows.test: FAIL: ${rel} holds a CR in this checkout, so it cannot be run by name; check it out again: rm '${rel}' && git checkout -- '${rel}'" >&2 + fail=1 + fi +done + if ! out="$(node "${CHECK}")"; then echo "${out}" echo "claude-workflows.test: FAIL: a committed workflow script is broken (above)." >&2 @@ -92,4 +111,4 @@ if [[ "${fail}" -ne 0 ]]; then echo "claude-workflows.test: FAIL" >&2 exit 1 fi -echo "claude-workflows.test: ${count} workflow script(s) pass; 7 refusals observed" +echo "claude-workflows.test: ${count} workflow script(s) pass and are LF; 7 refusals observed" diff --git a/tools/autonomy-controller/PILOT-PR4.md b/tools/autonomy-controller/PILOT-PR4.md deleted file mode 100644 index ec648f513..000000000 --- a/tools/autonomy-controller/PILOT-PR4.md +++ /dev/null @@ -1 +0,0 @@ -This file was written by the budgeted autonomy controller's pilot of PR 4 on 2026-10-11, in the workspace it was given. diff --git a/tools/autonomy-controller/README.md b/tools/autonomy-controller/README.md index e384ebd25..c8fd97dc7 100644 --- a/tools/autonomy-controller/README.md +++ b/tools/autonomy-controller/README.md @@ -1,6 +1,7 @@ # Budgeted autonomy controller -A small controller that takes an eligible task card, reserves budget for one bounded worker launch, +A small controller that takes the first eligible task from its queue (the operator adds each one with +`task add`, naming its card; nothing reads cards from GitHub), reserves budget for one bounded worker launch, records every attempt durably, and stops spending when the operator's policy says so. It is the foundation of the budgeted autonomous development workflow (workstream `budgeted-autonomy`, card #755). PR 1 shipped the policy and the durable state over a fake adapter. **PR 2 adds the Claude @@ -34,7 +35,7 @@ and nothing here runs outside the host the operator starts it on. | `ashlar_controller/store.py` | The SQLite schema, integrity checks and the single-writer lease | | `ashlar_controller/budget.py` | Budget pools, atomic reservations, settlement, usage deltas, the interactive reserve | | `ashlar_controller/events.py` | Event deduplication (an event id is an idempotency key) | -| `ashlar_controller/outbox.py` | Milestone posts (claim / handoff / done) with duplicate-safe recovery | +| `ashlar_controller/outbox.py` | Milestone posts (claim / handoff / done) that the operator enqueues and flushes (`outbox enqueue`, `outbox flush`), with duplicate-safe recovery; the tick posts nothing itself | | `ashlar_controller/controller.py` | The tick: settle, reconcile, then at most one launch | | `ashlar_controller/adapters/` | The adapter protocol, declared capabilities, the fake adapter, `claude.py` (the Claude Code CLI), `codex.py` (the Codex CLI) and `cursor.py` (the Cursor agent CLI); `locate_run_transcript` finds a run's transcript by the provider that wrote it | | `ashlar_controller/readiness.py` | The three gate commands, their parsers, stable failure identities, the bounded fixer launch | @@ -945,10 +946,12 @@ ASHLAR_MUTATION_RUNNER="bash tools/autonomy-controller/mutation-runner.sh" \ ## Not in PR 3 - The `phases` table exists but nothing writes it yet. -- No Codex or Cursor adapter (PR 5). `tick --adapter` accepts `fake` and `claude` only. +- No Codex or Cursor adapter: `tick --adapter` accepted `fake` and `claude` only. PR 5 added `codex` and + `cursor`. - A repair run that stops at the turn ceiling requeues like any run; the next observation of the still red head starts a new round with a fresh session rather than resuming the fixer's session (the rounds - bound it). Premium escalation and provider transfer per PR are counted in `prs` but not driven. + bound it). Premium escalation and provider transfer per PR were counted in `prs` but not driven. PR 5 + drives the transfer (`task transfer`); premium escalation is still not driven. - The `GhCli` argv shapes are the ones in the module docstring; `pr create`, `pr list --head`, `pr view --json`, the check-runs API and a closed PR were exercised live on 2026-10-11 (PR #775, see "Not in PR 4"); `pr merge --match-head-commit` only against the fake; none against every error GitHub can return. From f011cf60691fff7e1bbbd78725f03fd6815c4b55 Mon Sep 17 00:00:00 2001 From: t Date: Tue, 13 Oct 2026 16:17:06 -0400 Subject: [PATCH 2/2] chore: the workflow checker refuses control characters; review fixes scripts/phase-end/check_workflows.js refuses a workflow holding a control character other than tab and newline (the Workflow tool refuses one; a CRLF checkout holds a CR on every line), with a CRLF fixture in tests/scripts/claude-workflows.test.sh replacing the test's own byte check. ci/cert-gate-assertions.md's row for the test names both rules. README and the package docstring: task add names a card optionally. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 5 ++-- ci/cert-gate-assertions.md | 2 +- scripts/phase-end/check_workflows.js | 24 ++++++++++++++++++- tests/scripts/claude-workflows.test.sh | 22 ++++++++--------- tools/autonomy-controller/README.md | 2 +- .../ashlar_controller/__init__.py | 3 ++- 6 files changed, 40 insertions(+), 18 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 408ef8a6b..d640bb040 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -22,8 +22,9 @@ the version on nuget.org, which is why it trails `VERSION` between releases rath - `.gitattributes` keeps `.claude/workflows/*.js` LF on every checkout. With `core.autocrlf=true`, the Git for Windows default, they were checked out CRLF. The Workflow tool refuses a script that holds a control character, so a saved workflow such as `project-audit` could not be run by name. - `tests/scripts/claude-workflows.test.sh` now asserts the attribute, and that no checked-out workflow holds a CR. - To fix a checkout made before this change, run `rm && git checkout -- `. + `tests/scripts/claude-workflows.test.sh` now asserts the attribute. `scripts/phase-end/check_workflows.js` now + refuses a workflow that holds a control character other than tab and newline, which a CRLF fixture proves. To + fix a checkout made before this change, run `rm && git checkout -- `. - `tools/autonomy-controller/PILOT-PR4.md`, the throwaway file the PR 4 pilot merged (#777), is removed. ### Added — budgeted autonomy controller: task add --brief (card #776) diff --git a/ci/cert-gate-assertions.md b/ci/cert-gate-assertions.md index 9d250bbd8..ad33cd94e 100644 --- a/ci/cert-gate-assertions.md +++ b/ci/cert-gate-assertions.md @@ -79,7 +79,7 @@ away, can see what they would be turning off. | **An agent that reads labelled data cannot export it below its label under an enforcing profile.** Real RAG/model, Bing, factory, and loopback redirect paths refuse before the remote send; positive controls transmit. | `EgressEnforcementLeakTests` | Scenarios A/B/C and C0–C12 count sends and pin the label, reason, site, redacted memory and model observation. C12 preserves the actual error-ending cycle, then tests a subsequent model send in the same runner frame. Clearance remains a test input until PR 5. | | **Every Ashlar factory registration reserves its actual client name against host opt-outs, including commercial registrations.** | `EgressGuardConventionTests.Every_Ashlar_factory_registration_has_a_protected_opt_out_name`, `EgressHostClientOptOutTests` | Reuses the inventory's production population, resolves literal/constant/nameof and reviewed typed registration shapes, rejects unknown shapes, compares the complete protected registry, and resolves both typed registrations through the factory. | | **Enforced refusals remain visible at Warning.** The first refusal per site/reason carries the operator record, repeated refusals produce a counted summary at five minutes without further traffic, and disposal releases timers and flushes counts. EventSource event 2 is Warning. | `EgressRefusalDiagnosticsTests` | A deterministic clock exercises quiet-window expiry and cleanup; a throwing logger cannot escape the timer callback into the host. | -| **Saved Workflow scripts run the way the Workflow tool runs them.** Every `.claude/workflows/*.js` parses as the body of an async function after a pure-literal `meta` (only literals: no variable, call, operator or template interpolation); every `phase()` title and agent `phase:` option matches a `meta.phases` entry; no `Date.now()`, `Math.random()` or argless `new Date()`. | `tests/scripts/claude-workflows.test.sh` (required `shell-lint`, through `scripts/ci/run-repo-gates.sh`), which runs `scripts/phase-end/check_workflows.js` | `/handoff` runs `project-audit.js` at the end of every phase; a broken script would fail only then, in the middle of ending a phase. The test also proves the checker refuses five broken fixtures for their own reasons. It is a static check: it does not run a workflow's agents. Not a cert-gate test: it blocks through the required `shell-lint`, which runs every discovered repo gate, so Rule 1's namespace does not apply to it. | +| **Saved Workflow scripts run the way the Workflow tool runs them.** Every `.claude/workflows/*.js` parses as the body of an async function after a pure-literal `meta` (only literals: no variable, call, operator or template interpolation); every `phase()` title and agent `phase:` option matches a `meta.phases` entry; no `Date.now()`, `Math.random()` or argless `new Date()`; no control character other than tab and newline (the tool refuses one, so a CRLF checkout cannot be run by name); and every `.claude/workflows/*.js` is pinned `text eol=lf` in `.gitattributes` (`git check-attr eol`). | `tests/scripts/claude-workflows.test.sh` (required `shell-lint`, through `scripts/ci/run-repo-gates.sh`), which runs `scripts/phase-end/check_workflows.js` | `/handoff` runs `project-audit.js` at the end of every phase; a broken script would fail only then, in the middle of ending a phase. The test also proves the checker refuses six broken fixtures for their own reasons, a CRLF script among them. It is a static check: it does not run a workflow's agents. Not a cert-gate test: it blocks through the required `shell-lint`, which runs every discovered repo gate, so Rule 1's namespace does not apply to it. | The gate also carries the certification gate's own teeth, the hot-swap host, the adversarial campaigns, the analyzer fence, sandbox-escape tests and the dogfood suites — 152 `.cs` files in diff --git a/scripts/phase-end/check_workflows.js b/scripts/phase-end/check_workflows.js index 08b1b67ec..573198a1d 100644 --- a/scripts/phase-end/check_workflows.js +++ b/scripts/phase-end/check_workflows.js @@ -5,7 +5,9 @@ // - meta is a pure literal with a name and a description; // - every phase('') call has a meta.phases entry with the same title, and every entry is started // (by phase() or by an agent's { phase: '<title>' } option); -// - no Date.now(), Math.random() or argless new Date() (they throw: they would break resume). +// - no Date.now(), Math.random() or argless new Date() (they throw: they would break resume); +// - no control character other than tab and newline (the tool refuses one; a CRLF checkout holds a CR on +// every line). // Usage: node scripts/phase-end/check_workflows.js [file ...] (default: every .claude/workflows/*.js) // Exit 0 when every file passes; 1 otherwise, with one line per problem. 'use strict' @@ -91,9 +93,29 @@ function metaImpurity(text) { return null } +// The first control character other than tab and newline, as { code, line }, or null. The Workflow tool refuses a +// script that holds one ("script contains control characters that would be hidden in the approval dialog"); a CR +// was observed refused on 2026-10-13, which is why a CRLF checkout of a saved workflow cannot be run by name. +function controlCharacter(src) { + let line = 1 + for (let i = 0; i < src.length; i++) { + const code = src.charCodeAt(i) + if (code === 10) { line++; continue } + if (code === 9) continue + if (code < 32 || code === 127) return { code, line } + } + return null +} + function problemsOf(file) { const src = fs.readFileSync(file, 'utf8') const out = [] + const ctl = controlCharacter(src) + if (ctl) { + const hex = ctl.code.toString(16).toUpperCase().padStart(4, '0') + return [`holds a control character (U+${hex} at line ${ctl.line}), which the Workflow tool refuses; ` + + `a CRLF checkout is fixed with: rm <file> && git checkout -- <file>`] + } const span = metaSpan(src) if (!span) return ['no `export const meta = {...}` object literal'] const impure = metaImpurity(src.slice(span.start, span.end)) diff --git a/tests/scripts/claude-workflows.test.sh b/tests/scripts/claude-workflows.test.sh index ff3cf0eaa..f48143b12 100644 --- a/tests/scripts/claude-workflows.test.sh +++ b/tests/scripts/claude-workflows.test.sh @@ -4,10 +4,10 @@ # phase; a script that does not parse would otherwise fail only then, in the middle of ending a phase. # Discovered and run bare by scripts/ci/run-repo-gates.sh in the required shell-lint check. # -# The checker is proven both ways: every committed workflow must pass, and five broken fixtures (an unmatched +# The checker is proven both ways: every committed workflow must pass, and six broken fixtures (an unmatched # phase and Date.now, a meta naming a variable, a meta calling a builtin, a malformed meta literal, a body that -# does not parse) must each be refused for their own reason. A checker that passed everything would fail here. -# Every saved workflow is also LF, pinned by .gitattributes: the Workflow tool refuses a script holding a CR. +# does not parse, a CRLF script) must each be refused for their own reason. A checker that passed everything would +# fail here. Every saved workflow is also pinned LF in .gitattributes: the Workflow tool refuses a script holding a CR. # # Needs node (preinstalled on the hosted ubuntu runner shell-lint uses). No node is a failure, not a skip. set -uo pipefail @@ -27,10 +27,9 @@ if [[ "${count}" -lt 2 ]]; then exit 1 fi # The Workflow tool refuses a script that holds a control character, and a CR is one. A CRLF checkout of a saved -# workflow (core.autocrlf=true on Windows) therefore cannot be run by name, so .gitattributes pins them to LF, and -# no checked-out copy may hold a CR. Linux CI checks the attribute; a Windows checkout made before the attribute -# landed is caught by the byte check. The byte check compares the file with itself minus every CR, because Git for -# Windows' grep drops a CR before a line end and so cannot see one. +# workflow (core.autocrlf=true on Windows) therefore cannot be run by name, so .gitattributes pins every one to LF. +# The checker refuses a checked-out copy that holds a CR (the crlf.js fixture below), so a Windows checkout made +# before the attribute landed fails here too, with the fix in its message. for f in "${ROOT}"/.claude/workflows/*.js; do rel=".claude/workflows/$(basename "${f}")" eol="$(git -C "${ROOT}" check-attr eol -- "${rel}" | sed 's/^.*: eol: //')" @@ -38,10 +37,6 @@ for f in "${ROOT}"/.claude/workflows/*.js; do echo "claude-workflows.test: FAIL: ${rel} is not pinned to LF in .gitattributes (eol: ${eol})." >&2 fail=1 fi - if ! tr -d '\r' <"${f}" | cmp -s - "${f}"; then - echo "claude-workflows.test: FAIL: ${rel} holds a CR in this checkout, so it cannot be run by name; check it out again: rm '${rel}' && git checkout -- '${rel}'" >&2 - fail=1 - fi done if ! out="$(node "${CHECK}")"; then @@ -83,6 +78,8 @@ export const meta = { const r = await agent('hi') return 1 + JS +# A valid script, but with CRLF line ends: what a Windows checkout without the .gitattributes pin produces. +printf "export const meta = { name: 'x', description: 'y' }\r\nreturn 1\r\n" >"${TMP}/crlf.js" expect_refused() { # <fixture> <text the refusal must contain> local f="$1" want="$2" got @@ -106,9 +103,10 @@ expect_refused builtin.js "meta is not a pure literal" # Made only of allowed tokens, so the source scan passes it; evaluating it is what refuses it. expect_refused malformed.js "meta does not evaluate as a literal" expect_refused noparse.js "the body does not parse" +expect_refused crlf.js "holds a control character (U+000D at line 1)" if [[ "${fail}" -ne 0 ]]; then echo "claude-workflows.test: FAIL" >&2 exit 1 fi -echo "claude-workflows.test: ${count} workflow script(s) pass and are LF; 7 refusals observed" +echo "claude-workflows.test: ${count} workflow script(s) pass and are pinned LF; 8 refusals observed" diff --git a/tools/autonomy-controller/README.md b/tools/autonomy-controller/README.md index c8fd97dc7..c9a6847c2 100644 --- a/tools/autonomy-controller/README.md +++ b/tools/autonomy-controller/README.md @@ -1,7 +1,7 @@ # Budgeted autonomy controller A small controller that takes the first eligible task from its queue (the operator adds each one with -`task add`, naming its card; nothing reads cards from GitHub), reserves budget for one bounded worker launch, +`task add`, optionally naming its card; nothing reads cards from GitHub), reserves budget for one bounded worker launch, records every attempt durably, and stops spending when the operator's policy says so. It is the foundation of the budgeted autonomous development workflow (workstream `budgeted-autonomy`, card #755). PR 1 shipped the policy and the durable state over a fake adapter. **PR 2 adds the Claude diff --git a/tools/autonomy-controller/ashlar_controller/__init__.py b/tools/autonomy-controller/ashlar_controller/__init__.py index 5ef66a496..36b4d0b73 100644 --- a/tools/autonomy-controller/ashlar_controller/__init__.py +++ b/tools/autonomy-controller/ashlar_controller/__init__.py @@ -1,6 +1,7 @@ """Budgeted autonomy controller for Ashlar (PR 1: policy and durable state). -The controller takes an eligible task card, reserves budget for one bounded worker launch, records +The controller takes the first eligible task from its queue (added by the operator with `task add`, optionally +naming its card), reserves budget for one bounded worker launch, records every attempt durably in SQLite, and stops spending when the policy says so. PR 1 ships the foundation only: no live provider adapter, no model call. See tools/autonomy-controller/README.md. """