Skip to content

feat(cli): make case distributable — zero hard-coded paths, CLI subcommands, data directory#9

Open
nicknisi wants to merge 11 commits into
mainfrom
feat/case-distribution-phase-1
Open

feat(cli): make case distributable — zero hard-coded paths, CLI subcommands, data directory#9
nicknisi wants to merge 11 commits into
mainfrom
feat/case-distribution-phase-1

Conversation

@nicknisi
Copy link
Copy Markdown
Member

@nicknisi nicknisi commented May 16, 2026

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.

  • Canonical path resolver (src/paths.ts): single resolvePackageRoot() + resolveDataDir() replacing 4 inconsistent derivation strategies. Splits caseRoot into packageRoot (static assets) and dataDir (mutable state) across PipelineConfig and all call sites.
  • 7 CLI subcommands: 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 shared spawnScript() helper. Command router with --help and Levenshtein-1 typo suggestions.
  • ~/.config/case/ data directory: XDG-compliant user data dir for tasks, learnings, amendments, run log, agent versions. case init scaffolds the structure. All state call sites migrated with legacy fallback (reads dataDir first, falls back to repo-local paths).
  • Agent prompt rewrite: All 5 agent .md files use case <verb> instead of bash /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.
  • Script fixes: 3 marker scripts self-locate via BASH_SOURCE. upload-screenshot.sh reads ASSETS_REPO from config.json. snapshot-agent.sh writes to data dir.
  • bun build --compile fixed: src/binary-env.ts sets PI_PACKAGE_DIR before pi-coding-agent imports, and scripts/build-binary.sh writes a stub package.json next to the binary. ./dist/case --help works end-to-end.
  • Regex-based path lint: scripts/lint-paths.sh scans .sh and .md files for /Users/ references (ast-grep can't parse these formats). Wired into lint:ast:all via lint:paths npm script.
  • 20 pipeline test failures resolved: DAG builder now sequences verify→review (with predicate to skip review on verify fail when revision budget available), approve phase loops internally for human revision cycles, and resume from persisted pendingRevision correctly seeds graph state.

What was tested

  • bun run typecheck — clean
  • bun run lint — 0 errors, 3 warnings (false-positive no-unreachable in switch/case with infinite loop)
  • bun test431 pass / 0 fail (was 405 pass / 20 fail before this PR)
  • 76 new tests across 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 results
  • bash scripts/lint-paths.sh — PASS
  • bun run build:binary && ./dist/case --help — binary compiles and runs
  • Smoke-tested all 7 subcommands: --help, typo suggestion, error cases
  • case init end-to-end: fresh scaffold, idempotent re-run, --force rewrite, auto-detect migration
  • Assembler inject markers resolve at runtime; {{packageRoot}} template vars substitute correctly

Commits

  1. 807ed61refactor(paths): introduce canonical path resolver and split caseRoot
  2. c42f6adfeat(cli): port agent-facing scripts to case subcommands
  3. a7d8681feat(data-dir): move mutable state to ~/.config/case/ + add case init
  4. 50c0297feat(agents): replace hard-coded paths with case verbs and inline docs
  5. ee48679feat(lint): add regex-based path check for .sh/.md files
  6. 0ddd605fix(build): make bun build --compile work via PI_PACKAGE_DIR
  7. 4f6d61dfix(pipeline): resolve 20 pre-existing DAG executor test failures

Follow-ups

  • Fix bun build --compile runtime (pi-agent bunfs compatibility)
  • ast-grep rule extension for .md/.sh (or regex-based CI check)
  • Resolve 20 pre-existing pipeline.spec.ts failures
  • Homebrew tap / GitHub releases with prebuilt binaries

nicknisi added 4 commits May 16, 2026 00:09
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.
Copy link
Copy Markdown
Contributor

@devin-ai-integration devin-ai-integration Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Devin Review found 3 potential issues.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread scripts/mark-tested.sh
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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_DIRXDG_CONFIG_HOME/caseHOME/.config/case → package root fallback.

Comment thread src/commands/index.ts
Comment on lines +69 to +77
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;
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

Comment thread src/config.ts
Comment on lines +25 to +29
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`,
);
}
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 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().
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed in 76c3f7cprojectsManifestCandidates() 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.

devin-ai-integration Bot and others added 7 commits May 16, 2026 11:47
…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.
@nicknisi nicknisi force-pushed the feat/case-distribution-phase-1 branch from a1eb517 to 72d12b6 Compare May 16, 2026 20:03
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

1 participant