Skip to content

chore(loop): add Actions + R2 host for stage A1 (report-only) - #36

Merged
Alex1990 merged 18 commits into
mainfrom
chore/loop-a1-host
Sep 10, 2026
Merged

Alex1990 merged 18 commits into
mainfrom
chore/loop-a1-host

Conversation

@Alex1990

Copy link
Copy Markdown
Owner

Implements 05 §7 stage A1 (自动只报告) for tiny-oss: the event-triggered headless host — Actions as scheduler + sandbox, R2 as the state layer.

What lands

File Role
.github/workflows/loop.yml Single serial workflow (concurrency: {group: loop}) = scheduler + sandbox. Triggers: issues / issue_comment / pull_request / pull_request_review / pull_request_target(closed) / release / schedule / workflow_dispatch.
scripts/loop/entry.mjs Orchestrator: route → inbox | metrics | run. Single writer for closing a run.
scripts/loop/shared/state.mjs State-layer primitives (outcome map, stage whitelist, locks, acceptance, SUMMARY).
scripts/loop/shared/route.mjs Event → decision, pure functions (17/17 routing cases verified offline).
scripts/loop/shared/agent.mjs pi driver: prompt, spawn, usage accounting, failure classification.
scripts/loop/shared/r2.mjs R2 sync via the runner's preinstalled AWS CLI.
scripts/loop/run.mjs Now consumes the shared library; adds --write-level.

Write boundary (owner decision: strict report)

LOOP_WRITE_LEVEL=report by default. Runs write the state layer + a report; every GitHub write action (labels, comments, closing, opening a PR) is listed as a checklist in the report and in the Actions Step Summary, never executed. workflow_dispatch carries execute_writes=true so the L2 write path can be rehearsed in a controlled moment before it is left on.

Engine

pi (@earendil-works/pi-coding-agent@0.85.1) — three required verifications passed: headless --mode json JSONL event stream, DeepSeek built in, ~21 MB with no install scripts. --approve is mandatory or project-local resources are silently ignored. pi has no built-in permission gating, so the job's permissions: block is read-only and write credentials live only in default-branch contexts.

Two findings worth review

  1. R2 has no object versioning (checked against the R2 docs index and release notes; the nearest feature is bucket locks = retention, not history). 05 §3.1 assumes it. Consequence: deletes are not revertible, so push deliberately omits --delete and syncing is overwrite-plus-add only.
  2. .gitignore:4 (lib) matches at any depth, so the shared library under scripts/loop/lib/ would never have been committed and the runner checkout would have crashed. Renamed to scripts/loop/shared/; the existing ignore rule is untouched.

Verified locally

CLI report boundary performs zero GitHub writes · run closing is idempotent · stage→outcome whitelist enforced · event routing 17/17 · acceptance writes deduplicated by taskId+event+pr · system-level sweep/retro-scheduled runs work (task-less) · a stubbed engine exercising prompt → result.json → state transition → Step Summary with token accounting · provider flake classifies as retry instead of filling the human inbox.

Before this can run

Needs R2 + LLM secrets and a one-time state-layer seed — steps are in scripts/loop/README.md ("One-time setup"). The pi --list-models deepseek line printed on every run settles the one open question (deepseek/deepseek-flash is unconfirmed against pi's built-in catalogue).

A1 acceptance list (rewritten for the report boundary, since the drift criterion is vacuous when nothing is written) is in scripts/loop/README.md.

Implements 05 §7 stage A1 for tiny-oss: the event-triggered, headless
host that runs the loop on GitHub Actions with the state layer in R2.

- .github/workflows/loop.yml: single serial workflow (concurrency group
  `loop`) = scheduler + sandbox; read-only GITHUB_TOKEN, secrets only,
  `workflow_dispatch` with `execute_writes` for the L2 rehearsal.
- scripts/loop/entry.mjs: orchestrator; routes events, writes inbox and
  acceptance, runs one stage, and is the single writer for closing runs.
- scripts/loop/shared/: state-layer primitives, event routing (pure
  functions), pi driver, R2 sync — shared by the local CLI and the runner.
- run.mjs now consumes the shared library; adds `--write-level`
  (report default = GitHub writes are listed, never executed).
- .gitignore already ignored `lib` repo-wide, which would have silently
  excluded the shared library; the directory is `shared/` instead (D10).

Engine = pi (verified: headless JSON event stream, built-in DeepSeek,
~21 MB, no install scripts, `--approve` required). Failure classification
keeps provider/network flakes as `retry` instead of filling the human
inbox. R2 has no object versioning, so sync never deletes (D-note in
scripts/loop/README.md, which also carries the A1 acceptance list).

Verified locally: CLI report boundary writes no GitHub actions, run
closing is idempotent, stage→outcome whitelist enforced, event routing
17/17, acceptance write is deduplicated, system-level sweep/retro runs
work, and a stubbed engine exercises prompt → result.json → state
transition → Step Summary with token accounting.
@github-actions

Copy link
Copy Markdown
Contributor

Coverage

Metric % Covered / Total
Lines 90.19% 1003/1112
Statements 89.06% 1083/1216
Functions 91.03% 193/212
Branches 76.43% 519/679

GitHub could not parse loop.yml at all: the job-level `env` referenced
`runner.temp`, and the `runner` context only exists inside steps. The
only signal was a zero-second failed run titled with the file path, so
the workflow was never registered under its own name.

- Drop `LOOP_SESSION_DIR` from job env; set it on the step that needs it.
- Replace the `inputs.*` write-level expression with
  `github.event.inputs.execute_writes` (null on non-dispatch events,
  hence the `|| 'false'` fallback), which keeps the default report-only.
- entry.mjs derives the write level from that flag.

Verified with actionlint: the previous revision fails with
`context "runner" is not allowed here` (line 56), the fixed file passes.
…comes

Measured on the runner: pi installs in 6s (132 packages, v0.85.1), so the
engine choice holds on Actions. That run also showed
`pi --list-models deepseek` printing "No models available. Use /login ..."
when no key is present — the catalogue is credential-gated, and a missing
key would have been classified as an agent failure, pushing tasks into the
human inbox. Credential/config errors now classify as `retry`.

Also: a `retry` outcome proposed a `ready-for-agent` label even though the
run never did any work — such runs now propose no GitHub actions at all.

Env facts updated with the measured install cost/version and the
credential-gated catalogue finding.
@Alex1990

Copy link
Copy Markdown
Owner Author

The failing loop check is expected — and it already validated three things

loop runs on this PR (a PR is a triage surface), reaches the first credential-dependent step, and stops there. No secrets exist yet. That failure point is the designed one:

Step Result
Set up job / checkout / setup-node / pnpm install --frozen-lockfile pass
Install engine (pi) pass — added 132 packages in 6s, pi --version → 0.85.1
Pull state from R2 fail — secrets absent (correct place to stop)
Run stage skipped — a failed pull must not run the stage
Push state to R2 (if: always()) fail — same cause
Upload session transcripts pass

Three things now verified by real execution rather than by reading docs:

  1. pi installs on ubuntu-24.04 in ~6s (~21 MB, 132 packages, no install scripts) — the engine choice holds on Actions, well inside the job budget.
  2. A failed pull aborts the run instead of letting an empty local state/ reach the push. This was the main data-loss risk, since R2 has no object versioning.
  3. if: always() really does push after a crash, so a cancelled or timed-out job still lands its last state instead of losing it.

One finding from that same log: pi --list-models deepseek prints No models available. Use /login to log into a provider or API key — pi's model catalogue is credential-gated, so LOOP_MODEL=deepseek/deepseek-flash cannot be confirmed until DEEPSEEK_API_KEY exists. The value is printed on every run, so the first configured dispatch settles it. That log also showed a missing key would have been classified as an agent failure and pushed the task into the human inbox; credential/config errors now classify as retry (commit 5958e68).

To turn this green

Secrets (Settings → Secrets and variables → Actions): R2_ACCOUNT_ID, R2_ACCESS_KEY_ID, R2_SECRET_ACCESS_KEY, R2_BUCKET, DEEPSEEK_API_KEY (+ LOOP_GH_TOKEN, unused while report-only). Bucket creation, the scoped token, and the one-time seed are in scripts/loop/README.md → "One-time setup".

Until those exist, every push to this branch will show a red loop check. That is this workflow working as specified, not a broken PR.

Two defects surfaced while exercising the `deps` and external-PR routes on
real GitHub items (#35, #21):

- write actions always used `gh issue …`, which fails on a PR task — and
  "PRs are a triage surface" means most tasks are PRs. The subcommand now
  follows `task.kind`.
- `run.mjs` could only import issues, so the local coverage exercise could
  not create the task files the A1 sweep is meant to find. Added `--pr <n>`
  with the same OPEN validation as the issue path.

Also: a `retry` outcome no longer proposes a label, since such a run never
did any work.
`R2_ACCOUNT_ID` is not a credential: R2's S3 endpoint is only ever
`https://<ACCOUNT_ID>.r2.cloudflarestorage.com`, so the account ID is part
of an address, and it cannot be derived from an API token. Same for the
bucket name. Neither belongs in a secret.

- `R2_ACCOUNT_ID` is now read from a repository *variable* (falling back
  to a same-named secret, so either store works).
- `R2_BUCKET` defaults to `loop-state-alex1990` in `shared/r2.mjs` and no
  longer has to be configured at all.
- Required configuration drops from five items to four: three secrets
  (`R2_ACCESS_KEY_ID`, `R2_SECRET_ACCESS_KEY`, `DEEPSEEK_API_KEY`) plus
  one variable.
Owner decision: each repository gets its own bucket. Sharing one bucket
across repositories cannot be made safe, because an R2 API token can only
be scoped to a bucket, never to a key prefix — the access-policy resource
is `com.cloudflare.edge.r2.bucket.<ACCOUNT_ID>_<JURISDICTION>_<BUCKET>`,
with no prefix dimension. Under a shared bucket every repo token can read
and write the whole bucket, so one leaked credential or fork-context
injection reaches every repository's state layer.

- Bucket name is derived at runtime as `loop-state-<repo>`: from
  `GITHUB_REPOSITORY` in CI, from `package.json` name locally. A sibling
  repository therefore needs no code change to get its own bucket.
  `R2_BUCKET` still overrides (migration, triage, scratch bucket).
- `R2_PREFIX` drops the redundant repo segment (`state/`): the bucket name
  already carries the repository identity, so remote keys now mirror the
  local layout one-to-one.
- `R2_BUCKET` no longer has to be configured at all; unset expands to an
  empty string, which falls through to the derivation.
- README documents the per-repo setup and the onboarding steps for an
  additional repository, and records why sharing a bucket cannot work.
@Alex1990

Copy link
Copy Markdown
Owner Author

Design change: one R2 bucket per repository (commit 8eb59fd)

Answering "what happens when I add a second repo?" — and the answer turned out to constrain the design, so the bucket layout changed.

An R2 API token can be scoped to a bucket, but never to a key prefix. The access-policy resource is:

com.cloudflare.edge.r2.bucket.<ACCOUNT_ID>_<JURISDICTION>_<BUCKET_NAME>

There is no prefix dimension (unlike AWS S3 IAM, where arn:aws:s3:::bucket/prefix/* works). So under a shared bucket, "one token per repo" is meaningless — every token reads and writes the whole bucket, and one leaked credential or fork-context injection reaches every participating repository. That is precisely the blast radius this design otherwise avoids, so sharing a bucket cannot be made safe by adding tokens.

Bucket count is not a constraint (limit 1,000,000) and KB-scale state is free either way, so per-repo buckets buy real isolation at zero cost.

What changed:

  • Bucket name is derived: loop-state-<repo>, from GITHUB_REPOSITORY in CI and from package.json locally. A sibling repository gets its own bucket with no code change. R2_BUCKET still overrides.
  • R2_PREFIX drops the now-redundant repo segment — state/ instead of state/tiny-oss/ — since the bucket name already carries the identity. Remote keys now mirror the local layout one-to-one.
  • R2_BUCKET no longer has to be configured; unset expands to an empty string, which falls through to the derivation.
  • README documents per-repo setup plus the onboarding steps for an additional repository.

For tiny-oss this means the bucket is loop-state-tiny-oss, not the loop-state-alex1990 named in my earlier comment. Nothing has been seeded yet, so there is no data to migrate — if that bucket was already created, delete it and create loop-state-tiny-oss with a token scoped to it alone.

Verified: derivation yields loop-state-tiny-oss for GITHUB_REPOSITORY=Alex1990/tiny-oss, loop-state-other-pkg for a sibling, the same value locally via package.json, honours a R2_BUCKET override, and unset-in-Actions (empty string) correctly falls through.

Bucket names are a flat, account-wide namespace, so a name collision is a
real collision. Deriving `loop-state-<repo>` left same-named repositories
under different GitHub organisations or accounts pointed at one bucket.
The name is now `loop-state-<owner>-<repo>` — e.g. tiny-oss resolves to
`loop-state-alex1990-tiny-oss` — which moves the collision surface up to
owner + repository.

- Owner comes from `GITHUB_REPOSITORY` in CI and from `package.json`'s
  `repository` field locally. All npm forms resolve identically: object
  `{url}`, string shorthand `owner/repo`, `github:owner/repo`, SSH
  (`git@github.com:owner/repo.git`) and https with or without `.git`. A
  string `repository` value (previously unhandled, and a real failure mode)
  is now accepted.
- The name is normalised to R2's rules: lowercase letters, digits and
  hyphens only, no leading/trailing hyphen, 3-63 characters. A stable
  content hash replaces the tail when `owner-repo` would exceed 63, since
  GitHub permits repository names up to 100 characters.
- Local and CI derivations were verified to agree, which is what makes
  `seed` write to the same bucket the workflow reads.
- An unresolvable repository identity now fails with an actionable message
  pointing at `R2_BUCKET`, instead of silently deriving a wrong name.
@Alex1990

Copy link
Copy Markdown
Owner Author

Correction: bucket name includes the owner — loop-state-alex1990-tiny-oss

Supersedes the bucket name in my previous comment (8eb59fd → ba06f30).

Bucket names are a flat, account-wide namespace, so deriving loop-state-<repo> meant two same-named repositories under different GitHub organisations (or accounts) would resolve to the same bucket — and since an R2 token can only be scoped to a bucket, that is a collision an attacker or a future reorganisation could walk into. The name is now loop-state-<owner>-<repo>, moving the collision surface up to owner + repository.

  • Owner comes from GITHUB_REPOSITORY in CI and package.json's repository locally. All npm forms resolve identically — object {url}, string shorthand owner/repo, github:owner/repo, SSH and https (with or without .git). The string repository form was previously unhandled and would have failed; now covered.
  • Names are normalised to R2's rules (lowercase, [a-z0-9-], no leading/trailing hyphen, 3–63 chars). owner-repo longer than 63 falls back to a stable content hash, since GitHub permits repository names up to 100 characters — truncated-but-stable, so the same repo always maps to the same bucket and different repos never collide.

Verified: Alex1990/tiny-oss → loop-state-alex1990-tiny-oss; Acme-Corp/tiny-oss → loop-state-acme-corp-tiny-oss (the case this change exists for); ALEX1990/Tiny-OSS normalises to the same name as the canonical spelling; a 39+100-character owner/repo yields a valid 63-character name that is idempotent and collision-free. Local and CI derivations agree, which is what makes a local seed write to the same bucket the workflow reads.

So the bucket to create is loop-state-alex1990-tiny-oss, with the token scoped to it alone. Nothing has been seeded, so there is still no data to migrate.

The job always checked out the default branch. That is correct for every
production path — issues, schedule, release and pull_request_target must run
the repository's trusted host code, and a fork PR must never have its code
checked out at all — but it made the manual smoke entry unusable on this
workflow's own PR: `main` does not have `scripts/loop/` yet, so the run died
with MODULE_NOT_FOUND on a file that exists on the branch.

`workflow_dispatch` now checks out `github.ref`. Dispatch can only target refs
in this repository (fork branches are not among them), so the safety property
is unchanged, and the branch under review can be exercised before merging.
… smoke

First real end-to-end run (all steps green, 3m05s) surfaced three things:

- `LOOP_MODEL` was `deepseek/deepseek-flash`, which is not in pi's DeepSeek
  catalogue — the real ids are `deepseek-v4-flash`,
  `deepseek-v4-flash-vision-exp` and `deepseek-v4-pro`. pi **silently falls
  back** on an unknown `--model` rather than failing, so the run looked
  healthy while using an unintended model. The id is corrected, and the
  install step now validates it against `pi --list-models deepseek` and
  fails the job on a mismatch.
- There was no cheap way to check the pipeline: an empty `stage` still gets
  the input's `default:` (`triage`), so the smoke cost 102,860 tokens. Added
  a `smoke` input that exercises checkout/toolchain/pi install/R2 pull+push
  and skips the agent — the entry to use when onboarding a repo or when a
  credential is in doubt.
- Three comments still pointed at `scripts/loop/lib/` after the rename to
  `shared/`, and `run.mjs` referenced a `run-stage.mjs` that never existed.

Defect log records D18-D20 as fixed and D21 (the dependabot coverage-comment
403) as open, awaiting a maintainer decision. Report-boundary compliance of
the real run was verified independently — repository-wide, no item carries a
loop label and #21/#35 have no comments — not from the run's own summary.
A run with no task (sweep / retro / release preflight) carries
`taskId: null`, and renderSummary printed it literally as `task #null`.
System-level runs now render as `system`. Surfaced by the first real run,
which was a system-level triage.

Environment facts also record that AWS CLI v2 is installed locally to match
the runner, and why `pip install awscli` is not a substitute for seeding:
it produces a `.cmd` shim that Node refuses to spawn on Windows.
Three fixes for running the R2 sync from a real terminal:

- `AWS_PAGER: ''` — aws CLI v2 pipes results to a pager, which in a
  scripted call looks exactly like a hang: the cursor stops, nothing is
  printed, and it is waiting for a keypress. Programmatic calls must never
  page.
- `--cli-connect-timeout` / `--cli-read-timeout` and `AWS_MAX_ATTEMPTS`
  bound every call, so a slow link fails visibly instead of sitting silent.
- sync operations now stream to the terminal instead of being buffered by
  spawnSync. On a cross-border link a seed takes seconds to tens of
  seconds, and with no output that reads as a hang even while it is
  progressing normally.

The smoke mode also reports what it pulled — task count with status
breakdown, runs, reports, acceptance rows, whether SUMMARY.md is present —
since "the pipeline is green" is only half of what a smoke should tell you.
`LOOP_EXPECT_TASKS` turns that into an assertion for scripted use.
Two defects surfaced by the first real sweep run on Actions:

- System-level runs (sweep / retro / release preflight) had no outcome
  vocabulary of their own, and the sweep recorded `closed`. In the task
  vocabulary `closed` means "closed without a PR, excluded from the
  acceptance denominator", so retro reading end rows would have counted
  sweep/retro executions as closed tasks. System runs now use
  `completed / failed / retry / aborted`; `finishRun` rejects a task-scoped
  outcome when the run has no task (and vice versa), and the agent prompt
  states which vocabulary applies.
- `renderSummary` had no section for `status: new`, so a task the sweep had
  just created was invisible in the human summary — the single item most in
  need of attention. A `未处理 (new)` section now lists them.

Both were found by the loop running itself, not by inspection.
Checked items now cite the run that proves them, and the two real runs are
written up: the smoke (`34509932312`) and the first real stage
(`34511392550`, system-level sweep, 77,850 tokens).

The drift criterion needed correcting. While A1 runs at the report
boundary, every proposed GitHub action stays unexecuted *by design* — so a
task file saying `needs-triage` next to a GitHub item with no label is
expected behaviour, not drift. The real sweep surfaced this as an anomaly.
What the week should judge is whether each proposal was correct and whether
new events produce proposals matching reality.
Run 34511902164 (`task=36 stage=triage`, 80,289 tokens) exercised the path
that was still unproven: task claimed → processing → agent triage →
needs-triage → waiting-human with the matching label. Boundary re-checked
against GitHub, not the run's own summary: PR #36 carries no labels and no
label timeline events.
Alex1990 added a commit that referenced this pull request Sep 10, 2026
Run 34511902164 (`task=36 stage=triage`, 80,289 tokens) exercised the path
that was still unproven: task claimed → processing → agent triage →
needs-triage → waiting-human with the matching label. Boundary re-checked
against GitHub, not the run's own summary: PR #36 carries no labels and no
label timeline events.
Checking out the default branch unconditionally broke `pr-review`, which is
one of the loop's main jobs: reviewing a PR requires the PR's code, and
`main` never has it. Every `pull_request` run on this very PR failed with
MODULE_NOT_FOUND (`scripts/loop/` does not exist on `main` yet), which also
made the failure look like a defect rather than the missing file it was.

The rule is now keyed on whether the code is trusted:

  workflow_dispatch  → github.ref (dispatch can only target refs in this
                       repository)
  same-repo PR       → the PR head branch (only OWNER/MEMBER/COLLABORATOR
                       can push such branches; this is what makes loop's own
                       `loop/<n>-*` PRs reviewable)
  everything else    → the default branch, which is where **fork PRs** land:
                       external code is never checked out, only this
                       repository's trusted scripts run, and the diff is read
                       with `gh pr diff`

Verified the expression across seven event shapes, including the two that
matter for safety: a fork PR resolves to the default branch even though its
head ref is present, and a same-repo PR resolves to its head branch.
A skip was reported only in the Step Summary, so the Actions log showed the
route line and then stopped — indistinguishable from a silent failure. Under
an event storm this is exactly the line a human needs, since most events are
supposed to produce nothing.

Every no-run path now states its reason in the log: task not claimable
(idempotent skip), event with no matching task, event already consumed,
event queued in the inbox with no run, and unrouted events.

Also records D24 (same-repo PRs must check out the PR head, or `pr-review`
can never see what it reviews — this was why the A1 PR's own `loop` check
failed) and D26 as open: a `waiting-merge` task cannot be re-claimed, so a
loop PR that gains a commit is skipped rather than re-reviewed. D26 is left
for A1's week to quantify.
The type decides what `changelogen` puts in the user-facing CHANGELOG, so it
has to describe what a *package consumer* can observe — not what changed in
this repository.

Records the rule, and the reason maintenance work must not borrow `fix` or
`refactor`: both are rendered in the CHANGELOG and `refactor` also carries a
patch semver, so internal iteration would read to a user as a change to the
package. This came out of the A1 branch, where 14 loop commits were written
as `fix`/`refactor`/`docs` and would have shipped as "Fixes" and "Refactors".
@Alex1990

Copy link
Copy Markdown
Owner Author

Commit prefixes corrected — this branch was force-pushed

Raised in review: 14 of the commits here were fix(loop): / refactor(loop): / docs(loop):, and loop-host work is repository maintenance — not a change a package consumer can observe.

Why it mattered more than wording. bumpp regenerates CHANGELOG.md from conventional commits, and I verified with a real changelogen --from v1.1.0 run that all 11 fix(loop): entries landed in the user-facing section:

### 🩹 Fixes
- **loop:** Log every decision that produces no run
- **loop:** Check out the PR's code for same-repo PRs
...
### 💅 Refactors
- **loop:** Derive one R2 bucket per repository

A user installing v1.2.0 would have read "Fixes: log every decision that produces no run" and concluded the package had been fixed. refactor is worse for versioning: changelogen carries it at semver: patch, so maintenance work would have driven release numbers. The repo's own convention already pointed the right way — ci: for CI (dab2f55), chore: for the loop host (da28a14).

All 16 commits are now chore(loop):. Re-running the changelog generator, the 💅 Refactors section disappears entirely and zero loop entries remain in Fixes.

New SHAs (the old ones referenced in my earlier comments are gone; f5562b0 was already correct and is unchanged):

Old New Subject
5958e68 5b91b9e keep credential failures out of the human inbox
8eb59fd 0b820d5 derive one R2 bucket per repository
ba06f30 da8f781 include the owner in the derived bucket name
96b03c4 ce3fd02 shrink R2 configuration to three secrets
3c2bcc9 bc39614 repair workflow contexts that broke parsing
e0f7466 79c6241 let workflow_dispatch check out its own ref
45479f3 37e37fd correct the model id, validate it, add a smoke

Verified the rewrite changed no content: git diff <old-HEAD> <new-HEAD> is empty — only messages moved.

The rule is now recorded (cd29dad)

AGENTS.md gains a Commit messages section: the type must describe what a consumer can observe. feat/fix/perf are for the published package, docs for user-facing docs, and chore(<area>) for anything no consumer sees — the loop host, CI, the norms layer, tooling — with an explicit note never to use fix/refactor for maintenance.

That keeps the package's own PR loop honest: when a loop run commits a real feature or fix it will use feat/fix, and when it touches its own host it will use chore(loop).

Lint, Test and loop are all green on the rewritten branch (17 commits).

The repository is English-only apart from `README_zh-CN.md`, and the loop
host introduced in this branch was written in Chinese — comments, log
messages and error strings across nine files, which would have left
`scripts/loop/` half in one language and half in another (the A0 README and
the norms layer are English).

Translated: comments and every human-facing string in the four shared
modules, the orchestrator, both CLIs, the workflow and the host README.
Untouched by design: identifiers, JSON keys, CLI flags, env var names, paths,
the provider/network regexes that match English error text from pi and HTTP
servers, and the agent prompt body. `README.md`'s language switcher and the
existing Chinese in `example/`, `test/` and `src/cos/signature.ts` are out of
scope and unchanged.

`state/SUMMARY.md` section titles necessarily changed with `renderSummary`
(`In progress`, `Awaiting human merge`, `Inbox`, `Ready to claim`,
`Unprocessed (new)`, `Terminal counts`, `Recent acceptance`, `Recent runs`).
The immutable event logs under `state/tasks/` and `state/runs/` keep their
historical Chinese, which is what an append-only log should do.

Also fixes D27, found while reviewing the translated output: `run.mjs`
subcommands did not catch errors thrown by the shared library, so
`pnpm loop start --task <missing>` printed a Node stack trace instead of a
one-line reason.

Verified behaviour is unchanged: 17 route-decision cases, 9 failure-
classification cases (including the credential and rate-limit regexes), the
full CLI flow with its idempotence and stage-whitelist guards, every
orchestrator path (no-op, metrics, system-level run, task-scoped run, inbox),
smoke mode, the English SUMMARY rendering, and the R2 bucket derivation.
@Alex1990
Alex1990 merged commit 3c9cfac into main Sep 10, 2026
4 checks passed
Alex1990 added a commit that referenced this pull request Sep 10, 2026
Run 34511902164 (`task=36 stage=triage`, 80,289 tokens) exercised the path
that was still unproven: task claimed → processing → agent triage →
needs-triage → waiting-human with the matching label. Boundary re-checked
against GitHub, not the run's own summary: PR #36 carries no labels and no
label timeline events.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant