Skip to content
Draft
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
1 change: 1 addition & 0 deletions core/aidlc-common/protocols/stage-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ copies this table verbatim.
| `consumes[].artifact` | string | yes per entry | lowercase-kebab |
| `consumes[].required` | boolean | yes per entry | Scoped to the active plan. `true` means "if the producing stage runs, this consume must be satisfied" — not a global assertion that the artifact always exists. Scopes that skip the producer (e.g., `bugfix` skipping `units-generation`) make the consume moot; the stage body handles graceful degradation. The reserved `when:` primitive will eventually let authors express richer predicates |
| `consumes[].conditional_on` | string | optional | `brownfield` \| `greenfield`. Omit for unconditional consumes — no `always` value |
| `consumes[].kinds` | string[] | optional | Inline list of Unit kinds (`service` \| `spec` \| `ui` \| `packaging` \| `library`) this consume applies to; only valid on a `for_each: unit-of-work` stage. Omit for every kind. A Unit whose kind is not listed does not receive the consume in its directive and is not asked to reference it by `upstream-coverage`; an untagged Unit keeps it. The consumer-side twin of `produces_kinds` |
| `requires_stage` | string[] | yes | empty allowed; each entry a known stage slug. Two roles: (1) semantic data dependency; (2) presentation-order edge for stages with no semantic link but a fixed display order. Primary input to computed `display_order` |
| `scopes` | string[] | optional | each entry a scope name with a matching `{{HARNESS_DIR}}/scopes/aidlc-<name>.md` file. Naming a scope marks this stage EXECUTE under that scope; absence marks it SKIP. The per-stage transpose of the scope membership matrix — `aidlc-graph compile` reads every stage's `scopes:` and emits the compiled EXECUTE/SKIP grid (`tools/data/scope-grid.json`). The 3 initialization stages name all scopes (always EXECUTE). Absent and `[]` are treated identically |
| `inputs` | string | yes | human prose (preserves today's `**Inputs**:` line) |
Expand Down
4 changes: 4 additions & 0 deletions core/tools/aidlc-graph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -126,6 +126,7 @@ export interface Consume {
artifact: string;
required: boolean;
conditional_on?: "brownfield" | "greenfield";
kinds?: string[];
}

// Per-rule resolution row baked into each stage's rules_in_context.
Expand Down Expand Up @@ -2831,6 +2832,9 @@ function buildGraphStage(
if (c.conditional_on !== undefined) {
out.conditional_on = c.conditional_on;
}
if (c.kinds !== undefined) {
out.kinds = c.kinds;
}
return out;
});

Expand Down
19 changes: 17 additions & 2 deletions core/tools/aidlc-lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -146,7 +146,7 @@ export interface StageEntry {
// unit whose kind is not in its list (both directive paths and coverage).
// Absent map = full matrix (every produces entry applies to every unit).
produces_kinds?: Record<string, string[]>;
consumes?: Array<{ artifact: string; required: boolean; conditional_on?: string }>;
consumes?: Array<{ artifact: string; required: boolean; conditional_on?: string; kinds?: string[] }>;
requires_stage?: string[];
scopes?: string[];
inputs?: string;
Expand Down Expand Up @@ -32325,6 +32325,9 @@ export function emitStageFrontmatter(obj: Record<string, unknown>): string {
if (typeof e.conditional_on === "string") {
lines.push(` conditional_on: ${emitScalar(e.conditional_on)}`);
}
if (Array.isArray(e.kinds)) {
lines.push(` kinds: [${e.kinds.join(", ")}]`);
}
}
}
} else if (Array.isArray(v)) {
Expand Down Expand Up @@ -32458,7 +32461,11 @@ function objectListField(
current = {};
current[itemMatch[1]] = coerceScalar(itemMatch[2]);
} else if (subMatch && current) {
current[subMatch[1]] = coerceScalar(subMatch[2]);
const value = subMatch[2];
current[subMatch[1]] =
value.startsWith("[") && value.endsWith("]")
? parseInlineDepsList(value)
: coerceScalar(value);
} else {
throw new Error(
`Malformed ${key}[] entry in frontmatter: ${line.trim()}`
Expand Down Expand Up @@ -36290,6 +36297,14 @@ export function filterProducesByKind(
});
}

export function consumeAppliesToKind(
consume: { kinds?: string[] },
unitKind: string | null
): boolean {
if (unitKind === null || consume.kinds === undefined) return true;
return consume.kinds.includes(unitKind);
}

// -----------------------------------------------------------------------------
// State-schema-version classification (shared by runtime + doctor)
// -----------------------------------------------------------------------------
Expand Down
2 changes: 2 additions & 0 deletions core/tools/aidlc-orchestrate.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,7 @@ import {
effectivePlanAction,
errorMessage,
evaluateGuardRefusal,
consumeAppliesToKind,
filterProducesByKind,
firstInScopeStageOfPhase,
firstPlannedStageOfPhase,
Expand Down Expand Up @@ -3740,6 +3741,7 @@ function resolveConsumes(
) {
continue;
}
if (!consumeAppliesToKind(consume, unitKind)) continue;
const producer = producersOf(consume.artifact)[0];
if (
producer &&
Expand Down
4 changes: 2 additions & 2 deletions core/tools/aidlc-plugin.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1020,10 +1020,10 @@ function removeListValues(
}

function removeConsumes(content: string, artifacts: ReadonlySet<string>): string {
const block = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on):.*\n)*)*)/m;
const block = /^consumes:\n((?: {2}- artifact:.*\n(?: {4}(?:required|conditional_on|kinds):.*\n)*)*)/m;
const match = content.match(block);
if (!match) return content;
const kept = [...match[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on):.*\n)*/gm)]
const kept = [...match[1].matchAll(/^ {2}- artifact:\s*([\w-]+).*\n(?: {4}(?:required|conditional_on|kinds):.*\n)*/gm)]
.filter((entry) => !artifacts.has(entry[1]))
.map((entry) => entry[0]);
return content.replace(block, kept.length > 0 ? `consumes:\n${kept.join("")}` : "consumes: []\n");
Expand Down
22 changes: 22 additions & 0 deletions core/tools/aidlc-sensor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,7 @@ import {
artifactFilename,
auditLockDir,
codekbDir,
consumeAppliesToKind,
errorMessage,
getField,
holdsAuditLock,
Expand All @@ -70,6 +71,7 @@ import {
readRegularFileNoFollowOrThrow,
readStateFile,
recordDir,
resolveBoltDag,
resolveProjectDir,
sensorsDir,
usesStageLevelPerUnitArtifacts,
Expand Down Expand Up @@ -471,6 +473,24 @@ function artifactDirsForProducer(
return [join(rec, producer.phase, producer.slug)];
}

function unitKindForOutput(
pd: string,
stage: { slug: string; for_each?: string },
outputPath: string,
): string | null {
if (stage.for_each !== "unit-of-work") return null;
const rec = recordDir(pd);
if (rec === null) return null;
const norm = outputPath.replace(/\\/g, "/");
const marker = `${rec.replace(/\\/g, "/")}/construction/`;
if (!norm.startsWith(marker)) return null;
const rest = norm.slice(marker.length).split("/");
if (rest.length < 3 || rest[1] !== stage.slug) return null;
const dag = resolveBoltDag(pd);
if (dag.state !== "ok") return null;
return dag.unitKinds?.get(rest[0]) ?? null;
}

function presentConsumes(pd: string, slugs: string[]): string[] {
if (recordDir(pd) === null) return slugs;
return slugs.filter((name) => {
Expand Down Expand Up @@ -588,7 +608,9 @@ function handleFire(args: string[]): void {
scriptArgs.push("--output-path", outputPath);
}
if (id === "upstream-coverage") {
const unitKind = unitKindForOutput(projectDir, stageNode, outputPath);
const consumeSlugs = (stageNode.consumes ?? [])
.filter((c) => consumeAppliesToKind(c, unitKind))
.map((c) => c.artifact)
.filter((a) => typeof a === "string" && a.length > 0);
scriptArgs.push(
Expand Down
16 changes: 16 additions & 0 deletions core/tools/aidlc-stage-schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ export interface StageFrontmatter {
artifact: string;
required: boolean;
conditional_on?: "brownfield" | "greenfield";
kinds?: string[];
}>;
requires_stage: string[];
sensors?: string[];
Expand Down Expand Up @@ -577,6 +578,21 @@ export function validateStageFrontmatter(
);
}
}

if ("kinds" in e && e.kinds !== undefined) {
if (o.for_each !== "unit-of-work") {
errors.push(`consumes[${i}].kinds requires for_each: unit-of-work`);
}
if (!Array.isArray(e.kinds) || e.kinds.length === 0) {
errors.push(`consumes[${i}].kinds must be a non-empty list of unit kinds`);
} else {
for (const k of e.kinds) {
if (typeof k !== "string" || !(UNIT_KINDS as readonly string[]).includes(k)) {
errors.push(`consumes[${i}].kinds lists unknown kind "${typeof k === "string" ? k : describe(k)}"`);
}
}
}
}
});
}
}
Expand Down
29 changes: 29 additions & 0 deletions docs/reference/15-stage-definition.md
Original file line number Diff line number Diff line change
Expand Up @@ -327,6 +327,35 @@ For unconditional consumes, **omit the field entirely**. There is no
`always` value — an unconditional consume simply has no `conditional_on`
key.

### `consumes[].kinds`

Optional inline list on one consume of a `for_each: unit-of-work` stage: the
Unit kinds (`service | spec | ui | packaging | library`, the same set
`produces_kinds` uses) that input applies to. Omit it for an input every Unit
reads.

```yaml
for_each: unit-of-work
consumes:
- artifact: functional-spec
required: true
- artifact: mockups
required: false
kinds: [ui]
```

Why it exists: `produces_kinds` prunes what a Unit must *write*, but a consume
had no kind axis. A once-per-workflow artifact such as a UI mockup is a real
input of `ui` Units and noise for a `service` Unit — and `upstream-coverage`
threads every consume whose file exists, so the backend Unit's deliverables
were asked to reference mockups they never read, a false `SENSOR_FAILED` on
correct output. With `kinds`, the directive builder (`resolveConsumes`) and the
sensor dispatcher apply the same rule: a Unit whose kind is not listed does not
see the consume; an untagged Unit (no `kind` in the DAG) keeps it, exactly as
`produces_kinds` keeps the full matrix for an untagged Unit. Declaring `kinds`
on a stage that is not per-unit is a schema error, since there would be no
Unit kind to filter on and the list would be silently inert.

### `optional_produces`

A plain kebab-case string list, parallel to `produces:`. It names artifacts
Expand Down
2 changes: 1 addition & 1 deletion tests/.coverage-ratchet.json
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
{
"note": "Committed baseline: covered-unit count per class. The --check ratchet fails CI if any class's covered count DROPS below these numbers without a reviewed deferred entry. Monotonic anti-regression: you can cover more, never silently less. Regenerate with: bun tests/gen-coverage-registry.ts",
"coveredByClass": {
"function": 579,
"function": 580,
"audit": 70,
"scope": 11,
"stage": 11,
Expand Down
34 changes: 31 additions & 3 deletions tests/.coverage-registry.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,9 +20,9 @@
"render-surface": "tui"
},
"counts": {
"total": 1275,
"total": 1276,
"enumeratedByClass": {
"function": 943,
"function": 944,
"audit": 110,
"scope": 11,
"stage": 33,
Expand All @@ -31,7 +31,7 @@
"render-surface": 7
},
"coveredByClass": {
"function": 579,
"function": 580,
"audit": 70,
"scope": 11,
"stage": 11,
Expand Down Expand Up @@ -2833,6 +2833,18 @@
"coveredBy": [],
"status": "UNCOVERED"
},
{
"unitClass": "function",
"unitId": "function:consumeAppliesToKind",
"minMechanism": "none",
"coveredBy": [
{
"file": "tests/unit/t338-consumes-kinds.test.ts",
"mechanism": "cli"
}
],
"status": "covered"
},
{
"unitClass": "function",
"unitId": "function:consumeCopilotConversation",
Expand Down Expand Up @@ -3341,6 +3353,10 @@
"file": "tests/unit/t207-unit-kind-schema.test.ts",
"mechanism": "cli"
},
{
"file": "tests/unit/t338-consumes-kinds.test.ts",
"mechanism": "cli"
},
{
"file": "tests/unit/t64.test.ts",
"mechanism": "none"
Expand Down Expand Up @@ -6644,6 +6660,10 @@
"file": "tests/unit/t207-unit-kind-schema.test.ts",
"mechanism": "cli"
},
{
"file": "tests/unit/t338-consumes-kinds.test.ts",
"mechanism": "cli"
},
{
"file": "tests/unit/t64.test.ts",
"mechanism": "none"
Expand Down Expand Up @@ -13695,6 +13715,10 @@
"file": "tests/unit/t338-ceremony-verb.test.ts",
"mechanism": "cli"
},
{
"file": "tests/unit/t338-consumes-kinds.test.ts",
"mechanism": "cli"
},
{
"file": "tests/unit/t340-default-scope-resolver.test.ts",
"mechanism": "cli"
Expand Down Expand Up @@ -13955,6 +13979,10 @@
{
"file": "tests/integration/t92.test.ts",
"mechanism": "cli"
},
{
"file": "tests/unit/t338-consumes-kinds.test.ts",
"mechanism": "cli"
}
],
"status": "covered"
Expand Down
1 change: 1 addition & 0 deletions tests/unit/gen-coverage-registry.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -947,6 +947,7 @@ describe("mechanismsOf is body-derived (milestone 3)", () => {
// t337 spawns the shipped doctor to pin the "Workspace source boundary
// binds" row, which reads a real source walk against a real workspace.
"unit/t337-source-boundary-reason.test.ts",
"unit/t338-consumes-kinds.test.ts",
"unit/t339-construction-autonomy-gates.test.ts",
"unit/t340-grouped-plan-approval.test.ts",
"unit/t340-kiro-ide-ignore-sources-doctor.test.ts",
Expand Down
Loading
Loading