diff --git a/packages/framework/dashboard/components/AiQueue.SPEC.md b/packages/framework/dashboard/components/AiQueue.SPEC.md index 82713276..74f1f553 100644 --- a/packages/framework/dashboard/components/AiQueue.SPEC.md +++ b/packages/framework/dashboard/components/AiQueue.SPEC.md @@ -77,7 +77,7 @@ The user wants several queued entries worked on at once — the drain routine's #### Business logic -Each project's header carries a fan-out button beside a count. Clicking the button starts one agent per open entry, taken from the top of that project's queue, as many as the count says. The count is edited right beside the button, defaults to three, and is kept between one and the same maximum as the routine panel's concurrent-agents setting. With fewer open entries than the count, the batch is just the open entries — the button's label always names the number of agents a click would actually start. +Each project's header carries a fan-out button beside a count. Clicking the button starts one agent per open entry, taken from the top of that project's queue, as many as the count says. The count is edited right beside the button, defaults to three, and is floored at one with no maximum, like the routine panel's concurrent-agents setting. With fewer open entries than the count, the batch is just the open entries — the button's label always names the number of agents a click would actually start. Each agent of the batch is started exactly as the single play button starts one: pinned to its own entry's raw queue line, unattended. The agents are started one after another, and the first failed start ends the batch — the remaining entries are not started, and the failure is reported under the list the same way a single start's is. diff --git a/packages/framework/dashboard/components/AiQueue.tsx b/packages/framework/dashboard/components/AiQueue.tsx index bb952838..75b60de6 100644 --- a/packages/framework/dashboard/components/AiQueue.tsx +++ b/packages/framework/dashboard/components/AiQueue.tsx @@ -1,6 +1,6 @@ import { useState } from 'react' import type { ProjectQueue } from '../../src/index.js' -import { agentOptionsFromPreferences, MAX_AUTO_PM_CONCURRENCY } from '../../src/client.js' +import { agentOptionsFromPreferences } from '../../src/client.js' import { FastForward, ListTodo, Loader2, Play } from 'lucide-react' import { queueEntryLabel } from '../lib/queue-entry.js' import { usePreferences } from '../lib/preferences.js' @@ -148,14 +148,13 @@ export function AiQueue({ { - // Clamped like the routine panel's concurrency box: a number input + // Floored like the routine panel's concurrency box: a number input // still hands back whatever was typed, and an emptied box is mid-edit - // rather than a count — `Number('')` is 0, and the clamp would turn a + // rather than a count — `Number('')` is 0, and the floor would turn a // cleared field into a saved 1. const typed = event.target.value.trim() if (!typed) return @@ -163,7 +162,7 @@ export function AiQueue({ if (!Number.isFinite(next)) return setFanOutCounts(counts => ({ ...counts, - [q.projectId]: Math.min(Math.max(next, 1), MAX_AUTO_PM_CONCURRENCY), + [q.projectId]: Math.max(next, 1), })) }} className="h-7 w-11 shrink-0 rounded border border-border bg-background px-1 text-center text-xs tabular-nums text-foreground" diff --git a/packages/framework/dashboard/components/RoutineWork.SPEC.md b/packages/framework/dashboard/components/RoutineWork.SPEC.md index ec8fb043..83a6124c 100644 --- a/packages/framework/dashboard/components/RoutineWork.SPEC.md +++ b/packages/framework/dashboard/components/RoutineWork.SPEC.md @@ -141,7 +141,7 @@ See `## User story`. #### Business logic -A number beside the schedule switch sets how many agents the routines keep going at once while there is queued work, clamped between one and the allowed maximum. A cleared box is treated as mid-edit and saves nothing, so clearing it does not silently store the minimum. When the preference is unset, the number shown is the daemon's own default, so the figure on screen is the figure the daemon would use. +A number beside the schedule switch sets how many agents the routines keep going at once while there is queued work, floored at one and with no maximum. A cleared box is treated as mid-edit and saves nothing, so clearing it does not silently store the minimum. When the preference is unset, the number shown is the daemon's own default, so the figure on screen is the figure the daemon would use. Under it, a sentence states the consequence: at one agent, work runs only while nothing else is running and the week's allowance is not already spent; above one, up to that many agents are kept going on queued work, still only while the week's allowance is not spent. diff --git a/packages/framework/dashboard/components/RoutineWork.test.SPEC.md b/packages/framework/dashboard/components/RoutineWork.test.SPEC.md index 59bb8705..478043a2 100644 --- a/packages/framework/dashboard/components/RoutineWork.test.SPEC.md +++ b/packages/framework/dashboard/components/RoutineWork.test.SPEC.md @@ -16,7 +16,7 @@ What the tests cover: the "Routine work" card's rows, its Run now paths, and the **Trigger routine now.** It fires the sweep instead of waiting out the countdown, and stays available with the schedule off, where its hover says auto-run stays off. Its answer is reported: a single project's message plainly, several projects' messages each prefixed by folder name, and "not running the sweep" for a dashboard without one. The sweep-backed Run now clicks report their outcome the same way. -**Concurrent agents.** The box shows the daemon's default until it is set, up to the allowed maximum; typing writes the value clamped to that maximum and to a minimum of one, and emptying the box writes nothing. The sentence under it follows the number rather than promising an idle machine. +**Concurrent agents.** The box shows the daemon's default until it is set and offers no maximum; typing writes the value floored at one — any higher count is written as typed — and emptying the box writes nothing. The sentence under it follows the number rather than promising an idle machine. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/dashboard/components/RoutineWork.test.tsx b/packages/framework/dashboard/components/RoutineWork.test.tsx index b573a28f..8e30a4b8 100644 --- a/packages/framework/dashboard/components/RoutineWork.test.tsx +++ b/packages/framework/dashboard/components/RoutineWork.test.tsx @@ -7,7 +7,6 @@ import { AUTO_PM_DRAIN_JOB, AUTO_PM_MAINTENANCE_JOB, DEFAULT_AUTO_PM_CONCURRENCY, - MAX_AUTO_PM_CONCURRENCY, } from '../../src/client.js' import { hoverTooltip } from '../test-utils.js' @@ -512,17 +511,19 @@ describe('RoutineWork (#1159)', () => { const box = (await screen.findByLabelText('Concurrent agents')) as HTMLInputElement // The default rather than 1, so the number on screen is the number the sweep would use. expect(box.value).toBe(String(DEFAULT_AUTO_PM_CONCURRENCY)) - expect(box.max).toBe(String(MAX_AUTO_PM_CONCURRENCY)) + // No maximum: how many agents to run at once is the user's call. + expect(box.max).toBe('') }) - test('typing a concurrency writes it, clamped to the cap', async () => { + test('typing a concurrency writes it, floored at one', async () => { renderCard() const box = await screen.findByLabelText('Concurrent agents') fireEvent.change(box, { target: { value: '5' } }) expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: 5 }) - // The store clamps too, but a number input still hands back whatever was typed into it. + // No upper bound: a big count is written as typed. fireEvent.change(box, { target: { value: '999' } }) - expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: MAX_AUTO_PM_CONCURRENCY }) + expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: 999 }) + // The store floors too, but a number input still hands back whatever was typed into it. fireEvent.change(box, { target: { value: '0' } }) expect(updatePreferences).toHaveBeenCalledWith({ autoPmConcurrency: 1 }) // An emptied box is not a preference: it must not write NaN into the home file. diff --git a/packages/framework/dashboard/components/RoutineWork.tsx b/packages/framework/dashboard/components/RoutineWork.tsx index dcb334da..f3f41472 100644 --- a/packages/framework/dashboard/components/RoutineWork.tsx +++ b/packages/framework/dashboard/components/RoutineWork.tsx @@ -3,7 +3,6 @@ import type { AutoPmJob, AutoPmOnly, AutoPmOutcome, ProjectSummary } from '../.. import { AUTO_PM_ROUTINES, DEFAULT_AUTO_PM_CONCURRENCY, - MAX_AUTO_PM_CONCURRENCY, agentOptionsFromPreferences, } from '../../src/client.js' import { CalendarClock, ChevronDown, Play } from 'lucide-react' @@ -404,19 +403,18 @@ export function RoutineWork({ id="auto-pm-concurrency" type="number" min={1} - max={MAX_AUTO_PM_CONCURRENCY} step={1} value={concurrency} onChange={event => { - // Clamped here as well as in the store, because a number input still hands + // Floored here as well as in the store, because a number input still hands // back whatever was typed. An emptied box is mid-edit rather than a setting: - // it has to be caught by hand, since `Number('')` is 0, not NaN, and the clamp + // it has to be caught by hand, since `Number('')` is 0, not NaN, and the floor // below would turn a cleared field into a saved 1. const typed = event.target.value.trim() if (!typed) return const next = Math.round(Number(typed)) if (!Number.isFinite(next)) return - updatePreferences({ autoPmConcurrency: Math.min(Math.max(next, 1), MAX_AUTO_PM_CONCURRENCY) }) + updatePreferences({ autoPmConcurrency: Math.max(next, 1) }) }} className="w-16 rounded border border-border bg-background px-2 py-1 text-sm text-foreground" /> diff --git a/packages/framework/src/client.ts b/packages/framework/src/client.ts index bfe251b0..6d0c75e1 100644 --- a/packages/framework/src/client.ts +++ b/packages/framework/src/client.ts @@ -53,7 +53,7 @@ export { interventionKey, activityKey } from './dashboard/keys.js' // GitHub reach take an empty backlog for a real one. Pure, and its only import is a type. export { SeenTracker } from './dashboard/keyed-watcher.js' export type { ProjectionRead } from './dashboard/projects.js' -export { NOTIFICATION_DEFAULTS, MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET, DEFAULT_AUTO_PM_CONCURRENCY, MAX_AUTO_PM_CONCURRENCY, notifies, notifyMethodEnabled, notifyCategoryEnabled, type NotifyMethod, type NotifyCategory } from './preference-defaults.js' +export { NOTIFICATION_DEFAULTS, MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET, DEFAULT_AUTO_PM_CONCURRENCY, notifies, notifyMethodEnabled, notifyCategoryEnabled, type NotifyMethod, type NotifyCategory } from './preference-defaults.js' // The preferences -> run options mapping (#858), shared with the daemon so an unattended agent // starts with the same settings a launcher-started one would. Pure field logic, no Node imports. export { agentOptionsFromPreferences, handoffFromPreferences, preferencesFromFileConfig } from './agent-options.js' diff --git a/packages/framework/src/preference-defaults.SPEC.md b/packages/framework/src/preference-defaults.SPEC.md index 3323ddac..2d106653 100644 --- a/packages/framework/src/preference-defaults.SPEC.md +++ b/packages/framework/src/preference-defaults.SPEC.md @@ -4,7 +4,7 @@ What an unset preference means, and the bounds shared by the controls that write - **Notifications are a 2×2** - two delivery methods (browser, Discord) crossed with two categories ("needs you", plain activity); a notification is delivered only when its method and its category are both on. - **Defaults follow reach** - browser and "needs you" fire unless turned off; anything reaching outward (Discord) or merely informative (plain activity) is opt-in. -- **Shared bounds** - the slider that moves the unattended-work spend limit reaches at most 50 percentage points either way from the quota boundary, and defaults to half a day's allowance ahead of it; Auto PM runs 2 agents at once by default, 10 at most. +- **Shared bounds** - the slider that moves the unattended-work spend limit reaches at most 50 percentage points either way from the quota boundary, and defaults to half a day's allowance ahead of it; Auto PM runs 2 agents at once by default. ## Business logic @@ -26,11 +26,11 @@ The composition used to be open-coded per call site, which let one site get a ca #### Business logic -The slider that offsets the unattended-work spend limit from the quota boundary is bounded at ±50 percentage points, and its default position is half a day's worth of the week's allowance ahead of the boundary (100/14 points). The number of agents Auto PM's draining routine may keep going at once defaults to 2 and is capped at 10. These numbers live here because the control that writes each value runs in the browser while the sanitizer that clamps it runs in the daemon — both must import the same number. +The slider that offsets the unattended-work spend limit from the quota boundary is bounded at ±50 percentage points, and its default position is half a day's worth of the week's allowance ahead of the boundary (100/14 points). The number of agents Auto PM's draining routine may keep going at once defaults to 2, with no upper bound. These numbers live here because the control that writes each value runs in the browser while the sanitizer that clamps it runs in the daemon — both must import the same number. #### Rationale -A default spend limit sitting exactly on the boundary stops unattended work the moment the account is precisely on pace — which is normal jitter, not overspending. The half-day cushion gives it room to breathe without meaningfully loosening the policy. Auto PM's default of 2 (not 1) is the smallest value that makes the overlap feature visible at all while staying conservative about quota. +A default spend limit sitting exactly on the boundary stops unattended work the moment the account is precisely on pace — which is normal jitter, not overspending. The half-day cushion gives it room to breathe without meaningfully loosening the policy. Auto PM's default of 2 (not 1) is the smallest value that makes the overlap feature visible at all while staying conservative about quota. The agent count has no upper bound because how many agents to run at once is the user's call — the week's allowance is what actually paces unattended work. ## Before modifying/creating SPEC.md files diff --git a/packages/framework/src/preference-defaults.ts b/packages/framework/src/preference-defaults.ts index e19ea96d..1246bd6a 100644 --- a/packages/framework/src/preference-defaults.ts +++ b/packages/framework/src/preference-defaults.ts @@ -98,10 +98,3 @@ export const DEFAULT_SPEND_OFFSET = 100 / (7 * 2) * number that shows it while staying conservative about quota. */ export const DEFAULT_AUTO_PM_CONCURRENCY = 2 - -/** - * The most agents the routine may be asked to keep going at once (#1204). Like - * {@link MAX_SPEND_OFFSET}, the control that writes the value is in the browser and the sanitizer - * that clamps it is in the daemon, so the bound has to be one number both can import. - */ -export const MAX_AUTO_PM_CONCURRENCY = 10 diff --git a/packages/framework/src/registry.SPEC.md b/packages/framework/src/registry.SPEC.md index 0c87c750..7405efa4 100644 --- a/packages/framework/src/registry.SPEC.md +++ b/packages/framework/src/registry.SPEC.md @@ -82,7 +82,7 @@ Autonomy: - **Auto PM** - let the daemon start work by itself. Absent means off: it spends the user's allowance without being asked. - **routine opt-out** - the routines Auto PM must not fire, named individually. Absent or empty means every routine runs. It lists exceptions rather than selections so that a routine added in a later version is on for everyone, instead of silently never running for whoever saved the setting before it shipped; and it names routines rather than numbering them so reordering them cannot move which one is switched off. The names are stored as written and are not checked against the known routines, so a name from a newer version survives a downgrade instead of being erased by it. -- **routine concurrency** - how many agents a routine may keep going at once on one project. Absent means the standard default; the value is rounded, capped, and floored at one, because zero is what the Auto PM switch itself already means and a hand-edited zero would otherwise wedge the routine while the switch still read as on. Only the draining routine fans out — it takes work off the agent queue, one pinned entry per agent, so several at once do disjoint work. The routines that invent work each rewrite the queue file, so they stay one agent per tick whatever this says. +- **routine concurrency** - how many agents a routine may keep going at once on one project. Absent means the standard default; the value is rounded and floored at one — with no upper bound — because zero is what the Auto PM switch itself already means and a hand-edited zero would otherwise wedge the routine while the switch still read as on. Only the draining routine fans out — it takes work off the agent queue, one pinned entry per agent, so several at once do disjoint work. The routines that invent work each rewrite the queue file, so they stay one agent per tick whatever this says. - **routine project** - which project the Routine work card's "Run now" button targets. Absent means the first registered project. It is a stored setting rather than card state because the choice decides which repo spends quota and gets branches pushed, and card state forgot it on the most common navigation there is — open an agent, come back — so the next click landed on the user's real project. An id that no longer names a registered project simply falls back. - **spend offset** - how far the limit for unattended spending sits from the quota boundary, in percentage points. Absent means the standard cushion ahead of the boundary rather than sitting exactly on it. Negative holds unattended work back further; positive lets it borrow into the days still to come. It is an offset rather than an absolute percentage so the limit travels with the boundary as the week goes on, instead of being overtaken by it on the second day. The stored value is rounded and clamped, so a hand-edited file cannot put the limit anywhere the slider could not. diff --git a/packages/framework/src/registry.test.SPEC.md b/packages/framework/src/registry.test.SPEC.md index 95382264..928cc6dc 100644 --- a/packages/framework/src/registry.test.SPEC.md +++ b/packages/framework/src/registry.test.SPEC.md @@ -5,7 +5,7 @@ What the tests cover: - **Reading the project list** - a missing, empty, unparseable, or non-object file reads as no projects; malformed records inside a good file are dropped while the rest survive; duplicate paths collapse to the first, comparing paths after resolving them. A file in the older bare-list shape is read as an empty registry rather than being supported forever. - **Registering a project** - a new record is appended and the file written back as readable JSON; registering an already-registered path — including through a trailing slash or a path with `..` in it — returns the existing record with its original date and writes nothing; the stored path is always absolute; existing preferences survive. - **Validating preferences** - unknown fields and wrong-typed values are dropped; every boolean preference survives a save, so none can be silently discarded; preferences drawn from a fixed set (run target, driver, theme, handoff) keep known values and drop unknown ones; renamed preferences from older versions read as unknown and are dropped rather than being translated. -- **Individual preferences** - free-form strings are trimmed and a blank one is dropped, so blanking is how the dashboard clears the model and the editor; the literal word `Default` is rejected as a model however a file came to hold it, because it would otherwise be handed to the CLI as a model nobody chose; the routine opt-out list is trimmed and de-duplicated, drops junk entries one by one rather than losing the whole list, and an empty list clears the setting; the routine card's project id is trimmed and an empty one dropped, without being checked against the registered projects; the spend offset and the concurrency count are rounded and clamped to what their controls allow, with concurrency additionally floored at one so a hand-edited zero cannot wedge the routine while its switch reads as on; non-numbers are dropped from both; custom presets keep only well-formed entries — trimmed, no duplicate ids, no missing id, label or prompt — and the field is left off entirely when none survive. +- **Individual preferences** - free-form strings are trimmed and a blank one is dropped, so blanking is how the dashboard clears the model and the editor; the literal word `Default` is rejected as a model however a file came to hold it, because it would otherwise be handed to the CLI as a model nobody chose; the routine opt-out list is trimmed and de-duplicated, drops junk entries one by one rather than losing the whole list, and an empty list clears the setting; the routine card's project id is trimmed and an empty one dropped, without being checked against the registered projects; the spend offset is rounded and clamped to what its slider allows; the concurrency count is rounded and floored at one — any higher count is kept as written — so a hand-edited zero cannot wedge the routine while its switch reads as on; non-numbers are dropped from both; custom presets keep only well-formed entries — trimmed, no duplicate ids, no missing id, label or prompt — and the field is left off entirely when none survive. - **Merging versus replacing** - a merge touches only the keys it names and leaves the rest of the stored preferences alone; the merged result is validated as a whole, so an unknown key or an out-of-set value never lands; blanking a value through a merge clears it without any special sentinel. - **Notifying the daemon's services** - the preferences store hands its listener the keys the caller wrote rather than the merged result, so a listener can tell a write that switched something on from a write that happened while it was already on; a listener that fails does not turn a successful save into a failed one. - **Atomic, owner-only writes** - the live file is never written in place, only replaced by renaming a temporary file over it, and no temporary file is left beside it on success; a write that dies partway leaves the previous registry completely intact; the owner-only permissions are applied to the temporary file so the real path is never briefly world-readable; a filesystem without permissions still gets its write. diff --git a/packages/framework/src/registry.test.ts b/packages/framework/src/registry.test.ts index aebb812a..c5eb0833 100644 --- a/packages/framework/src/registry.test.ts +++ b/packages/framework/src/registry.test.ts @@ -18,7 +18,6 @@ import { REGISTRY_FILE, REGISTRY_FILE_MODE, MAX_SPEND_OFFSET, - MAX_AUTO_PM_CONCURRENCY, type Preferences, type ProjectRecord, type RegistryFs, @@ -334,16 +333,16 @@ test('writePreferences keeps the routine card\'s picked project, trimmed, and dr assert.deepEqual(await readPreferences(fs, ENV), {}) }) -test('writePreferences round-trips and clamps the concurrent-agents setting (#1204)', async () => { +test('writePreferences round-trips and floors the concurrent-agents setting (#1204)', async () => { const fs = memFs({ [FILE]: JSON.stringify({ projects: [APP_A], preferences: {} }) }) await writePreferences({ autoPmConcurrency: 4 }, fs, ENV) assert.deepEqual(await readPreferences(fs, ENV), { autoPmConcurrency: 4 }) - // Clamped to the same bound the browser control offers, and floored at one: zero agents is what - // the `autoPm` switch already spells, and a hand-edited nought would wedge the routine with the - // switch still reading on. + // No upper bound — how many agents to run at once is the user's call — but floored at one: zero + // agents is what the `autoPm` switch already spells, and a hand-edited nought would wedge the + // routine with the switch still reading on. await writePreferences({ autoPmConcurrency: 9000 }, fs, ENV) - assert.deepEqual(await readPreferences(fs, ENV), { autoPmConcurrency: MAX_AUTO_PM_CONCURRENCY }) + assert.deepEqual(await readPreferences(fs, ENV), { autoPmConcurrency: 9000 }) await writePreferences({ autoPmConcurrency: 0 }, fs, ENV) assert.deepEqual(await readPreferences(fs, ENV), { autoPmConcurrency: 1 }) await writePreferences({ autoPmConcurrency: -3 }, fs, ENV) diff --git a/packages/framework/src/registry.ts b/packages/framework/src/registry.ts index 79ce3211..9a2a8234 100644 --- a/packages/framework/src/registry.ts +++ b/packages/framework/src/registry.ts @@ -4,7 +4,7 @@ import { basename, dirname, join, resolve } from 'node:path' import { randomBytes } from 'node:crypto' import { isDriverName } from './driver-names.js' import { nodeFs } from './node-fs.js' -import { MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET, MAX_AUTO_PM_CONCURRENCY } from './preference-defaults.js' +import { MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET } from './preference-defaults.js' /** * The multi-project registry (#390): the list of projects the user has @@ -130,7 +130,7 @@ export interface Preferences { autoPmOptOut?: string[] /** * How many agents the routine may keep going at once on one project (#1204). Absent defaults to - * `DEFAULT_AUTO_PM_CONCURRENCY`, and the value is clamped to `MAX_AUTO_PM_CONCURRENCY`. + * `DEFAULT_AUTO_PM_CONCURRENCY`, and the value is floored at one, with no upper bound. * * Only the draining routine fans out: it takes work *off* the queue, one pinned entry per agent, * so several at once do disjoint work. The rotation invents work and each of its jobs rewrites @@ -174,7 +174,6 @@ export { MAX_SPEND_OFFSET, DEFAULT_SPEND_OFFSET, DEFAULT_AUTO_PM_CONCURRENCY, - MAX_AUTO_PM_CONCURRENCY, } from './preference-defaults.js' /** @@ -387,12 +386,13 @@ function sanitizePreferences(value: unknown): Preferences { // is dropped like every other empty list — nothing opted out is exactly what absent means. const optOut = sanitizeNameList(input['autoPmOptOut']) if (optOut.length) preferences.autoPmOptOut = optOut - // `autoPmConcurrency` (#1204) is a count of agents, so it is clamped like `autoSpendOffset` and - // additionally floored at one: zero concurrent agents is what the `autoPm` switch already spells, - // and a hand-edited nought would otherwise wedge the routine with the switch still reading on. + // `autoPmConcurrency` (#1204) is a count of agents, rounded like `autoSpendOffset` and floored + // at one: zero concurrent agents is what the `autoPm` switch already spells, and a hand-edited + // nought would otherwise wedge the routine with the switch still reading on. No upper bound — + // how many agents to run at once is the user's call, and the week's allowance paces them anyway. const concurrency = input['autoPmConcurrency'] if (typeof concurrency === 'number' && Number.isFinite(concurrency)) - preferences.autoPmConcurrency = Math.min(Math.max(Math.round(concurrency), 1), MAX_AUTO_PM_CONCURRENCY) + preferences.autoPmConcurrency = Math.max(Math.round(concurrency), 1) // `autoPmProject` (#1647) is a project id, kept as a bounded free-form string rather than checked // against the project list for the reason the opt-out names are not checked against the // catalog: the card validates it against the projects it shows, and an id of a project removed