M5: action gate + review inbox — edit distance captured for real
SharedKernel:
- ActionRisk (risk lives on the action) + GatePolicy (the pure autonomy x risk matrix:
Read never holds, Draft/Publish hold unless Autonomous, Destructive ALWAYS holds).
- IActionGate (AgentActionProposal -> execute|hold) and IBoardWriter.AttachArtifactAsync.
Governance:
- ReviewItem (held action: artifact, child titles, trace, decision, edit distance) in a new
review_items table (AddReviewItems migration).
- ActionGate: hold -> ReviewItem + "action.held" audit; autonomous -> execute + audit.
- HeldActionExecutor: writes the artifact onto the task and creates the child tasks via
IBoardWriter (implemented by OrgBoard — no cross-module table access).
- Review inbox API: GET /api/governance/reviews (scope-filtered to where the caller may
approve), POST /reviews/{id}/approve (optional edited content/children -> normalized
edit distance recorded — the north-star metric), POST /reviews/{id}/sendback. Deciding
twice is 409; Members are 403.
Assembler:
- OutputParser (numbered-list child titles, conservative) and the executor now hands every
completed run's proposal to the gate.
OrgBoard: WorkItem.AttachArtifact + BoardWriter.AttachArtifactAsync.
Verified: build green; ArchitectureTests 8/8; IntegrationTests 41/41 incl. the full M5
acceptance — Aria (gated) proposes a spec, it waits in the inbox with its trace, a Member is
403'd, the owner edits-and-approves, the spec + four child stories land on the board, edit
distance > 0 is recorded and audited; Quill (autonomous) executes straight to the board;
destructive holds even for an autonomous seat and can be sent back. Plus the GatePolicy matrix.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -61,4 +61,13 @@ internal sealed class WorkItem : Entity
|
||||
AssigneeId = null;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
|
||||
/// <summary>Appends an approved agent artifact (spec / test plan) to the task.</summary>
|
||||
public void AttachArtifact(string content, DateTimeOffset nowUtc)
|
||||
{
|
||||
Description = string.IsNullOrWhiteSpace(Description)
|
||||
? content
|
||||
: Description + "\n\n---\n\n" + content;
|
||||
UpdatedAtUtc = nowUtc;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ using TeamUp.Modules.OrgBoard.Endpoints;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.Modules.OrgBoard.Runtime;
|
||||
using TeamUp.SharedKernel.Ai;
|
||||
using TeamUp.SharedKernel.Board;
|
||||
using TeamUp.SharedKernel.Modularity;
|
||||
using TeamUp.SharedKernel.Persistence;
|
||||
|
||||
@@ -25,6 +26,7 @@ public sealed class OrgBoardModule : IModule
|
||||
services.AddDbContext<OrgBoardDbContext>(options => options.UseNpgsql(connectionString));
|
||||
services.AddScoped<IModuleDbContext>(sp => sp.GetRequiredService<OrgBoardDbContext>());
|
||||
services.AddScoped<IAgentRunContextProvider, AgentRunContextProvider>();
|
||||
services.AddScoped<IBoardWriter, BoardWriter>();
|
||||
services.TryAddSingleton(TimeProvider.System);
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using TeamUp.Modules.OrgBoard.Domain;
|
||||
using TeamUp.Modules.OrgBoard.Persistence;
|
||||
using TeamUp.SharedKernel.Board;
|
||||
|
||||
namespace TeamUp.Modules.OrgBoard.Runtime;
|
||||
|
||||
/// <summary>Writes approved agent actions onto the board — creating child tasks under a parent.</summary>
|
||||
internal sealed class BoardWriter(OrgBoardDbContext db, TimeProvider clock) : IBoardWriter
|
||||
{
|
||||
public async Task<int> CreateChildTasksAsync(
|
||||
Guid teamId,
|
||||
Guid parentWorkItemId,
|
||||
IReadOnlyList<ChildTaskSpec> children,
|
||||
Guid? createdByMemberId,
|
||||
CancellationToken cancellationToken = default)
|
||||
{
|
||||
var now = clock.GetUtcNow();
|
||||
var creator = createdByMemberId ?? Guid.Empty;
|
||||
|
||||
foreach (var child in children)
|
||||
{
|
||||
var type = Enum.TryParse<WorkItemType>(child.Type, ignoreCase: true, out var parsed)
|
||||
? parsed
|
||||
: WorkItemType.Story;
|
||||
db.WorkItems.Add(new WorkItem(teamId, child.Title, description: null, type, creator, now, parentWorkItemId));
|
||||
}
|
||||
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
return children.Count;
|
||||
}
|
||||
|
||||
public async Task AttachArtifactAsync(Guid workItemId, string content, CancellationToken cancellationToken = default)
|
||||
{
|
||||
var item = await db.WorkItems.FirstOrDefaultAsync(w => w.Id == workItemId, cancellationToken)
|
||||
?? throw new InvalidOperationException($"Work item {workItemId} not found.");
|
||||
|
||||
item.AttachArtifact(content, clock.GetUtcNow());
|
||||
await db.SaveChangesAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user