A Prompt Is a Specification, Not an Assignment
If a question is still open inside the text, the text isn't ready to be handed over. ๐ Hi - I'm Anton, a software engineer working mostly in PHP/Symfony and Go, currently carving a live PHP monolith into Go services. This part is about the thing I hand over when I hand over work: the text itself. It's one habit from one codebase - maybe it's useful to you, maybe you already do it better, maybe you read it differently. Notes: github.com/brilliant-almazov. The difference An assignment describes what should come out and leaves the executor to go and get the facts. A specification carries the facts inside itself. That isn't a difference of style. It decides who pays for a fact, and how many times. ASSIGNMENT SPECIFICATION โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ "wire the reads onto the create: .../methods/search.go shared core" plugs into: Reader[In, Out] โ fields: executor, statement, โโโโถ which core? scanner โโโโถ which file? values: ErrEntityNotFound โโโโถ which error name? accept: go test ./... -race โโโโถ which test command? nothing left to look up four searches, per executor one text, written once The reason the difference bites is the execution model I work in, which I'd summarise as done and forgotten. An executor gets the whole task in one message and runs it without reading anything else. Nothing carries over: not the conversation, not the neighbouring iteration, not what was discovered ten minutes ago. One executor, one task, one context. A follow-up isn't a reply in a thread - it's a new task with a new complete text. Under that model an address is worthless. "See how the neighbour does it" assumes a reader who can go and look and who will still remember tomorrow. There is no such reader. There is a reader who resolves the address by reading whatever it finds, once, at your expense, and then evaporates. The rule that follows An open question inside the text means the text isn't ready. Not "should be tightened up later". Not ready. The text gets written after every fact is collected - not while collecting, not with the rest to be gathered along the way. A TBD , a "decide for yourself", a "check how the neighbour does it" - each one is a question I chose not to answer, handed to someone with less context than me. It's a checkable condition, which is why I like it: read the text, look for a place where a decision hasn't been made. Found one? The text goes back on my desk, not out. The trap is that the open question rarely looks like a question. It looks like a considerate phrase. "By analogy with the neighbouring domain" is a search. "Adjust if needed" is a decision I didn't make. "Clarify the approach first" is work that belongs before the text exists, not inside it. Each one reads as flexibility and executes as a hole. What the text has to carry I keep one section at the top of every set of tasks, and it always has the same five items: - Exact paths - the package and every file that will be created or changed. - Full signatures of the types and interfaces the work plugs into: name, type parameters, methods, package, import alias. - The generics and shared primitives that already exist, each with its path and signature, and each marked don't make a copy of this. - Values, not descriptions - sentinel error names, variable names, queue names, metric names, table names, written out as the literal strings they are. - One acceptance command, on one line, that runs with no substitution. the facts section paths internal/repository/entity/methods/search.go signature Reader[In, Out] ยท Query(ctx, in) (Out, error) reuse internal/repository/rows โ do not copy values ErrEntityNotFound acceptance go test ./internal/repository/entity/... -race -count=1 The wording of each of those five carries more weight than it looks like it does, so it's worth being concrete about what each one costs when it's written loosely. A path is not a package. "The entity repository" is an address; it resolves to a directory someone has to open and then choose inside. internal/repository/entity/methods/search.go is a fact, and it also happens to be checkable afterwards - a diff that touched a different file is visibly outside the specification, without anyone reading the code. A signature is not a name. "Plug into the reader type" leaves the type parameters, the field names and the method shape to be discovered. The fact is the declaration: Reader[In, Out] with fields executor , statement , scanner , and the method Query(ctx context.Context, in In) , in package (Out, error)internal/repository/rows , imported under a stated alias. That's four lines that remove one file read. The list of existing primitives is the highest-value line in the whole text, and it's the one I most often find missing when something went wrong. The default failure of a fast executor isn't laziness - it's writing a fresh, locally reasonable version of something that already exists in the repository. Naming it, with a path, and marking it don't make a copy of this, is the only thing that reliably prevents it. Values are strings, not categories. "Use the usual error naming" is a description. ErrEntityNotFound is a value. The same goes for queue names, metric names, environment variable names and table names: written out as literals, they're either used or visibly not used. One acceptance command, and it has to run with no substitution. No placeholder, no "and run the linter too". If I can't paste the line into a terminal and get a verdict, then "done" is an opinion, and an opinion can't close an iteration. Who collects the facts Someone has to read the code to produce that section, and the whole point of the arrangement is that it's exactly one person, once. In the way I run it, the lead doesn't write code at all. The chain is: collect the facts - the only place where reading around the repository is allowed - then hand out the work, then accept each iteration on its own package, then run one full pass at the end of the stage, then close the executors, then audit the set against what was asked for. That constraint is what makes the facts section honest. If reconnaissance were allowed downstream, a gap in my text would quietly get filled by whoever hit it, differently each time, and I'd never learn the text had a gap. Because it isn't allowed, a missing fact comes back to me as one line of question - which is a defect report about my file, and gets fixed in the file rather than in the conversation. Why values, and not addresses Because of the arithmetic: A fact written into the text is paid for once. The same fact, found by the executor, is paid for on every executor. When I tracked where the spend on a set of tasks actually goes, it sorted like this, largest first: reconnaissance - types, paths and signatures looked up again, neighbouring packages read again; full re-runs - every iteration running the entire suite and the linter over the whole repository; rework - an iteration that failed acceptance and got written twice; guard trips - a copy of an existing generic gets written, a check blocks it, and then someone hunts for why; duplicated rules - the shared style block pasted into every file of the set, riding along in every context; and last, small but constant, re-reading files that were just written. Only the first one looks like ignorance. The rest look like diligence, which is why they survive so long. One early set ran at roughly 350,000 tokens per iteration, for the plain reason that the executor was assembling its own context. That number is what made me start writing the facts down instead of the addresses. written once found by the executor โโโโโโโโโโโโ โโโโโโโโโโโโโโโโโโโโโ โโโโโโโโ โโโโโโโโ โโโโโโโโ โโโโโโโโ โ text โ ร1 โ exec โ โ exec โ โ exec โ โโโโโโโโ โโโโโโโโ โโโโโโโโ โโโโโโโโ ร1 ร1 ร1 โฆ รN The same piece of work, twice The assignment version: Wire the read path of this entity onto the shared core. The specification version: create: internal/repository/entity/methods/search.go plugs into: Reader[In, Out] - internal/repository/rows fields: executor, statement, scanner method: Query(ctx context.Context, in In) (Out, error) already exists, do not copy: internal/repository/rows - the generic read core values: sentinel: ErrEntityNotFound read only: internal/repository/entity/methods/search.go internal/repository/entity/methods/search_test.go acceptance: go test ./internal/repository/entity/... -race -count=1 Same work. The second one has nothing left to find, and nothing left to decide. It's also worth noticing what the second version does not contain: the reason the shared core exists, what the previous iteration did, how the neighbouring entity was converted, or any of the discussion that led to the design. All of that is real context and all of it would be paid for again on every executor, with no effect on the diff. What a specification is not Three things get proposed as substitutes for the facts section, and none of them works. An architecture document. It describes intent across the whole system; the executor needs five concrete values for two files. Handing over the document doesn't remove the search - it widens it, because now there's more to read before anything can be written. A link to the neighbouring implementation. This is the most tempting one, because the neighbour genuinely is the right shape. But "look at how it's done next door" is an address, and resolving it costs a file read on every executor and carries a real chance of copying the wrong neighbour - the one that predates the current convention, or the one that was itself a shortcut. The conversation that produced the design. It's the largest of the three and the least useful per token. It contains options that were rejected, which an executor has no way to distinguish from options that were chosen. What replaces all three is the same thing: the decision, already made, written as a value. What it costs The work doesn't disappear. It moves: off every executor
Comments
No comments yet. Start the discussion.