Skip to content

feat(cli): migrate ASK workspace to .ask/ + introduce ask.lock + Zod-validated I/O#3

Merged
amondnet merged 19 commits into
mainfrom
feat/ask-workspace-migration-track
Apr 7, 2026
Merged

feat(cli): migrate ASK workspace to .ask/ + introduce ask.lock + Zod-validated I/O#3
amondnet merged 19 commits into
mainfrom
feat/ask-workspace-migration-track

Conversation

@amondnet

@amondnet amondnet commented Apr 7, 2026

Copy link
Copy Markdown
Contributor

Context

Three new agent skills (add-docs, setup-docs, sync-docs) shipped in feat/add-docs-skills describe a workspace layout the CLI did not yet implement: storage path .ask/docs/, config file .ask/config.json, and a new .ask/ask.lock recording resolved versions, content hashes, and source-specific metadata. Without this PR, the skills and the CLI describe two different worlds.

This PR closes the gap by migrating the CLI to .ask/, introducing a Zod-validated lockfile, and routing all config/lock I/O through deterministic, type-safe helpers.

Track: .please/docs/tracks/completed/ask-workspace-migration-20260407/

What changed

New modules

  • packages/cli/src/schemas.ts — Zod discriminated unions for SourceConfig, Config, LockEntry, Lock. Single source of truth for runtime invariants. Schema versions pinned to v1.
  • packages/cli/src/io.tsreadConfig/writeConfig/readLock/writeLock/upsertLockEntry + sortedJSON (deterministic serializer) + contentHash (SHA-256 over framed file list).
  • packages/cli/src/migrate-legacy.ts — one-shot .please/.ask/ migration with .ask/config.json as the idempotency sentinel.

Pipeline changes

  • storage.ts.please/docs/.ask/docs/
  • config.ts — pass-through wrapper around io.ts helpers
  • agents.ts — auto-generated marker block now references .ask/docs/
  • sources/index.tsSourceConfig consolidated to schemas.ts via Extract<>; getSource typed to SourceKind
  • sources/github.ts — exposes commit sha via git ls-remote (uses execFileSync to bypass shell). Logs warning on resolution failure.
  • sources/npm.ts — exposes dist.integrity via the existing npm view call
  • index.tsadd command now records to .ask/ask.lock; sync command uses the lock as the drift baseline; legacy migration runs at the top of every subcommand.

Tests

  • 58 unit + integration tests across 7 files
  • Schema validation, determinism (write→read→write byte-stable), content hash order-independence, AGENTS.md marker preservation, drift classification, migration idempotency, command injection prevention

Documentation

  • README.md.ask/ paths
  • ARCHITECTURE.md.ask/ paths

Migration safety

Existing projects with .please/docs/ are migrated automatically on the next CLI invocation:

  1. Parse legacy .please/config.json in memory and validate via Zod
  2. Write the new .ask/config.json (becomes the idempotency sentinel)
  3. Move .please/docs/* into .ask/docs/
  4. Best-effort cleanup of legacy config

Failures throw rather than silently leaving a half-migrated workspace. Re-running is safe — the function short-circuits when .ask/config.json exists.

Review history

  • /review:code-review iteration 1: 6 critical + 3 important issues found by review-code-reviewer + silent-failure-hunter + type-analyzer agents in parallel
  • /review:code-review iteration 2: all 9 fixes verified, no new issues introduced
  • /simplify: 11 of 13 findings applied (-21 net lines, -30%)
  • Skipped (tracked separately): perf(cli): parallelize github+npm fetches in sync command #2 — parallelize github+npm fetches in sync command

Verification Checklist

Automated Tests

  • ConfigSchema.parse() accepts valid github/npm/web/llms-txt configs and rejects each with one required field missing
  • writeConfig followed by readConfig followed by writeConfig produces byte-identical output
  • contentHash is order-independent (shuffling the input file array yields the same hash)
  • ask docs add <pkg> end-to-end creates .ask/docs/, .ask/config.json, .ask/ask.lock, and updates AGENTS.md marker block
  • ask docs sync after a simulated version bump re-fetches only the changed entry, deletes the old version dir, and updates the lock
  • Legacy migration test: seeded .please/docs/foo@1.0.0/ and .please/config.json get moved into .ask/, second run is silent

Manual Testing

  • In a fresh /tmp/test-fresh/ directory with only package.json, run node packages/cli/dist/index.js docs add zod and inspect .ask/
  • Copy a real project that already has .please/docs/ populated, run any docs subcommand, confirm migration runs once and never again
  • Manually corrupt .ask/config.json and confirm the next CLI run fails with a clear Zod error pointing at the offending field

Acceptance Criteria Check

  • SC-1: Fresh project produces .ask/docs/, .ask/config.json, .ask/ask.lock, AGENTS.md referencing .ask/, no .please/docs/
  • SC-2: Existing .please/ project migrated on next run, deprecation warning printed once, second run silent
  • SC-3: Zod rejects malformed config with a clear path-aware error
  • SC-4: Two consecutive identical add runs leave config and lock byte-identical except generatedAt/fetchedAt only updating when content changes
  • SC-5: sync after bun update zod re-fetches only zod, deletes old dir after new fetch succeeds, updates lock

Quality gates

  • 58 tests passing (bun run --cwd packages/cli test)
  • TypeScript compiles clean (bun run --cwd packages/cli build)
  • ESLint passes (bun run --cwd packages/cli lint)

Follow-up

amondnet added 18 commits April 7, 2026 15:28
ASK CLI를 .ask/ 워크스페이스로 이전하고 ask.lock + Zod 스키마 기반의
타입세이프 결정적 직렬화를 도입하기 위한 트랙. 새로 머지된 add-docs/
setup-docs/sync-docs 스킬이 전제하는 레이아웃과 CLI 동작을 정합 맞춤.

- spec.md: FR-1~6, SC-1~5, risks, out-of-scope 정리
- plan.md: 15개 task, Zod schemas → paths → lockfile → sync → legacy
  migration 순의 점진 전략. validator 통과
- workspace skeleton: state/, tracks/active|completed/, investigations/,
  research/, templates/, scripts/ 디렉터리와 .gitkeep 추가
Discriminated union on source field for SourceConfig (github/npm/web/
llms-txt) and LockEntry. Wraps each in ConfigSchema and LockSchema with
schemaVersion/lockfileVersion literal pinning to 1.

- contentHash regex enforces sha256-<hex>
- npm integrity regex permits sha256/384/512 SRI hashes
- github commit field optional, hex 40 when present
- generatedAt/fetchedAt require ISO-8601 datetime

17 unit tests cover valid/invalid cases for each variant.
sortedJSON recursively sorts object keys (arrays preserve order),
emits 2-space indent + trailing newline. Output is byte-stable across
runs with shuffled input.

contentHash computes SHA-256 over files sorted by relpath, framed as
<relpath>\0<bytes>\0 so path/content boundaries cannot collide.
Returns sha256-<64hex>.

11 unit tests cover key sorting, determinism, primitives, hash
order-independence, content sensitivity, and path/content disambiguation.
readConfig/writeConfig/readLock/writeLock validate via Zod, write
through sortedJSON for byte-stable output, and sort docs[] by name.
Default empty values returned when files do not exist. Invalid input
throws without writing.

Paths:
- .ask/config.json
- .ask/ask.lock

10 unit tests cover round-trip, byte-identity, sorting, and validation
errors using a temp directory per test.
getDocsDir now returns <project>/.ask/docs instead of .please/docs.
saveDocs and listDocs follow automatically through the helper.

Adds storage.test.ts asserting the path change and round-trip behavior
for write + replace.
config.ts now delegates to readConfig/writeConfig from io.ts. Hand-rolled
JSON.parse/stringify removed. addDocEntry matches by name only (versions
change over time and we keep one entry per library).

schemas.ts: WebSourceConfig.maxDepth changed from .default(1) to optional
to match the existing sources/index.ts interface — runtime supplies the
default.
agents.ts builds the marker block from getLibraryDocsDir, which already
returns .ask/docs/... after T004 — no source change needed. Test pins
the behavior so future regressions in the storage path or marker logic
are caught.

5 tests cover empty state, marker block content, surrounding-content
preservation, CLAUDE.md creation, and CLAUDE.md idempotency.
GithubSource.fetch now resolves the ref to a commit sha via
`git ls-remote` and returns it through FetchResult.meta. The dereferenced
tag commit is preferred when ls-remote returns both ref and ^{} forms.

If git is unavailable or the ref cannot be resolved, commit is left
undefined — the lockfile records the absence rather than guessing.

FetchResult gains an optional meta field carrying source-specific
metadata (commit, ref, integrity, tarball, urls) so all four sources
can populate ask.lock through a uniform shape.
NpmSource.fetch now calls `npm view <spec> dist.integrity` and
returns the SRI hash through FetchResult.meta.integrity alongside the
tarball URL. The lookup is wrapped in try/catch — older registries may
not expose the field, in which case the lock leaves it undefined.
After saveDocs, the add command now builds a LockEntry from the source
config + FetchResult.meta + computed contentHash and calls
upsertLockEntry. The lock entry includes:

- version, fetchedAt, fileCount, contentHash for every source
- repo/ref/commit for github (commit omitted when ls-remote fails)
- tarball/integrity for npm (from dist.integrity)
- urls for web, url for llms-txt

upsertLockEntry only updates generatedAt when the entry actually
changed (ignoring fetchedAt), so back-to-back identical add runs leave
the lock byte-stable. Adds removeLockEntries for the upcoming sync work.

Also corrects the consola message from .please/config.json to
.ask/config.json.
sync now reads .ask/ask.lock and re-fetches each entry. Per entry:

1. Run the source adapter to get current files + meta.
2. Compute the new contentHash and compare to the lockfile entry.
3. If unchanged → skip (logs 'unchanged'), preserving disk state.
4. If drifted → save new docs, delete the old version dir (only after
   the new fetch succeeded), update config, upsert lockfile, regenerate
   skill.

AGENTS.md is regenerated once at the end. Final summary reports
drifted/unchanged/failed counts so the user knows exactly what moved.
migrate-legacy.ts moves .please/docs/ → .ask/docs/ and parses
.please/config.json through Zod, rewriting it as .ask/config.json
through the deterministic writer. The presence of .ask/ is the
idempotency sentinel — once migrated, the function returns early.

Hooked into all four subcommands (add, sync, list, remove) at the top
of run() so any invocation triggers migration once.

ask.lock is not migrated — it does not exist in the legacy layout and
will be populated by the next add or sync run.

README.md and ARCHITECTURE.md updated to reference .ask/ paths.
Asserts that write→read→write→read→write produces byte-identical lock
files across 30 github entries. This catches any future regression in
sorted-key serialization or in the read/write helper round-trip.
- migrate-legacy.test.ts: 5 tests covering empty state, .ask sentinel,
  doc move, config rewrite via writeConfig (sorted by name), and
  exactly-once execution against a tampered file
- lock-pipeline.test.ts: 6 tests exercising the add+sync wiring at the
  helper level (saveDocs + addDocEntry + upsertLockEntry round-trip,
  SC-4 byte-stable re-add, content drift updates generatedAt, SC-5
  version bump, removeLockEntries prune)
- migrate-legacy.ts now uses writeConfig instead of writing JSON
  directly so the migrated config is sorted and validated through the
  same pipeline as a fresh write
- github.ts moves the whitespace regex to module scope (e18e/prefer-
  static-regex)
- lint:fix reordered imports across all touched files

58 tests pass, lint clean, build clean.
All 15 tasks complete: 58 unit tests passing, lint clean, build clean.
Status moves to 'implemented'; phase moves to 'review'.
Critical fixes (from review-code-reviewer + silent-failure-hunter +
type-analyzer agents):

1. npm integrity: schema field made optional and buildLockEntry omits
   when undefined instead of coercing to '' (which crashes Zod
   validation downstream). Matches the existing github.commit pattern.

2. npm tarball: throw early in buildLockEntry if missing rather than
   coercing to '' and producing an opaque Zod URL error far from the
   real cause.

3. Command injection: github resolveCommit now uses execFileSync to
   bypass the shell — branch/tag are user-supplied. Schema also adds
   a GitRefField regex constraint on branch/tag in GithubSourceConfig.

4. Silent failure: github resolveCommit now logs a consola.warn with
   the underlying error before returning undefined, so users notice
   when commit pinning silently fails.

5. JSON.parse error wrapping: io.ts readConfig/readLock wrap parse
   failures with the file path and a recovery hint instead of bubbling
   the raw SyntaxError.

6. Sync ordering: drift branch in syncCmd now runs every write that
   can throw (saveDocs, addDocEntry, upsertLockEntry) BEFORE the
   destructive removeDocs. A mid-flow failure leaves both directories
   on disk with config still pointing at the old version — recoverable.

7. Migration safety: migrate-legacy.ts parses+validates the legacy
   config first (in memory), then writes the new config (which becomes
   the idempotency sentinel — .ask/config.json, not bare .ask/), then
   moves docs, then removes legacy. Failures throw rather than
   silently leaving a half-migrated workspace.

Type-safety fixes:

8. SourceConfig duplication: sources/index.ts now re-exports
   SourceConfig from schemas.ts and derives the per-source aliases
   via Extract. The Zod schema is the single source of truth for
   runtime invariants — callers can no longer construct a value that
   type-checks but fails Zod (e.g. urls: [] for web).

9. getSource is typed: Record<SourceKind, DocSource> instead of
   Record<string, DocSource>, so adding a fifth source variant is a
   compile error until the registry map is updated.

migrate-legacy.test.ts updated to use the new sentinel
(.ask/config.json instead of bare .ask/).

58 tests pass, lint clean, build clean.
Findings from /simplify code-reuse + quality + efficiency review:

Reuse:
- storage.ts now calls getAskDir() from io.ts instead of hardcoding
  '.ask' as a string literal
- migrate-legacy.ts uses getAskDir() and getConfigPath() helpers,
  drops the duplicate NEW_DIR constant
- contentHash now accepts DocFile-like input ({ relpath, content })
  directly so callers don't have to repeat the TextEncoder adapter
- index.ts buildLockEntry uses the FetchResult type instead of an
  inline structural duplicate

Quality:
- config.ts drops dead 're-export getConfigPath' (unused outside io.ts)
- io.ts EMPTY_CONFIG/EMPTY_LOCK constants removed — readConfig/readLock
  now return fresh literals directly (clearer than spread+override)
- migrate-legacy.ts strips redundant 'Step 1/2/3/4' inline narration
  comments — the function-level jsdoc already enumerates the same flow
  with rationale

Efficiency:
- upsertLockEntry early-returns on no-op writes, preserving the lock
  file's mtime for build caches and file watchers
- contentHash hoists TextEncoder and NUL separator to module scope
  (allocated once per module instead of per file)
- consola messages use path.relative(getConfigPath/getLockPath) so the
  '.ask/config.json' / '.ask/ask.lock' string literals live in one place

Skipped (out of scope for this loop):
- Parallelizing github+npm fetches in syncCmd — significant code change,
  separate track
- Per-entry batched lock writes in syncCmd — sync already short-circuits
  unchanged entries via continue, so the win is small
- readJson<T>/writeJson<T> generic helpers — only 2 callers each,
  premature DRY

58 tests pass, lint clean, build clean.
@amondnet amondnet added scope:breaking Breaking change type:feature New feature or request labels Apr 7, 2026
@vercel

vercel Bot commented Apr 7, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
ask Error Error Apr 7, 2026 9:17am

Request Review

@cloudflare-workers-and-pages

cloudflare-workers-and-pages Bot commented Apr 7, 2026

Copy link
Copy Markdown

Deploying ask-registry with  Cloudflare Pages  Cloudflare Pages

Latest commit: 962ceed
Status: ✅  Deploy successful!
Preview URL: https://867e18eb.ask-registry.pages.dev
Branch Preview URL: https://feat-ask-workspace-migration.ask-registry.pages.dev

View logs

@amondnet amondnet merged commit a9907fa into main Apr 7, 2026
2 of 3 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

scope:breaking Breaking change type:feature New feature or request

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant