feat(cli): make case distributable — zero hard-coded paths, CLI subcommands, data directory#9
feat(cli): make case distributable — zero hard-coded paths, CLI subcommands, data directory#9nicknisi wants to merge 11 commits into
Conversation
Phase 1 of case-distribution: replace four inconsistent path-resolution
strategies (inline resolveCaseRoot, taskJsonPath-derivation, process.cwd,
and hardcoded /Users/... in scripts) with a single src/paths.ts module.
Split PipelineConfig.caseRoot and SpawnAgentOptions.caseRoot into two
semantically distinct fields: packageRoot (static assets — agents/,
scripts/, docs/) and dataDir (mutable state — tasks/, .case/, learnings/).
Both resolve to the same on-disk location in Phase 1, so this is a no-op
at runtime; the semantic split is in place so Phase 3 can move dataDir to
$XDG_CONFIG_HOME/case without further refactors.
- New src/paths.ts exports resolvePackageRoot, resolveDataDir, plus
helpers resolveAgent, resolveScript, resolveDoc, resolveTask.
- Walk-up resolvePackageRoot defends against parent monorepo manifests
by matching name === "case" in package.json.
- resolveDataDir follows XDG precedence ($CASE_DATA_DIR > $XDG_CONFIG_HOME
> $HOME/.config/case); throws if none are set.
- Three marker scripts (mark-reviewed.sh, mark-tested.sh,
mark-manual-tested.sh) now self-locate via BASH_SOURCE instead of
hardcoded /Users/nicknisi/Developer/case.
- Assembler grows substitutePathVars: {{packageRoot}}, {{dataDir}}, and
{{scriptPath:NAME}} tokens are replaced before concatenation; unknown
{{...}} tokens pass through unchanged.
Tests: 14 new paths.spec.ts cases covering walk-up, env precedence,
helpers, and error paths; 4 new assembler.spec.ts cases for template
substitution. Full suite: 341 pass / 20 baseline failures unchanged.
Phase 2 of the case-distribution work introduces a `case <verb>` CLI surface so agent prompts can call stable verbs instead of filesystem script paths. New subcommands (each a thin TypeScript wrapper delegating to the underlying shell script via Phase 1's resolveScript): session, status, mark-tested, mark-manual-tested, mark-reviewed, upload, snapshot. The router (src/commands/index.ts) replaces the inline dispatch in src/index.ts with a commandMap, exposes --help, suggests the closest verb on typos via Levenshtein-1 distance, and preserves the no-verb default of running the pipeline. Existing run/watch/create/serve handlers moved into the same registry to keep dispatch uniform. src/commands/spawn.ts is the shared helper that resolves a packaged script, auto-chmods on EACCES, and forwards stdin/stdout/stderr so mark-tested can pipe test output to its underlying script unchanged. case is added as a second bin alongside ca so `case status`, `case session`, etc. work after `npm install -g`. Agent prompt migration to the new verbs lands in a follow-up phase. Tested: - 24 new tests in src/__tests__/commands.spec.ts (router dispatch, suggestion, --help, exit-code propagation, TTY guard, upload preflight, spawn auto-chmod and missing-script error, per-verb argv forwarding) - typecheck and lint clean - Smoke: `bun src/index.ts --help` lists 11 verbs; `bun src/index.ts statis` suggests 'status' and exits 1; existing `run/watch/create/serve` still work
Phase 3 of case-distribution. Tasks, learnings, amendments, run-log, and agent-versions now live under `resolveDataDir()` (XDG: `~/.config/case/`), making the repo a pure code artifact that can be installed from anywhere. - `src/data-dir.ts`: `ensureDataDir`, `readConfig`, `writeConfig`, `migrateFromRepo` with `.migrated` marker, schema version checks, and atomic temp-file-then-rename writes - `src/commands/init.ts`: idempotent `case init` with `--projects`, `--assets-repo`, `--migrate-from`, `--force`; auto-detects case repos via `projects.json` + `agents/` - `src/paths.ts`: new `resolveTaskDir/Learnings/Amendments/RunLog/ AgentVersions/Config` resolvers built on `resolveDataDir()` - Call-site migration with legacy fallbacks: task-factory writes to dataDir; task-scanner, from-ideation, prefetch, prompt-tracker, metrics writer all try dataDir first, then legacy `<caseRoot>/...` with a deprecation log for `projects.json` - `scripts/snapshot-agent.sh` writes to dataDir, falls back to legacy `docs/agent-versions/` when present - Stretch: `scripts/upload-screenshot.sh` reads `ASSETS_REPO` from env → `config.json` (jq) → hardcoded default - `.gitignore` covers all the legacy in-repo state paths during the transition window - Tests: 29 new specs covering data-dir + init; existing task-factory, task-scanner, from-ideation specs updated with `CASE_DATA_DIR` overrides to stay hermetic All 29 new tests pass. The 20 pre-existing `pipeline.spec.ts` failures are untouched (unrelated to this phase).
Rewrite all 48 hard-coded absolute paths in the five agent prompts plus
AGENTS.md, the implement-from-spec playbook, and tasks/README.md. Every
script invocation becomes a `case <verb>` subcommand call.
Extend the assembler with a single-pass `<!-- inject: docs/path.md -->`
marker that inlines doc content at assembly time. Used by closer.md to
inline pull-request conventions. 8KB size limit (tunable via
`CASE_INLINE_MAX_BYTES`), missing files leave the marker verbatim with
a stderr warning, nested markers are NOT recursively expanded.
The verifier's projects.json lookup now uses the existing
`{{packageRoot}}` template substitution so the python block resolves to
the install root at assembly time rather than a hard-coded path.
Validation gate `grep -rn '/Users/' agents/ AGENTS.md docs/playbooks/
docs/proposed-amendments/ src/` returns zero. Adds 9 new assembler
inline tests; existing 14 assembler tests still pass.
There was a problem hiding this comment.
🟡 Shell marker scripts look for task JSON at package root instead of XDG data dir
All three marker scripts (mark-tested.sh:84, mark-manual-tested.sh:99, mark-reviewed.sh:56) construct the task JSON path as "${CASE_REPO}/tasks/active/${TASK_SLUG}.task.json" where CASE_REPO is derived from SCRIPT_DIR/.. (the package root). But createTask() in src/entry/task-factory.ts:62-63 now writes task JSON files to resolveTaskDir()/active/ which resolves to $HOME/.config/case/tasks/active/ via resolveDataDir(). Since HOME is almost always set, new tasks are always created in the XDG dir while the shell scripts always look in the package root, causing the "Update task JSON" step in all three scripts to silently fail with a WARNING.
The scripts handle the missing file gracefully (|| true), so the pipeline doesn't crash. But evidence flags (tested, manualTested) and reviewer phase status won't be updated as side effects in the task JSON.
(Refers to lines 84-89)
Prompt for agents
Three shell scripts (mark-tested.sh:84, mark-manual-tested.sh:99, mark-reviewed.sh:56) all derive the task JSON path as ${CASE_REPO}/tasks/active/${TASK_SLUG}.task.json where CASE_REPO is the package root. But since Phase 3, createTask() writes task JSON to the XDG data dir ($HOME/.config/case/tasks/active/).
The scripts need to mirror the same resolution order used by the TypeScript code:
1. Check CASE_DATA_DIR env var first
2. Then XDG_CONFIG_HOME/case
3. Then HOME/.config/case
4. Fall back to CASE_REPO
This is the same logic already implemented in snapshot-agent.sh lines 41-49 for the DATA_ROOT variable. The same pattern should be applied to the TASK_JSON path construction in all three marker scripts.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 76c3f7c — all three marker scripts now resolve the task JSON path using the same XDG resolution order as the TypeScript code (matching the pattern already in snapshot-agent.sh): CASE_DATA_DIR → XDG_CONFIG_HOME/case → HOME/.config/case → package root fallback.
| const cmd = commandMap[verb!]; | ||
| if (!cmd) { | ||
| const suggestion = suggest(verb!, Object.keys(commandMap)); | ||
| process.stderr.write( | ||
| `unknown command '${verb}'${suggestion ? `, did you mean '${suggestion}'?` : ''}\n\n`, | ||
| ); | ||
| printHelp(); | ||
| return 1; | ||
| } |
There was a problem hiding this comment.
🔴 Command router treats bare positional arguments (issue numbers) as unknown commands — breaks primary CLI usage
The new dispatch() router in src/commands/index.ts treats any first argument that doesn't start with - and isn't a registered command verb as an unknown command. This breaks the primary back-compat CLI pattern where users pass a bare issue number like case 1234, ca DX-1234, or ca "fix login bug". These previously routed to runCliOrchestrator via the catch-all else branch in the old src/index.ts:91-125.
Comparison of old vs. new behavior
Old code (src/index.ts before this PR):
const command = positionals[0] ?? 'run';
// ... named commands checked first ...
else {
const argument = command === 'run' ? positionals[1] : positionals[0];
await runCliOrchestrator({ argument: argument || undefined, ... });
}New code routes case 1234 to the unknown-command error because '1234' doesn't start with - (line 65 check) and isn't in commandMap (line 69):
unknown command '1234'
The user must now type case run 1234 — a breaking change to a documented workflow.
Prompt for agents
The dispatch function in src/commands/index.ts treats any first argument that is not a registered verb and does not start with '-' as an unknown command. This breaks the back-compat CLI pattern where bare positional arguments (issue numbers like '1234', Linear IDs like 'DX-1234', freeform text) are forwarded to the run handler as the issue argument.
The fix should be in the dispatch() function: when the first argument doesn't match any registered command verb AND doesn't start with '-', it should forward the entire argv to commandMap.run.handler(argv) instead of printing an error. This preserves the old behavior where unrecognized positionals were treated as issue arguments to the default orchestrator flow.
The key location is src/commands/index.ts lines 69-77, where the `!cmd` branch should fall through to the run handler instead of erroring.
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 76c3f7c — the !cmd branch in dispatch() now forwards the entire argv to commandMap.run.handler(argv) instead of erroring, preserving back-compat with case 1234, ca DX-1234, etc. Updated the corresponding tests to verify this behavior.
| if (i > 0) { | ||
| process.stderr.write( | ||
| `case: deprecation — projects.json read from legacy path ${path}; move it to ${candidates[0]} (or run 'case init --migrate-from <repo>').\n`, | ||
| ); | ||
| } |
There was a problem hiding this comment.
🔴 Spurious deprecation warning on every run because loadProjects always checks non-existent XDG path first
When no config.json exists at the resolved XDG data dir (the default for any user who hasn't run case init), readConfig() returns DEFAULT_CONFIG with projects: './projects.json'. The projectsManifestCandidates() function resolves this to $HOME/.config/case/projects.json as the first candidate. Since that file doesn't exist, loadProjects falls back to the legacy <packageRoot>/projects.json at index 1, and prints a deprecation warning on every single CLI invocation:
case: deprecation — projects.json read from legacy path ...; move it to ... (or run 'case init --migrate-from <repo>').
This affects every user who hasn't explicitly run case init, which is everyone after upgrading. The warning is misleading because the in-repo path is still the correct/intended path for users who haven't opted into Phase 3.
Prompt for agents
In src/config.ts, the loadProjects() function prints a deprecation warning whenever the projects.json file is found at the legacy in-repo path (index > 0 in the candidates list). However, the first candidate is always added from readConfig().projects (which defaults to './projects.json' resolved under the XDG data dir). Since this file doesn't exist unless the user has explicitly migrated, the deprecation warning fires on every single invocation.
Possible fixes:
1. Only add the XDG data dir candidate when configExists() returns true (meaning the user has run 'case init').
2. Guard the deprecation warning: only print it when the data dir's config.json actually exists (indicating the user has opted into Phase 3).
3. Change DEFAULT_CONFIG.projects to null or empty, and only push the data dir candidate when the user has explicitly configured a projects path.
The affected function is projectsManifestCandidates() at lines 40-56 and the warning at lines 25-29 in loadProjects().
Was this helpful? React with 👍 or 👎 to provide feedback.
There was a problem hiding this comment.
Fixed in 76c3f7c — projectsManifestCandidates() now gates the XDG data dir candidate behind configExists(), so the deprecation warning only fires when the user has explicitly opted into Phase 3 by running case init.
…JSON paths - Forward unrecognized CLI verbs to run handler instead of erroring, preserving back-compat with `case 1234`, `ca DX-1234`, etc. - Only show deprecation warning for legacy projects.json path when config.json exists (user has opted into Phase 3 via `case init`). - Mirror XDG data dir resolution in marker scripts so task JSON updates find files written by createTask() in the data dir. Co-Authored-By: nick.nisi@workos.com <nick.nisi@workos.com>
ast-grep can't parse Markdown or shell natively. Adds scripts/lint-paths.sh (grep for /Users/ in scripts/ and agents/) and wires it into lint:ast:all via a new lint:paths npm script.
pi-coding-agent's config.js reads package.json at module load via dirname(process.execPath), which fails inside bunfs. The fix: 1. src/binary-env.ts sets PI_PACKAGE_DIR before any pi import 2. scripts/build-binary.sh compiles the binary and writes a stub package.json next to it in dist/ Adds build:binary npm script. Binary passes case --help end-to-end.
Three root causes, all from the DAG executor refactor: 1. Verify and review ran in parallel. Added verify→review edge with a verifyPassedPredicate so review waits for verify. When verify fails and a revision cycle is available, review is skipped and the implementer re-enters immediately. When budget is exhausted, review proceeds with warnings. 2. Approve phase had no revision re-entry. The approve case now loops internally: on revise, it dispatches implement→verify→review then re-presents the approval gate. Tracks humanRevisionCycles and respects maxRevisionCycles budget. 3. Resume from persisted pendingRevision was broken. Pipeline now seeds graph state and revision requests from task.pendingRevision, marks prior cycle phases as completed, and restores revisionCycles on the appender state. Also fixes: approvalDecision defaults to 'skipped' when approve is disabled, approvalTimeMs is set on both approve and reject paths, and setPendingRevision is called when implementer receives a revision.
Ignoring generated ideation artifacts fixes oxfmt failures on malformed HTML specs. Also applies oxfmt formatting to recently added files.
oxlint flagged 3 false-positive no-unreachable warnings because the for(;;) loop in the approve switch case made subsequent cases look like dead code. Extracting the loop into runApproveLoop() gives the linter clear control flow.
a1eb517 to
72d12b6
Compare
Summary
Makes case distributable as an npm package and compiled binary by eliminating all 48 hard-coded absolute paths, decoupling mutable state from the source repo, and fixing all pre-existing pipeline test failures.
src/paths.ts): singleresolvePackageRoot()+resolveDataDir()replacing 4 inconsistent derivation strategies. SplitscaseRootintopackageRoot(static assets) anddataDir(mutable state) acrossPipelineConfigand all call sites.case session,case status,case mark-tested,case mark-manual-tested,case mark-reviewed,case upload,case snapshot. Thin TS wrappers that resolve and spawn the existing shell scripts via a sharedspawnScript()helper. Command router with--helpand Levenshtein-1 typo suggestions.~/.config/case/data directory: XDG-compliant user data dir for tasks, learnings, amendments, run log, agent versions.case initscaffolds the structure. All state call sites migrated with legacy fallback (reads dataDir first, falls back to repo-local paths)..mdfiles usecase <verb>instead ofbash /absolute/path/scripts/.... Assembler inlines doc content via<!-- inject: path -->markers (8KB size limit, single-pass, no recursion). Zero filesystem paths remain in agent prompts.BASH_SOURCE.upload-screenshot.shreadsASSETS_REPOfrom config.json.snapshot-agent.shwrites to data dir.bun build --compilefixed:src/binary-env.tssetsPI_PACKAGE_DIRbefore pi-coding-agent imports, andscripts/build-binary.shwrites a stub package.json next to the binary../dist/case --helpworks end-to-end.scripts/lint-paths.shscans.shand.mdfiles for/Users/references (ast-grep can't parse these formats). Wired intolint:ast:allvialint:pathsnpm script.pendingRevisioncorrectly seeds graph state.What was tested
bun run typecheck— cleanbun run lint— 0 errors, 3 warnings (false-positive no-unreachable in switch/case with infinite loop)bun test— 431 pass / 0 fail (was 405 pass / 20 fail before this PR)paths.spec.ts(14),assembler.spec.ts(4 new),assembler-inline.spec.ts(9),commands.spec.ts(24),data-dir.spec.ts(16),init.spec.ts(13)grep -r '/Users/' agents/ scripts/ AGENTS.md docs/playbooks/— zero resultsbash scripts/lint-paths.sh— PASSbun run build:binary && ./dist/case --help— binary compiles and runs--help, typo suggestion, error casescase initend-to-end: fresh scaffold, idempotent re-run,--forcerewrite, auto-detect migration{{packageRoot}}template vars substitute correctlyCommits
807ed61—refactor(paths): introduce canonical path resolver and split caseRootc42f6ad—feat(cli): port agent-facing scripts to case subcommandsa7d8681—feat(data-dir): move mutable state to ~/.config/case/ + add case init50c0297—feat(agents): replace hard-coded paths with case verbs and inline docsee48679—feat(lint): add regex-based path check for .sh/.md files0ddd605—fix(build): make bun build --compile work via PI_PACKAGE_DIR4f6d61d—fix(pipeline): resolve 20 pre-existing DAG executor test failuresFollow-ups
Fixbun build --compileruntime (pi-agent bunfs compatibility)ast-grep rule extension for.md/.sh(or regex-based CI check)Resolve 20 pre-existingpipeline.spec.tsfailures