fullstack-agentic-flow 2.0.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 (91) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -0
  3. package/dist/src/cli/args.js +74 -0
  4. package/dist/src/cli/commands/doctor.js +133 -0
  5. package/dist/src/cli/commands/init.js +103 -0
  6. package/dist/src/cli/commands/shared.js +20 -0
  7. package/dist/src/cli/commands/tools.js +21 -0
  8. package/dist/src/cli/commands/update.js +21 -0
  9. package/dist/src/cli/main.js +6 -0
  10. package/dist/src/cli/output.js +11 -0
  11. package/dist/src/cli/prompter.js +13 -0
  12. package/dist/src/cli/report.js +13 -0
  13. package/dist/src/cli/run.js +48 -0
  14. package/dist/src/domain/canon-mapping.js +26 -0
  15. package/dist/src/domain/claude-md-mode.js +17 -0
  16. package/dist/src/domain/enum-parse.js +9 -0
  17. package/dist/src/domain/errors.js +13 -0
  18. package/dist/src/domain/pipeline-config.js +58 -0
  19. package/dist/src/domain/runtime.js +19 -0
  20. package/dist/src/domain/tool.js +129 -0
  21. package/dist/src/domain/topology.js +39 -0
  22. package/dist/src/install/adapters.js +39 -0
  23. package/dist/src/install/applier.js +150 -0
  24. package/dist/src/install/detect.js +70 -0
  25. package/dist/src/install/managed-section.js +35 -0
  26. package/dist/src/install/manifest.js +63 -0
  27. package/dist/src/install/planned-file.js +77 -0
  28. package/dist/src/install/planner.js +139 -0
  29. package/dist/src/install/rules-preset.js +37 -0
  30. package/dist/src/install/setup-guide.js +60 -0
  31. package/dist/src/io/fs.js +39 -0
  32. package/dist/src/io/paths.js +6 -0
  33. package/dist/src/io/shell.js +25 -0
  34. package/dist/src/registry/commands.js +52 -0
  35. package/dist/src/registry/subagents.js +52 -0
  36. package/dist/src/registry/tools.js +99 -0
  37. package/package.json +53 -0
  38. package/payload/ai-agents/agents/00-bootstrap.md +473 -0
  39. package/payload/ai-agents/agents/00a-scaffold.md +295 -0
  40. package/payload/ai-agents/agents/00b-module-scaffold.md +108 -0
  41. package/payload/ai-agents/agents/01-intake.md +308 -0
  42. package/payload/ai-agents/agents/01b-impact-analysis.md +274 -0
  43. package/payload/ai-agents/agents/02a-dependency-map.md +247 -0
  44. package/payload/ai-agents/agents/02b-backend-contract.md +434 -0
  45. package/payload/ai-agents/agents/02c-ui-contract.md +367 -0
  46. package/payload/ai-agents/agents/02d-interface-seam.md +373 -0
  47. package/payload/ai-agents/agents/03a-backend-sequence.md +233 -0
  48. package/payload/ai-agents/agents/03b-ui-sequence.md +230 -0
  49. package/payload/ai-agents/agents/04a-backend-implementer.md +290 -0
  50. package/payload/ai-agents/agents/04b-ui-implementer.md +302 -0
  51. package/payload/ai-agents/agents/05-ci-validator.md +125 -0
  52. package/payload/ai-agents/agents/06-qa-spec.md +249 -0
  53. package/payload/ai-agents/agents/07-security.md +228 -0
  54. package/payload/ai-agents/agents/08-performance.md +208 -0
  55. package/payload/ai-agents/agents/09a-feature-notes.md +156 -0
  56. package/payload/ai-agents/agents/09b-release.md +111 -0
  57. package/payload/ai-agents/architecture-context.template.md +554 -0
  58. package/payload/ai-agents/archive/README.md +22 -0
  59. package/payload/ai-agents/canon/backend-canon.md +295 -0
  60. package/payload/ai-agents/canon/frontend-canon.md +47 -0
  61. package/payload/ai-agents/canon/typed-data.md +158 -0
  62. package/payload/ai-agents/observations/README.md +11 -0
  63. package/payload/ai-agents/scripts/check-test-with-change.mjs +103 -0
  64. package/payload/ai-agents/scripts/check-typed-boundaries.mjs +329 -0
  65. package/payload/ai-agents/scripts/lib/config.mjs +63 -0
  66. package/payload/ai-agents/scripts/lib/glob.mjs +40 -0
  67. package/payload/ai-agents/state/README.md +108 -0
  68. package/payload/ai-agents/state/current-stage.md +10 -0
  69. package/payload/ai-agents/templates/README.md +22 -0
  70. package/payload/ai-agents/templates/ci/github-actions.rules.yml +35 -0
  71. package/payload/ai-agents/templates/ci/gitlab-ci.rules.yml +30 -0
  72. package/payload/ai-agents/toolchain.md +214 -0
  73. package/payload/commands/bootstrap.md +17 -0
  74. package/payload/commands/contract.md +64 -0
  75. package/payload/commands/finalize.md +26 -0
  76. package/payload/commands/impact.md +25 -0
  77. package/payload/commands/implement.md +58 -0
  78. package/payload/commands/intake.md +16 -0
  79. package/payload/commands/perf.md +20 -0
  80. package/payload/commands/qa.md +15 -0
  81. package/payload/commands/release.md +22 -0
  82. package/payload/commands/resume.md +52 -0
  83. package/payload/commands/scaffold-module.md +20 -0
  84. package/payload/commands/scaffold.md +35 -0
  85. package/payload/commands/seam.md +28 -0
  86. package/payload/commands/security.md +16 -0
  87. package/payload/commands/sequence.md +22 -0
  88. package/payload/commands/status.md +37 -0
  89. package/payload/legacy/v1-checksums.json +197 -0
  90. package/payload/root/contracts-README.md +19 -0
  91. package/payload/root/pipeline-section.md +33 -0
@@ -0,0 +1,295 @@
1
+ # Backend Canon
2
+
3
+ The one module shape every backend in this pipeline takes, whatever the
4
+ framework. A developer who has read one module in a Laravel repo should be able
5
+ to open a NestJS repo built by this pipeline and find the same things in the
6
+ same places, under the same names.
7
+
8
+ This file is **pipeline-owned**, not repository-owned. `architecture-context.md`
9
+ §5.0 records *how* this repo maps the canon onto its framework, and at which
10
+ strictness. The canon itself does not change per repo; the mapping does.
11
+
12
+ Agents that read this file: 00a scaffold, 00b module scaffold, 00 bootstrap,
13
+ 02b backend contract, 03a backend sequence, 04a backend implementer.
14
+
15
+ ---
16
+
17
+ ## 1. Why a canon
18
+
19
+ Framework conventions answer "where does the framework look for a controller".
20
+ They do not answer "where does the rule that an invoice cannot be edited after
21
+ approval live", and every team answers that differently. Readability across a
22
+ consultancy's portfolio comes from answering it once.
23
+
24
+ The canon is deliberately boring: four layers, one direction of dependency, one
25
+ file per use case, typed data at every boundary. Nothing here is novel. The
26
+ value is that it is the same everywhere.
27
+
28
+ ---
29
+
30
+ ## 2. The four layers
31
+
32
+ ```
33
+ {modules-root}/{Module}/
34
+ ├── Domain/ what the business is
35
+ ├── Application/ what the system does
36
+ ├── Infrastructure/ how it is stored and connected
37
+ └── Interface/ how the outside world reaches it
38
+ + {Module} registration (service provider, Nest module, app config)
39
+ ```
40
+
41
+ | Layer | Contains | May depend on | Must never |
42
+ |-------|----------|---------------|------------|
43
+ | **Domain** | Entities, value objects, enums, domain errors, domain events, repository **interfaces** (ports), pure domain services | Nothing outside Domain. The language standard library. | Import the framework, the ORM (strict mode), HTTP, the queue, the clock or randomness directly (inject them) |
44
+ | **Application** | Use cases (one per action), input Data classes (commands and queries), output Data classes (results), ports for external services, transaction boundaries | Domain | Build HTTP responses, read the request, know about the view, run raw queries |
45
+ | **Infrastructure** | ORM models (strict mode), repository implementations, query objects, migrations, external API adapters, queue jobs, mail/notification adapters | Domain, Application | Contain business rules. A rule found here is a bug in placement |
46
+ | **Interface** | Controllers, GraphQL resolvers, CLI commands, request-to-Data mapping, presenters/resources, view models (MVC), route definitions, policies/guards | Application (and Domain types for reading) | Call repositories or the ORM directly. Contain business rules. Open transactions |
47
+
48
+ **Dependency direction is one way**: Interface → Application → Domain.
49
+ Infrastructure implements ports declared in Domain and Application, and is wired
50
+ in the module registration. Nothing depends on Interface.
51
+
52
+ **The rule that matters most**: *a controller calls exactly one use case.* If a
53
+ controller needs two, there is a use case missing.
54
+
55
+ ---
56
+
57
+ ## 3. Strictness: `strict` or `pragmatic`
58
+
59
+ Chosen once per repository at scaffold time and recorded in
60
+ `architecture-context.md` §5.0. Agents never mix them inside one repo.
61
+
62
+ | | `strict` | `pragmatic` |
63
+ |--|----------|-------------|
64
+ | Domain entities | Plain classes, no ORM | ORM models live in `Domain/Models` and act as entities |
65
+ | Repositories | Interface in Domain, implementation in Infrastructure | Interface in Domain, implementation in Infrastructure (unchanged) |
66
+ | Mapping cost | Entity ↔ ORM model mapper per aggregate | None |
67
+ | When to choose | Complex rules, long-lived core domain, multiple persistence targets | CRUD-heavy modules, active-record frameworks, small team speed |
68
+ | What never relaxes | Typed Data classes at boundaries; one use case per action; controllers never touch persistence | Same |
69
+
70
+ `pragmatic` is the honest default for Laravel and Rails-style frameworks. It
71
+ relaxes exactly one thing, the purity of Domain, and nothing else. If an agent
72
+ finds itself relaxing a second thing under the banner of "pragmatic", that is a
73
+ finding, not a convention.
74
+
75
+ ---
76
+
77
+ ## 4. Naming — no synonyms
78
+
79
+ | Thing | Name | Example |
80
+ |-------|------|---------|
81
+ | Use case | `{Verb}{Noun}` — imperative, business verb | `ApproveInvoice`, `ListBrands`, `ImportHsnCodes` |
82
+ | Use case entry method | `execute` / `handle` (one, chosen per repo, recorded in §5.0) | `ApproveInvoice::execute(ApproveInvoiceData $data): InvoiceResult` |
83
+ | Input Data (write) | `{UseCase}Data` | `CreateBrandData` |
84
+ | Input Data (read filters) | `{Noun}FilterData` | `BrandFilterData` |
85
+ | Output Data | `{Noun}Result` or `{Noun}View` (one, per repo) | `BrandResult` |
86
+ | Repository port | `{Noun}Repository` | `BrandRepository` |
87
+ | Repository implementation | `{Tech}{Noun}Repository` | `EloquentBrandRepository`, `PrismaBrandRepository` |
88
+ | Domain error | `{Noun}{Condition}` + language suffix | `BrandNotFound` + `Exception`/`Error` |
89
+ | Domain event | `{Noun}{PastTenseVerb}` | `InvoiceApproved` |
90
+ | Query object | `{Noun}{Purpose}Query` | `OutstandingBalancesByAgeQuery` |
91
+ | Enum | Singular noun | `BrandStatus` |
92
+
93
+ Repository read methods are fixed across every repo:
94
+
95
+ | Method | Returns | On not found |
96
+ |--------|---------|--------------|
97
+ | `findById(id)` | the entity | throws `{Noun}NotFound` |
98
+ | `findByIdOrNull(id)` | entity or null | null — only when absence is a normal branch |
99
+ | `paginate(filter: {Noun}FilterData)` | typed page of entities | empty page |
100
+ | `listForDropdown()` | typed collection of `OptionData` | empty collection |
101
+ | `existsBy{Field}(value)` | bool | false |
102
+
103
+ A repo may extend this list in §5.3. **New repositories use these names
104
+ exactly.** A repository bootstrapped from existing code may record an
105
+ established spelling as its canonical one in §5.3 — for example
106
+ `getForDropdown` in place of `listForDropdown`, or `getAll(FilterData)` in place
107
+ of `paginate`. That spelling is then the only one allowed in that repo, and
108
+ the canon name becomes a forbidden synonym there. What is never allowed is two
109
+ spellings in one repository.
110
+
111
+ ---
112
+
113
+ ## 5. Typed data at every boundary
114
+
115
+ See `canon/typed-data.md`. In short: **nothing crosses a layer boundary as an
116
+ untyped array, map, dictionary, or `mixed`/`any` value.** Input arrives as a
117
+ Data class, output leaves as a Data class, collections are typed collections of
118
+ Data classes. This is a pipeline-wide rule, not a repo preference, and it
119
+ outranks brevity every time.
120
+
121
+ ---
122
+
123
+ ## 6. Tests mirror the layers
124
+
125
+ ```
126
+ tests/
127
+ ├── Unit/{Module}/Domain/ pure, no framework boot, no database
128
+ ├── Unit/{Module}/Application/ use cases with in-memory fakes of the ports
129
+ ├── Integration/{Module}/Infrastructure/ repositories against a real database
130
+ └── Feature/{Module}/Interface/ HTTP / GraphQL / page tests end to end
131
+ ```
132
+
133
+ (Or co-located `*.spec.ts` beside the file, when the framework's convention is
134
+ co-location — recorded in §9. The *levels* are fixed; the *location* is mapped.)
135
+
136
+ Every use case ships with an in-memory fake for each port it uses, in
137
+ `tests/Support/Fakes/{Module}/`. Fakes are what make the Application layer
138
+ testable in milliseconds, which is what makes TDD tolerable at scale.
139
+
140
+ ---
141
+
142
+ ## 7. Framework mappings
143
+
144
+ `00a` and `00b` generate from these. `00` bootstrap verifies the repo still
145
+ matches the mapping recorded in §5.0.
146
+
147
+ ### 7.1 Laravel (PHP 8.3+)
148
+
149
+ ```
150
+ app/Modules/{Module}/
151
+ ├── {Module}ServiceProvider.php binds ports → implementations, loads routes + migrations
152
+ ├── Domain/
153
+ │ ├── Models/ pragmatic only: Eloquent models
154
+ │ ├── Entities/ strict only: plain PHP classes
155
+ │ ├── ValueObjects/
156
+ │ ├── Enums/ backed enums
157
+ │ ├── Events/
158
+ │ ├── Exceptions/
159
+ │ └── Contracts/ repository + service ports (interfaces)
160
+ ├── Application/
161
+ │ ├── UseCases/ ApproveInvoice.php — one public method
162
+ │ ├── Data/ Spatie Data (or readonly classes): *Data, *FilterData, *Result
163
+ │ └── Contracts/ ports to external services
164
+ ├── Infrastructure/
165
+ │ ├── Persistence/
166
+ │ │ ├── Repositories/ EloquentBrandRepository.php
167
+ │ │ ├── Queries/ raw/aggregate query objects
168
+ │ │ ├── Scopes/
169
+ │ │ └── Migrations/
170
+ │ ├── Jobs/
171
+ │ ├── Excel/ import/export adapters, if any
172
+ │ └── External/ HTTP clients, SDK adapters
173
+ └── Interface/
174
+ ├── Http/
175
+ │ ├── Controllers/ thin: map request → Data → use case → response
176
+ │ ├── Resources/ JSON output shaping (API topologies)
177
+ │ ├── ViewModels/ Inertia/Blade page props as Data (MVC topology)
178
+ │ └── routes.php
179
+ ├── Policies/
180
+ └── Console/
181
+ ```
182
+
183
+ - Input validation: Spatie Laravel Data (`rules()` on the Data class), injected
184
+ as the controller method parameter. Never `$request->validate()`, never
185
+ `$request->all()` past the controller.
186
+ - Collections: `DataCollection<BrandResult>` / `Collection<int, Brand>` with
187
+ generics declared in docblocks; PHPStan checks them.
188
+ - Static analysis: Larastan at the level recorded in §3, with iterable value
189
+ types required.
190
+ - Tests: Pest. Unit tests extend a base that does **not** boot the app.
191
+
192
+ ### 7.2 NestJS (TypeScript strict)
193
+
194
+ ```
195
+ src/modules/{module}/
196
+ ├── {module}.module.ts providers: port tokens → implementations
197
+ ├── domain/
198
+ │ ├── entities/ plain classes (strict) or ORM entities (pragmatic)
199
+ │ ├── value-objects/
200
+ │ ├── enums/
201
+ │ ├── events/
202
+ │ ├── errors/
203
+ │ └── ports/ brand.repository.ts (interface + injection token)
204
+ ├── application/
205
+ │ ├── use-cases/ approve-invoice.use-case.ts — one class, one execute()
206
+ │ ├── data/ *.data.ts, *.filter.ts, *.result.ts — classes, readonly fields
207
+ │ └── ports/
208
+ ├── infrastructure/
209
+ │ ├── persistence/
210
+ │ │ ├── repositories/ prisma-brand.repository.ts / typeorm-…
211
+ │ │ ├── queries/
212
+ │ │ └── migrations/ or the ORM's migration dir, mapped in §4
213
+ │ ├── jobs/ BullMQ processors
214
+ │ └── external/
215
+ └── interface/
216
+ ├── http/
217
+ │ ├── controllers/
218
+ │ ├── dto/ class-validator request DTOs → mapped to application Data
219
+ │ └── presenters/
220
+ ├── graphql/ resolvers + object types (if GraphQL)
221
+ └── guards/
222
+ ```
223
+
224
+ - Request DTOs (class-validator) live in Interface and are **mapped** to
225
+ Application Data classes. Application never imports `class-validator`
226
+ decorators — that is a framework concern leaking inward.
227
+ - `strict: true`, `noUncheckedIndexedAccess: true`, `exactOptionalPropertyTypes: true`.
228
+ - ESLint: `@typescript-eslint/no-explicit-any: error`, `no-unsafe-*: error`.
229
+ - Tests: Jest or Vitest per repo; `*.spec.ts` co-located, `test/` for e2e.
230
+
231
+ ### 7.3 Express / Fastify / Hono (TypeScript)
232
+
233
+ Same tree as NestJS without the module file. Wiring happens in
234
+ `src/modules/{module}/index.ts`, which exports a `register{Module}(app, deps)`
235
+ function. Validation with Zod at Interface; `z.infer` types are **not** used as
236
+ Application Data — map them to classes or branded readonly types so the
237
+ Application layer does not depend on the validator.
238
+
239
+ ### 7.4 Django / FastAPI (Python 3.12+)
240
+
241
+ ```
242
+ {project}/modules/{module}/
243
+ ├── domain/ dataclasses(frozen=True, slots=True), Enum, exceptions, Protocol ports
244
+ ├── application/ use_cases/, data/ (dataclasses or pydantic models — one choice per repo)
245
+ ├── infrastructure/ persistence/ (Django models or SQLAlchemy), repositories/, tasks/
246
+ └── interface/ http/ (views / routers), serializers or request schemas, urls.py
247
+ ```
248
+
249
+ `mypy --strict` or `pyright strict`. `dict[str, Any]` crossing a layer is the
250
+ Python spelling of an untyped array and is forbidden the same way.
251
+
252
+ ### 7.5 Spring Boot (Java 21 / Kotlin)
253
+
254
+ Package-by-module, then by layer: `com.acme.{module}.{domain|application|infrastructure|interface}`.
255
+ Records for Data classes. ArchUnit test enforces the dependency direction.
256
+
257
+ ### 7.6 Go
258
+
259
+ `internal/{module}/{domain,app,store,transport}`. Go's naming replaces
260
+ Application/Infrastructure/Interface with `app`/`store`/`transport`; the
261
+ mapping table in §5.0 records the equivalence. Structs for Data, no
262
+ `map[string]any` across package boundaries.
263
+
264
+ ### 7.7 A framework not listed
265
+
266
+ `00a` proposes a mapping using the table in §2 as the invariant and the
267
+ framework's own conventions for everything else, records it in
268
+ `scaffold-decisions.md` as **defaulted**, and asks the developer to confirm it
269
+ before the first commit. `find-skills` may be used to look for an established
270
+ skill for that framework; any skill adopted is recorded in `toolchain.md`
271
+ §Project additions.
272
+
273
+ ---
274
+
275
+ ## 8. MVC topology specifics
276
+
277
+ In an MVC repository the Interface layer also owns **view models**: one Data
278
+ class per screen, holding exactly what the template or page component receives.
279
+
280
+ - Controllers return `render(view, ViewModel)`. Never an array of props.
281
+ - The view model is the seam between backend and UI tracks (see
282
+ `agents/02d-interface-seam.md`, mode `page-contract`).
283
+ - Form error shape and flash messages are defined once in §7 and reused.
284
+
285
+ ---
286
+
287
+ ## 9. What the canon does not decide
288
+
289
+ Deliberately left to the repo, recorded in the architecture context:
290
+
291
+ - The ORM, the validator library, the test runner
292
+ - Co-located vs mirrored tests
293
+ - `execute` vs `handle`; `Result` vs `View`
294
+ - Whether events are synchronous or queued
295
+ - Frontend structure (see `canon/frontend-canon.md`, which is lighter by design)
@@ -0,0 +1,47 @@
1
+ # Frontend Canon
2
+
3
+ Lighter than the backend canon on purpose. Frontend frameworks disagree more
4
+ about structure than backend frameworks do, and the component inventory (§8.3)
5
+ and design tokens (§8.4) already carry most of the consistency. This file fixes
6
+ only the things that stay true across React, Vue, Svelte, Angular, Blade,
7
+ Livewire, and mobile.
8
+
9
+ ## 1. Feature folders over type folders
10
+
11
+ ```
12
+ {ui-root}/
13
+ ├── app/ or pages/ routing entry points — thin: fetch/receive data, compose a feature
14
+ ├── features/{feature}/
15
+ │ ├── api/ typed client calls for this feature (generated or hand-written against the seam)
16
+ │ ├── model/ types, state, derived data — named types only (typed-data T1)
17
+ │ └── ui/ feature-local components, composed from the shared inventory
18
+ ├── shared/
19
+ │ ├── ui/ the approved component inventory (§8.3) — nothing else lives here
20
+ │ ├── lib/ framework-agnostic helpers
21
+ │ └── api/ the HTTP/GraphQL client, error normalisation
22
+ └── styles/ or theme/ design tokens (§8.4) — or the path DESIGN.md points to
23
+ ```
24
+
25
+ A repo maps these names onto its framework in `architecture-context.md` §8.0.
26
+ MVC repos with server templates map `features/{feature}/ui` onto the
27
+ template directory for that module, and `api/` onto the view-model types.
28
+
29
+ ## 2. Rules that hold everywhere
30
+
31
+ 1. Pages/routes never call `fetch` directly — they go through `features/*/api`.
32
+ 2. Response bodies are parsed into named types at the edge (typed-data T5).
33
+ 3. `shared/ui` grows only through Gate 1 approval.
34
+ 4. Tokens, never literal values.
35
+ 5. Every data-bound region implements the required states (§8.5).
36
+
37
+ ## 3. Topology notes
38
+
39
+ - **mvc**: the "API" is the view model the controller passes. Types for it are
40
+ generated or hand-mirrored from the backend ViewModel Data classes; the seam
41
+ document is the source of truth for both.
42
+ - **unified-api**: types are generated from the backend's schema (OpenAPI,
43
+ GraphQL SDL, tRPC router) where the repo supports it; otherwise mirrored by
44
+ hand from the seam and checked in the integration task.
45
+ - **split-frontend**: types come from the imported seam in
46
+ `contracts/{slug}/` (see `agents/02d-interface-seam.md`). The frontend repo
47
+ never edits an imported contract.
@@ -0,0 +1,158 @@
1
+ # Typed Data — pipeline-wide rule
2
+
3
+ **Nothing crosses a boundary as an untyped array, map, dictionary, or
4
+ `mixed`/`any` value. Every shape that moves between layers, modules, processes,
5
+ or the network has a named type. Even when that means many small Data classes.**
6
+
7
+ This is a pipeline rule, installed into every repository's §10 as rules
8
+ `T1`–`T6`. A repo may tighten it. It may not relax it without a recorded Gate 1
9
+ decision naming the exact file and reason.
10
+
11
+ ---
12
+
13
+ ## Why
14
+
15
+ Arrays are the fastest way to write the first version and the slowest way to
16
+ change the tenth. A `$filters` array accumulates keys nobody documented; a
17
+ `Record<string, unknown>` gets a third optional field that half the callers set.
18
+ Each iteration is individually reasonable and collectively unreadable. A Data
19
+ class makes the shape a thing you can find, rename, and let the type checker
20
+ chase through the codebase for you.
21
+
22
+ For agents specifically: a named type is the cheapest possible context. An
23
+ implementer that sees `paginate(BrandFilterData $filter)` knows every legal
24
+ filter from one file. One that sees `paginate(array $filters)` has to read every
25
+ call site and guess.
26
+
27
+ ---
28
+
29
+ ## The rules
30
+
31
+ | # | Rule | Checked by |
32
+ |---|------|------------|
33
+ | T1 | Layer boundaries (controller→use case, use case→repository, module→module, job payloads, events) accept and return **named types only**. No `array`, `mixed`, `any`, `object`, `unknown`, `dict`, `Record<…>`, `Map<string, …>` in those signatures. | Static analysis + `scripts/check-typed-boundaries.mjs` on the diff |
34
+ | T2 | Collections are **typed collections of named types**: `list<BrandResult>`, `DataCollection<BrandResult>`, `ReadonlyArray<BrandResult>`, `list[BrandResult]`. A bare `array` / `any[]` is never a return type. | Static analysis (iterable value types required) |
35
+ | T3 | Fixed sets of values are **enums**, not strings. Status, type, kind, role, direction. | Review |
36
+ | T4 | Optional is explicit: nullable types or dedicated variants — never "key may be missing". | Type checker (`exactOptionalPropertyTypes`, PHPStan) |
37
+ | T5 | Untyped data is allowed **only at the parse edge**: the line that reads JSON, a request body, a CSV row, or an env var — and it is converted to a named type **on the next line**. | Review |
38
+ | T6 | Money, quantities, identifiers, and dates are value objects or branded types when they carry rules (currency, precision, format). A `float` is never money. | Review |
39
+
40
+ ---
41
+
42
+ ## What "boundary" means
43
+
44
+ Inside a single private method, a local array used as a scratch list is fine.
45
+ The rule applies where one piece of code hands data to another that was written
46
+ at a different time by a different mind:
47
+
48
+ - Public and protected method signatures in Domain, Application, Infrastructure
49
+ - Controller → use case, use case → repository, use case → port
50
+ - Anything serialised: API responses, job payloads, events, cache entries,
51
+ view models, exports
52
+ - Configuration read at runtime
53
+ - Test fixtures shared across more than one test file
54
+
55
+ ---
56
+
57
+ ## Spelling it per language
58
+
59
+ ### PHP (Laravel)
60
+
61
+ ```php
62
+ // Wrong
63
+ public function getAll(array $filters): LengthAwarePaginator
64
+ public function getForDropdown(): array
65
+
66
+ // Right
67
+ public function paginate(BrandFilterData $filter): LengthAwarePaginator // @return LengthAwarePaginator<int, Brand>
68
+ public function listForDropdown(): DataCollection // @return DataCollection<int, OptionData>
69
+
70
+ final class BrandFilterData extends Data
71
+ {
72
+ public function __construct(
73
+ public readonly ?string $search = null,
74
+ public readonly ?BrandStatus $status = null,
75
+ public readonly int $perPage = 15,
76
+ ) {}
77
+ }
78
+
79
+ final class OptionData extends Data
80
+ {
81
+ public function __construct(
82
+ public readonly int $id,
83
+ public readonly string $label,
84
+ ) {}
85
+ }
86
+ ```
87
+
88
+ Analytics that previously returned `['labels' => [...], 'series' => [...]]`:
89
+
90
+ ```php
91
+ final class TimeSeriesData extends Data
92
+ {
93
+ /** @param list<string> $labels @param list<float> $series */
94
+ public function __construct(
95
+ public readonly array $labels, // typed via docblock; the class is the boundary type
96
+ public readonly array $series,
97
+ ) {}
98
+ }
99
+ ```
100
+
101
+ Note the last case: a PHP `array` property **inside** a Data class with a
102
+ precise `list<T>` docblock is acceptable — the boundary type is `TimeSeriesData`,
103
+ and PHPStan enforces the element type. A bare `array` in a signature is not.
104
+
105
+ Tooling: Larastan/PHPStan level ≥ 6 (iterable value types required),
106
+ `phpstan-strict-rules`, `spatie/laravel-data`.
107
+
108
+ ### TypeScript (NestJS, Express, any frontend)
109
+
110
+ ```ts
111
+ // Wrong
112
+ async list(filters: Record<string, unknown>): Promise<any[]>
113
+
114
+ // Right
115
+ export class BrandFilter {
116
+ constructor(
117
+ readonly search: string | null,
118
+ readonly status: BrandStatus | null,
119
+ readonly perPage: number,
120
+ ) {}
121
+ }
122
+ async paginate(filter: BrandFilter): Promise<Page<BrandResult>>
123
+ ```
124
+
125
+ Frontend: every API response has a named type generated from, or checked
126
+ against, the frozen seam. `as` casts on response bodies are a T5 violation — parse
127
+ (Zod, Valibot, or a generated client) at the edge instead.
128
+
129
+ Tooling: `strict`, `noUncheckedIndexedAccess`, `exactOptionalPropertyTypes`,
130
+ `@typescript-eslint/no-explicit-any`, `no-unsafe-assignment`,
131
+ `no-unsafe-member-access`, `no-unsafe-return`.
132
+
133
+ ### Python
134
+
135
+ `@dataclass(frozen=True, slots=True)` or pydantic models; `Enum`/`StrEnum`;
136
+ `mypy --strict`. `dict[str, Any]` in a signature is the Python spelling of an
137
+ untyped array.
138
+
139
+ ---
140
+
141
+ ## How agents apply it
142
+
143
+ - **02b backend contract** names every Data class in §3 and §5, with fields and
144
+ types. "Accepts filters" is not a specification; `BrandFilterData { search:
145
+ ?string, status: ?BrandStatus, perPage: int = 15 }` is.
146
+ - **02c UI contract** names the frontend types it consumes; **02d seam** freezes
147
+ them, field by field.
148
+ - **04a / 04b implementers** create the Data classes the contract names, in the
149
+ task that first needs them. Creating an extra one the contract did not name is
150
+ fine when it removes an array from a boundary — record it in the decisions log.
151
+ - **Gate 2** reviewers look for arrays in signatures before anything else.
152
+
153
+ ## Interaction with ponytail
154
+
155
+ Ponytail pushes toward writing less code. That applies to **logic, abstractions,
156
+ and dependencies** — not to type declarations. A Data class with four readonly
157
+ fields is not over-engineering; it is the rule. When ponytail's review flags a
158
+ Data class as unnecessary, T1 wins. See `toolchain.md` §Precedence.
@@ -0,0 +1,11 @@
1
+ # Observations
2
+
3
+ Written by the task-observer skill while agents work: repeated corrections,
4
+ preferences, and gaps it notices in the agent instructions or skills.
5
+
6
+ Nothing here changes the pipeline by itself. At release, agent 09b reads the
7
+ logs written while those features were built, groups recurring ones, and writes
8
+ `proposals-{version}.md` — specific edits to specific files — for a human to
9
+ accept or reject. Processed logs move to `processed/`.
10
+
11
+ Commit this directory. The patterns in it are team knowledge.
@@ -0,0 +1,103 @@
1
+ #!/usr/bin/env node
2
+ // @ts-check
3
+ /**
4
+ * Rule TDD-1: a commit that changes source under the configured paths must add
5
+ * or change a test in the same commit — or say why not with `[no-test: reason]`
6
+ * in its message. It cannot prove the test came first (tdd-log.md and Gate 2
7
+ * do that); it proves the test exists, which is the part CI can check honestly.
8
+ *
9
+ * Usage:
10
+ * node .ai-agents/scripts/check-test-with-change.mjs --base origin/main # every commit in base..HEAD
11
+ * node .ai-agents/scripts/check-test-with-change.mjs --staged # staged files, before commit
12
+ */
13
+ import { execFileSync } from 'node:child_process';
14
+ import { pathToFileURL } from 'node:url';
15
+
16
+ import { matchesAny } from './lib/glob.mjs';
17
+ import { loadRulesConfig } from './lib/config.mjs';
18
+
19
+ /**
20
+ * @typedef {object} CommitFacts
21
+ * @property {string} sha
22
+ * @property {string} message
23
+ * @property {ReadonlyArray<string>} files
24
+ */
25
+
26
+ /**
27
+ * @typedef {object} TddViolation
28
+ * @property {'TDD-1'} rule
29
+ * @property {string} sha
30
+ * @property {ReadonlyArray<string>} sourceFiles
31
+ */
32
+
33
+ /**
34
+ * @param {CommitFacts} commit
35
+ * @param {import('./lib/config.mjs').TestWithChangeConfig} config
36
+ * @returns {TddViolation | null}
37
+ */
38
+ export function evaluateCommit(commit, config) {
39
+ const tests = commit.files.filter((file) => matchesAny(file, config.testPaths));
40
+ const sources = commit.files.filter(
41
+ (file) => matchesAny(file, config.sourcePaths) && !matchesAny(file, config.testPaths),
42
+ );
43
+ if (sources.length === 0 || tests.length > 0) return null;
44
+ const exemption = new RegExp(`\\[${config.exemptionTag}:\\s*[^\\]\\s][^\\]]*\\]`);
45
+ if (exemption.test(commit.message)) return null;
46
+ return { rule: 'TDD-1', sha: commit.sha, sourceFiles: sources };
47
+ }
48
+
49
+ /**
50
+ * @param {ReadonlyArray<string>} args
51
+ * @returns {string}
52
+ */
53
+ function git(args) {
54
+ return execFileSync('git', args, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
55
+ }
56
+
57
+ /**
58
+ * @param {ReadonlyArray<string>} argv
59
+ * @returns {number}
60
+ */
61
+ export function main(argv) {
62
+ const config = loadRulesConfig(process.cwd()).testWithChange;
63
+ if (config.sourcePaths.length === 0) {
64
+ console.log('TDD-1 test-with-change: not configured (testWithChange.sourcePaths is empty) — skipping.');
65
+ return 0;
66
+ }
67
+
68
+ /** @type {Array<CommitFacts>} */
69
+ const commits = [];
70
+ if (argv.includes('--staged')) {
71
+ const files = git(['diff', '--cached', '--name-only']).split('\n').filter(Boolean);
72
+ commits.push({ sha: 'staged', message: '', files });
73
+ } else {
74
+ const baseIndex = argv.indexOf('--base');
75
+ const base = baseIndex >= 0 ? argv[baseIndex + 1] ?? 'origin/main' : 'origin/main';
76
+ const shas = git(['rev-list', '--no-merges', `${base}..HEAD`]).split('\n').filter(Boolean);
77
+ for (const sha of shas) {
78
+ commits.push({
79
+ sha,
80
+ message: git(['log', '-1', '--format=%B', sha]),
81
+ files: git(['show', '--name-only', '--format=', sha]).split('\n').filter(Boolean),
82
+ });
83
+ }
84
+ }
85
+
86
+ const violations = commits
87
+ .map((commit) => evaluateCommit(commit, config))
88
+ .filter(/** @returns {v is TddViolation} */ (v) => v !== null);
89
+
90
+ for (const v of violations) {
91
+ console.log(
92
+ `${v.rule} ${v.sha.slice(0, 8)} — source changed with no test change: ${v.sourceFiles.join(', ')}. ` +
93
+ `Add the test, or explain with [${config.exemptionTag}: reason] in the commit message.`,
94
+ );
95
+ }
96
+ console.log(`TDD-1 test-with-change: ${violations.length} violation(s) in ${commits.length} commit(s).`);
97
+ return violations.length === 0 ? 0 : 1;
98
+ }
99
+
100
+ const invoked = process.argv[1];
101
+ if (invoked !== undefined && import.meta.url === pathToFileURL(invoked).href) {
102
+ process.exit(main(process.argv.slice(2)));
103
+ }