Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
Expand Up @@ -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
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,16 @@ 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. `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 <file> && git checkout -- <file>`.
- `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 <text>`: a short title, one line, not blank, at most 72 characters (anything else is a usage
Expand Down
2 changes: 1 addition & 1 deletion ci/cert-gate-assertions.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 23 additions & 1 deletion scripts/phase-end/check_workflows.js
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,9 @@
// - meta is a pure literal with a name and a description;
// - every phase('<title>') 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'
Expand Down Expand Up @@ -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))
Expand Down
23 changes: 20 additions & 3 deletions tests/scripts/claude-workflows.test.sh
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +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.
# 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
Expand All @@ -25,6 +26,19 @@ 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 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: //')"
if [[ "${eol}" != "lf" ]]; then
echo "claude-workflows.test: FAIL: ${rel} is not pinned to LF in .gitattributes (eol: ${eol})." >&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
Expand Down Expand Up @@ -64,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
Expand All @@ -87,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; 7 refusals observed"
echo "claude-workflows.test: ${count} workflow script(s) pass and are pinned LF; 8 refusals observed"
1 change: 0 additions & 1 deletion tools/autonomy-controller/PILOT-PR4.md

This file was deleted.

11 changes: 7 additions & 4 deletions tools/autonomy-controller/README.md
Original file line number Diff line number Diff line change
@@ -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`, 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
Expand Down Expand Up @@ -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 |
Expand Down Expand Up @@ -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.
Expand Down
3 changes: 2 additions & 1 deletion tools/autonomy-controller/ashlar_controller/__init__.py
Original file line number Diff line number Diff line change
@@ -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.
"""
Expand Down
Loading