Skip to content

Initialize local Foundation Plan drafts - #2

Open
raghubetina wants to merge 1 commit into
codex/auditable-cli-scaffoldfrom
codex/plan-init
Open

Initialize local Foundation Plan drafts#2
raghubetina wants to merge 1 commit into
codex/auditable-cli-scaffoldfrom
codex/plan-init

Conversation

@raghubetina

Copy link
Copy Markdown
Contributor

Summary

  • add firstdraft plan init with explicit application key and display name inputs
  • create an exact empty Sketch 0.19 Plan plus client-generated UUIDv7 Project state under .firstdraft/
  • keep the whole scratch directory ignored without editing the host project .gitignore
  • validate user-authored strings at the service schema and I-JSON boundary before randomness or filesystem access
  • refuse existing paths and use exclusive writes so initialization never overwrites local data
  • extend exact package-content checks and Node 22 compatibility coverage to macOS

Stack

This PR contains one commit and is intentionally based on #1. Review only:

codex/auditable-cli-scaffold...codex/plan-init

The command is entirely local: it reads no project files, uses no environment configuration, and makes no network request. API push behavior remains deferred to the next slice.

Verification

  • npm run check on Node 24.18.0
  • npm test, typecheck, lint, package-content check, and packed-tarball smoke on Node 22.0.0
  • 31 tests passing
  • zero runtime dependencies and zero production audit findings
  • generated Plan independently accepted by the First Draft Sketch 0.19 loader with zero diagnostics

Agents need a deterministic, private starting point before network behavior arrives. Add plan init to create the exact empty sketch/0.19 document and UUIDv7 Project state beneath a nested ignored .firstdraft directory.

Validate keys and names at the schema and I-JSON boundary before invoking randomness or the filesystem. Refuse every existing .firstdraft path and use exclusive writes so initialization never overwrites existing data.

Extend the package allowlist and tests for the new runtime files, and exercise the Node 22 compatibility job across Linux, Windows, and macOS.
@raghubetina

Copy link
Copy Markdown
Contributor Author

Review: technical

Reviewed the single commit 749a90e..a732fb2. The claim that matters most here crosses repositories, so I tested it across repositories.

The headline probe

I ran firstdraft plan init --application-key movies --name Movies in a clean directory, then fed the generated artifacts to the server side at firstdraft/firstdraft@82aa82d (#151's import line):

  • FoundationPlan.load accepts the generated Plan with zero diagnostics;
  • the generated state.json project_id matches the server's UUIDv7 pattern;
  • FoundationPlan::Import.call with that ID and those bytes succeeds and creates the Project — movies / Movies / rails-sketch/2026-07, graph version 1.

The CLI's output and the server's bootstrap contract agree end to end, verified before either has shipped. The PR body claims loader acceptance; the import-service acceptance goes one step further and also holds.

Also verified

  • npm run check passes on the pinned Node 24; the body's Node 22 and macOS coverage claims match the CI matrix change.
  • Filesystem discipline: .firstdraft/ is created 0700, files written 0600 with the exclusive wx flag and flush: true. A rerun refuses with exit 1 and the carefully worded "The directory may be incomplete; no existing files were overwritten." — accurate in the partial-failure case too, since mkdirSync is non-recursive and every write is exclusive, so nothing existing is ever clobbered and nothing pretends cleanup happened.
  • The self-ignoring directory: .firstdraft/.gitignore containing * keeps the scratch state out of the host project's version control without editing the host's .gitignore — no diff in the user's repo from running init.
  • Validation parity with the server, checked rule by rule: the key regex is the server's local-key pattern; isValidApplicationName rejects NUL, surrogate code points, U+FDD0–FDEF, and plane-final noncharacters — the same (cp & 0xfffe) === 0xfffe mask the Ruby loader uses since #145 — and requires one non-whitespace character, which is the schema's nonblank rule. Bad key and blank name both exit 2 with no side effects, and the code path confirms the body's ordering claim: validation runs before createProjectId() and before any filesystem call, so invalid input consumes no randomness and touches no disk.
  • Repeated flags are rejected. parseArgs silently keeps the last occurrence of a repeated --name; the tokens:-based check turns that quiet data loss into a usage error.
  • The UUIDv7 implementation is RFC 9562-correct: 48-bit big-endian millisecond timestamp, version nibble forced to 7, variant bits to 10, remaining 74 bits from crypto.randomBytes, with range/type guards and injectable clock and randomness (which is what makes the 121-line test file deterministic). Hand-rolling is justified: Node's built-in randomUUID is v4-only and this package's zero-runtime-dependency rule forecloses the alternative.
  • isFileSystemError treats only non-ERR_-prefixed codes as environmental, so programmer errors still crash with a stack trace instead of being laundered into the polite exit-1 message.

Small

  • plan with no subcommand prints the plan help at exit 0, matching the root command's convention. Fine; just noting the convention is now two levels deep and future subcommands should keep it.
  • state.json gets its own versioned format discriminator (firstdraft.cli-state/1) — cheap now, and exactly what will let a future CLI migrate old state directories without guessing.

Summary

The first real command arrives with the same character as the scaffold: validated before it acts, exclusive where it writes, honest where it fails, and — the part I could prove mechanically — in exact agreement with the server it has never met. Nothing to fix.

@raghubetina

Copy link
Copy Markdown
Contributor Author

Lesson: what this PR actually did

The CLI gets its first verb. The theme is a contract between two programs in two languages in two repositories — and how you keep such a contract honest.

The one-sentence version

firstdraft plan init --application-key movies --name Movies creates a .firstdraft/ directory containing an empty starter Plan and a client-minted Project ID — the exact document and the exact ID shape that the server's new bootstrap endpoint (#151 in the main repo) was built to accept.

Two programs, one contract

Stand back and look at what the last few PRs across both repos add up to. The server (#147, #151) said: I accept exactly the empty starter Plan, with these keys, this target, no entities, and a client-supplied UUIDv7. The CLI (this PR) says: I produce exactly that document and exactly that ID. Neither program has ever run against the other — the API push command doesn't even exist yet.

Contracts like this rot in the gap between repositories. So the most important sentence in the PR body is the verification line: the generated Plan was fed to the actual Ruby loader in the main repo and accepted. In review I extended that probe one step further — the generated Plan plus the generated Project ID, through the actual import service, created a real Project row at graph version 1. When your client and server live in different repos, this kind of cross-repo test is the only thing standing between you and the day the two halves meet in production for the first time.

The same discipline shows up in miniature inside the validation code. The server rejects application names containing NUL, lone surrogates, or Unicode noncharacters (#145, #151); the CLI now rejects them locally, before doing anything at all — same rules, and literally the same bitmask trick for noncharacters, translated from Ruby to JavaScript. Fail at the keyboard rather than at the network, but with identical judgment, so passing locally predicts passing remotely.

Order of operations as a design principle

Look at what plan init does with bad input: exits 2, and nothing else happened. That's a deliberate sequence — parse arguments, validate both strings, and only then generate randomness and touch the filesystem. Validation before side effects means the failure mode of "typo in the key" is indistinguishable from never having run the command.

And when it does touch the filesystem, every operation is defensive:

  • the directory is created with mode: 0o700 and files with 0o600 — private to you, since this state will eventually relate to your account;
  • every write uses the wx flag — exclusive create, which atomically fails if the file already exists. There is no "check if exists, then write" race, and rerunning init in an initialized project refuses rather than overwrites;
  • the error message for a half-initialized directory says exactly what is true: it may be incomplete, and nothing existing was overwritten. No fake cleanup, no false reassurance.

One small trick worth stealing: the created directory contains its own .gitignore with the single line *. The scratch state ignores itself, and the user's project .gitignore is never edited. A tool that modifies files you didn't ask it to modify is a tool you stop trusting; this one leaves no footprint outside its own directory.

Rolling your own UUIDv7, legitimately

The scaffold PR (#1) committed to zero runtime dependencies, and this is the first place that costs something: the Project ID must be a UUIDv7 (time-ordered, per the identity design in the main repo's ADR 0017), Node's built-in crypto.randomUUID() only makes v4, and the usual fix — npm install uuid — is off the table.

So there are fifty lines of honest bit-twiddling: 48 bits of millisecond timestamp packed big-endian into the first six bytes, four version bits forced to 0111, two variant bits to 10, and the other 74 bits from crypto.randomBytes. If you've never read a UUID implementation, this one is a good first — small, guarded (it validates its own inputs and the randomness source's output), and shaped for testing: the clock and the random source are injectable parameters, which is how the test file pins exact byte layouts without mocking globals. "Hard-to-test code" is usually just code that reached for Date.now() directly; passing time in as a function is the whole cure.

The quiet parser fix

node:util's parseArgs has a default behavior worth knowing: repeat a flag (--name A --name B) and the last one silently wins. For a tool where the flag value becomes durable state, silently discarding user input is a small lie. This PR opts into tokens: mode and rejects repeated value options as a usage error. It's two lines, and it converts an ambiguity the user probably didn't intend into a question they get to answer.

@raghubetina

Copy link
Copy Markdown
Contributor Author

The next stacked CLI slice is #3. It adds conditional whole-document plan push on top of this initializer, preserves the exact Plan bytes, and stores the accepted API origin plus opaque server ETag in .firstdraft/state.json. It does not change the plan init contract.

The client slice is exercised against the server contract in firstdraft/firstdraft#162.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant