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
9 changes: 9 additions & 0 deletions docs/design/loom-design.md
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
14 changes: 12 additions & 2 deletions docs/team-walkthrough.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
{
Expand Down
7 changes: 6 additions & 1 deletion src/Loom.Application/Workflows/Kickoff/IKickoffService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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);
65 changes: 61 additions & 4 deletions src/Loom.Application/Workflows/Kickoff/KickoffService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -25,22 +25,35 @@ public async Task<IReadOnlyList<NodeId>> AcceptDecompositionAsync(
await using var tx = await uow.BeginTransactionAsync(ct);
try
{
var childIds = new List<NodeId>(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<string, NodeId>(StringComparer.Ordinal);
var childIds = new List<NodeId>(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);
}

Expand Down Expand Up @@ -68,4 +81,48 @@ await engine.ResolveGateAsync(
throw;
}
}

/// <summary>
/// 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.
/// </summary>
private static List<ProposedChildAcceptance> TopologicalOrder(
IReadOnlyList<ProposedChildAcceptance> input)
{
var bySlug = new Dictionary<string, ProposedChildAcceptance>(StringComparer.Ordinal);
foreach (var c in input)
{
ArgumentNullException.ThrowIfNull(c);
bySlug[c.Slug.Value] = c;
}

var visited = new HashSet<string>(StringComparer.Ordinal);
var onStack = new HashSet<string>(StringComparer.Ordinal);
var ordered = new List<ProposedChildAcceptance>(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;
}
}
28 changes: 20 additions & 8 deletions src/Loom.Application/Workflows/Kickoff/SeedFragments.cs
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand Down Expand Up @@ -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
}
]
}
Expand All @@ -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
Expand Down
16 changes: 13 additions & 3 deletions src/Loom.Web/Components/Pages/PoGate.razor
Original file line number Diff line number Diff line change
Expand Up @@ -117,7 +117,7 @@ else if (_step.Key == KickoffWorkflowFactory.DecomposeStepKey && _decomposition
<section class="ws-section">
<EditForm Model="_proposalEdits" OnValidSubmit="AcceptDecompositionAsync">
<table class="run-table">
<thead><tr><th>Accept</th><th>Type</th><th>Slug</th><th>Title</th><th>Intent</th></tr></thead>
<thead><tr><th>Accept</th><th>Type</th><th>Slug</th><th>Title</th><th>Intent</th><th>Under</th></tr></thead>
<tbody>
@for (var i = 0; i < _proposalEdits.Children.Count; i++)
{
Expand All @@ -129,6 +129,7 @@ else if (_step.Key == KickoffWorkflowFactory.DecomposeStepKey && _decomposition
<td><input @bind="c.Slug" /></td>
<td><input @bind="c.Title" /></td>
<td><input @bind="c.Intent" /></td>
<td>@(string.IsNullOrWhiteSpace(c.ParentSlug) ? "—" : c.ParentSlug)</td>
</tr>
}
</tbody>
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -707,7 +715,8 @@ else
Slug = c.Slug,
Title = c.Title,
Intent = c.Intent,
Type = c.Type
Type = c.Type,
ParentSlug = c.ParentSlug
})]
};
}
Expand All @@ -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
{
Expand Down
Loading