agentic-teams: epics, tasks, git forges, agent authority #139
@@ -3089,9 +3089,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"120"
|
"120"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-08T08:55:58.627Z"
|
"updatedAt": "2026-08-09T22:34:49.672Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "153",
|
"id": "153",
|
||||||
@@ -3301,9 +3301,9 @@
|
|||||||
"120",
|
"120",
|
||||||
"152"
|
"152"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-09T19:36:34.052Z"
|
"updatedAt": "2026-08-09T22:34:49.682Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "167",
|
"id": "167",
|
||||||
@@ -3426,9 +3426,9 @@
|
|||||||
"dependencies": [
|
"dependencies": [
|
||||||
"170"
|
"170"
|
||||||
],
|
],
|
||||||
"status": "pending",
|
"status": "done",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-09T21:09:27.819Z"
|
"updatedAt": "2026-08-09T22:36:53.472Z"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"id": "176",
|
"id": "176",
|
||||||
@@ -3443,13 +3443,27 @@
|
|||||||
"status": "pending",
|
"status": "pending",
|
||||||
"subtasks": [],
|
"subtasks": [],
|
||||||
"updatedAt": "2026-08-09T21:09:27.819Z"
|
"updatedAt": "2026-08-09T21:09:27.819Z"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "177",
|
||||||
|
"title": "Agent chat --project reaches project tools with only run:agents",
|
||||||
|
"description": "`mcpctl chat <agent> --project <p>` surfaces that project prompts and MCP tools while checking only run:agents:<name>. A caller with no run:projects:<p> reaches project tools through the agent.",
|
||||||
|
"details": "Raised by the agent that built AgentProject membership, which deliberately did NOT add a second check because this is already todays behaviour for an agent with a legacy projectId — adding one would have been new policy invented mid-task. Correct call; the policy decision is ours.\n\nWhat changed is the blast radius: membership makes this reachable for many more (agent, project) pairs than a single scalar pin ever could.\n\nOptions:\n- require run:projects:<p> in addition to run:agents:<name> when --project is used (tightest; may break existing single-pin agents whose users were never granted the project);\n- require it only for membership-derived context, grandfathering the legacy pin;\n- leave as-is and document that agent membership delegates project tool access.\n\nWhichever way, it should be a deliberate decision with a test, not an accident of which check happened to be written first.",
|
||||||
|
"testStrategy": "See details.",
|
||||||
|
"priority": "high",
|
||||||
|
"dependencies": [
|
||||||
|
"166"
|
||||||
|
],
|
||||||
|
"status": "pending",
|
||||||
|
"subtasks": [],
|
||||||
|
"updatedAt": "2026-08-09T22:34:45.085Z"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"metadata": {
|
"metadata": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"lastModified": "2026-08-09T21:09:27.647Z",
|
"lastModified": "2026-08-09T22:36:53.472Z",
|
||||||
"taskCount": 174,
|
"taskCount": 177,
|
||||||
"completedCount": 130,
|
"completedCount": 133,
|
||||||
"tags": [
|
"tags": [
|
||||||
"master"
|
"master"
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -72,6 +72,8 @@ export class EpicTaskService {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.assertCriteriaBelongToEpic(readCriteria(epic), data.doneCriteria, epic.name);
|
||||||
|
|
||||||
return this.repo.create({
|
return this.repo.create({
|
||||||
name: data.name,
|
name: data.name,
|
||||||
epicId: data.epicId,
|
epicId: data.epicId,
|
||||||
@@ -85,7 +87,13 @@ export class EpicTaskService {
|
|||||||
|
|
||||||
async update(id: string, input: unknown): Promise<EpicTask> {
|
async update(id: string, input: unknown): Promise<EpicTask> {
|
||||||
const data = UpdateEpicTaskSchema.parse(input);
|
const data = UpdateEpicTaskSchema.parse(input);
|
||||||
await this.get(id); // 404 if missing
|
const node = await this.get(id); // 404 if missing
|
||||||
|
|
||||||
|
if (data.doneCriteria !== undefined) {
|
||||||
|
const epic = await this.epicRepo.findById(node.epicId);
|
||||||
|
if (epic === null) throw new NotFoundError(`Epic not found: ${node.epicId}`);
|
||||||
|
this.assertCriteriaBelongToEpic(readCriteria(epic), data.doneCriteria, epic.name);
|
||||||
|
}
|
||||||
|
|
||||||
return this.repo.update(id, {
|
return this.repo.update(id, {
|
||||||
...(data.description !== undefined ? { description: data.description } : {}),
|
...(data.description !== undefined ? { description: data.description } : {}),
|
||||||
@@ -101,6 +109,37 @@ export class EpicTaskService {
|
|||||||
await this.repo.delete(id);
|
await this.repo.delete(id);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A roadmap node may only claim criteria its parent Epic actually has.
|
||||||
|
*
|
||||||
|
* Epic.doneCriteria and EpicTask.doneCriteria are both JSON blobs joined by a
|
||||||
|
* free-form id, and until now nothing checked that the ids matched. A typo
|
||||||
|
* produced a criterion that belonged to nobody: the reconciliation reported
|
||||||
|
* it as `unowned`, which is honest but late — the mistake was made at write
|
||||||
|
* time and is far cheaper to refuse there.
|
||||||
|
*
|
||||||
|
* Enforced in the service, not the schema: production provisions with
|
||||||
|
* `prisma db push`, so a constraint here would never exist in production.
|
||||||
|
*/
|
||||||
|
private assertCriteriaBelongToEpic(
|
||||||
|
epicCriteria: DoneCriterion[],
|
||||||
|
nodeCriteria: DoneCriterion[] | undefined,
|
||||||
|
epicName: string,
|
||||||
|
): void {
|
||||||
|
if (nodeCriteria === undefined || nodeCriteria.length === 0) return;
|
||||||
|
const known = new Set(epicCriteria.map((c) => c.id));
|
||||||
|
const unknown = nodeCriteria.map((c) => c.id).filter((id) => !known.has(id));
|
||||||
|
if (unknown.length === 0) return;
|
||||||
|
|
||||||
|
const available = epicCriteria.map((c) => c.id);
|
||||||
|
throw badRequest(
|
||||||
|
`Epic '${epicName}' has no done-criterion ${unknown.map((u) => `'${u}'`).join(', ')}. ` +
|
||||||
|
(available.length > 0
|
||||||
|
? `Its criteria are: ${available.join(', ')}.`
|
||||||
|
: 'It has no criteria yet — add them to the Epic first.'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Backup / restore helpers ──
|
// ── Backup / restore helpers ──
|
||||||
|
|
||||||
async upsertByName(data: Record<string, unknown>, ownerId: string): Promise<EpicTask> {
|
async upsertByName(data: Record<string, unknown>, ownerId: string): Promise<EpicTask> {
|
||||||
|
|||||||
@@ -248,3 +248,76 @@ describe('EpicTaskService CRUD', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('EpicTaskService — criterion ids must belong to the parent Epic', () => {
|
||||||
|
beforeEach(() => { vi.clearAllMocks(); });
|
||||||
|
|
||||||
|
/** An epicRepo whose Epic carries the given criterion ids. */
|
||||||
|
function withEpicCriteria(ids: string[]) {
|
||||||
|
const { service, epicRepo, repo } = makeGraph([{ id: 'a', name: 'chassis' }]);
|
||||||
|
(epicRepo.findById as ReturnType<typeof vi.fn>).mockResolvedValue({
|
||||||
|
id: 'epic-1',
|
||||||
|
name: 'the-car',
|
||||||
|
doneCriteria: ids.map((id) => ({ id, text: id, kind: 'outcome', met: false })),
|
||||||
|
} as never);
|
||||||
|
return { service, repo };
|
||||||
|
}
|
||||||
|
|
||||||
|
it('accepts a node claiming a criterion the Epic has', async () => {
|
||||||
|
const { service } = withEpicCriteria(['wheels', 'engine']);
|
||||||
|
await expect(
|
||||||
|
service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [
|
||||||
|
{ id: 'wheels', text: 'has wheels', kind: 'outcome', met: false },
|
||||||
|
] }, 'user-1'),
|
||||||
|
).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('refuses a criterion the Epic never had, and lists what it does have', async () => {
|
||||||
|
// Previously this succeeded and the criterion silently belonged to nobody —
|
||||||
|
// surfacing only later as `unowned` in the reconciliation.
|
||||||
|
const { service } = withEpicCriteria(['wheels', 'engine']);
|
||||||
|
await expect(
|
||||||
|
service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [
|
||||||
|
{ id: 'whels', text: 'typo', kind: 'outcome', met: false },
|
||||||
|
] }, 'user-1'),
|
||||||
|
).rejects.toThrow(/no done-criterion 'whels'.*wheels, engine/s);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('names every unknown id, not just the first', async () => {
|
||||||
|
const { service } = withEpicCriteria(['wheels']);
|
||||||
|
await expect(
|
||||||
|
service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [
|
||||||
|
{ id: 'aaa', text: 'a', kind: 'outcome', met: false },
|
||||||
|
{ id: 'bbb', text: 'b', kind: 'outcome', met: false },
|
||||||
|
] }, 'user-1'),
|
||||||
|
).rejects.toThrow(/'aaa', 'bbb'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('says so plainly when the Epic has no criteria at all', async () => {
|
||||||
|
const { service } = withEpicCriteria([]);
|
||||||
|
await expect(
|
||||||
|
service.create({ name: 'n1', epicId: 'epic-1', doneCriteria: [
|
||||||
|
{ id: 'x', text: 'x', kind: 'outcome', met: false },
|
||||||
|
] }, 'user-1'),
|
||||||
|
).rejects.toThrow(/no criteria yet — add them to the Epic first/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('allows a node with no criteria of its own', async () => {
|
||||||
|
// Not every roadmap node claims a criterion; that is not an error.
|
||||||
|
const { service } = withEpicCriteria(['wheels']);
|
||||||
|
await expect(service.create({ name: 'n1', epicId: 'epic-1' }, 'user-1')).resolves.toBeDefined();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('enforces the same rule on update', async () => {
|
||||||
|
const { service } = withEpicCriteria(['wheels']);
|
||||||
|
await expect(
|
||||||
|
service.update('a', { doneCriteria: [{ id: 'nope', text: 'n', kind: 'outcome', met: false }] }),
|
||||||
|
).rejects.toThrow(/no done-criterion 'nope'/);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('does not re-check when an update leaves criteria alone', async () => {
|
||||||
|
const { service, repo } = withEpicCriteria(['wheels']);
|
||||||
|
await expect(service.update('a', { status: 'done' })).resolves.toBeDefined();
|
||||||
|
expect(repo.update).toHaveBeenCalled();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user