@defrex/autobuild 0.9.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. package/README.md +1 -1
  2. package/SPEC.md +10 -0
  3. package/docs/architecture.md +109 -0
  4. package/docs/configuration.md +24 -3
  5. package/docs/remote-store-protocol.md +35 -0
  6. package/docs/website.md +4 -0
  7. package/package.json +6 -4
  8. package/packages/core/src/cli/dashboard/facts.ts +215 -0
  9. package/packages/core/src/cli/dashboard/frame-parse.ts +223 -0
  10. package/packages/core/src/cli/dashboard/model.ts +85 -147
  11. package/packages/core/src/cli/dashboard/poll.ts +7 -2
  12. package/packages/core/src/cli/dispatch-process.ts +1 -13
  13. package/packages/core/src/cli/dispatch.ts +80 -37
  14. package/packages/core/src/cli/help.ts +15 -1
  15. package/packages/core/src/cli/init-validation.ts +18 -0
  16. package/packages/core/src/cli/installation.ts +50 -8
  17. package/packages/core/src/cli/self-update.ts +77 -2
  18. package/packages/core/src/cli/status.ts +1 -1
  19. package/packages/core/src/cli/ticket.ts +146 -1
  20. package/packages/core/src/cli/wait.ts +10 -2
  21. package/packages/core/src/cli/watch.ts +10 -2
  22. package/packages/core/src/config/live.ts +1 -0
  23. package/packages/core/src/config/pipeline-source.ts +17 -5
  24. package/packages/core/src/config/schema.ts +21 -0
  25. package/packages/core/src/integration/harness.ts +113 -1
  26. package/packages/core/src/kernel/__snapshots__/reducer-registry.test.ts.snap +1369 -0
  27. package/packages/core/src/kernel/auto-merge-default.ts +43 -9
  28. package/packages/core/src/kernel/auto-merge.ts +99 -22
  29. package/packages/core/src/kernel/dispatch-settings.ts +42 -17
  30. package/packages/core/src/kernel/dispatch-status.ts +23 -0
  31. package/packages/core/src/kernel/engine.ts +9 -438
  32. package/packages/core/src/kernel/generators/build-log.ts +259 -0
  33. package/packages/core/src/kernel/generators/repository-journals.ts +206 -0
  34. package/packages/core/src/kernel/generators/session-log.ts +72 -0
  35. package/packages/core/src/kernel/harvest.ts +62 -18
  36. package/packages/core/src/kernel/incremental-contract.ts +75 -0
  37. package/packages/core/src/kernel/incremental-fixtures.ts +211 -0
  38. package/packages/core/src/kernel/incremental.ts +53 -0
  39. package/packages/core/src/kernel/log-index.ts +620 -0
  40. package/packages/core/src/kernel/pr-attachments.ts +164 -46
  41. package/packages/core/src/kernel/reducer-registry.ts +164 -0
  42. package/packages/core/src/kernel/reducer.ts +216 -74
  43. package/packages/core/src/operator/query.ts +68 -28
  44. package/packages/core/src/operator/read-view.ts +98 -0
  45. package/packages/core/src/ports/tickets/fake.ts +5 -1
  46. package/packages/core/src/ports/tickets/file.ts +169 -48
  47. package/packages/core/src/ports/tickets/linear.ts +33 -1
  48. package/packages/core/src/ports/tickets/operations.ts +1 -1
  49. package/packages/core/src/ports/tickets/remote.ts +8 -1
  50. package/packages/core/src/ports/types.ts +32 -1
  51. package/packages/core/src/processes/build-execution-state.ts +31 -15
  52. package/packages/core/src/processes/build-runner.ts +184 -90
  53. package/packages/core/src/processes/dispatcher-selectors.ts +123 -0
  54. package/packages/core/src/processes/dispatcher.ts +270 -176
  55. package/packages/core/src/processes/execution-settlement.ts +72 -39
  56. package/packages/core/src/processes/harvest-execution-state.ts +41 -29
  57. package/packages/core/src/processes/infrastructure-failure-budget.ts +31 -18
  58. package/packages/core/src/processes/orchestrator-tick.ts +44 -3
  59. package/packages/core/src/processes/publication-state.ts +164 -52
  60. package/packages/core/src/processes/repo-view.ts +1436 -0
  61. package/packages/core/src/processes/sandbox-state.ts +26 -2
  62. package/packages/core/src/processes/tick-state.ts +33 -0
  63. package/packages/core/src/store/adapter.ts +10 -1
  64. package/packages/core/src/store/build-scope.ts +26 -0
  65. package/packages/core/src/store/contract.ts +337 -0
  66. package/packages/core/src/store/digest.ts +71 -18
  67. package/packages/core/src/store/local/schema.ts +17 -0
  68. package/packages/core/src/store/local/store.ts +111 -4
  69. package/packages/core/src/store/memory.ts +68 -1
  70. package/packages/core/src/store/phase-session.ts +23 -0
  71. package/packages/core/src/store/remote/client.ts +50 -1
  72. package/packages/core/src/store/remote/index.ts +2 -0
  73. package/packages/core/src/store/remote/protocol.ts +15 -0
  74. package/packages/core/src/store/repo-state-events.ts +72 -7
  75. package/packages/core/src/store/session-handle.ts +30 -0
  76. package/packages/core/src/store/session-reducer.ts +52 -11
  77. package/packages/core/src/store/snapshots.ts +88 -0
  78. package/packages/core/src/store/ticket-assets.ts +41 -8
  79. package/packages/core/src/store/types.ts +38 -0
  80. package/skills/guide/SKILL.md +26 -7
  81. package/skills/guide/references/remote-store-protocol.md +35 -0
  82. package/skills/tickets/SKILL.md +57 -5
@@ -0,0 +1,88 @@
1
+ /**
2
+ * Reducer snapshots: a cache beside the event log, never an authority.
3
+ *
4
+ * For one scope (a build log or a repository journal) and one registered
5
+ * reducer, the store may keep that reducer's serialized accumulator as of a
6
+ * cursor, so a process with no hot memory loads it, reads only the events
7
+ * newer than the cursor, and advances — instead of replaying the whole log.
8
+ *
9
+ * The log always regenerates a snapshot. Every consumer treats a missing,
10
+ * version-mismatched, ahead-of-log, malformed or unreadable snapshot as a miss
11
+ * and replays; deleting every snapshot changes no observable output.
12
+ */
13
+ import { z } from 'zod'
14
+ import type { IncrementalReducer } from '../kernel/incremental'
15
+ import type { BuildStore } from './types'
16
+
17
+ export type SnapshotScope = { kind: 'build'; slug: string } | { kind: 'repo'; repo: string }
18
+
19
+ export interface ReducerSnapshot {
20
+ /** The reducer version that wrote `state`; a reader on another version
21
+ * ignores the snapshot. */
22
+ version: number
23
+ /** The greatest event seq folded into `state`. */
24
+ cursor: number
25
+ /** The reducer's JSON accumulator as of `cursor`. */
26
+ state: unknown
27
+ }
28
+
29
+ export const reducerSnapshotWireSchema = z.object({
30
+ version: z.number().int().min(1),
31
+ cursor: z.number().int().min(0),
32
+ state: z.unknown(),
33
+ })
34
+
35
+ /** The registry name a snapshot is keyed by: a short identifier, safe in a URL
36
+ * path segment and a SQL key. */
37
+ export const SNAPSHOT_REDUCER_NAME = /^[A-Za-z][A-Za-z0-9._-]{0,63}$/
38
+
39
+ export function snapshotScopeKey(scope: SnapshotScope): string {
40
+ return scope.kind === 'build' ? `build:${scope.slug}` : `repo:${scope.repo}`
41
+ }
42
+
43
+ /** Whether a put replaces the stored row. The stored cursor never decreases:
44
+ * a higher cursor wins whatever the versions, and an upgraded reducer replaces
45
+ * an older shape at an unchanged cursor. */
46
+ export function snapshotSupersedes(
47
+ next: Pick<ReducerSnapshot, 'version' | 'cursor'>,
48
+ stored: Pick<ReducerSnapshot, 'version' | 'cursor'> | undefined,
49
+ ): boolean {
50
+ if (stored === undefined) return true
51
+ if (next.cursor !== stored.cursor) return next.cursor > stored.cursor
52
+ return next.version > stored.version
53
+ }
54
+
55
+ type SnapshotStore = Pick<BuildStore, 'getReducerSnapshot' | 'putReducerSnapshot'>
56
+
57
+ /** Read a snapshot, degrading every failure to a miss. */
58
+ export async function loadReducerSnapshot(
59
+ store: Partial<SnapshotStore>,
60
+ scope: SnapshotScope,
61
+ name: string,
62
+ reducer: Pick<IncrementalReducer<unknown, unknown, unknown>, 'version'>,
63
+ ): Promise<ReducerSnapshot | null> {
64
+ if (store.getReducerSnapshot === undefined) return null
65
+ try {
66
+ const found = await store.getReducerSnapshot(scope, name, reducer.version)
67
+ if (found === null || found.version !== reducer.version) return null
68
+ if (!Number.isInteger(found.cursor) || found.cursor < 0) return null
69
+ return found
70
+ } catch {
71
+ return null
72
+ }
73
+ }
74
+
75
+ /** Write a snapshot best-effort; a failing store never reaches the caller. */
76
+ export async function persistReducerSnapshot(
77
+ store: Partial<SnapshotStore>,
78
+ scope: SnapshotScope,
79
+ name: string,
80
+ snapshot: ReducerSnapshot,
81
+ ): Promise<boolean> {
82
+ if (store.putReducerSnapshot === undefined) return false
83
+ try {
84
+ return await store.putReducerSnapshot(scope, name, snapshot)
85
+ } catch {
86
+ return false
87
+ }
88
+ }
@@ -10,6 +10,7 @@
10
10
 
11
11
  import type { AbEvent } from '../events/catalog'
12
12
  import type { PinnedAsset } from '../events/payloads'
13
+ import { defineReducer } from '../kernel/incremental'
13
14
  import { contentHash, type BlobStore, type BuildStore } from './types'
14
15
 
15
16
  export type { PinnedAsset }
@@ -379,19 +380,40 @@ export async function samplePinnedAssets(
379
380
  return (await store.listTicketAssets(repo, ticketId)).map(pinnedAssetOf)
380
381
  }
381
382
 
382
- /** Latest-pinned revision of `kind/name` as the build's events record it, or
383
- * (with `rev`) the revision when any pin in the log named it. */
384
- export function findPinnedRevision(
385
- events: AbEvent[],
383
+ export const PINNED_ASSETS_REDUCER_VERSION = 1
384
+
385
+ /** The asset sets recorded by every `build.created`/`spec.revised` pin event
386
+ * that carries one, in array order. Each pin event replaces the set, so the
387
+ * newest decides the live pin; all of them answer "was this revision ever
388
+ * named". `pinnedRevision` is the query over the finished state. */
389
+ export const pinnedAssetsReducer = defineReducer<
390
+ { pins: PinnedAsset[][] },
391
+ AbEvent,
392
+ { pins: PinnedAsset[][] }
393
+ >({
394
+ version: PINNED_ASSETS_REDUCER_VERSION,
395
+ initial: () => ({ pins: [] }),
396
+ fold(acc, events) {
397
+ for (const event of events) {
398
+ if (event.type !== 'build.created' && event.type !== 'spec.revised') continue
399
+ const assets = event.payload.assets
400
+ if (assets === undefined) continue
401
+ acc.pins.push(assets)
402
+ }
403
+ },
404
+ finish: (acc) => ({ pins: acc.pins.slice() }),
405
+ })
406
+
407
+ /** Latest-pinned revision of `kind/name` over a reduced pin state, or (with
408
+ * `rev`) the revision when any pin named it. */
409
+ export function pinnedRevision(
410
+ state: { pins: readonly (readonly PinnedAsset[])[] },
386
411
  kind: string,
387
412
  name: string,
388
413
  rev?: number,
389
414
  ): number | undefined {
390
415
  let latest: number | undefined
391
- for (const event of events) {
392
- if (event.type !== 'build.created' && event.type !== 'spec.revised') continue
393
- const assets = event.payload.assets
394
- if (assets === undefined) continue
416
+ for (const assets of state.pins) {
395
417
  if (rev !== undefined) {
396
418
  if (assets.some((a) => a.kind === kind && a.name === name && a.revision === rev)) return rev
397
419
  continue
@@ -403,6 +425,17 @@ export function findPinnedRevision(
403
425
  return latest
404
426
  }
405
427
 
428
+ /** Latest-pinned revision of `kind/name` as the build's events record it, or
429
+ * (with `rev`) the revision when any pin in the log named it. */
430
+ export function findPinnedRevision(
431
+ events: AbEvent[],
432
+ kind: string,
433
+ name: string,
434
+ rev?: number,
435
+ ): number | undefined {
436
+ return pinnedRevision(pinnedAssetsReducer.reduce(events), kind, name, rev)
437
+ }
438
+
406
439
  /** `BuildStore.getPinnedTicketAsset` for any adapter: read the build's own
407
440
  * events to find the pinned revision, then fetch exactly that revision from the
408
441
  * ticket's assets. Earlier revisions stay retrievable after a replace or a
@@ -26,6 +26,8 @@ import type {
26
26
  } from '../events/sessions'
27
27
  export type { SessionEventEnvelope }
28
28
  import type { TicketRef } from '../ontology'
29
+ import type { ReducerSnapshot, SnapshotScope } from './snapshots'
30
+ export type { ReducerSnapshot, SnapshotScope }
29
31
  import type {
30
32
  TicketAsset,
31
33
  TicketAssetInput,
@@ -115,6 +117,12 @@ export interface BuildDigest {
115
117
  * when any of its merges is strictly after the oldest unclaimed
116
118
  * observation). */
117
119
  merged?: string
120
+ /** A `pr-attachment.hosted` has no later matching `pr-attachment.reclaimed`
121
+ * (the `pendingPrAttachmentReclaims` rule). Absent otherwise. */
122
+ reclaimPending?: true
123
+ /** The latest `execution.started` has no later `execution.ended` with the
124
+ * same `instance` (the `openExecution` rule). Absent otherwise. */
125
+ executionOpen?: true
118
126
  }
119
127
 
120
128
  export interface RepositoryRecord {
@@ -365,6 +373,36 @@ export interface BuildStore {
365
373
  * replay yields (`projectRepositoryStateEvents` is the normative oracle).
366
374
  * Unknown repo rejects like `getRepoEvents`. */
367
375
  getRepoStateEvents(repo: string): Promise<RepositoryEvent[]>
376
+ /** The journal's true high-water mark: the greatest assigned seq of any
377
+ * event type, 0 for an empty journal. Returns no event rows — the cursor a
378
+ * long-lived reader (the dispatcher's repository view) needs to resume with
379
+ * `getRepoEvents(repo, cursor)` even when the bounded subset excludes the
380
+ * journal's trailing events. Unknown repo rejects like `getRepoEvents`;
381
+ * never an `ensureRepo` side effect. */
382
+ getRepoHighWater(repo: string): Promise<number>
383
+ /** The stored reducer snapshot for a scope (a build log or a repository
384
+ * journal), a cache beside the log and never an authority. `null` when
385
+ * absent, when the stored version differs from `version`, when the stored
386
+ * cursor is ahead of the scope's log tail, or when the scope is unknown (no
387
+ * `ensureRepo` side effect, no rejection). A store without snapshot storage
388
+ * answers `null`. */
389
+ getReducerSnapshot(
390
+ scope: SnapshotScope,
391
+ reducer: string,
392
+ version: number,
393
+ ): Promise<ReducerSnapshot | null>
394
+ /** Upsert a reducer snapshot; the stored cursor never decreases. Writes only
395
+ * when no row exists, when `cursor` is above the stored one (whatever the
396
+ * versions — a mismatched row is replaced as soon as any writer advances
397
+ * past it), or when the cursor is equal and `version` is higher. Everything
398
+ * else, and any cursor beyond the log tail, is a no-op. Atomic, so
399
+ * concurrent writers cannot undo each other's progress. Resolves `true`
400
+ * when the row was written. */
401
+ putReducerSnapshot(
402
+ scope: SnapshotScope,
403
+ reducer: string,
404
+ snapshot: ReducerSnapshot,
405
+ ): Promise<boolean>
368
406
  putRepoArtifact(repo: string, artifact: ArtifactInput): Promise<RepositoryArtifactMeta>
369
407
  getRepoArtifact(repo: string, kind: string, rev?: number): Promise<RepositoryArtifact | null>
370
408
  listRepoArtifacts(repo: string, kind?: string): Promise<RepositoryArtifactMeta[]>
@@ -732,7 +732,8 @@ state eligible.
732
732
  |---|---|---|---|
733
733
  | `source` | — | **required**, nonblank builtin or plugin registration name | Which provider backs all ticket operations. Unknown names report every available builtin and plugin source. |
734
734
  | `readyLabels` | — (source-aware) | optional; array of nonempty strings | A ticket must carry **every** listed label to be dispatchable. `[]` = **no label gate**. Absent uses the source default below. |
735
- | `readyState` | — | **required**, non-blank string | The one workflow state a ticket must sit in to be dispatchable. Linear matches exactly and case-sensitively; file canonicalizes it to a state directory (`ready` → `ready/`). There is no any-state mode. |
735
+ | `readyState` | — | **required**, non-blank string | The one workflow state a ticket must sit in to be dispatchable. Linear matches exactly and case-sensitively; file matches it case-insensitively to a state directory (`ready` → `ready/`). There is no any-state mode. |
736
+ | `readyAssignee` | — | optional; `"me"` or an email; `linear` only | Also require the ticket to be assigned to that Linear user (`me` = owner of `LINEAR_API_KEY`); unassigned tickets are never ready. |
736
737
  | `teamKey` | — | required by `linear`, forbidden by `file`, allowed for plugins | The Linear team key (e.g. `"ENG"`) or an existing plugin configuration field. |
737
738
  | `claimedState` | — | optional nonempty string; forbidden by `file`, allowed for plugins | Workflow state `claim()` moves a ticket to when a build starts. |
738
739
  | `createState` | — | optional, nonempty string | Default state for new tickets when a create does not name one. Absent = the provider's default (Linear: the team's default, e.g. Backlog; file: Triage). |
@@ -772,11 +773,16 @@ Cross-field rules, each an **error**:
772
773
  janitor completion. Every source-agnostic `ab ticket` command loads and uses
773
774
  the same configured registration.
774
775
 
775
- The file tracker is **directory-per-state**: `<dir>/<state>/<id>.md` over
776
- `triage/ ready/ doing/ done/`. The directory *is* the state, so a transition or
776
+ The file tracker is **directory-per-state**: `<dir>/<state>/<id>.md`, where
777
+ every directory directly under the root is a state: the four lifecycle
778
+ directories `triage/ ready/ doing/ done/` (always present, created on first use)
779
+ plus any others the repository adds, such as `icebox/`. A state exists when its
780
+ directory exists; state names match directories case-insensitively, and a name
781
+ with no directory is an error listing those that exist. The directory *is* the
782
+ state, so a transition or
777
783
  claim is a rename — frontmatter carries no `state`/`claimedBy`, and a ticket
778
784
  body survives byte-exactly because a move never rewrites the file. A malformed
779
- record in any lifecycle directory is diagnosed with its path and validation
785
+ record in any state directory is diagnosed with its path and validation
780
786
  problem, left untouched, and excluded from listing/claim so unrelated valid
781
787
  ready work can continue. `ab ticket list` sends those diagnostics to stderr and
782
788
  keeps JSON stdout as one bare valid-ticket array. Tracker-wide safety checks
@@ -784,8 +790,8 @@ remain fatal: the same id in two state dirs is a loud error naming both paths,
784
790
  as are root-level ticket files with no state and filesystem failures. When
785
791
  `dir` is defaulted, the backlog writes its own `.gitignore` of `*`, so git never sees it; an
786
792
  explicit `dir` is the user's and is left alone. Agents and operators drive it
787
- through the source-agnostic `ab ticket` commands rather than running `mv` by
788
- hand.
793
+ through the source-agnostic `ab ticket` commands rather than running `mv` or
794
+ `mkdir` by hand; `ab ticket state create` makes a new state.
789
795
 
790
796
  **Secrets never live in this file.** `LINEAR_API_KEY` and plugin-declared
791
797
  `requiredEnv` credentials are environment variables (a local `.env` works).
@@ -1063,6 +1069,19 @@ with Linear and the file tracker, without provider-specific API/MCP calls:
1063
1069
  labeled metadata and the body verbatim, so it can read a stored spec back.
1064
1070
  - `ab ticket move <id> <state> [--json]` transitions one ticket and reports its
1065
1071
  post-transition value.
1072
+ - `ab ticket states [--json]` lists every state of the configured source:
1073
+ lifecycle states first (triage, ready, doing, done), then custom states
1074
+ alphabetically. Each row has the state name, the roles it plays under the
1075
+ current config (create, ready, claimed, triage, proposal, done, or none), its
1076
+ ticket count, and its purpose when set. `--json` emits one bare array of
1077
+ `{name, roles, tickets, about?}`.
1078
+ - `ab ticket state create <name> [--about <text>] [--json]` makes a state, for
1079
+ example `icebox`. It is idempotent: an existing state is reported as already
1080
+ existing, and `--about` (one line) records or replaces its purpose. A name is
1081
+ one to forty lowercase letters, digits, and hyphens starting with a letter,
1082
+ and may not match an existing state case-insensitively. There is no rename or
1083
+ delete. Only the file source supports `states` and `state create`; other
1084
+ sources exit nonzero naming the source and the unsupported operation.
1066
1085
 
1067
1086
  State names and ids are source-local. For block and unblock, the first id is the
1068
1087
  ticket being changed and the comma-separated second operand names its blockers.
@@ -1849,7 +1868,7 @@ default, when you need to know what this repo's version says).
1849
1868
  | Skill | Place in the lifecycle | Purpose |
1850
1869
  |---|---|---|
1851
1870
  | `ab-spec` | Before a build exists | Design a feature spec-first through conversation, or flesh out a ticket to the spec standard. The human-interactive surface; takes a ticket, not a build slug. **Model-invocable.** |
1852
- | `ab-tickets` | Before a build exists | Drive this repo's local file tracker: create a ticket, report the backlog, groom or move one between `triage/ ready/ doing/ done/`. The agent-facing surface on the tracker — use it instead of `mv`. **Model-invocable.** |
1871
+ | `ab-tickets` | Before a build exists | Drive this repo's local file tracker: create a ticket, report the backlog, groom or move one between the lifecycle directories (`triage/ ready/ doing/ done/`) and any repository-defined states, including the icebox. The agent-facing surface on the tracker — use it instead of `mv`. **Model-invocable.** |
1853
1872
  | `ab-guide` | Outside the pipeline | This skill: reference for the lifecycle, config surface, setup/upgrade behavior, and the installed skills. Its editable `references/setup.md` guides the init handoff. **Model-invocable.** |
1854
1873
  | `ab-harvest` | harvest `synthesize` step | Continue the producer across review rounds: cluster the claimed structured observations and author typed spec-standard create/join/suppress proposals. Runner-only. |
1855
1874
  | `ab-harvest-review` | harvest `review` step | Fresh adversarial reviewer for proposal coverage, semantic dedup, spec quality, and evidence; returns `approve`/`revise`/`escalate`. Runner-only. |
@@ -297,6 +297,8 @@ it to exist. An unknown build returns `404 not-found`.
297
297
  | `appendIfCurrent` | `POST /builds/{slug}/events/conditional` | `{"expectedSeq": nonnegative integer, "event": event write}` | appended: `201` + build event envelope; stream advanced: `200 null` |
298
298
  | `getEvents` | `GET /builds/{slug}/events?since={n}&wait={s}` | optional `since` query value, parsed below; absence defaults to `0`. Optional `wait` in whole seconds, parsed per the event-wait rules below | `200` + envelopes whose `seq` is strictly greater than parsed `since`, in increasing sequence order |
299
299
  | `appendWithArtifacts` | `POST /builds/{slug}/deposits` | atomic deposit request | `201` + `{event, artifacts}` |
300
+ | `getReducerSnapshot` | `GET /builds/{slug}/snapshots/{reducer}?version={n}` | required `version` query value, a positive integer; `reducer` is a registry name (`^[A-Za-z][A-Za-z0-9._-]{0,63}$`) | `200` + `{"version", "cursor", "state"}` or `200 null` when absent, written by another reducer version, or ahead of the build log's tail; an unknown build is `404`, which the shipped client also maps to `null` |
301
+ | `putReducerSnapshot` | `PUT /builds/{slug}/snapshots/{reducer}` | `{"version": int >= 1, "cursor": int >= 0, "state": JSON}`, at most 16 MiB (`413` above) | `200 {"written": boolean}`; the stored cursor never decreases, so a write at an equal or lower cursor (or an equal cursor at an equal or lower version) and one past the log's tail answer `written: false` |
300
302
  | `putArtifact` | `POST /builds/{slug}/artifacts` | artifact input | `201` + build artifact metadata |
301
303
  | `getArtifact` | `GET /builds/{slug}/artifacts?kind={kind}&rev={n}` | `kind` is required and nonempty; optional `rev` is parsed below | `200` + artifact read; an absent `rev` parameter selects the latest revision; a missing kind/revision is `200 null` |
302
304
  | `listArtifacts` | `GET /builds/{slug}/artifact-list?kind={kind}` | optional `kind`; absence means all kinds | `200` + metadata ordered by kind and then increasing revision |
@@ -336,6 +338,32 @@ part of this protocol. Retention is enforced server-side inside the store at
336
338
  deposit time (bounded pruning of dispatcher run/config artifact revisions),
337
339
  not through any endpoint.
338
340
 
341
+ ### Reducer snapshots
342
+
343
+ A reducer snapshot is one registered reducer's serialized state as of a log
344
+ cursor, kept per scope (one build log or one repository journal) and reducer
345
+ name. It is a cache the log regenerates, never an authority: a process with no
346
+ hot memory loads it, reads only the events newer than its cursor, advances the
347
+ reducer, and writes the advanced state back, and deleting every snapshot changes
348
+ no observable output. The routes above and in section 4 are additive and carry
349
+ no protocol-version bump. The store, not the client, enforces the invariants:
350
+
351
+ - the stored cursor never decreases, whatever the versions of the competing
352
+ writers; a write succeeds only when no row exists, when its cursor is above the
353
+ stored one, or when its cursor equals the stored one and its version is higher
354
+ (an upgraded reducer replacing the old shape at an unchanged tail);
355
+ - a read answers `null` for a row whose version differs from the requested one,
356
+ and for one whose cursor is beyond the scope's log tail; a write beyond the
357
+ tail is refused;
358
+ - an unknown build or repository answers `null` (read) or `written: false`
359
+ (write) and never creates the scope.
360
+
361
+ A client treats `404` and `405` from either route as "no snapshot", so a client
362
+ newer than its store replays the log with no error, and a client older than its
363
+ store never calls the routes. Authentication and transport failures still
364
+ surface normally. A build token may touch only its own build's snapshots and a
365
+ repository token only its own repository's, exactly as for events.
366
+
339
367
  ### The event-read bounded wait
340
368
 
341
369
  Both event reads (`getEvents` and, symmetrically, the `getRepoEvents` read of
@@ -382,6 +410,8 @@ without a journal record, or a repository without builds.
382
410
  | `appendRepo` | `POST /repos/{repo}/events` | event write | `201` + repository event envelope |
383
411
  | `getRepoEvents` | `GET /repos/{repo}/events?since={n}&wait={s}` | optional `since` query value, parsed as in section 3; absence defaults to `0`. Optional `wait` in whole seconds, per the event-wait rules of section 3 | `200` + envelopes with `seq >` parsed `since`, in increasing sequence order |
384
412
  | `getRepoStateEvents` | `GET /repos/{repo}/state-events` | none | `200` + the bounded repository-journal subset (AUT-489): every durable event type (`harvest.*`, `orchestrator.sandbox.*`, the three dispatcher setting types) plus, when the journal has one, the tail from the latest `dispatcher.run-started`, in increasing sequence order; served inside the repository-existence gate like `GET events`, so an unknown repository is `404` |
413
+ | `getReducerSnapshot` (repo scope) | `GET /repos/{repo}/snapshots/{reducer}?version={n}` | required `version` query value, a positive integer; `reducer` is a registry name (`^[A-Za-z][A-Za-z0-9._-]{0,63}$`) | `200` + `{"version", "cursor", "state"}` or `200 null` when absent, written by another reducer version, ahead of the journal's tail, or the repository has no journal record; an unknown repository is `404`, which the shipped client also maps to `null` |
414
+ | `putReducerSnapshot` (repo scope) | `PUT /repos/{repo}/snapshots/{reducer}` | `{"version": int >= 1, "cursor": int >= 0, "state": JSON}`, at most 16 MiB (`413` above) | `200 {"written": boolean}`; the stored cursor never decreases, so a write at an equal or lower cursor (or an equal cursor at an equal or lower version) and one past the journal's tail answer `written: false` |
385
415
  | `appendRepoWithArtifacts` | `POST /repos/{repo}/deposits` | atomic deposit request | `201` + `{event, artifacts}` using repository shapes |
386
416
  | `putRepoArtifact` | `POST /repos/{repo}/artifacts` | artifact input | `201` + repository artifact metadata |
387
417
  | `getRepoArtifact` | `GET /repos/{repo}/artifacts?kind={kind}&rev={n}` | required nonempty `kind`; optional `rev` is parsed as in section 3 | `200` + artifact read; latest only when the `rev` parameter is absent; missing kind/revision is `200 null` |
@@ -834,6 +864,11 @@ resource access; an explicit resource whose id is `"*"` is not admin.
834
864
  | repo | no | no | no | exact id only | yes | exact id only | exact id only | exact repo only | no |
835
865
  | session | no | no | no | no | no | no | no | no | exact id only |
836
866
 
867
+ The snapshot routes (`/builds/{slug}/snapshots/{reducer}` and
868
+ `/repos/{repo}/snapshots/{reducer}`) follow the one-matching-build and
869
+ one-matching-repo columns: a build token reads and writes only its own build's
870
+ snapshots, a repo token only its own repository's.
871
+
837
872
  A valid token used for the wrong resource receives `403 auth`. Resource scope
838
873
  gates all operations, including reads, artifact operations, and leases. A
839
874
  session resource token's authority is exactly its one session — it cannot
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: tickets
3
- description: Work this repo's local ticket tracker — create or edit a ticket, amend blockedBy dependencies, report the backlog, move work between triage/ready/doing/done, or answer "what's the status of ticket X". Use whenever the user asks about tickets, blockers, the backlog, or wants something queued for autobuild to build.
3
+ description: Work this repo's local ticket tracker — create or edit a ticket, amend blockedBy dependencies, report the backlog, triage the inbox, move work between the lifecycle directories and any repository-defined states such as the icebox, or answer "what's the status of ticket X". Use whenever the user asks about tickets, blockers, the backlog, or wants something queued for autobuild to build.
4
4
  ---
5
5
 
6
6
  # /tickets
@@ -12,7 +12,11 @@ content and blocker relationships. The default file source needs no secret.
12
12
  ## Where it lives
13
13
 
14
14
  `.autobuild/tickets/` by default (if `autobuild.toml` has a `[tickets] dir`,
15
- that directory instead), holding exactly four state directories:
15
+ that directory instead), holding the four lifecycle state directories plus any others this repo adds.
16
+ `triage/`, `ready/`, `doing/`, and `done/` are the lifecycle directories the
17
+ dispatcher uses. Any other directory is a repository-defined state: the
18
+ dispatcher never moves tickets into it and never dispatches from it unless
19
+ `autobuild.toml` names it (for example `readyState` or `proposalState`).
16
20
 
17
21
  ```
18
22
  .autobuild/tickets/
@@ -20,6 +24,7 @@ that directory instead), holding exactly four state directories:
20
24
  ready/ # groomed; file lifecycle-state gate satisfied
21
25
  doing/ # claimed; a build is running
22
26
  done/ # merged
27
+ icebox/ # example of a repo-added state, present only once created
23
28
  ```
24
29
 
25
30
  **A ticket's state is the directory it is in.** There is no `state` field
@@ -71,13 +76,17 @@ retry, and adding validates the blocker exists and is not the ticket itself.
71
76
  ## Report the backlog
72
77
 
73
78
  ```
79
+ ab ticket states # every state, lifecycle and custom: role, count, purpose
74
80
  ls .autobuild/tickets/ready # groomed candidates in the ready lifecycle state
75
81
  ls .autobuild/tickets/doing # what's building right now
76
82
  ```
77
83
 
78
84
  The filename is the id. For a title, read the file's frontmatter. To report
79
- the whole backlog, list all four directories — that listing is complete and
80
- current by construction.
85
+ the whole backlog, cover every state — lifecycle and custom — via
86
+ `ab ticket states` or by listing every directory under the tracker root, so the
87
+ report includes `icebox/` and any other category. `ab ticket list` without
88
+ filters shows only the ready state, regardless of custom states, so it is not a
89
+ backlog report.
81
90
 
82
91
  ## Groom / transition a ticket
83
92
 
@@ -113,6 +122,49 @@ bounces.
113
122
  **Don't hand-move anything out of `doing/`** — a build owns it. If you need to
114
123
  stop a build, that's `ab` (or a human), not `mv`.
115
124
 
125
+ ## Icebox
126
+
127
+ `icebox/` is work deliberately deferred, groomed or not, that nothing
128
+ dispatches from. The first time it is needed, create it:
129
+
130
+ ```
131
+ ab ticket state create icebox --about "Deferred on purpose; nothing dispatches from here"
132
+ ```
133
+
134
+ Move a ticket in with `ab ticket move file-3 icebox`. To revive one, move it to
135
+ `triage/` when it needs regrooming, or to `ready/` when its spec still holds.
136
+
137
+ ## Triage the backlog
138
+
139
+ To run a pass over `triage/`:
140
+
141
+ 1. Run `ab ticket states` to see which destinations exist.
142
+ 2. For each ticket in `triage/`, read it with `ab ticket show <id>` and judge
143
+ whether its body meets the
144
+ [spec standard](../ab-guide/references/spec-standard.md).
145
+ 3. Move it to `ready/` if it conforms and should be built; to `icebox/` if it
146
+ should wait; to another existing category if one fits; otherwise leave it in
147
+ `triage/` and tell the user why.
148
+ 4. Report every move to the user in the session — id, title, destination, and
149
+ reason. A move leaves no comment on the ticket, so the report is the only
150
+ record of why.
151
+
152
+ ## Inventing categories
153
+
154
+ Run `ab ticket states` before creating anything. Reuse an existing state whose
155
+ purpose fits. Create a new one only for a recurring reason that no existing
156
+ state covers, with a lowercase hyphenated noun name and a one-line `--about`
157
+ purpose. `icebox` is the first worked example; `rejected` is a second:
158
+
159
+ ```
160
+ ab ticket state create rejected --about "Considered and declined; kept for the record"
161
+ ```
162
+
163
+ Never invent a second ready, doing, or done state, never move anything out of
164
+ `doing/`, and never create states for a build's internal progress — the
165
+ lifecycle belongs to the dispatcher. Remember that `ab ticket list` without
166
+ filters still shows only the ready state, however many custom states exist.
167
+
116
168
  ## Rules
117
169
 
118
170
  - Never edit frontmatter to change state — the directory is the state.
@@ -132,7 +184,7 @@ listing under default config. That does not make `ready/` sufficient;
132
184
  unresolved blockers are an independent dispatch gate.
133
185
 
134
186
  No Linear lifecycle UI. Directory-based lifecycle operations (`ls`, `mv`, and
135
- the four state directories) are file-tracker-only. If this repo's
187
+ the state directories) are file-tracker-only. If this repo's
136
188
  `autobuild.toml` sets `[tickets] source = "linear"`, do not apply that lifecycle
137
189
  guidance: state and labels live in Linear. The source-agnostic `ab ticket block`
138
190
  and `ab ticket unblock` commands still edit blocker relationships through the