Initial Commit
This commit is contained in:
265
openspec/archive/2026-05-25-enterprise-emblem-solver/design.md
Normal file
265
openspec/archive/2026-05-25-enterprise-emblem-solver/design.md
Normal file
@@ -0,0 +1,265 @@
|
||||
# Design: `@suaveplan/emblem-solver`
|
||||
|
||||
> Non-normative companion to `specs/emblem-solver/spec.md`. Architecture,
|
||||
> algorithm sketches, file layout, and rationale. The requirements live in
|
||||
> the spec delta.
|
||||
|
||||
## Goals
|
||||
|
||||
- Pure, universal-tier TypeScript. Zero browser / Node / React APIs in the
|
||||
runtime hot path.
|
||||
- Single public entry point `convert(pixels, width, height, options?)` that
|
||||
decomposes an opaque-pixel grid into the minimum number of axis-aligned
|
||||
rectangles, layered with z-index, and serialised to a `<svg>` string.
|
||||
- Deterministic given inputs + an injected `Rng` (defaults to `Math.random`).
|
||||
- Tree-shakeable: subpath exports for `./types`, `./errors`, `./grid`,
|
||||
`./io`, `./svg`.
|
||||
|
||||
## Tier discipline
|
||||
|
||||
Universal means no `window`, `document`, `navigator`, `fs`, `path`,
|
||||
`process`. All inputs are JS values (`RGBA[]`, `Uint8Array`, `number[][]`);
|
||||
output is a `string` + plain data structures. No DOM is created.
|
||||
|
||||
## Public surface
|
||||
|
||||
Exported from the package root (`@suaveplan/emblem-solver`):
|
||||
|
||||
| Symbol | Kind | Purpose |
|
||||
|---|---|---|
|
||||
| `convert` | fn | Decompose grid → `ConvertResult` |
|
||||
| `analyseGrid` | fn | Diagnostic colour/island analysis |
|
||||
| `parseRGBABuffer` | fn | `Uint8Array` (RGBA bytes) → `RGBA[]` |
|
||||
| `fromIndexedGrid` | fn | `number[][]` + palette → grid |
|
||||
| `buildSVG` | fn | `ColourLayer[]` → `<svg>` string |
|
||||
| `EmblemSolverError` | class | Base error |
|
||||
| `EMBLEM_SOLVER_CODES` | const | `defineCodes("EMBLEM_SOLVER", …)` |
|
||||
| `ConvertOptionsSchema` | const | Zod schema for `ConvertOptions` |
|
||||
| `RGBA`, `Rect`, `Island`, `ColourAnalysis`, `ColourLayer`, `ConvertOptions`, `ConvertResult`, `GridAnalysis` | types | Public type surface |
|
||||
|
||||
Subpath exports: `./types`, `./errors`, `./schemas`, `./grid`, `./io`,
|
||||
`./svg`, `./solver` (for advanced consumers wiring custom orderings).
|
||||
|
||||
## File layout
|
||||
|
||||
```text
|
||||
packages/media/image/emblem-solver/
|
||||
package.json # 0.1.0, sideEffects: false
|
||||
tsconfig.lib.json
|
||||
vite.config.ts
|
||||
vitest.config.ts
|
||||
README.md
|
||||
src/
|
||||
index.ts # root barrel (named re-exports)
|
||||
types/
|
||||
types.ts # RGBA, Rect, Island, ColourAnalysis, …
|
||||
types.md
|
||||
errors/
|
||||
errors.ts # defineCodes + EmblemSolverError
|
||||
errors.test.ts
|
||||
errors.md
|
||||
index.ts
|
||||
schemas/
|
||||
schemas.ts # Zod schemas (ConvertOptionsSchema)
|
||||
schemas.test.ts
|
||||
schemas.md
|
||||
index.ts
|
||||
helpers/
|
||||
helpers.ts # pack/unpack/keys/grids/bounds
|
||||
helpers.test.ts
|
||||
helpers.md
|
||||
index.ts
|
||||
grid/
|
||||
analyse/
|
||||
analyse.ts # analyseGrid + flood-fill islands
|
||||
analyse.test.ts
|
||||
analyse.md
|
||||
index.ts
|
||||
index.ts
|
||||
io/
|
||||
parse/
|
||||
parse.ts # parseRGBABuffer + fromIndexedGrid
|
||||
parse.test.ts
|
||||
parse.md
|
||||
index.ts
|
||||
index.ts
|
||||
solver/
|
||||
greedy/
|
||||
greedy.ts # enhancedGreedyCover + perIslandCover
|
||||
greedy.test.ts
|
||||
greedy.md
|
||||
index.ts
|
||||
histogram/
|
||||
histogram.ts # dualAxisMaximalRects
|
||||
histogram.test.ts
|
||||
histogram.md
|
||||
index.ts
|
||||
strip/
|
||||
strip.ts # stripDecomposition (h + v)
|
||||
strip.test.ts
|
||||
strip.md
|
||||
index.ts
|
||||
ordering/
|
||||
ordering.ts # permutations + factorial + getStaticOrdering
|
||||
ordering.test.ts
|
||||
ordering.md
|
||||
index.ts
|
||||
layer/
|
||||
layer.ts # processLayerMultiStrategy
|
||||
layer.test.ts
|
||||
layer.md
|
||||
index.ts
|
||||
index.ts
|
||||
optimizer/
|
||||
merge/
|
||||
merge.ts # mergeAdjacentRects
|
||||
merge.test.ts
|
||||
merge.md
|
||||
index.ts
|
||||
overlap/
|
||||
overlap.ts # bboxMerge, extendToSubsume, crossReplace
|
||||
overlap.test.ts
|
||||
overlap.md
|
||||
index.ts
|
||||
redundancy/
|
||||
redundancy.ts # eliminateRedundant across layers
|
||||
redundancy.test.ts
|
||||
redundancy.md
|
||||
index.ts
|
||||
index.ts
|
||||
svg/
|
||||
build/
|
||||
build.ts # buildSVG
|
||||
build.test.ts
|
||||
build.md
|
||||
index.ts
|
||||
index.ts
|
||||
telemetry/
|
||||
telemetry.ts # lazy OTel counter + histogram
|
||||
telemetry.test.ts
|
||||
telemetry.md
|
||||
index.ts
|
||||
fixtures/
|
||||
fixtures.ts # canonical test grids
|
||||
fixtures.md
|
||||
index.ts
|
||||
converter/
|
||||
converter.ts # public convert() orchestrator
|
||||
converter.test.ts # the canonical regression suite
|
||||
converter.md
|
||||
index.ts
|
||||
```
|
||||
|
||||
Each non-`.test.ts` `.ts` has a sibling `.md` ≥ 200 words.
|
||||
|
||||
## Algorithm overview
|
||||
|
||||
Upstream's pipeline, preserved verbatim:
|
||||
|
||||
1. **`analyseGrid`** — Walk the RGBA grid. For each opaque pixel
|
||||
(`a === 1`), record it in `opaquePixels` and bucket by colour key
|
||||
(`"r,g,b"`). For each colour bucket, flood-fill with 4-connected BFS to
|
||||
recover `Island[]`, compute bounds, density (`pixels / bbox area`), and
|
||||
fragmentation (`islands / pixels`).
|
||||
2. **Ordering search** — pick a z-stacking order over the colours.
|
||||
- `auto` mode: if `colourCount! ≤ maxPermutations` (default 40320 = 8!),
|
||||
enumerate all permutations via Heap's algorithm. Otherwise evaluate the
|
||||
four static heuristics plus up to 1000 random shuffles (using the
|
||||
injected `Rng`).
|
||||
- Explicit modes: `area-desc`, `area-asc`, `island-aware`,
|
||||
`perimeter-desc`.
|
||||
3. **Per-ordering layer build** — for each colour in the ordering, build an
|
||||
`available` grid (opaque pixels minus earlier-layer pixels) and a
|
||||
`required` grid (this colour's pixels). Run four covering strategies:
|
||||
- **A. Dual-axis histogram → greedy → overlap optimise**:
|
||||
`dualAxisMaximalRects` enumerates every maximal rect in `available` by
|
||||
sweeping both axes via the classic "largest rectangle in histogram"
|
||||
stack algorithm; `enhancedGreedyCover` selects rects with a three-key
|
||||
score (covered count, efficiency, centroid proximity);
|
||||
`optimiseOverlap` pairwise-merges bboxes, extends rects to subsume
|
||||
neighbours, and replaces 3 rects with 2 cross/T/L patterns.
|
||||
- **B. Per-island cover** (only if >1 island): same greedy, run
|
||||
independently on each island so spanning rects between disconnected
|
||||
islands aren't considered.
|
||||
- **C. Strip decomposition**: maximal horizontal runs merged vertically
|
||||
when same-extent + adjacent, and the transposed variant; keep whichever
|
||||
produces fewer rects, but only adopt it if the strips fully cover the
|
||||
required pixels.
|
||||
- **D. Adjacent-rect merge pass** on the best result so far.
|
||||
|
||||
Best = fewest rects.
|
||||
4. **Redundancy elimination across layers** — after all layers are built,
|
||||
composite them top-down into a per-pixel colour grid and remove any rect
|
||||
that is fully occluded by higher-z rects of any colour or by other rects
|
||||
in the same layer.
|
||||
5. **`buildSVG`** — sort layers by `zIndex` ascending and emit
|
||||
`<svg viewBox="0 0 W H" shape-rendering="crispEdges">…</svg>` with one
|
||||
`<rect>` per layer-rect, fill = `rgb(r,g,b)`. Bottom layer is drawn
|
||||
first, so layer 0 is the background and higher z-indexes overdraw.
|
||||
|
||||
## Options + validation
|
||||
|
||||
```ts
|
||||
import z from "zod";
|
||||
|
||||
export const ConvertOptionsSchema = z
|
||||
.object({
|
||||
layerOrder: z
|
||||
.enum(["auto", "area-desc", "area-asc", "island-aware", "perimeter-desc"])
|
||||
.default("auto"),
|
||||
maxPermutations: z.number().int().min(1).default(40320),
|
||||
rng: z.custom<() => number>((v) => typeof v === "function").optional(),
|
||||
})
|
||||
.strict();
|
||||
```
|
||||
|
||||
`convert` calls `ConvertOptionsSchema.safeParse`; failure → `EmblemSolverError(INVALID_OPTIONS, …)`.
|
||||
Invalid grid dimensions (`width ≤ 0`, `height ≤ 0`, `pixels.length !== width*height`) → `INVALID_GRID`.
|
||||
|
||||
## Error codes
|
||||
|
||||
```ts
|
||||
defineCodes("EMBLEM_SOLVER", [
|
||||
"INVALID_GRID",
|
||||
"INVALID_OPTIONS",
|
||||
"INVALID_PIXEL",
|
||||
] as const);
|
||||
```
|
||||
|
||||
Each code is reachable from a test.
|
||||
|
||||
## OTel instrumentation
|
||||
|
||||
Lazy-initialised on first `convert()` call (no module-load side effects):
|
||||
|
||||
- Counter `emblem_solver.convert.total`
|
||||
- Histogram `emblem_solver.convert.duration_ms` (unit: `ms`)
|
||||
|
||||
## Determinism
|
||||
|
||||
The only random site upstream is the `auto`-mode shuffle for >8 colours. We
|
||||
inject `rng: () => number` (default `Math.random`); tests pass a seeded
|
||||
Mulberry32 to verify the heuristic branch is reachable + stable.
|
||||
|
||||
## Tests
|
||||
|
||||
- Port the upstream regression suite verbatim (chessboard 8×8 ≤ 33,
|
||||
cross 3×3 ≤ 2, cross 6×6 ≤ 2, T-shape ≤ 2, donut 5×5 ≤ 4, circle 16 ≤ 5,
|
||||
star ratio > 3, flag crosses ≤ 5, multi-island stripes ≤ 3, shield 32×32
|
||||
ratio > 50, crest 12×12 ratio > 3).
|
||||
- Edge cases: solid 4×4 = 1, all-transparent = 0, single pixel = 1, SVG
|
||||
structure contains required tags, `auto` evaluates 3! = 6 orderings on
|
||||
the 3-colour flag, `auto` beats any single static strategy on the crest.
|
||||
- Property-based via `fast-check`: any random solid-colour grid satisfies
|
||||
"every required pixel is covered by at least one rect in its layer and
|
||||
no rect leaks outside the opaque mask"; SVG output round-trips the
|
||||
composited layers back to the original pixel grid.
|
||||
- Schema tests: every error code is reachable; Zod refines `maxPermutations
|
||||
≥ 1`; unknown options rejected by `.strict()`.
|
||||
|
||||
## Out of scope
|
||||
|
||||
- PNG decoding: callers feed `RGBA[]` directly or via `parseRGBABuffer`.
|
||||
- Path-based SVG (curves / `<path d>`): emblems are pixel-grid → axis-aligned
|
||||
rects only.
|
||||
- Multi-frame animation.
|
||||
104
openspec/archive/2026-05-25-enterprise-emblem-solver/proposal.md
Normal file
104
openspec/archive/2026-05-25-enterprise-emblem-solver/proposal.md
Normal file
@@ -0,0 +1,104 @@
|
||||
# Proposal: Enterprise-grade migration of `@suaveplan/emblem-solver`
|
||||
|
||||
- **Change ID**: enterprise-emblem-solver
|
||||
- **Package**: `@suaveplan/emblem-solver`
|
||||
- **Status**: PROPOSED
|
||||
- **Tier**: universal (zero browser/Node/React runtime deps)
|
||||
- **Category / plugin**: `media` / `image`
|
||||
- **Target path**: `packages/media/image/emblem-solver/`
|
||||
- **Design**: ./design.md
|
||||
- **Spec delta**: ./specs/emblem-solver/spec.md
|
||||
|
||||
## Summary
|
||||
|
||||
Migrate `@suaveplan/emblem-solver` from upstream
|
||||
(`/mnt/s/web/suaveplan/packages/universal/emblem-solver`, v1.0.3) onto
|
||||
`genesis` at enterprise quality, version `0.1.0`. The package decomposes an
|
||||
RGBA pixel grid (pixel-art sprite emblem) into the minimum number of
|
||||
axis-aligned coloured rectangles and emits a compact SVG by layering them
|
||||
with z-index so that overlap optimisations reduce the rect count below what
|
||||
any non-overlapping cover can achieve.
|
||||
|
||||
The actual upstream implementation is a layered, multi-strategy solver — not
|
||||
a single-pass scan-line — and the migration preserves that algorithmic depth
|
||||
verbatim while adding Zod input validation, an `EMBLEM_SOLVER.*` error code
|
||||
registry, injectable `Rng`, and OTel instrumentation.
|
||||
|
||||
## Why
|
||||
|
||||
Genesis ships no emblem decomposition tool. Downstream consumers
|
||||
(`packer-browser`, future game-server emblem packing, future client-side
|
||||
guild/clan badge editors) need an enterprise-grade SVG converter for
|
||||
pixel-art emblems. Upstream proves the algorithm works against
|
||||
well-understood game-emblem fixtures (chessboard, donut, cross, T-shape,
|
||||
circle, star, flag, shield, crest); we port those fixtures verbatim as the
|
||||
algorithmic regression suite.
|
||||
|
||||
Hand-rolling a replacement would be worse than porting. The upstream package
|
||||
is small (~2k LOC) and self-contained: zero `@suaveplan/*` runtime deps. It
|
||||
fits cleanly into genesis without dragging a dependency train.
|
||||
|
||||
## What changes
|
||||
|
||||
A net-new universal-tier package at `packages/media/image/emblem-solver/`.
|
||||
No upstream consumer exists on genesis (`grep -r "@suaveplan/emblem-solver"
|
||||
packages/` returns nothing), so this is purely additive.
|
||||
|
||||
### Affected dependents
|
||||
|
||||
- **None** on genesis today. Downstream packages will start importing the
|
||||
public surface listed below.
|
||||
|
||||
## Parity Checklist
|
||||
|
||||
| Capability | Upstream | Migrated | Notes |
|
||||
|---|---|---|---|
|
||||
| `convert(pixels, w, h, options)` pure entry point | ✅ | ✅ | |
|
||||
| `analyseGrid` exported | ✅ | ✅ | |
|
||||
| `parseRGBABuffer` + `fromIndexedGrid` | ✅ | ✅ | |
|
||||
| `buildSVG` | ✅ | ✅ | |
|
||||
| 4-connected island flood-fill | ✅ | ✅ | |
|
||||
| Dual-axis histogram maximal-rect sweep | ✅ | ✅ | |
|
||||
| Strip decomposition (h+v, picks smaller) | ✅ | ✅ | |
|
||||
| Enhanced greedy cover (3-key tiebreak) | ✅ | ✅ | |
|
||||
| Per-island independent cover | ✅ | ✅ | |
|
||||
| Overlap optimiser (bbox-merge, extend-to-subsume, 3→2 cross replace) | ✅ | ✅ | |
|
||||
| Adjacent-rect merge pass | ✅ | ✅ | |
|
||||
| Redundancy elimination across z-stacked layers | ✅ | ✅ | |
|
||||
| Heap's-algorithm permutations + factorial | ✅ | ✅ | |
|
||||
| Static orderings (`area-desc` / `area-asc` / `island-aware` / `perimeter-desc`) | ✅ | ✅ | |
|
||||
| `auto` mode: exhaustive ≤ N!, heuristics+random sampling above | ✅ | ✅ | Random branch now uses injectable `Rng` (default `Math.random`) |
|
||||
| `ConvertResult.colourAnalysis` exposed | ✅ | ✅ | |
|
||||
| Solid / fully-transparent / single-pixel edge cases | ✅ | ✅ | |
|
||||
| Sprite fixtures (chessboard, donut, cross, T, circle, star, flag, shield, crest, stripes) | ✅ | ✅ | Ported verbatim |
|
||||
| Zod-validated `ConvertOptions` | 🚫 | ⬆️ | Enhanced — runtime schema + `EmblemSolverError(INVALID_OPTIONS)` |
|
||||
| Structured error hierarchy | 🚫 | ⬆️ | New `EMBLEM_SOLVER.*` registry via `defineCodes` |
|
||||
| OTel counter + histogram on `convert` | 🚫 | ⬆️ | New |
|
||||
| Deterministic RNG injection | 🚫 | ⬆️ | New — replaces the lone `Math.random` shuffle site |
|
||||
| Property-based tests via `fast-check` | 🚫 | ⬆️ | New on geometric kernels (rect cover correctness, SVG round-trip) |
|
||||
| Co-located `.md` per impl file | partial | ✅ | Per CLAUDE.md doc rule |
|
||||
| Subpath exports (`./types`, `./errors`, `./svg`, `./io`, `./grid`) | 🚫 | ⬆️ | New for tree-shaking |
|
||||
|
||||
No upstream feature is dropped.
|
||||
|
||||
## Impact
|
||||
|
||||
- **Code**: ~1.8k LOC pure TypeScript in `packages/media/image/emblem-solver/src/`.
|
||||
- **Build**: Standard universal-tier `vite build` + dts emit.
|
||||
- **Tests**: 100/100/100/100 via `provider: 'istanbul'`. Upstream's snapshot
|
||||
fixtures (`chessboard8x8`, `cross3x3`, `cross6x6`, `tShape`, `donut5x5`,
|
||||
`circle16`, `flagCrosses`, `multiIslandStripes`, `shieldEmblem32`,
|
||||
`crest12x12`, `star`) ported verbatim as the deterministic regression set.
|
||||
- **Docs**: README at package root + sibling `.md` per impl file.
|
||||
- **STATUS.md**: flips `@suaveplan/emblem-solver` `–` → `✅`. Counter
|
||||
`289 → 290` migrated; `204 → 203` pending.
|
||||
- **Manifest**: regenerated via `bun scripts/folder-reorg/generate-manifest.ts`.
|
||||
|
||||
## Risks & mitigation
|
||||
|
||||
| Risk | Mitigation |
|
||||
|---|---|
|
||||
| `auto` mode blows up for >8 colours | Hard `maxPermutations` cap (default 40320 = 8!), heuristic fallback identical to upstream; Zod refines `maxPermutations ≥ 1` |
|
||||
| Non-determinism from `Math.random` | Injectable `Rng` (defaults to `Math.random` for back-compat); tests pass a seeded fake to lock the random-shuffle branch |
|
||||
| Coverage gaps in the heuristic branch | Dedicated tests construct >8-colour fixtures to exercise the heuristic + sampling path |
|
||||
| Floating-point density / score determinism | All scoring is deterministic given inputs; no `Date.now` / module-scope `Math.random` |
|
||||
@@ -0,0 +1,441 @@
|
||||
<!-- markdownlint-disable MD024 -->
|
||||
|
||||
# Capability Spec Delta: emblem-solver (emblem-solver)
|
||||
|
||||
**Package**: `@suaveplan/emblem-solver`
|
||||
**Status**: PROPOSED
|
||||
|
||||
> Every requirement below carries a `**Verified by:**` line at authoring time. No exceptions.
|
||||
|
||||
## ADDED Requirements
|
||||
|
||||
### Requirement: Stable error code registry
|
||||
|
||||
The package SHALL expose `EMBLEM_SOLVER_CODES` created via
|
||||
`defineCodes("EMBLEM_SOLVER", [...] as const)` containing exactly the codes
|
||||
`INVALID_GRID`, `INVALID_OPTIONS`, `INVALID_PIXEL`. The registry MUST be
|
||||
frozen and each value MUST be the namespaced string
|
||||
(`EMBLEM_SOLVER.INVALID_GRID`, …).
|
||||
|
||||
#### Scenario: registry shape is frozen
|
||||
|
||||
- **WHEN** a consumer reads `EMBLEM_SOLVER_CODES`
|
||||
- **THEN** the returned object MUST contain all three expected keys with
|
||||
namespaced string values and MUST be `Object.isFrozen`.
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"EMBLEM_SOLVER_CODES exposes all three codes"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: EmblemSolverError extends SuaveplanError
|
||||
|
||||
The package SHALL export `EmblemSolverError extends SuaveplanError<EmblemSolverCode>`.
|
||||
Constructing `new EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID, "msg")`
|
||||
MUST yield an instance whose `.code === "EMBLEM_SOLVER.INVALID_GRID"`, whose
|
||||
`.message === "msg"`, and which satisfies `instanceof EmblemSolverError`
|
||||
AND `instanceof Error`. A `cause` passed in options MUST be preserved.
|
||||
|
||||
#### Scenario: round-trips through cause chain
|
||||
|
||||
- **WHEN** an `EmblemSolverError` is constructed with `cause: new Error("inner")`
|
||||
- **THEN** `error.cause` MUST return the original error and
|
||||
`error.code` MUST preserve the namespaced code.
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"EmblemSolverError preserves code and cause"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: ConvertOptions Zod validation
|
||||
|
||||
The package SHALL expose `ConvertOptionsSchema` (Zod) accepting
|
||||
`layerOrder` (enum: `auto | area-desc | area-asc | island-aware | perimeter-desc`,
|
||||
default `auto`), `maxPermutations` (integer ≥ 1, default `40320`), and
|
||||
optional `rng` (`() => number`). The schema MUST reject unknown keys
|
||||
(strict). `convert()` MUST call `safeParse` and on failure throw
|
||||
`EmblemSolverError(INVALID_OPTIONS)` whose message embeds the Zod issue list.
|
||||
|
||||
#### Scenario: rejects unknown option
|
||||
|
||||
- **WHEN** `convert(pixels, w, h, { wibble: 1 } as never)` is invoked
|
||||
- **THEN** the call MUST throw `EmblemSolverError` whose `.code` equals
|
||||
`EMBLEM_SOLVER.INVALID_OPTIONS`.
|
||||
|
||||
#### Scenario: rejects non-positive maxPermutations
|
||||
|
||||
- **WHEN** `convert(pixels, w, h, { maxPermutations: 0 })` is invoked
|
||||
- **THEN** the call MUST throw `EmblemSolverError(INVALID_OPTIONS)`.
|
||||
|
||||
**Verified by:** `src/schemas/schemas.test.ts::"ConvertOptionsSchema rejects unknown keys"` AND `src/converter/converter.test.ts::"throws INVALID_OPTIONS on bad option"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Grid input validation
|
||||
|
||||
The `convert()` entry point SHALL throw
|
||||
`EmblemSolverError(EMBLEM_SOLVER_CODES.INVALID_GRID)` when `width ≤ 0`,
|
||||
`height ≤ 0`, or `pixels.length !== width * height`. The
|
||||
`parseRGBABuffer()` entry point SHALL throw the same error when the supplied
|
||||
`Uint8Array` length is not exactly `width * height * 4`.
|
||||
|
||||
#### Scenario: width/height mismatch
|
||||
|
||||
- **WHEN** `convert([], 2, 2)` is invoked with empty pixels
|
||||
- **THEN** the call MUST throw `EmblemSolverError` whose `.code` equals
|
||||
`EMBLEM_SOLVER.INVALID_GRID`.
|
||||
|
||||
#### Scenario: buffer length mismatch
|
||||
|
||||
- **WHEN** `parseRGBABuffer(new Uint8Array(3), 2, 2)` is invoked
|
||||
- **THEN** the call MUST throw `EmblemSolverError(INVALID_GRID)`.
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"throws INVALID_GRID on dimension mismatch"` AND `src/io/parse/parse.test.ts::"throws INVALID_GRID on buffer length mismatch"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Public type surface
|
||||
|
||||
The package SHALL export the following types verbatim from the package root:
|
||||
`RGBA`, `Rect`, `Island`, `ColourAnalysis`, `ColourLayer`, `ConvertOptions`,
|
||||
`ConvertResult`, `GridAnalysis`, `Rng`, `EmblemSolverCode`. The shapes MUST
|
||||
match the upstream contract (`RGBA.a: 0 | 1`, `Rect: {x,y,w,h}`,
|
||||
`Island.pixels: Set<number>`, `ColourAnalysis` includes
|
||||
`density`/`fragmentation`/`bounds`/`islands`).
|
||||
|
||||
#### Scenario: type re-exports are present
|
||||
|
||||
- **WHEN** a TypeScript consumer imports the type set above
|
||||
- **THEN** type-checking MUST succeed against the documented shapes.
|
||||
|
||||
**Verified by:** `src/types/types.md` AND `src/converter/converter.test.ts::"ConvertResult exposes all documented fields"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Pixel-grid analysis with 4-connected islands
|
||||
|
||||
The package SHALL expose `analyseGrid(pixels, width, height)` which produces
|
||||
a `GridAnalysis` containing `width`, `height`, `opaquePixels` (a
|
||||
`Set<number>` of packed indices), `colourGroups` (a `Map<string, Set<number>>`
|
||||
keyed by `"r,g,b"`), and `colourAnalysis` (a `Map<string, ColourAnalysis>`)
|
||||
where each entry reports `totalPixels`, `bounds`, `density = pixels / bbox area`,
|
||||
`fragmentation = islands / pixels`, and the full `islands` list discovered via
|
||||
4-connected BFS flood-fill.
|
||||
|
||||
#### Scenario: detects two disjoint islands
|
||||
|
||||
- **WHEN** `analyseGrid` runs on a grid where one colour appears in two
|
||||
blocks separated by transparency
|
||||
- **THEN** that colour's `islands.length` MUST equal `2`.
|
||||
|
||||
#### Scenario: diagonals are not connected
|
||||
|
||||
- **WHEN** `analyseGrid` runs on a `[[1,0],[0,1]]` grid
|
||||
- **THEN** the only colour's `islands.length` MUST equal `2`.
|
||||
|
||||
**Verified by:** `src/grid/analyse/analyse.test.ts::"detects two islands separated by transparency"` AND `src/grid/analyse/analyse.test.ts::"treats diagonal pixels as separate islands (4-connected)"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: RGBA buffer + indexed grid IO
|
||||
|
||||
The package SHALL expose `parseRGBABuffer(buf, w, h)` returning `RGBA[]` of
|
||||
length `w*h` where `a` is quantised to `0 | 1` at the threshold `byte ≥ 128`.
|
||||
It SHALL also expose `fromIndexedGrid(grid, palette)` returning
|
||||
`{ pixels, width, height }` from a `number[][]` palette map. Index `0` MUST
|
||||
map to a fully-transparent pixel (`a === 0`). Indices not present in the
|
||||
palette MUST also produce a transparent pixel.
|
||||
|
||||
#### Scenario: alpha quantisation rounds half-up
|
||||
|
||||
- **WHEN** `parseRGBABuffer` reads a byte with `a = 128`
|
||||
- **THEN** the resulting `RGBA.a` MUST equal `1`; for `a = 127` it MUST be `0`.
|
||||
|
||||
#### Scenario: indexed grid maps palette correctly
|
||||
|
||||
- **WHEN** `fromIndexedGrid([[1,0],[0,2]], {1:r, 2:g})` is called
|
||||
- **THEN** result `pixels[0]` MUST be the `r`-colour with `a===1`,
|
||||
`pixels[1]` MUST be transparent, and `pixels[3]` MUST be the `g`-colour.
|
||||
|
||||
**Verified by:** `src/io/parse/parse.test.ts::"parseRGBABuffer quantises alpha"` AND `src/io/parse/parse.test.ts::"fromIndexedGrid maps palette indices to RGBA pixels"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Dual-axis histogram maximal-rectangles
|
||||
|
||||
The package SHALL expose a `dualAxisMaximalRects(available, rows, cols)`
|
||||
solver that enumerates every distinct maximal-rectangle candidate in the
|
||||
opaque-pixel grid by combining a normal histogram sweep and a transposed
|
||||
sweep. Duplicates MUST be deduplicated by their `(x,y,w,h)` key.
|
||||
|
||||
#### Scenario: solid 3x3 produces the full rect
|
||||
|
||||
- **WHEN** the input is a 3×3 all-`true` grid
|
||||
- **THEN** the returned set MUST include `{x:0,y:0,w:3,h:3}`.
|
||||
|
||||
**Verified by:** `src/solver/histogram/histogram.test.ts::"includes the full grid rect for a solid grid"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Strip decomposition picks the smaller axis
|
||||
|
||||
The package SHALL expose `stripDecomposition(required, rows, cols)` that
|
||||
computes maximal horizontal strips (and the transposed vertical variant)
|
||||
where same-extent + adjacent runs merge, and returns whichever orientation
|
||||
yields fewer rects.
|
||||
|
||||
#### Scenario: tall vertical strip beats horizontal runs
|
||||
|
||||
- **WHEN** the required mask is a single column of height `5`
|
||||
- **THEN** the returned strips MUST contain exactly one rect of width `1` and height `5`.
|
||||
|
||||
**Verified by:** `src/solver/strip/strip.test.ts::"single column produces one vertical strip"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Enhanced greedy set-cover with three-key tiebreak
|
||||
|
||||
The package SHALL expose `enhancedGreedyCover(candidates, required, w, h)`
|
||||
that selects rects to cover the required mask using a deterministic score
|
||||
of `covered*1000 + efficiency*100 + proximity*10`. When no rect overlaps the
|
||||
remaining uncovered pixels, the function MUST fall back to emitting 1×1 rects
|
||||
for the residual.
|
||||
|
||||
#### Scenario: empty required returns no rects
|
||||
|
||||
- **WHEN** all entries of `required` are `false`
|
||||
- **THEN** `enhancedGreedyCover` MUST return an empty array.
|
||||
|
||||
**Verified by:** `src/solver/greedy/greedy.test.ts::"returns empty array when nothing is required"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Per-island independent cover
|
||||
|
||||
The package SHALL expose `perIslandCover(islands, candidates, w, h)` that
|
||||
runs `enhancedGreedyCover` independently on each island's local required
|
||||
mask, restricting candidates to those that touch the island.
|
||||
|
||||
#### Scenario: independent covers don't span islands
|
||||
|
||||
- **WHEN** two 2×2 islands are separated by transparency
|
||||
- **THEN** `perIslandCover` MUST produce at least two rects, one per island,
|
||||
and no rect MUST span the transparent gap.
|
||||
|
||||
**Verified by:** `src/solver/greedy/greedy.test.ts::"covers two islands without spanning the gap"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Layer-ordering search
|
||||
|
||||
The package SHALL expose `getStaticOrdering(analysis, strategy)` for the
|
||||
strategies `area-desc`, `area-asc`, `island-aware`, `perimeter-desc`, and
|
||||
`permutations<T>(arr)` implementing Heap's algorithm yielding `n!`
|
||||
permutations including the identity, and `factorial(n)` returning the
|
||||
mathematical factorial with `factorial(0) === factorial(1) === 1`.
|
||||
|
||||
#### Scenario: permutations yields n! arrays in total
|
||||
|
||||
- **WHEN** `permutations(["a","b","c"])` is fully iterated
|
||||
- **THEN** the iteration MUST emit exactly `6` arrays.
|
||||
|
||||
#### Scenario: factorial agrees with the definition
|
||||
|
||||
- **WHEN** `factorial(5)` is called
|
||||
- **THEN** the return value MUST equal `120`.
|
||||
|
||||
**Verified by:** `src/solver/ordering/ordering.test.ts::"permutations yields n! arrays"` AND `src/solver/ordering/ordering.test.ts::"factorial matches the standard definition"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Multi-strategy layer processor
|
||||
|
||||
The package SHALL expose `processLayerMultiStrategy(key, ownPixels,
|
||||
analysis, earlierPixels)` that runs the four covering strategies
|
||||
(dual-axis-histogram + greedy, per-island, strip decomposition where it
|
||||
fully covers, and a merge pass on the best so far) and returns the rect
|
||||
list with the smallest cardinality.
|
||||
|
||||
#### Scenario: returns a valid cover
|
||||
|
||||
- **WHEN** the layer processor runs on the upstream `cross3x3` fixture's
|
||||
red pixels
|
||||
- **THEN** the returned rect list MUST cover every required red pixel and
|
||||
MUST contain at most `2` rects.
|
||||
|
||||
**Verified by:** `src/solver/layer/layer.test.ts::"cross3x3 produces at most 2 rects"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Adjacent-rect merge optimisation
|
||||
|
||||
The package SHALL expose `mergeAdjacentRects(rects, available)` which
|
||||
combines edge-sharing same-extent rect pairs into a single larger rect
|
||||
provided the merged span is fully `available`. The function MUST iterate
|
||||
until no further merge is possible.
|
||||
|
||||
#### Scenario: two adjacent unit squares merge
|
||||
|
||||
- **WHEN** `mergeAdjacentRects([{0,0,1,1},{1,0,1,1}], all-available)` runs
|
||||
- **THEN** the output MUST be exactly `[{0,0,2,1}]`.
|
||||
|
||||
**Verified by:** `src/optimizer/merge/merge.test.ts::"merges two horizontally adjacent unit squares"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Overlap optimiser
|
||||
|
||||
The package SHALL expose `optimiseOverlap(rects, required, available, rows,
|
||||
cols)` that (1) merges pairs whose bbox is fully available and whose union
|
||||
covers no more than the original required pixels, (2) extends a rect to
|
||||
subsume another while remaining inside `available`, and (3) replaces a
|
||||
triple of rects with a 2-rect cross/T/L pattern when one exists.
|
||||
|
||||
#### Scenario: cross 3x3 reduces to two rects
|
||||
|
||||
- **WHEN** `convert` runs on the upstream `cross3x3` fixture
|
||||
- **THEN** `ConvertResult.totalRects` MUST be at most `2`.
|
||||
|
||||
**Verified by:** `src/optimizer/overlap/overlap.test.ts::"reduces a 3-rect L to 2 rects"` AND `src/converter/converter.test.ts::"cross 3x3 reduces to <=2 rects"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Cross-layer redundancy elimination
|
||||
|
||||
The package SHALL expose `eliminateRedundant(layers, width, height)` which,
|
||||
given a list of `ColourLayer` objects, removes any rect whose pixels are
|
||||
either fully occluded by higher-z layers (of any colour) or already covered
|
||||
by sibling rects in the same layer.
|
||||
|
||||
#### Scenario: fully-occluded rect is removed
|
||||
|
||||
- **WHEN** layer 0 contains a 4×4 red rect at (0,0) and layer 1 contains a
|
||||
4×4 blue rect at (0,0)
|
||||
- **THEN** after `eliminateRedundant` layer 0 MUST have zero rects.
|
||||
|
||||
**Verified by:** `src/optimizer/redundancy/redundancy.test.ts::"removes a fully-occluded rect"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: SVG emission
|
||||
|
||||
The package SHALL expose `buildSVG(layers, width, height)` returning a string
|
||||
that begins with `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 W H"
|
||||
shape-rendering="crispEdges">` and ends with `</svg>`, contains one `<rect>`
|
||||
per layer-rect with `fill="rgb(r,g,b)"`, and renders layers in `zIndex`
|
||||
ascending order.
|
||||
|
||||
#### Scenario: empty layer list produces an empty viewBox svg
|
||||
|
||||
- **WHEN** `buildSVG([], 4, 4)` is invoked
|
||||
- **THEN** the result MUST contain `viewBox="0 0 4 4"` and zero `<rect>` tags.
|
||||
|
||||
**Verified by:** `src/svg/build/build.test.ts::"empty layers yields a rect-free svg"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Public `convert()` orchestrator
|
||||
|
||||
The package SHALL expose `convert(pixels, width, height, options?)`. The
|
||||
function MUST (1) parse `options` via `ConvertOptionsSchema`, (2) call
|
||||
`analyseGrid`, (3) early-return an empty result for fully-transparent input,
|
||||
(4) explore orderings (exhaustive for `colourCount! ≤ maxPermutations`;
|
||||
heuristics + ≤ 1000 RNG-driven samples otherwise), (5) pick the ordering
|
||||
with the smallest `totalRects`, (6) emit the SVG via `buildSVG`, and (7)
|
||||
return `{ svg, layers, totalRects, naiveRects, ratio, orderingUsed,
|
||||
orderingsEvaluated, colourAnalysis }`.
|
||||
|
||||
#### Scenario: chessboard 8x8 ≤ 33 rects
|
||||
|
||||
- **WHEN** `convert` runs on the upstream `chessboard8x8` fixture
|
||||
- **THEN** `result.totalRects ≤ 33`.
|
||||
|
||||
#### Scenario: solid grid produces exactly one rect
|
||||
|
||||
- **WHEN** `convert` runs on a 4×4 solid-red grid
|
||||
- **THEN** `result.totalRects === 1`.
|
||||
|
||||
#### Scenario: fully transparent grid produces zero rects
|
||||
|
||||
- **WHEN** `convert` runs on a 4×4 fully-transparent grid
|
||||
- **THEN** `result.totalRects === 0` and `result.layers` MUST be empty.
|
||||
|
||||
#### Scenario: shield emblem 32x32 compresses > 50x
|
||||
|
||||
- **WHEN** `convert` runs on the upstream `shieldEmblem32` fixture
|
||||
- **THEN** `result.ratio > 50`.
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"chessboard 8x8: ≤33 rects"` AND `src/converter/converter.test.ts::"solid colour 4x4 = 1 rect"` AND `src/converter/converter.test.ts::"fully transparent = 0 rects"` AND `src/converter/converter.test.ts::"shield emblem 32x32 ratio > 50"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Injectable Rng for deterministic auto-ordering
|
||||
|
||||
`convert()` SHALL accept an optional `rng: () => number` in its options. The
|
||||
heuristics-plus-sampling branch (entered when `colourCount! > maxPermutations`)
|
||||
MUST consume the injected `rng` for every Fisher–Yates shuffle step, falling
|
||||
back to `Math.random` only when no `rng` is supplied. The same seeded `rng`
|
||||
MUST yield byte-identical `ConvertResult.svg` across runs.
|
||||
|
||||
#### Scenario: seeded rng yields deterministic output
|
||||
|
||||
- **WHEN** `convert` runs twice on the same > 8-colour fixture with the same
|
||||
seeded mulberry32 `rng`
|
||||
- **THEN** the two `ConvertResult.svg` strings MUST be identical.
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"seeded rng yields deterministic auto output"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: OpenTelemetry instrumentation
|
||||
|
||||
`convert()` SHALL emit an OTel counter
|
||||
`emblem_solver.convert.total` (incremented once per call) and a histogram
|
||||
`emblem_solver.convert.duration_ms` (one observation per call, unit `ms`).
|
||||
Instruments MUST be created lazily on first call (no module-load side
|
||||
effects) using `metrics.getMeter("@suaveplan/emblem-solver")`.
|
||||
|
||||
#### Scenario: no instrument is registered before the first call
|
||||
|
||||
- **WHEN** the module is imported but `convert` has not been called
|
||||
- **THEN** internal `counter`/`histogram` references MUST remain `undefined`.
|
||||
|
||||
#### Scenario: first call registers both instruments
|
||||
|
||||
- **WHEN** `convert` is invoked once
|
||||
- **THEN** the internal `counter` and `histogram` MUST be defined and the
|
||||
counter MUST have been incremented by `1`.
|
||||
|
||||
**Verified by:** `src/telemetry/telemetry.test.ts::"instruments are lazily registered"` AND `src/telemetry/telemetry.test.ts::"convert increments counter and records duration"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Tree-shakeable subpath exports
|
||||
|
||||
The package's `package.json` SHALL expose `./types`, `./errors`,
|
||||
`./schemas`, `./grid`, `./io`, `./svg`, `./solver`, and `./optimizer`
|
||||
subpath exports in addition to the root entry, each pointing at a feature
|
||||
barrel. `sideEffects` MUST be `false`.
|
||||
|
||||
#### Scenario: each subpath is importable
|
||||
|
||||
- **WHEN** a consumer imports `@suaveplan/emblem-solver/errors`
|
||||
- **THEN** the import MUST resolve and expose `EmblemSolverError` and
|
||||
`EMBLEM_SOLVER_CODES`.
|
||||
|
||||
**Verified by:** `src/errors/errors.test.ts::"errors subpath barrel re-exports the public surface"`
|
||||
|
||||
---
|
||||
|
||||
### Requirement: Property-based coverage correctness
|
||||
|
||||
A property test SHALL assert that for arbitrary single-colour pixel grids
|
||||
generated via `fast-check`, the `convert()` output satisfies: (a) every
|
||||
opaque pixel in the input is covered by at least one rect in the
|
||||
corresponding layer, and (b) no rect's footprint extends beyond the grid
|
||||
bounds.
|
||||
|
||||
#### Scenario: random solid-colour grid round-trip
|
||||
|
||||
- **WHEN** `fast-check` generates 50 random grids of width/height in `[1, 12]`
|
||||
- **THEN** every required pixel MUST be covered by at least one rect.
|
||||
|
||||
**Verified by:** `src/converter/converter.test.ts::"property: every opaque pixel is covered"`
|
||||
101
openspec/archive/2026-05-25-enterprise-emblem-solver/tasks.md
Normal file
101
openspec/archive/2026-05-25-enterprise-emblem-solver/tasks.md
Normal file
@@ -0,0 +1,101 @@
|
||||
# Tasks: enterprise-emblem-solver
|
||||
|
||||
**Change ID**: enterprise-emblem-solver
|
||||
**Package**: `@suaveplan/emblem-solver`
|
||||
**Target path**: `packages/media/image/emblem-solver/`
|
||||
|
||||
## 0. Pre-flight
|
||||
|
||||
- [x] 0.1 `git status` clean; `git worktree list` reviewed; worktree synced to `EXPECTED_BASE`.
|
||||
- [x] 0.2 Read upstream reference at `/mnt/s/web/suaveplan/packages/universal/emblem-solver/`.
|
||||
- [x] 0.3 Read enterprise audit at `/mnt/s/web/suaveplan/enterprise/emblem-solver.md`.
|
||||
|
||||
## 1. Scaffold
|
||||
|
||||
- [x] 1.1 Create `packages/media/image/emblem-solver/` and directory tree per `design.md`.
|
||||
- [x] 1.2 Write `package.json` (v `0.1.0`, type module, sideEffects false, subpath exports).
|
||||
- [x] 1.3 Write `tsconfig.lib.json` extending `tsconfig.base.json`.
|
||||
- [x] 1.4 Write `vite.config.ts` with externalised deps + preserveModules.
|
||||
- [x] 1.5 Write `vitest.config.ts` (istanbul, 100% thresholds, forks pool).
|
||||
|
||||
## 2. Types
|
||||
|
||||
- [x] 2.1 Write `src/types/types.ts` — public RGBA/Rect/Island/etc.
|
||||
- [x] 2.2 Write `src/types/types.md`.
|
||||
|
||||
## 3. Errors
|
||||
|
||||
- [x] 3.1 Write `src/errors/errors.ts` — `defineCodes("EMBLEM_SOLVER", […])` + `EmblemSolverError`.
|
||||
- [x] 3.2 Write `src/errors/errors.test.ts` exercising every code.
|
||||
- [x] 3.3 Write `errors.md` + `index.ts` barrel.
|
||||
|
||||
## 4. Schemas
|
||||
|
||||
- [x] 4.1 Write `src/schemas/schemas.ts` — `ConvertOptionsSchema` (Zod, strict).
|
||||
- [x] 4.2 Write `src/schemas/schemas.test.ts`.
|
||||
- [x] 4.3 Write `schemas.md` + `index.ts`.
|
||||
|
||||
## 5. Helpers
|
||||
|
||||
- [x] 5.1 Write `src/helpers/helpers.ts`.
|
||||
- [x] 5.2 Write `src/helpers/helpers.test.ts`.
|
||||
- [x] 5.3 Write `helpers.md` + `index.ts`.
|
||||
|
||||
## 6. Grid analysis
|
||||
|
||||
- [x] 6.1 Write `src/grid/analyse/analyse.ts`.
|
||||
- [x] 6.2 Write `src/grid/analyse/analyse.test.ts`.
|
||||
- [x] 6.3 Write `.md` + barrels.
|
||||
|
||||
## 7. IO
|
||||
|
||||
- [x] 7.1 Write `src/io/parse/parse.ts`.
|
||||
- [x] 7.2 Write tests + `.md` + barrels.
|
||||
|
||||
## 8. Solver kernels
|
||||
|
||||
- [x] 8.1 histogram + tests/.md/barrel.
|
||||
- [x] 8.2 strip + tests/.md/barrel.
|
||||
- [x] 8.3 greedy + tests/.md/barrel.
|
||||
- [x] 8.4 ordering + tests/.md/barrel.
|
||||
- [x] 8.5 layer + tests/.md/barrel.
|
||||
- [x] 8.6 `src/solver/index.ts`.
|
||||
|
||||
## 9. Optimizers
|
||||
|
||||
- [x] 9.1 merge + tests/.md/barrel.
|
||||
- [x] 9.2 overlap + tests/.md/barrel.
|
||||
- [x] 9.3 redundancy + tests/.md/barrel.
|
||||
- [x] 9.4 `src/optimizer/index.ts`.
|
||||
|
||||
## 10. SVG + telemetry + fixtures + converter
|
||||
|
||||
- [x] 10.1 svg/build + tests/.md/barrel.
|
||||
- [x] 10.2 telemetry + tests/.md/barrel.
|
||||
- [x] 10.3 fixtures port.
|
||||
- [x] 10.4 converter orchestrator.
|
||||
- [x] 10.5 converter tests (baseline + edges + auto + heuristic + fast-check property).
|
||||
- [x] 10.6 converter.md.
|
||||
|
||||
## 11. Root barrel + docs
|
||||
|
||||
- [x] 11.1 `src/index.ts` explicit named re-exports.
|
||||
- [x] 11.2 `README.md`.
|
||||
|
||||
## 12. Verification
|
||||
|
||||
- [x] 12.1 lint green.
|
||||
- [x] 12.2 typecheck green.
|
||||
- [x] 12.3 test green.
|
||||
- [x] 12.4 build green.
|
||||
- [x] 12.5 coverage 100/100/100/100.
|
||||
|
||||
## 13. Status + manifest
|
||||
|
||||
- [x] 13.1 Regenerate `MANIFEST.md`.
|
||||
- [x] 13.2 STATUS.md flip + counter adjust.
|
||||
|
||||
## 14. Archive
|
||||
|
||||
- [x] 14.1 Fold spec delta into `openspec/specs/emblem-solver/spec.md`.
|
||||
- [x] 14.2 Move change folder to `openspec/archive/2026-05-25-enterprise-emblem-solver/`.
|
||||
Reference in New Issue
Block a user