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
126 changes: 126 additions & 0 deletions packages/tbd/CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,131 @@
# get-tbd

## 0.8.1

A patch release about what an agent reads before it touches your code.
Rust becomes a first-class guideline family, four language-neutral topics that were
previously scattered across other documents get their own, and the set an agent is told
to read for *any* engineering work drops from nine documents to one.
No format change: repositories stay on `f08`, and there is no migration step.

### Rust is a first-class guideline family

Eight documents, reachable the same way as the TypeScript and Python families:

- `rust-rules` — general coding rules for libraries, applications, services, and CLIs
- `rust-project-setup` — structuring, validating, and maintaining packages and
workspaces
- `rust-lint-format-rules` — the `[lints]` block, `clippy.toml`, rustfmt and toolchain
pinning, hooks and CI gates, and how to prove the floor is live
- `rust-testing-rules` — unit, integration, property, snapshot, and cross-platform tests
- `rust-cli-rules` — composable, testable, cross-platform command-line applications
- `rust-filesystem-rules` — path and string types, write boundaries, and the `tempfile`
atomic-replacement sequence
- `rust-code-review-rules` — which guideline owns each changed surface, plus the
`unsafe` and FFI checklist
- `rust-release-rules` — crates.io publishing and workspace ordering, trusted
publishing, semver checks, and shipping a Rust binary as a Python wheel through
maturin

```bash
tbd guidelines rust-rules rust-lint-format-rules
tbd shortcut review-code-rust
```

`rust-lint-format-rules` reports measured adoption cost for the lints above the floor,
taken from a real 35k-line codebase, so a project can choose its lint set from evidence
rather than taste. `check-rust-gate.mjs` ships beside the guidelines so a project can
check that the floor it declares is the floor it actually runs.

`rust` is now a doc category, so `tbd docs fork --category=rust` forks the family and
the generated agent surfaces list it under its own heading.
`tbd shortcut review-code` routes a Rust diff to the Rust documents.

### Four cross-cutting topics that used to be implicit

Each is language-neutral and pairs with the language-specific document where one exists:

- `code-review-rules` — the Blocker/High/Medium/Low severity vocabulary, establishing a
baseline before hunting findings, reviewing the highest-risk boundaries first, and
what makes a finding actionable.
The `review-code` shortcuts are the procedure; this is the substance they apply.
- `ci-and-gates-rules` — one entry point in two modes, thin workflow orchestration over
tested project-native programs, and the traps that keep a gate green while it checks
nothing: swallowed pipeline exit status, self-recorded evidence, single-platform
blindness, and scope holes.
- `filesystem-rules` — atomic publication of finished files, atomic visibility versus
crash durability, cross-device moves, deterministic traversal, symlink and root
boundaries, and honest partial failure.
- `release-engineering-rules` — one release identity, a pre-release gate that runs where
publishing happens, least-privilege publishing authority, build-once-and-promote, and
smoke-testing the packaged artifact rather than the build output.

### The always-read core is one document, not nine

The generated guideline directory sorted documents into groups, and the first group told
agents to “read all of these for any engineering work.”
That group had grown to nine documents and roughly 10,600 words — read in full before
the agent had seen a line of your code, competing directly with attention for your
actual repository.

It is now one document, `general-eng-agent-principles`, at about 1,100 words.
Everything else is routed by what the change touches, under a new **Cross-cutting
engineering topics** group and the per-language groups, each of which now says to select
from it rather than load it whole.

Group membership is matched on exact names instead of substrings.
The old rule matched any name containing `testing`, `tdd`, or `golden`, so the Rust
family arriving in this release would have pulled `rust-testing-rules` into the group
every agent reads for every task, in any language.
A test now measures what the always-read core renders to and fails if it grows.

### A push failure is classified by the error, not by stray digits

`tbd sync` classifies a failed push as permanent, transient, or unknown, and acts on
that: a permanent failure auto-saves your changes to the outbox and reports the push as
blocked, while the other two suggest a retry.
The HTTP status patterns it classified with (`403`, `401`, `5\d\d`) matched bare digits
anywhere in the error text, including the repository path.

So a push failure under a directory like `/tmp/build-05895p` read as an HTTP 5xx server
error, because `5\d\d` matched the `589` in that name — and the identical failure in a
differently-named directory classified as `unknown`. A path containing `403` was worse:
it made an ordinary network failure look policy-blocked and triggered the outbox save.

Status codes are now recognized only where git actually reports one — `HTTP 500`,
`returned error: 403`, `status=401` — and not when the digits are embedded in a path,
object id, or larger number.

This surfaced during the release validation for this version: the same sync test
classified its failure differently between runs, purely on the random temp directory
name it happened to get.

### Guidelines and content

- `general-testing-rules` grew from a stub into a full document: keeping suites concise
and portable, not just testing the test, asserting transferred data rather than that a
mock was called, keeping the inner loop fast, raising a timeout only with a recorded
measurement, and never letting an absent test look like a passing one.
- `typescript-rules` replaces its general file-operations section with an explicit rule
to atomically publish files completed in one operation.
- `release-notes-guidelines` adds the rule that a defect introduced and corrected before
a release is not a shipped fix, with the cross-check for finding them.
- `new-guideline`, `review-code`, `review-code-typescript`, and `review-code-python`
were updated for the new routing; `supply-chain-hardening`,
`golden-testing-guidelines`, `error-handling-rules`, `typescript-cli-tool-rules`,
`typescript-lint-format-rules`, `python-rules`, and `python-modern-guidelines`
received focused corrections.

The bundled guideline count goes from 31 to 43.

### Security

Lockfile byte-identical since v0.8.0, so the installed dependency tree is unchanged.
`pnpm audit --prod` reports no runtime advisories.
The 32 open advisories are all dev-only, reached through `vitest`/`vite` and other build
tooling that is not shipped to users; they are tracked in the repository rather than
blocking this release.

## 0.8.0

tbd could say that work was open, started, or closed.
Expand Down
2 changes: 1 addition & 1 deletion packages/tbd/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "get-tbd",
"version": "0.8.0",
"version": "0.8.1",
"description": "Git-native issue tracking for AI agents and humans",
"license": "MIT",
"author": "Joshua Levy <joshua@cal.berkeley.edu> (https://github.com/jlevy)",
Expand Down
31 changes: 28 additions & 3 deletions packages/tbd/src/cli/lib/errors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -132,16 +132,32 @@ export type SyncErrorType = 'permanent' | 'transient' | 'unknown';
* @param error - Error message or Error object from git push
* @returns Classification of the error type
*/
/**
* Match an HTTP status code only where the message reports one.
*
* A bare `/5\d\d/` matches digits anywhere, so any path, object id, or byte count
* carrying those characters decides the classification. That is not theoretical: a
* missing-remote push failure under `/tmp/tryscript-05895p` classified as a 5xx
* server error because `5\d\d` matched the `589` in the directory name, while the
* identical failure elsewhere classified as `unknown`.
*
* A status code git reports stands on its own — `HTTP 500`, `returned error: 403`,
* `status=401` — so require a boundary on both sides. The two sides differ: a
* leading `.` means the digits are part of a longer number (`v1.500.2`), while a
* trailing `.` is ordinary sentence punctuation (`returned error: 503.`).
*/
function reportsStatusCode(lowerMessage: string, code: string): boolean {
return new RegExp(String.raw`(?<![\w./-])${code}(?![\w/-])`).test(lowerMessage);
}

export function classifySyncError(error: Error | string): SyncErrorType {
const msg = typeof error === 'string' ? error : error.message;
const lower = msg.toLowerCase();

// Permanent indicators - push is blocked by policy/permissions
const permanentPatterns = [
/403/, // HTTP 403 Forbidden
/forbidden/,
/permission denied/,
/401/, // HTTP 401 Unauthorized
/unauthorized/,
/protected branch/,
/remote rejected/,
Expand All @@ -150,6 +166,11 @@ export function classifySyncError(error: Error | string): SyncErrorType {
/not allowed to push/,
];

// HTTP 403 Forbidden, HTTP 401 Unauthorized
if (['403', '401'].some((code) => reportsStatusCode(lower, code))) {
return 'permanent';
}

for (const pattern of permanentPatterns) {
if (pattern.test(lower)) {
return 'permanent';
Expand All @@ -164,7 +185,6 @@ export function classifySyncError(error: Error | string): SyncErrorType {
/connection reset/,
/network/,
/dns/,
/5\d\d/, // HTTP 5xx server errors
/server error/,
/temporarily/,
/try again/,
Expand All @@ -173,6 +193,11 @@ export function classifySyncError(error: Error | string): SyncErrorType {
/connection closed/,
];

// HTTP 5xx server errors
if (reportsStatusCode(lower, String.raw`5\d\d`)) {
return 'transient';
}

for (const pattern of transientPatterns) {
if (pattern.test(lower)) {
return 'transient';
Expand Down
31 changes: 31 additions & 0 deletions packages/tbd/tests/errors.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -210,6 +210,37 @@ describe('classifySyncError', () => {
});
});

describe('status codes are read as status codes, not as stray digits', () => {
// A status pattern that matches bare digits anywhere reclassifies a failure
// based on characters that carry no meaning: a temp directory, a SHA, a byte
// count. This is not hypothetical — a missing-remote push failure under a
// directory named `tryscript-05895p` was classified `transient` because
// `5\d\d` matched the `589` inside it, while the same failure elsewhere was
// `unknown`. The classification decides whether tbd auto-saves to the outbox
// or tells the user to retry, so it must depend only on the error.
it('ignores status-code digits embedded in a path', () => {
const missingRemote =
'Command failed: git -C /tmp/tryscript-05895p push --no-verify origin ' +
'refs/heads/tbd-sync:refs/heads/tbd-sync\n' +
"fatal: 'origin' does not appear to be a git repository";
expect(classifySyncError(missingRemote)).toBe('unknown');
});

it('ignores status-code digits inside longer numbers and identifiers', () => {
expect(classifySyncError('wrote 4031 objects')).toBe('unknown');
expect(classifySyncError('object 5008f2a is missing')).toBe('unknown');
expect(classifySyncError('/tmp/build-401k/repo is not a git repository')).toBe('unknown');
expect(classifySyncError('/var/data/x-500-y is missing')).toBe('unknown');
});

it('still reads real status codes in the shapes git reports them', () => {
expect(classifySyncError('The requested URL returned error: 403')).toBe('permanent');
expect(classifySyncError('error: RPC failed; HTTP 500 curl 22')).toBe('transient');
expect(classifySyncError('The requested URL returned error: 503.')).toBe('transient');
expect(classifySyncError('status=401, push rejected')).toBe('permanent');
});
});

describe('unknown errors', () => {
it('classifies ambiguous errors as unknown', () => {
expect(classifySyncError('Something went wrong')).toBe('unknown');
Expand Down
Loading