Skip to content

Move the four third-party Tuval programs (cron, shell, notify, workspace) into packages/ as opt-in @kampus packages #9374

Description

@cansirin

Pitch

Problem: The four Tuval programs reach @kampus/tuval through link:../../../../phoenix/apps/tuval, a path that only resolves on one laptop with both checkouts side by side. Their CI is red because of it, a user's config imports them by path instead of by name, and every program-side fix they surface has to be proven by hand across two repos. Verified at the source commit: all four package.json files carry that link:, and the .claude/phoenix symlink that makes it resolve is a root-level symlink in cansirin/monorepo pointing at this checkout — it appears nowhere inside the four packages, so that acceptance criterion is about the four link: lines, not about a path to hunt for.

Arc: Tuval programs (milestone 55) — the campaign that owns the programs themselves; this is the move that lets them be worked at all.

Appetite: 2 cycles

Rabbit-holes: (1) tsconfig paths to workspace:* is not a straight swap. Each package maps its own name and its siblings' at src/index.ts so the .tuval/ fixture and the cross-package composition proof type-check with no build; a workspace:* dep resolves through exports to dist/index.d.ts, which does not exist until the upstream builds. packages/depo already answers this with a "development": "./src/index.ts" export condition — pick that or keep the paths, but decide it before the first package lands, because every later package inherits it. (2) cron-parser is in cansirin/monorepo's root catalog at ^5.10.1 and is absent from this repo's pnpm-workspace.yaml; a new catalog entry is part of the move, not a footnote. (3) effect via catalog:tuval is 4.0.0-rc.112 in both repos and @demlik/tea is 0.12.0 in both — those two are clean, do not re-pin them. (4) Adopting effect-tsgo diagnostics --strict per packages/depo runs over a program that pulls apps/tuval's whole reachable source tree in as raw TypeScript; cron, notify and workspace each carry a second window tsconfig on top of the node one, so it is seven projects, not four, and the source tsconfigs deliberately set exactOptionalPropertyTypes: false and lib: DOM for that reason. (5) The space-to-tab reformat over ~13.3k lines of TS will bury the real diff — land it as its own commit so the conversion stays reviewable. (6) @kampus/tuval is private: true and ships raw src/*.ts with no dist; four publishConfig.access: public packages depending on it are unpublishable until that changes, which is fine but should not surprise release tooling.

No-gos: No publish to npm, and no change that makes publishing a prerequisite. Nothing under apps/tuval changes — not a registration, not an import, not a features.* flag; the never-default-on rule from #9247/#9260 holds and grep -r "tuval-cron\|tuval-shell\|tuval-notify\|tuval-workspace" apps/tuval/src stays empty. No catalog page. No rewriting a program's behaviour while moving it: the test counts at the source commit are cron 76, shell 69, notify 87, workspace 156 — verified green on 2026-09-16 — and any child that changes a count owes a reason.

Epic — awaiting plan

plan-epic appends its plan and dependency topology below.

Original brief (verbatim)

What is true now

Four third-party Tuval programs live in cansirin/monorepo under packages/: @cansirin/tuval-cron (a scheduled job, cron-parser schedules, a window), @cansirin/tuval-shell (a job that runs a command), @cansirin/tuval-notify (delivers a job's result to a Discord/webhook/ntfy target, a window), @cansirin/tuval-workspace (an isolated worktree per task with an agent inside, a window, five persona configs). All four are opt-in: a user names them in ~/<redacted>; nothing in apps/tuval knows they exist.

They depend on @kampus/tuval through "link:../../../../phoenix/apps/tuval" because @kampus/tuval is private: true and unpublished. That link is the reason their CI is red, the reason a user's config imports them by path, and the reason none of the program-side fixes they surfaced (#9227, #9229, #9230, #9292, #9295, #9365) could be proven in one repo.

Decided today by both maintainers: they move into this monorepo under packages/, as their own packages, and stay opt-in. Not into apps/tuval, not on by default, no features.* flag; the never-default-on rule from #9247/#9260 holds.

What moves

from (cansirin/monorepo) to name
packages/tuval-cron packages/tuval-cron @kampus/tuval-cron
packages/tuval-shell packages/tuval-shell @kampus/tuval-shell
packages/tuval-notify packages/tuval-notify @kampus/tuval-notify
packages/tuval-workspace packages/tuval-workspace @kampus/tuval-workspace

Each keeps its src/, tests, README, .tuval/tuval.config.ts fixture, examples/ (workspace), and window build. Source of record for the copy: cansirin/monorepo main at the PR's stated commit; the PR body names it so the diff can be checked against it.

What changes in the move

  • @kampus/tuval becomes workspace:*; the link: and the .claude/phoenix symlink hack go away.
  • Package shape follows packages/depo: @kampus/ scope, repository.directory, publishConfig.access: public, catalog: for shared deps (effect, react, vitest, typescript), typecheck runs effect-tsgo diagnostics --strict, tabs per this repo's Biome.
  • Cross-package imports (notify reads BRIEF_PORT from cron) become workspace:* deps, not tsconfig paths.
  • Doc comments and READMEs that say @cansirin/ or "imports by path until npm" state the new truth.
  • ~/<redacted> user configs will import @kampus/tuval-cron etc. once published; until then the READMEs say the path form.

Not in scope

Publishing to npm (waits on @kampus/tuval itself); a catalog page; any change under apps/tuval.

Acceptance criteria

  • The four packages exist under packages/ with the @kampus/ names, and pnpm -r --filter './packages/tuval-*' typecheck test build is green in this repo's CI
  • @kampus/tuval is a workspace:* dependency in each; no link: and no .claude/phoenix reference remains anywhere in the four packages
  • Every test the source packages carried passes here unchanged in count (cron 76, shell 69, notify 87, workspace 156 at the source commit; the PR states the source commit)
  • A boot proof: a ~/<redacted>-style config naming all four boots the desk from apps/tuval with no features.* flag and no change under apps/tuval; the PR shows the boot line
  • No package is registered, imported, or enabled by apps/tuval; grep -r "tuval-cron\|tuval-shell\|tuval-notify\|tuval-workspace" apps/tuval/src is empty
  • READMEs and doc comments name @kampus/… and describe the workspace dependency, not the path link

Plan (plan-epic)

Summary

Four Tuval programs — cron, shell, notify, workspace — move from cansirin/monorepo into this
repo's packages/ as @kampus/tuval-*, one package per child, keeping their src/, tests,
README, .tuval/ fixture, examples/ and window build. @kampus/tuval becomes a workspace:*
dependency and the link:../../../../phoenix/apps/tuval line disappears. They stay opt-in:
nothing under apps/tuval changes, and no features.* flag appears.

The first child is not just "cron". It is cron plus the conventions every later package
inherits
— the cron-parser catalog entry, the cross-package resolution answer, the packages/depo
package shape, and the space-to-tab reformat landing as its own commit. That is why it is alone in
phase 1.

Problem & who has it

The four packages reach @kampus/tuval through link:../../../../phoenix/apps/tuval. That path
resolves on exactly one laptop, with both checkouts side by side and a root-level .claude/phoenix
symlink. Three things follow, and all three land on the two people who maintain these programs:

The cause is that @kampus/tuval is private: true and unpublished, so no registry version exists
to depend on. Publishing is not the answer available now. Co-location is.

What changes

Four new workspace members under packages/, each a straight move with a rename:

from (cansirin/monorepo) to name size at source
packages/tuval-cron packages/tuval-cron @kampus/tuval-cron 2,194 LOC, 76 tests, one window
packages/tuval-shell packages/tuval-shell @kampus/tuval-shell 1,916 LOC, 69 tests, no window
packages/tuval-notify packages/tuval-notify @kampus/tuval-notify 3,157 LOC, 87 tests, one window
packages/tuval-workspace packages/tuval-workspace @kampus/tuval-workspace 5,444 LOC, 156 tests, one window, five persona examples

Per package: @kampus/ scope, repository.directory, publishConfig.access: public,
catalog: for shared deps, tabs per this repo's Biome, @kampus/tuval at workspace:*.

Root-level, once, in the first child: cron-parser: ^5.10.1 enters pnpm-workspace.yaml's root
catalog (it is absent here today; verified by grep over pnpm-workspace.yaml at 718a7ad8).

Nothing under apps/tuval changes — not a registration, not an import, not a flag.

User stories

  1. As a Tuval program maintainer, I want the four programs to live in this repo with @kampus/tuval
    as a workspace:* dependency, so that their typecheck, tests and build run in this repo's CI
    instead of on one laptop with two checkouts.
  2. As a Tuval user, I want to name the programs by their @kampus/tuval-* package names in my
    config and have the desk boot, so that I am not importing a program by a filesystem path that
    only exists on someone else's machine.
  3. As the author of the fifth Tuval program, I want the first package landed to have already settled
    the cross-package resolution convention, the catalog entry and the package shape, so that I copy
    one answer instead of inventing a second.
  4. As an apps/tuval maintainer, I want these programs to stay opt-in and unreferenced by the app,
    so that co-locating them turns nothing on by default.

Goal / non-goals

Goal. The four programs are workspace members of this repo, green in this repo's CI, reachable
by name, still opt-in, with every test they carried passing here at the same count.

Non-goals. Publishing to npm, and any change that makes publishing a prerequisite — the four
stay unpublishable until @kampus/tuval itself changes, and that is fine. A catalog page. Any
change under apps/tuval. Any rewrite of a program's behaviour while it moves: a child that changes
a test count owes a reason in its PR body.

Founder rulings (grilling session #9378)

Both round-1 decision questions are ruled; grill read reports the frontier clear.

  • R1.1 — the sibling edge carries the depo export condition, not the tsconfig paths it arrived
    with.
    Ruled by @cansirin on 2026-09-17: "ok if its the current answer inside the repo, we can
    use it imo."
    packages/tuval-cron's exports gains "development": "./src/index.ts"; shell and
    notify declare "@kampus/tuval-cron": "workspace:*" and delete the sibling paths entry pointing
    at ../tuval-cron/src/index.ts.
  • R1.2 — the four adopt effect-tsgo diagnostics --strict in this epic, across all seven
    tsconfig projects (one node project per package, plus the second window tsconfig that cron,
    notify and workspace each carry). Ruled by @cansirin on 2026-09-17: "we should try to stay closer
    as much as possible to current repo rules yeah?"
    This reverses the draft's recommendation to
    defer it.

Resolved questions

  • Does @kampus/tuval need a development export condition for the four to consume it? No.
    apps/tuval/package.json's exports map already points every subpath at raw ./src/*.ts and
    ships no dist, so a workspace:* dependency resolves to source with no build. The export-condition
    question is about the sibling edge (shell and notify importing cron), not this one — see Approach.
  • Does publish-isolation-guard red on four publishConfig.access: public packages depending on
    a private @kampus/tuval?
    No. The guard derives its scope from publish.yml's release-tag
    grammar, and publish.yml matches fabrika-cli and fabrika-pi only. A package outside that set
    is outside the guard's scope.
  • Which shared pins are already correct? effect at catalog:tuval is 4.0.0-rc.112 in both
    repos and @demlik/tea is 0.12.0 in both. Neither is re-pinned by any child.
  • Which dependency is genuinely missing here? cron-parser only. It is ^5.10.1 in the source
    repo's root catalog and absent from this one's.

Approach

The cross-package edge is settled by R1.1 and lands in phase 1. In the source repo, shell and
notify reach cron through tsconfig paths pointing at ../tuval-cron/src/index.ts, because a
workspace:* dependency resolves through exports to dist/index.d.ts, which does not exist until
cron builds. packages/depo already answers this here with a "development": "./src/index.ts"
export condition, and the founder ruled that answer in: cron's exports gains the development
condition, shell and notify declare "@kampus/tuval-cron": "workspace:*" and drop the sibling
paths entry. That makes the dependency visible in the manifest — where the build graph and the
lockfile can see it — rather than in a compiler lens only.

Phase 1 is one package because the conventions are not divisible. Cron carries the catalog
entry, the export condition, the depo-shaped manifest, and the tab reformat pattern. Landing a second
package beside it means two answers to each of those questions and a reconciliation nobody asked for.

The reformat is its own commit. ~13.3k lines of TS convert from spaces to tabs for this repo's
Biome. Every child lands the move in one commit and the reformat in a second, so the real diff is
reviewable against the source commit the PR body names.

effect-tsgo diagnostics --strict is adopted in this epic, per R1.2. Every package's
typecheck script becomes tsc -p <project> && effect-tsgo diagnostics --project <project> --strict
for each of its projects — the node project in all four, plus the window project cron, notify and
workspace each carry, so seven projects in total. Each package takes @effect/tsgo as a
devDependency at catalog:, matching packages/depo and packages/fabrika-cli.

This is the expensive part of the move and the children carry it, not a follow-up. Each package
consumes @kampus/tuval as raw TypeScript, so the app's whole reachable source tree enters the
program and is checked under the package's options — which is why the source tsconfigs deliberately
set exactOptionalPropertyTypes: false and lib: ["ES2023","DOM","DOM.Iterable"]. A child that has
to relax or tighten an option to get a clean --strict run says which option and why in its PR body;
a child that cannot get one stops and reports rather than deleting the tsgo step it was ruled
into. Phase 1 lands the pattern on cron — the smallest of the four and the one with a window project
— so the three phase-2 children copy a proven invocation instead of each inventing one.

The boot proof is its own child. It is the only acceptance criterion that cannot be met by any
single package PR — it names all four in one config. Folding it into the last package to land would
make which package that is decide where the proof lives.

Acceptance criteria

  • The four packages exist under packages/ with @kampus/tuval-* names and pnpm -r --filter './packages/tuval-*' typecheck test build is green in this repo's CI
  • @kampus/tuval is a workspace:* dependency in each; no link: and no .claude/phoenix reference remains in any of the four packages
  • Test counts are unchanged from the source commit: cron 76, shell 69, notify 87, workspace 156; any child that changes a count states why in its PR body
  • A config naming all four boots the desk from apps/tuval with no features.* flag and no change under apps/tuval; the boot line is in the PR
  • grep -r "tuval-cron\|tuval-shell\|tuval-notify\|tuval-workspace" apps/tuval/src is empty
  • Every README and doc comment in the four names @kampus/… and describes the workspace dependency, not the path link
  • cron-parser is a root catalog entry and every one of the four consumes its shared deps through catalog: or catalog:tuval
  • Each of the four runs effect-tsgo diagnostics --strict over every project it carries — seven in total — and all seven are clean in this repo's CI; any tsconfig option changed to get there is named with its reason in the child's PR body

Testing strategy

The tests already exist and move with the code; the count is the contract. Each child runs its
package's own vitest run and states the observed count against the source number in its PR body.
Each child's pnpm typecheck is the ruled two-step — tsc -p then effect-tsgo diagnostics --strict — over every project in the package, and a child's PR body reports both as run, not just
the tsc half. Nothing new is written except where the move itself changes a fact — a fixture whose import
specifier changed, a test asserting a @cansirin/ renderer ref that is now @kampus/.

.tuval/ fixtures and examples/ are type-checked, not executed, and that is deliberate: they are
real user configs, held to compile so a README cannot promise a config that does not work.

The epic's own proof is the boot in child 5: a real config naming all four, the desk starting, and
the grep over apps/tuval/src coming back empty. That is the difference between four packages that
compile and four programs a user can actually reach.

Task-split rationale

One child per package, plus one assembly child for the proof no single package can give.

  • Phase 1 — cron alone. It is the only package no sibling imports, and it is where the shared
    answers land (catalog entry, the ruled export condition, the ruled effect-tsgo --strict typecheck
    invocation over a node project and a window project, depo shape, reformat pattern). Story 1 and
    story 3.
  • Phase 2 — shell, notify, workspace, in parallel. Shell and notify both import cron (shell's
    compose.unit.test.ts reads cron, jobShape; notify's notify.unit.test.ts reads BRIEF_PORT),
    so both require it. Workspace imports no sibling, but it requires cron for the convention: landing
    it beside cron would have it invent the resolution answer a second time. Story 1 and story 3.
  • Phase 3 — the boot proof. Requires all four. Story 2 and story 4.

The one file three phase-2 children share is pnpm-lock.yaml. No two of them write any other
file: each touches only its own packages/tuval-* directory, and only cron touches
pnpm-workspace.yaml. A lockfile collision is a rebase-and-regenerate, not a merge conflict to
resolve by hand, so they stay parallel rather than serialized behind each other.

Vocabulary impact

Four package names enter this repo's vocabulary: @kampus/tuval-cron, @kampus/tuval-shell,
@kampus/tuval-notify, @kampus/tuval-workspace. The old @cansirin/ spellings survive only as
history — no doc, comment or fixture in this repo keeps them.

No new concept is introduced. "Program", "row", "window", "desk" and "port" already mean here what
they mean in the moved source; that is what makes this a move rather than a port.

Dependencies

Activity

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

Metadata

Metadata

Assignees

No one assigned

    Labels

    p1Medium priorityready-for:agentAn execution engine may pick this up.status:triagedTriage signed off; ready for write-code to picktype:epicToo big for one PR; spawns children

    Type

    No type

    Projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions