{"schema":"groundwork-plan/1","name":"tier-2","title":"A product with a fixed scope","release":"b155517c3eaff44615027b9f0d74299d7138c775","audience":"1 to 3 people, months. Someone will ask: \"Are we on track?\"","instructions":["Work through steps in order. Each step is independent of the ones after it.","write-file: fetch source as plain text and write it verbatim to target, creating directories as needed. Do not reformat it, do not improve it, do not translate it.","merge-into: target already exists. Fetch source and merge it in without disturbing what is there. Never overwrite the file.","run: fetch source and run it as a shell command from the repo root.","convention: no file operation. Apply what source describes wherever targetNote says.","targetNote is for you to act on too: \"make it executable\" means chmod +x.","sha256 is the hash of exactly what source serves. Hash what you fetched before you write it. If it differs, the site has moved since this plan was generated: fetch the plan again from the same URL and start over. Never write content that does not match its step.","customise: this step is a template or a configurable check. Write it verbatim first, then make exactly the edits this field names and nothing else. A step without customise is never edited.","A step with customise is not done until you have made those edits. Say which values you set and what you set them from.","If a step needs something listed in needs and the repo does not have it, skip the step and say so. Do not install a framework to satisfy a check.","Do not invent a step that is not in this plan.","Steps carry the tier that introduced them, or the capability that did. That is provenance, not priority: a step from tier 1 is as required as a step from tier 4, and a step a capability added is as required as either.","If the repo already has a working version of a step, leave it alone and say so. Re-running a plan is expected."],"prerequisites":[],"steps":[{"id":"constitution","title":"Shared project instructions","tier":1,"action":"write-file","source":"https://ntent.app/r/f/constitution","sha256":"4ad91e85a752a81387840681f5da12246530202cb4d9e9170eb40621324e8ebc","target":"AGENTS.md","targetNote":"point CLAUDE.md at it with a one-line @AGENTS.md import","customise":"It is a template. Replace every <angle bracket>, delete the sections marked for tiers above yours, and cut any row of the enforcement table whose command this repo does not have.","why":"Everyone inventing their own conventions, and every assistant inventing a different set again.","enforcement":"review","verify":"Ask an assistant \"what are the rules in this repo\" and it answers from the file."},{"id":"symlink-claude","title":"Shared instructions for coding agents","tier":1,"action":"run","source":"https://ntent.app/r/f/symlink-claude","sha256":"390916ef5802ea148badbf0e934bd6a3ba6c140408f2e2a3cbd6ea1e3146859a","why":"2 rule files that disagree, because one tool reads CLAUDE.md and another reads AGENTS.md.","enforcement":"none","needs":["AGENTS.md"],"verify":"Run it twice: the second run says there is nothing to do. Then start a session and ask the assistant to name a rule that only exists in AGENTS.md. In Claude Code, /context lists CLAUDE.md under Memory files. Copy a line of AGENTS.md into CLAUDE.md and run it again: it names that line and leaves it where it is."},{"id":"pre-commit","title":"Pre-commit checks","tier":1,"action":"write-file","source":"https://ntent.app/r/f/pre-commit","sha256":"93626e41aa0df823c263ceec1b231b50fe409d79a36a28d1b5b4c54f233d1f6a","target":"scripts/git-hooks/pre-commit","targetNote":"make it executable","why":"A mistake caught an hour later on the build server, after someone has already spent time reviewing it. This catches it on your machine, before the commit.","enforcement":"gate","verify":"Stage a file that fails a check this tier installs and the commit is refused, naming the check and the fix. At tier 1 that is check-env: add a variable to the env schema and not to .env.example. The hardcoded-colour case needs the tier 2 lint rules."},{"id":"hooks-path","title":"Git hook setup","tier":1,"action":"merge-into","source":"https://ntent.app/r/f/hooks-path","sha256":"7155c50ee73ae905e6fd1104dca859559de83208a86cb4fd968472d31268a728","target":"package.json","targetNote":"inside the \"scripts\" block","why":"Store hook setup in git so every contributor and coding agent runs the same checks.","enforcement":"none","verify":"Run pnpm install, then `git config core.hooksPath` prints scripts/git-hooks. A fresh clone gets the same answer with no manual step. Leave .git/hooks alone: it may hold hooks somebody else installed."},{"id":"check-env","title":"Environment variable checks","tier":1,"action":"write-file","source":"https://ntent.app/r/f/check-env","sha256":"1930b023e3cf1f54d9c0cabc5720bc9aa5509b10d6d1d9ae05b9212064ba56ad","target":"scripts/check-env.mjs","customise":"Point CONFIG.schemaFile at this repo’s env schema, CONFIG.exampleFile at its example file, and CONFIG.sourceDirs at the directories it keeps source in, if they are not src/env.ts, .env.example and src. The pre-commit hook reads those three back out of the file, so moving them keeps the gate wired.","why":"A setting the app needs that is missing from .env.example, so someone who downloads the repo cannot start it and has no idea why. Also the reverse, and code that reads process.env directly instead of through the schema.","enforcement":"gate","verify":"Add a variable to the schema, do not add it to .env.example, and the check names it."},{"id":"decisions","title":"Decision log","tier":1,"action":"write-file","source":"https://ntent.app/r/f/decisions","sha256":"64d083971ed620984d7f0d0bb99d59b52982caf8d703764dc23f9218c8d80e5b","target":"DECISIONS.md","customise":"The first entry is an example from a billing app: delete it. Then write the decisions this project has actually made, one entry each, and label any whose source is memory rather than a thread or a call as \"(from memory)\". A fresh project may have none yet, and a file with only the template entry in it is correct.","why":"The same decision being re-made in month 6 because nobody remembers the reason for the first one.","enforcement":"review","verify":"Every entry names a real decision, who agreed it and when. Nothing in the file is invented, and an entry reconstructed from memory says so."},{"id":"eslint-rules","title":"Design convention lint rules","tier":2,"action":"write-file","source":"https://ntent.app/r/f/eslint-rules","sha256":"c5955230d11e8c7aec73feb82ada6afa0c6fbbc9fa2af9297392bfbb8c87a4ee","target":"scripts/eslint-rules.mjs","targetNote":"import it from eslint.config.mjs","why":"Catch hardcoded colours, font sizes, and interface strings. Flag spacing that does not adapt to right-to-left layouts.","enforcement":"gate","needs":["ESLint flat config"],"verify":"Write text-[13px] in a component. pnpm lint fails and points at it."},{"id":"rules-probe","title":"Probe fixtures","tier":2,"action":"write-file","source":"https://ntent.app/r/f/rules-probe","sha256":"8a5f7d6bd06ca38e1b0011d29671a0565065fa0348eed18aa1b8b9f01e7647ff","target":"scripts/probes/rules.probe.tsx","why":"A rule everyone believes is on, which has been silently off since a config change.","enforcement":"none","verify":"check-probes reports one hit per rule, and zero false hits."},{"id":"check-probes","title":"Lint rule probes","tier":2,"action":"write-file","source":"https://ntent.app/r/f/check-probes","sha256":"398d392979f7f550187391c5842fd2ed0ff7094a2667ef1545061644fafd4c13","target":"scripts/check-probes.mjs","customise":"CONFIG.PACKAGES ships with one entry for a single-package repo. In a monorepo add one entry per package that lints, each naming a real source file in it.","why":"Catch lint rules that have quietly stopped working: they miss what they should catch, complain about good code, or are switched off in one package.","enforcement":"gate","needs":["eslint-rules.mjs","rules.probe.tsx"],"verify":"Break a rule pattern on purpose and the probe fails even though lint passes. Take the rules out of one package config and it names that package and says how many rules run nowhere in it."},{"id":"waivers","title":"Reviewed rule exceptions","tier":2,"action":"write-file","source":"https://ntent.app/r/f/waivers","sha256":"33cfef32b39fb292804badd37d4451118be1c0b5645dd33f1b99da92803b19f3","target":"scripts/lib/waivers.mjs","why":"Keep rule exceptions visible to reviewers. Record the reason, owner, and expiry so exceptions can be checked later.","enforcement":"none","needs":["a checker of your own to import it"],"verify":"Waive a line with no reason and the run prints NO REASON GIVEN against it. Fix the underlying value and leave the waiver, and the next run names it as stale and tells you to delete it. Date one `until` yesterday and the finding comes back, naming the waiver that ran out."},{"id":"check-tokens","title":"Design token checks","tier":2,"action":"write-file","source":"https://ntent.app/r/f/check-tokens","sha256":"02acb84dfd1f4a3cc6dd6ca53262990a70a5d501fede77411d199d932eb43ee3","target":"scripts/check-tokens.mjs","customise":"Point CONFIG.stylesheets at the generated stylesheet this repo actually ships, and list a family in CONFIG.ownedFamilies only once your tokens replace the framework’s scale for it.","why":"Catch design token names that do not exist in the stylesheet, so the style silently does nothing. Also catch colours and sizes typed in by hand instead of taken from the tokens.","enforcement":"gate","needs":["a generated stylesheet","waivers.mjs"],"verify":"Write text-fg-nope next to a real text-fg-muted. The check names it, says it resolves to nothing, and lists the nearest real tokens. Delete every token in a family and it says that family is no longer checked rather than passing."},{"id":"check-api-routes","title":"API route checks","tier":2,"action":"write-file","source":"https://ntent.app/r/f/check-api-routes","sha256":"f6425cede5aa0af7c8be8feebfaf909183ce86c07a7a4210664f08720cac2201","target":"scripts/check-api-routes.mjs","customise":"Set CONFIG.routeDirs to where this repo keeps its handlers, and add your own validation, auth and rate-limit helper names to the three matcher lists.","why":"A server endpoint that accepts form data without checking it, does not check who is asking, or has no limit on how often it can be called. Your forms talk to these directly, so a gap here is a gap in the UI.","enforcement":"gate","verify":"Add a POST handler with no schema parse. The check names the file and the missing piece."},{"id":"verify-fixtures","title":"Real data validation","tier":2,"action":"write-file","source":"https://ntent.app/r/f/verify-fixtures","sha256":"7cf6ccf2e953af6201f4b9a5232b34238ccc194f2f38ea6f92fe7f6dacfa69ce","target":"scripts/verify-fixtures.mjs","targetNote":"put real captured payloads in fixtures/","customise":"It ships pointed at fixtures/ and src/lib/schemas/index.js. Change CONFIG.fixtureDir and CONFIG.schemaModule if this repo keeps either somewhere else.","why":"The app expects data in one shape and the server now sends another. On screen that looks like a column of NaN, an empty list when there is data, or Invalid Date.","enforcement":"gate","needs":["Zod schemas","at least one captured payload"],"verify":"fixtures/invoice.json parses with InvoiceSchema. Change a field type in the schema and the check prints the field path and the reason. Replace a fixture with `[]` and it fails: an empty batch exercises nothing."},{"id":"error-to-surface","title":"Message placement rules","tier":2,"action":"write-file","source":"https://ntent.app/r/f/error-to-surface","sha256":"ce977002088a91103655ff2d19149fe3eee87eafba1612b3725bec8e2bca7234","target":"src/lib/errorToSurface.ts","why":"The same message written 2 different ways in one product. Or a pop-up toast for a problem that is still there after the toast fades away.","enforcement":"review","verify":"Every message in the product resolves to one of 4 surfaces: full-page empty state, inline, persistent banner, toast."},{"id":"error-to-surface-test","title":"Message rule tests","tier":2,"action":"write-file","source":"https://ntent.app/r/f/error-to-surface-test","sha256":"ea86119bd4a1a71c9c8e74a16a7f00070e4bf8bd80b1163df3e4c61591ba6f76","target":"src/lib/errorToSurface.test.mjs","why":"A message rule that reads well in a doc and does something else in code.","enforcement":"none","verify":"node --test passes."},{"id":"contract","title":"Product intent model","tier":2,"action":"write-file","source":"https://ntent.app/r/f/contract","sha256":"bc7d9681bfb24c99955cd20c06b4d8088fe232e55aee4d2274b7fd8b99ac0a47","target":"contract.json","customise":"It is an example billing product. Keep the shape and replace the actors, entities, journeys, rules, constraints and open questions with this product’s own. Delete the example acceptance records: they describe journeys you have not built.","why":"\"Mostly done\" as an answer. With this you can say the screens lift 80% of the model, here are the 3 named gaps, and one journey has no UI at all.","enforcement":"none","verify":"The coverage check runs and prints a number, and `--refs` says the references resolve. Start with entities and journeys; add a pillar when you can name the screen it changes. Mark a journey built only with its acceptance record beside it."},{"id":"access","title":"Access rules","tier":2,"action":"merge-into","source":"https://ntent.app/r/f/access","sha256":"bd42be7d5d74ab5e2208b294fa3f5c1e3f69f8e757c6e0f53bf994c4ff25e796","target":"contract.json","targetNote":"at the top level, after actors","why":"Define permissions and row access in one place. Use them to design gated navigation, empty lists, and disabled actions.","enforcement":"review","verify":"Point at a new route and ask which matrix row permits it. If the answer needs a conversation, the row is missing."},{"id":"check-contract-coverage","title":"Contract coverage","tier":2,"action":"write-file","source":"https://ntent.app/r/f/check-contract-coverage","sha256":"a851e9b9aec9684f33772fe529715f5cb02077258b8f3e824eaf2fe1070cad0f","target":"scripts/check-contract-coverage.mjs","customise":"The maps at the top of the file describe an example billing app. Replace CONFIG.entityTypeMap, CONFIG.entityUiMap and CONFIG.fieldAliases with this product’s own entities, and point typeDirs/fixtureDirs/uiDirs at its directories.","why":"Shows how much of the model is in the code, as 3 separate numbers: fields in the types, fields in real data, and fields the screens mention. Also catches the model contradicting itself: duplicate ids, links to things that do not exist, and a journey marked built while its questions are open or with no note of who saw it work.","enforcement":"nag","needs":["contract.json","TypeScript"],"verify":"It prints a table with a percentage per entity, names the gaps, and says how many of the contract’s fields the number is even about. Under --check an unmapped entity fails until you map it or excuse it with a reason. Set minCoverage just below your current number so it fails when coverage goes backwards. `--refs` is the gate half: it runs in milliseconds, exits non-zero on a duplicate id or a broken reference, and every finding names its fix. None of it says a journey works: that is the journey’s state, set by hand, and `--refs` refuses `built` unless an acceptance record sits under it: who exercised it, when, at which commit, against what evidence."},{"id":"check-contract-coverage-test","title":"Contract coverage tests","tier":2,"action":"write-file","source":"https://ntent.app/r/f/check-contract-coverage-test","sha256":"9aa9fc70e644cfece52612e2dfb2c2ea4e5939abb29efb0714c5505ea9b2abe4","target":"scripts/check-contract-coverage.test.mjs","why":"Test that each contract check catches its target issue and stays quiet on valid cases.","enforcement":"none","needs":["check-contract-coverage.mjs","contract.json"],"verify":"node --test passes. Break one reference in the contract by hand and exactly one test goes red, naming the check that caught it."},{"id":"doctor","title":"Project health check","tier":2,"action":"write-file","source":"https://ntent.app/r/f/doctor","sha256":"6b60694f4a9001e582a74ee1b100291b2ed392b5ce15dcd3821cec0135ed624a","target":"scripts/doctor.sh","targetNote":"make it executable","why":"Find broken local setup and missing generated files. Give new contributors a clear list of issues to fix.","enforcement":"none","verify":"Run it on a fresh clone. Every line is a pass, or names the one thing to fix."},{"id":"session-status","title":"Session status report","tier":2,"action":"write-file","source":"https://ntent.app/r/f/session-status","sha256":"7c0f359a09eb9a9b85316db13374836f66ae9ab4bf95e3925abe8cb253157472","target":"scripts/session-status.sh","targetNote":"make it executable","why":"Report issues that cannot block offline work. Flag generated files that are older than their sources.","enforcement":"nag","verify":"It prints when someone opens the repo, which is the moment they can act on it. Touch a token source without rebuilding and it tells you that you are looking at the previous build."}],"finally":["Run the verify line for each step you completed.","For a gate step, running the verify line means having watched its probe turn it red first — the red gate.","Write groundwork.manifest.json at the repo root in the shape under manifest, with this plan's release, every step you installed with its sha256, every step you skipped with the reason, and every customise edit you made.","Report a list: what you installed, what you skipped, and the reason for each skip.","Do not report success for a step whose verify line you did not run."],"manifest":{"target":"groundwork.manifest.json","why":"So the next run, and the next person, can tell which release this repo is on, what was installed, what was skipped and why, and what was edited on purpose. Merge into it if it exists: replace the entries for the steps you touched and leave the rest.","shape":{"schema":"groundwork-manifest/1","release":"b155517c3eaff44615027b9f0d74299d7138c775","plan":"tier-2","on":"<today, YYYY-MM-DD>","installed":[{"id":"<step id>","target":"<step target>","sha256":"<the sha256 from the step>"}],"skipped":[{"id":"<step id>","reason":"<the need this repo does not have, or \"already present\">"}],"customised":[{"id":"<step id>","edits":"<what you set, and what you set it from>"}]}}}