Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/auto-onboard-installs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@gemstack/the-framework': patch
---

The repos-directory auto-scan is removed (#1600): the daemon no longer installs and registers every repo under a configured directory at boot (the `reposDirectory`/`reposDirectoryAutoGrant` preferences are gone with it) β€” the dashboard's "Add project" is the one onboarding path, so a repo is always installed before any agent touches it. Two fixes ride along: activation now means the install-written `.the-framework/.gitignore` exists (not merely the `.the-framework/` directory), and the repo-root `branches` shortcut is hidden through the repo-level git exclude the moment it is created.
1 change: 0 additions & 1 deletion FEATURES-SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,6 @@ happens while nobody is at the keyboard.
| 1 | Install globally or run via `npx` |
| 2 | `the-framework` spins up the dashboard and the daemon, in the foreground β€” Ctrl-C closes the dashboard and every agent with it |
| 3 | Activate a repo from the dashboard (commits dirty state, creates `.the-framework/`, teaches `.gitignore`, registers it) |
| 4 | Auto-register every repo under a configured "repos directory" |
| 5 | Onboarding checklist β€” each step derived from a real fact, not a click |
| 6 | Prerequisite checks surfaced by the dashboard |
| 7 | Per-agent preflight β€” probe the driver CLI before spending a branch |
Expand Down
1 change: 1 addition & 0 deletions packages/the-framework/src/branch-links.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ Keeps every session checkout reachable by its branch name: new checkouts live in
- A new checkout's folder is already named as its branch, so most need nothing extra. When a session renames its branch (most do, early on), the background pass adds a link under the new name β€” a rename costs a link, never moving a checkout under a running session.
- Only the framework's own links are ever created, replaced, or removed: a user's own file, folder, or symlink at any of these paths is left alone.
- A session on a branch whose name cannot be a folder name (old slashed names) simply gets no link.
- The `branches` shortcut at the repo root is hidden from git the moment it is made β€” it is the framework's, and left visible it would ride any sweeping commit onto a work branch. A user's own `branches` folder is never hidden.

## Before modifying/creating SPEC.md files

Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/branch-links.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Covers the branches view: a checkout still on its birth branch needs no link, a rename settles in one pass (old name dropped, new sibling link created), reclaimed checkouts lose their link, detached and slash-named branches get none, user files and foreign symlinks are never touched, the repo-root shortcut is created once without clobbering, and the daemon pass visits every project.
Covers the branches view: a checkout still on its birth branch needs no link, a rename settles in one pass (old name dropped, new sibling link created), reclaimed checkouts lose their link, detached and slash-named branches get none, user files and foreign symlinks are never touched, the repo-root shortcut is created once without clobbering and hidden from git at creation (while an occupied path stays visible), and the daemon pass visits every project.

## Before modifying/creating SPEC.md files

Expand Down
11 changes: 8 additions & 3 deletions packages/the-framework/src/branch-links.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -66,14 +66,19 @@ test('only our own links are touched: user files and foreign symlinks stay', asy
assert.equal(links.has(join(LINKS, 'tf-mine')), false, 'nothing was created over the user file')
})

test('the repo-root branches shortcut is created once, relative, and never clobbers', async () => {
test('the repo-root branches shortcut is created once, relative, hidden from git, and never clobbers (#1600)', async () => {
const fresh = memFs()
await reconcileBranchLinks(CWD, { fs: fresh.fs, worktrees: async () => [] })
const excluded: string[] = []
const exclude = async (_repo: string, rule: string) => void excluded.push(rule)
await reconcileBranchLinks(CWD, { fs: fresh.fs, worktrees: async () => [], exclude })
assert.equal(fresh.links.get(ROOT_LINK), join(FRAMEWORK_DIR, BRANCHES_DIR))
assert.deepEqual(excluded, ['/branches', '!/branches/'], 'the link is excluded the moment it is made')

const taken = memFs({ files: [ROOT_LINK] })
await reconcileBranchLinks(CWD, { fs: taken.fs, worktrees: async () => [] })
excluded.length = 0
await reconcileBranchLinks(CWD, { fs: taken.fs, worktrees: async () => [], exclude })
assert.equal(taken.links.has(ROOT_LINK), false, 'an occupied path is left alone')
assert.deepEqual(excluded, [], "a user's own entry is never hidden from their git status")
})

test('the pass covers every registered project and a stopped pass does nothing', async () => {
Expand Down
11 changes: 11 additions & 0 deletions packages/the-framework/src/branch-links.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { basename, join } from 'node:path'
import { nodeGitRunner, type GitRunner } from './project.js'
import { excludeFromGit } from './git-exclude.js'
import { FRAMEWORK_DIR, BRANCHES_DIR, worktreeDirEntries, currentBranch, type WorktreeDirEntry } from './store/index.js'
import { isWorktreeDirName } from './branch-names.js'

Expand Down Expand Up @@ -51,6 +52,8 @@ export interface BranchLinksDeps {
worktrees?: (cwd: string) => Promise<WorktreeDirEntry[]>
/** The branch a worktree is on (default {@link currentBranch}). */
branchOf?: (path: string) => Promise<string | undefined>
/** Hide a repo-root entry from git (default {@link excludeFromGit} over `git`). */
exclude?: (repo: string, rule: string) => Promise<void>
}

/**
Expand All @@ -67,6 +70,7 @@ export async function reconcileBranchLinks(cwd: string, deps: BranchLinksDeps =
const fs = deps.fs ?? nodeLinksFs()
const worktrees = deps.worktrees ?? worktreeDirEntries
const branchOf = deps.branchOf ?? ((path: string) => currentBranch(path, git))
const exclude = deps.exclude ?? ((repo: string, rule: string) => excludeFromGit(repo, rule, undefined, git))

const linksDir = join(cwd, FRAMEWORK_DIR, BRANCHES_DIR)
/** Link name -> relative target, derived from what is actually checked out. */
Expand Down Expand Up @@ -101,10 +105,17 @@ export async function reconcileBranchLinks(cwd: string, deps: BranchLinksDeps =

// The repo-root `branches` shortcut (#1580). Relative, so a checkout that moves keeps working;
// created only when nothing sits at that path β€” a user's own `branches` file or dir is theirs.
// The link is framework state, so it is hidden from git the moment it is made (#1600):
// uncommitted at the root, it would ride any sweeping `git add -A` onto a code branch. The
// same exclude pair as the data checkout's `tickets` link: `/branches` hides root entries of
// that name, and `!/branches/` re-includes directories (a trailing slash never matches a
// symlink), so a user's own `branches` directory keeps committing while the link stays hidden.
const rootLink = join(cwd, 'branches')
if (!(await fs.lexists(rootLink))) {
await fs.mkdir(linksDir)
await fs.symlink(join(FRAMEWORK_DIR, BRANCHES_DIR), rootLink).catch(() => {})
await exclude(cwd, '/branches').catch(() => {})
await exclude(cwd, '!/branches/').catch(() => {})
}
}

Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/daemon.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@ The process behind the dashboard: it serves the UI, spawns agents, and runs the
- The trade that buys: unattended work needs a window left open, the way any dev server does. The product's promise is spending idle quota while nobody is at the keyboard, and that still holds β€” but it is now visible and killable rather than invisible and persistent, which is the right direction for a tool that spends a subscription: nothing burns quota after you have closed it.
- The dashboard is a projection of each project's on-disk event log, and steering flows back through an append-only control file β€” files are the seam, never a direct agent-to-dashboard connection.
- Bound to localhost by default; binding to the network requires a generated shared token, because a process that spawns agents would otherwise be remote code execution for whoever finds the port.
- At boot it registers the home project (and, when opted in, every repo in the user's repos directory), marks agents a dead process left "running" as stopped, and starts the background services. It resumes nothing: Ctrl-C was deliberate.
- At boot it registers the home project, marks agents a dead process left "running" as stopped, and starts the background services. It resumes nothing: Ctrl-C was deliberate. Every other project joins through the dashboard's "Add project" β€” the one onboarding path, so a repo is always installed before an agent can touch it.
- Shutdown is ordered: background services quiesce first, live agents are stopped, their archives committed, then the dashboard goes. Each step is waited out rather than merely started, so the archives being committed are the finished ones β€” the sweeps are off the repo before the agents are torn down, and the teardowns are done before their work is committed.

## Before modifying/creating SPEC.md files
Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/daemon.test.SPEC.md
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
Covers the daemon's lifecycle and dashboard-driven behavior: coming up on a fresh workspace and reporting where it bound, serving the dashboard until its signal aborts, event-log tailing, home-project and repos-directory registration rules, and starts over the dashboard β€” the JSON spec each child is handed, concurrent agents in their own worktrees, teardown reclaiming a checkout once its work is on the remote, steering through the control log, and the guard that refuses to re-exec a test file as an agent.
Covers the daemon's lifecycle and dashboard-driven behavior: coming up on a fresh workspace and reporting where it bound, serving the dashboard until its signal aborts, event-log tailing, home-project registration rules, and starts over the dashboard β€” the JSON spec each child is handed, concurrent agents in their own worktrees, teardown reclaiming a checkout once its work is on the remote, steering through the control log, and the guard that refuses to re-exec a test file as an agent.

## Before modifying/creating SPEC.md files

Expand Down
54 changes: 13 additions & 41 deletions packages/the-framework/src/daemon.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import {
isProcessAlive,
runDaemon,
registerHomeProject,
registerReposDirectory,
isNestedWithin,
type DaemonState,
type RunDaemonOptions,
Expand Down Expand Up @@ -50,8 +49,9 @@ async function startDaemon(cwd: string, opts: RunDaemonOptions): Promise<{ done:
import { listAgents } from './store/index.js'
import { EVENTS_FILE, FRAMEWORK_DIR, addWorktree, worktreePath } from './store/index.js'
import { controlPath } from './control.js'
import { projectId, listProjects, addProject, writePreferences } from './registry.js'
import { projectId, listProjects, addProject } from './registry.js'
import { nodeGitRunner } from './project.js'
import { gitignorePath, frameworkGitignore } from './framework-gitignore.js'

// The dashboard steers + starts over the daemon's in-process RPC mount (#405/#426), not the
// retired per-read HTTP routes. Post to `/_rpc/<name>` (same-origin) and return the unwrapped `ret`.
Expand All @@ -75,9 +75,15 @@ const logEvent = (message: string): FrameworkEvent => ({ kind: 'log', message })
const line = (message: string): string => JSON.stringify(logEvent(message)) + '\n'
const sleep = (ms: number): Promise<void> => new Promise(resolve => setTimeout(resolve, ms))

/** Fake an activated workspace: the install-written ignore file is the activation marker (#1600). */
async function activate(cwd: string): Promise<void> {
await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true })
await writeFile(gitignorePath(cwd), frameworkGitignore())
}

async function tmpWorkspace(): Promise<string> {
const cwd = await mkdtemp(join(tmpdir(), 'framework-daemon-'))
await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true })
await activate(cwd)
return cwd
}

Expand Down Expand Up @@ -204,7 +210,7 @@ test('a git project starts concurrent runs, each in its own worktree (#736)', as
const git = nodeGitRunner()
const ac = new AbortController()
try {
await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true })
await activate(cwd)
await git(['init'], cwd)
await git(['config', 'user.email', 't@t'], cwd)
await git(['config', 'user.name', 't'], cwd)
Expand Down Expand Up @@ -271,7 +277,7 @@ test('a run loses its worktree once its work is on the remote, whatever the run
const git = nodeGitRunner()
const ac = new AbortController()
try {
await mkdir(join(cwd, FRAMEWORK_DIR), { recursive: true })
await activate(cwd)
await git(['init'], cwd)
await git(['config', 'user.email', 't@t'], cwd)
await git(['config', 'user.name', 't'], cwd)
Expand Down Expand Up @@ -570,7 +576,7 @@ test('registerHomeProject skips a cwd nested inside an already-tracked project (
await addProject(parent, new Date().toISOString(), undefined, env)
// A nested, activated subfolder (like packages/the-framework inside the repo).
const nested = join(parent, 'packages', 'framework')
await mkdir(join(nested, FRAMEWORK_DIR), { recursive: true })
await activate(nested)

await registerHomeProject(nested, env)

Expand All @@ -589,7 +595,7 @@ test('registerHomeProject still adds an activated cwd that is not nested (#647)'
const home = await mkdtemp(join(tmpdir(), 'framework-home-'))
const env = await configEnv(home)
try {
await mkdir(join(home, FRAMEWORK_DIR), { recursive: true })
await activate(home)
await registerHomeProject(home, env)
const projects = await listProjects(undefined, env)
assert.deepEqual(projects.map(p => p.path), [home])
Expand All @@ -598,37 +604,3 @@ test('registerHomeProject still adds an activated cwd that is not nested (#647)'
}
})

test('registerReposDirectory auto-adds the git repos when the opt-in is on (#1123)', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'framework-repos-')))
const env = await configEnv(root)
try {
// Two git repos and one plain directory directly inside the repos dir.
await mkdir(join(root, 'app-a', '.git'), { recursive: true })
await mkdir(join(root, 'app-b', '.git'), { recursive: true })
await mkdir(join(root, 'not-a-repo'), { recursive: true })
await writePreferences({ reposDirectory: root, reposDirectoryAutoGrant: true }, undefined, env)

await registerReposDirectory(env)

const projects = (await listProjects(undefined, env)).map(p => p.path).sort()
assert.deepEqual(projects, [join(root, 'app-a'), join(root, 'app-b')])
} finally {
await rm(root, { recursive: true, force: true })
}
})

test('registerReposDirectory adds nothing while the opt-in is off (#1123)', async () => {
const root = await realpath(await mkdtemp(join(tmpdir(), 'framework-repos-off-')))
const env = await configEnv(root)
try {
await mkdir(join(root, 'app-a', '.git'), { recursive: true })
// reposDirectory is set, but the auto-grant is not: the default must stay hands-off.
await writePreferences({ reposDirectory: root }, undefined, env)

await registerReposDirectory(env)

assert.deepEqual(await listProjects(undefined, env), [])
} finally {
await rm(root, { recursive: true, force: true })
}
})
25 changes: 1 addition & 24 deletions packages/the-framework/src/daemon.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { mkdir } from 'node:fs/promises'
import { basename, join, relative, resolve, isAbsolute } from 'node:path'
import { basename, join, relative, isAbsolute } from 'node:path'
import type { FrameworkEvent } from './events.js'
import { FRAMEWORK_DIR, isPidAlive, reconcileOrphanedAgents } from './store/index.js'
import { startDashboard, type Dashboard, type StartAgentOptions } from './dashboard/index.js'
Expand All @@ -9,7 +9,6 @@ import { startBackgroundServices, type BackgroundServices } from './daemon-servi
import { resolveDashboardBundle } from './dashboard/bundle.js'
import { isActivated } from './project.js'
import { addProject, ensureDaemonToken, listProjects, nodeRegistryFs, readPreferences, registryPreferencesStore, type Preferences } from './registry.js'
import { listReposInDirectory } from './repos-directory.js'
import { registryDiscordCredentialsStore } from './discord-credentials-store.js'
import { JsonlTailer } from './jsonl-tail.js'
import { isLoopbackHost } from './loopback-host.js'
Expand Down Expand Up @@ -86,25 +85,6 @@ export async function registerHomeProject(cwd: string, env: NodeJS.ProcessEnv =
await addProject(cwd, new Date().toISOString(), undefined, env).catch(() => {})
}

/**
* Auto-register every git repo in the user's repos directory (#1123), when they turned the opt-in on.
*
* Off unless `reposDirectoryAutoGrant` is set: it grants the app filesystem access to a whole
* directory of repos at once, so it stays an explicit choice, and the blast radius is contained to
* that one directory. Idempotent (addProject dedupes by path); logs one line per newly added repo.
* Best-effort, so a bad path never blocks the daemon coming up.
*/
export async function registerReposDirectory(env: NodeJS.ProcessEnv = process.env): Promise<void> {
const { reposDirectory, reposDirectoryAutoGrant } = await readPreferences(undefined, env).catch((): Preferences => ({}))
if (!reposDirectoryAutoGrant || !reposDirectory) return
const known = new Set((await listProjects(undefined, env).catch(() => [])).map(p => resolve(p.path)))
for (const repo of await listReposInDirectory(reposDirectory).catch(() => [])) {
if (known.has(resolve(repo))) continue
await addProject(repo, new Date().toISOString(), undefined, env).catch(() => {})
console.log(`[framework] auto-added repo ${basename(repo)} from ${reposDirectory}`)
}
}

/** True when a process with this id is still running (best-effort, signal 0). The store's
* {@link isPidAlive} under the daemon's historical public name -- the two were byte-identical. */
export { isPidAlive as isProcessAlive } from './store/index.js'
Expand Down Expand Up @@ -166,9 +146,6 @@ export async function runDaemon(cwd: string, opts: RunDaemonOptions = {}): Promi
// Multi-project (#392): make sure an activated home repo shows up in the Projects list.
await registerHomeProject(cwd, env)

// Repos directory (#1123): when the opt-in is on, add every git repo in the user's repos dir.
await registerReposDirectory(env)

// Crash recovery (#642): a fresh daemon drives no in-flight run, so any run a dead
// process left marked `running` is orphaned β€” it would show as active forever with a
// no-op Stop. Reconcile them to `stopped` across every registered project at boot.
Expand Down
2 changes: 1 addition & 1 deletion packages/the-framework/src/project.SPEC.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ Read-only project helpers: whether a repo has The Framework installed, what its

## TLDR

- A repo counts as activated when the framework's marker directory exists; creating it is a separate concern.
- A repo counts as activated when the ignore file the install writes exists β€” the file that keeps the framework's transient state off the repo's branches. That way a repo can never look activated while it still lacks the one protection activation is about; writing it is a separate concern.
- Detection signals are the dependency names from the project's package manifest; a from-scratch project simply has none.
- The file crawl lists everything git sees (tracked and untracked, honoring ignores) and yields nothing rather than failing.
- Git operations get one of three time budgets β€” read, local write, or network/whole-checkout β€” because killing a slow push or checkout mid-flight can corrupt real work, while a hung read must not hold the daemon for minutes.
Expand Down
Loading
Loading