diff --git a/docs/design/loom-design.md b/docs/design/loom-design.md index 2c67d9b..f60337f 100644 --- a/docs/design/loom-design.md +++ b/docs/design/loom-design.md @@ -133,6 +133,15 @@ Project ───┬─< FeatureNode (recursive: parent_id) >─── Artifact ### FeatureNode The unit of context. Same shape at every level (initiative / feature / capability / slice). Hierarchy via `parent_id`, type via enum. +The four levels are not interchangeable — each one names a different thing: + +- **Initiative** — top-level intent for a project, theme, or quarter outcome. Sits directly under a Project. *"Make checkout faster."* +- **Feature** — a coherent capability bundle delivered to users; child of an Initiative (or directly under a Project for one-off work). *"Express checkout."* +- **Capability** — a discrete behaviour or system competence required by a feature; child of a Feature. *"Saved-card surfacing."* Capabilities never sit beside features as peers — if a proposal puts them at the same level, decompose ran wrong. +- **Slice** — a thin, end-to-end deliverable that exercises one capability; child of a Capability. The unit a developer pair picks up. *"Render saved card on the checkout page (read-only)."* + +Decompose steps must respect this nesting: a feature decomposition produces capabilities under that feature, not capability siblings of it. + ```csharp public record FeatureNode( Guid Id, diff --git a/docs/team-walkthrough.md b/docs/team-walkthrough.md index 3e765ef..e7de214 100644 --- a/docs/team-walkthrough.md +++ b/docs/team-walkthrough.md @@ -416,8 +416,18 @@ round-trip via Figma and the "we've seen this before" memory. ## 6. Glossary -- **Node** — a `FeatureNode`, the unit of context (initiative / - feature / capability / slice). +- **Node** — a `FeatureNode`, the unit of context. The hierarchy is + strict: **initiative ▸ feature ▸ capability ▸ slice**. Same shape at + every level, distinguished by `NodeType`. + - **Initiative** — top-level intent for a project, theme, or quarter + outcome. Sits directly under a Project. + - **Feature** — a coherent capability bundle delivered to users; + child of an Initiative. + - **Capability** — a discrete behaviour or system competence required + by a feature; child of a Feature. Never a peer of a feature. + - **Slice** — a thin end-to-end deliverable that exercises one + capability; child of a Capability. The unit a developer pair picks + up. - **Fragment** — a tagged, versioned piece of prompt content. See [fragment-library.md](fragment-library.md) for the seeded library. - **Run** — a single agent execution against a node, with full diff --git a/src/Loom.Application/Workflows/Kickoff/DecompositionProposal.cs b/src/Loom.Application/Workflows/Kickoff/DecompositionProposal.cs index c4ce12a..42e7208 100644 --- a/src/Loom.Application/Workflows/Kickoff/DecompositionProposal.cs +++ b/src/Loom.Application/Workflows/Kickoff/DecompositionProposal.cs @@ -16,7 +16,12 @@ public sealed record ProposedChild( [property: JsonPropertyName("slug")] string Slug, [property: JsonPropertyName("title")] string Title, [property: JsonPropertyName("type")] string Type, - [property: JsonPropertyName("intent")] string? Intent) + [property: JsonPropertyName("intent")] string? Intent, + // Slug of another proposed child that this one nests under. null / + // omitted means top-level (under the kickoff parent). Capabilities + // must set this to a Feature's slug; the methodology hierarchy is + // initiative ▸ feature ▸ capability ▸ slice. + [property: JsonPropertyName("parentSlug")] string? ParentSlug = null) { public NodeType ParseType() => Type?.Trim().ToLowerInvariant() switch { diff --git a/src/Loom.Application/Workflows/Kickoff/IKickoffService.cs b/src/Loom.Application/Workflows/Kickoff/IKickoffService.cs index 8a69e85..bee41ef 100644 --- a/src/Loom.Application/Workflows/Kickoff/IKickoffService.cs +++ b/src/Loom.Application/Workflows/Kickoff/IKickoffService.cs @@ -43,4 +43,9 @@ public sealed record ProposedChildAcceptance( Slug Slug, NodeType Type, string Title, - string? Intent); + string? Intent, + // Slug of another acceptance entry in the same batch that this one + // nests under. null means top-level (under the kickoff parent node). + // Resolution happens in KickoffService — children whose ParentSlug + // does not match a sibling fall back to the kickoff parent. + Slug? ParentSlug = null); diff --git a/src/Loom.Application/Workflows/Kickoff/KickoffService.cs b/src/Loom.Application/Workflows/Kickoff/KickoffService.cs index e414aa7..cfa1ced 100644 --- a/src/Loom.Application/Workflows/Kickoff/KickoffService.cs +++ b/src/Loom.Application/Workflows/Kickoff/KickoffService.cs @@ -25,22 +25,35 @@ public async Task> AcceptDecompositionAsync( await using var tx = await uow.BeginTransactionAsync(ct); try { - var childIds = new List(children.Count); - foreach (var c in children) + // Resolve nesting up front: each acceptance may name a + // ParentSlug that points at another acceptance in this batch + // (e.g. capabilities nesting under their feature). We sort + // topologically — entries whose parent is also being created + // here come after that parent — and keep a slug→NodeId map + // so children land on the right NodeId. Anything whose + // ParentSlug doesn't match a sibling falls back to the + // kickoff parentNodeId. + var ordered = TopologicalOrder(children); + var slugToId = new Dictionary(StringComparer.Ordinal); + var childIds = new List(ordered.Count); + foreach (var c in ordered) { - ArgumentNullException.ThrowIfNull(c); if (string.IsNullOrWhiteSpace(c.Title)) { continue; } + var localParent = c.ParentSlug is { } ps && slugToId.TryGetValue(ps.Value, out var pid) + ? pid + : parentNodeId; // CreateChildNodeAsync is idempotent on slug — if a // sibling with this slug already exists the existing // one is returned. So a partial prior accept doesn't // block this one. var node = await features.CreateChildNodeAsync( - parentNodeId, c.Slug, c.Type, c.Title.Trim(), acceptedBy, + localParent, c.Slug, c.Type, c.Title.Trim(), acceptedBy, intent: string.IsNullOrWhiteSpace(c.Intent) ? null : c.Intent.Trim(), ct: ct); + slugToId[c.Slug.Value] = node.Id; childIds.Add(node.Id); } @@ -68,4 +81,48 @@ await engine.ResolveGateAsync( throw; } } + + /// + /// Order acceptances so that any entry whose ParentSlug matches + /// another entry's Slug comes after that entry. Cycles are broken by + /// emitting cycle members in their original order; bad pointers are + /// tolerated and resolve to the kickoff parent at create-time. + /// + private static List TopologicalOrder( + IReadOnlyList input) + { + var bySlug = new Dictionary(StringComparer.Ordinal); + foreach (var c in input) + { + ArgumentNullException.ThrowIfNull(c); + bySlug[c.Slug.Value] = c; + } + + var visited = new HashSet(StringComparer.Ordinal); + var onStack = new HashSet(StringComparer.Ordinal); + var ordered = new List(input.Count); + + void Visit(ProposedChildAcceptance c) + { + if (!visited.Add(c.Slug.Value)) + { + return; + } + onStack.Add(c.Slug.Value); + if (c.ParentSlug is { } ps + && bySlug.TryGetValue(ps.Value, out var parent) + && !onStack.Contains(parent.Slug.Value)) + { + Visit(parent); + } + onStack.Remove(c.Slug.Value); + ordered.Add(c); + } + + foreach (var c in input) + { + Visit(c); + } + return ordered; + } } diff --git a/src/Loom.Application/Workflows/Kickoff/SeedFragments.cs b/src/Loom.Application/Workflows/Kickoff/SeedFragments.cs index d6e459e..7841c82 100644 --- a/src/Loom.Application/Workflows/Kickoff/SeedFragments.cs +++ b/src/Loom.Application/Workflows/Kickoff/SeedFragments.cs @@ -100,13 +100,20 @@ below. Do not wrap in prose. Do not invent fields. { "children": [ { - "slug": string, // kebab-case, project-unique - "title": string, // noun-shaped, ≤ 60 chars - "type": string, // "Initiative" | "Feature" | "Capability" | "Slice" - "intent": string|null // one sentence; reuses parent's framing + "slug": string, // kebab-case, project-unique + "title": string, // noun-shaped, ≤ 60 chars + "type": string, // "Initiative" | "Feature" | "Capability" | "Slice" + "intent": string|null, // one sentence; reuses parent's framing + "parentSlug": string|null // slug of another child this nests under } ] } + + Hierarchy is initiative ▸ feature ▸ capability ▸ slice. + Capabilities MUST set parentSlug to the feature they + implement. Top-level features set parentSlug to null. Do not + output flat lists that mix features and capabilities at the + same level. """), new( @@ -786,10 +793,11 @@ Output JSON matching the DecompositionProposal schema { "children": [ { - "slug": string, // kebab-case, project-unique, ≤ 40 chars - "title": string, // noun-shaped, ≤ 60 chars - "type": string, // "Capability" for v1; "Slice" only when explicitly asked - "intent": string|null // one sentence, reuses parent's framing + "slug": string, // kebab-case, project-unique, ≤ 40 chars + "title": string, // noun-shaped, ≤ 60 chars + "type": string, // "Capability" for v1; "Slice" only when explicitly asked + "intent": string|null, // one sentence, reuses parent's framing + "parentSlug": string|null // omit / null when decomposing one feature } ] } @@ -798,6 +806,10 @@ Output JSON matching the DecompositionProposal schema - Stop at capability level for v1. Do NOT propose slices unless the capability has obvious independent surfaces and the team has asked for slice-level decomposition. + - When decomposing a single feature, all children are its + capabilities and parentSlug stays null. When decomposing + a broader scope that includes multiple features, set each + capability's parentSlug to its feature's slug. - Use the exact lowercase property names above; no "description", "name", "summary", or other synonyms. - Each child has a noun-shaped title and an intent line diff --git a/src/Loom.Web/Components/Pages/PoGate.razor b/src/Loom.Web/Components/Pages/PoGate.razor index 81249fa..d224ac6 100644 --- a/src/Loom.Web/Components/Pages/PoGate.razor +++ b/src/Loom.Web/Components/Pages/PoGate.razor @@ -117,7 +117,7 @@ else if (_step.Key == KickoffWorkflowFactory.DecomposeStepKey && _decomposition
- + @for (var i = 0; i < _proposalEdits.Children.Count; i++) { @@ -129,6 +129,7 @@ else if (_step.Key == KickoffWorkflowFactory.DecomposeStepKey && _decomposition + } @@ -475,11 +476,18 @@ else Slug slug; try { slug = Slug.From(c.Slug.Trim()); } catch { _error = $"Invalid slug '{c.Slug}'."; return; } + Slug? parentSlug = null; + if (!string.IsNullOrWhiteSpace(c.ParentSlug)) + { + try { parentSlug = Slug.From(c.ParentSlug.Trim()); } + catch { _error = $"Invalid parent slug '{c.ParentSlug}'."; return; } + } accepted.Add(new Loom.Application.Workflows.Kickoff.ProposedChildAcceptance( Slug: slug, Type: c.ParseType(), Title: c.Title.Trim(), - Intent: string.IsNullOrWhiteSpace(c.Intent) ? null : c.Intent.Trim())); + Intent: string.IsNullOrWhiteSpace(c.Intent) ? null : c.Intent.Trim(), + ParentSlug: parentSlug)); } // One transactional unit: create children + emit the @@ -707,7 +715,8 @@ else Slug = c.Slug, Title = c.Title, Intent = c.Intent, - Type = c.Type + Type = c.Type, + ParentSlug = c.ParentSlug })] }; } @@ -719,6 +728,7 @@ else public string Title { get; set; } = string.Empty; public string? Intent { get; set; } public string Type { get; set; } = "Capability"; + public string? ParentSlug { get; set; } public NodeType ParseType() => Type?.Trim().ToLowerInvariant() switch {
AcceptTypeSlugTitleIntent
AcceptTypeSlugTitleIntentUnder
@(string.IsNullOrWhiteSpace(c.ParentSlug) ? "—" : c.ParentSlug)