feat(FN-1567): add mission contract assertions model types and APIs

- Add mission contract assertions model with types: ContractAssertion, ContractAssertionStatus, ContractAssertionType
- Add assertions table to schema v29 with migration from v28
- Implement assertion APIs: create, update, link/unlink to features, list, getWithContext
- Add rollup computation (assertion counts, pass rates) for missions and milestones
- Add many-to-many feature-to-assertion linking via featureAssertions table
- Add project memory documentation for assertion lifecycle and patterns
- Update schema version assertions in db.test.ts, run-audit.test.ts, and task-documents.test.ts
This commit is contained in:
gsxdsm
2026-04-10 21:29:54 -07:00
parent c812bdbc2a
commit e258279a5f
9 changed files with 1312 additions and 27 deletions

View File

@@ -446,18 +446,10 @@ The `@fusion/tui` package provides Ink-based React components for terminal UI.
- Use `setTimeout(resolve, ms)` for async state updates in tests - Use `setTimeout(resolve, ms)` for async state updates in tests
- Track captured handlers via module-level variables for test assertions - Track captured handlers via module-level variables for test assertions
## Kimi/Moonshot API Usage (FN-1578) ## Kimi/Moonshot API Usage
- **Primary endpoint**: `/v1/coding_plan/usage` (underscore) — Codexbar-validated working endpoint. - Kimi usage endpoint uses `/v1/coding_plan/usage` (underscore) as the primary endpoint — this is the Codexbar-validated working endpoint. The hyphen variant (`/v1/coding-plan/usage`) is a legacy fallback that may return 404 `url.not_found` for some accounts.
- **Fallback endpoint**: `/v1/coding-plan/usage` (hyphen) — Legacy endpoint for older accounts/API versions. - When implementing endpoint fallbacks for API providers, test the fallback logic with extra fields in the error payload (e.g., `{"code":5,"error":"url.not_found","message":"没找到对象",...}`) to ensure the fallback trigger remains robust.
- **Fallback trigger**: ANY 404 response triggers fallback (regardless of body content).
- **Auth errors (401/403)**: Short-circuit immediately — no fallback for authentication failures.
- **Known 404 error shapes**:
- `{"code":5,"error":"url.not_found","message":"没找到对象",...}` — endpoint not available (no coding plan active).
- `{"error":"url_not_found"}` — alternative format.
- **User-facing error**: When last endpoint returns `url.not_found`, show friendly message: "Usage endpoint unavailable — Kimi coding plan may not be active on this account".
- **Auth**: Uses `Authorization: Bearer <api_key>` header with `kimi-coding` key from `~/.pi/agent/auth.json`.
- **Response parsing**: Supports `data.windows[]` array and flat `data.used/total/remaining` shapes.
## FN-1516: Periodic Auto-Merge Sweep ## FN-1516: Periodic Auto-Merge Sweep
- The `canAutoMergeTask()` function must be defined locally inside `runDashboard()` to work correctly with Vitest mocks. Module-level exports capture the real `getTaskMergeBlocker` at import time, before mocks are applied. - The `canAutoMergeTask()` function must be defined locally inside `runDashboard()` to work correctly with Vitest mocks. Module-level exports capture the real `getTaskMergeBlocker` at import time, before mocks are applied.
@@ -638,3 +630,21 @@ updatedAt=now WHERE column='done' AND columnMovedAt < cutoff` is safe and
fast — subsequent watch() polls will pick up the changes and emit fast — subsequent watch() polls will pick up the changes and emit
`task:moved` events. (Beware emitting hundreds of events in one cycle if a `task:moved` events. (Beware emitting hundreds of events in one cycle if a
dashboard is connected.) dashboard is connected.)
## FN-1567: Mission Contract Assertions
**Schema version bump pattern:** When adding a new schema migration:
1. Increment `SCHEMA_VERSION` constant in `db.ts`
2. Add `if (version < N)` block with `applyMigration(N, () => { ... })`
3. Use `addColumnIfMissing()` for column additions (idempotent)
4. Use `CREATE TABLE IF NOT EXISTS` for new tables (idempotent)
5. Use `CREATE INDEX IF NOT EXISTS` for indexes (idempotent)
6. Update all schema version assertions in test files (`db.test.ts`, `run-audit.test.ts`, `__tests__/task-documents.test.ts`)
**Optional fields in interfaces:** When adding an optional field to an existing interface (like `validationState?: MilestoneValidationState`), TypeScript may infer `T | undefined` when the field is used in SQL prepared statements. Use type assertions (`as string`) or null coalescing (`?? "default"`) to satisfy SQLite type requirements.
**Deterministic ordering:** Always use `ORDER BY orderIndex ASC, createdAt ASC, id ASC` for listing items within a parent. This ensures stable ordering even when items have identical timestamps.
**Event emitter events:** Add new event types to `MissionStoreEvents` interface and emit them from the appropriate methods. The EventEmitter pattern follows the same conventions as `TaskStore`.
**Many-to-many relationship pattern:** Use a composite primary key `(featureId, assertionId)` to prevent duplicate links. Check for existing links before inserting and throw meaningful errors. Use `hasTable()` guards before creating indexes on new tables.

View File

@@ -51,7 +51,7 @@ describe("TaskStore task documents", () => {
expect(tableNames.has("task_documents")).toBe(true); expect(tableNames.has("task_documents")).toBe(true);
expect(tableNames.has("task_document_revisions")).toBe(true); expect(tableNames.has("task_document_revisions")).toBe(true);
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
const index = db const index = db
.prepare( .prepare(

View File

@@ -112,7 +112,7 @@ describe("Database", () => {
}); });
it("seeds schema version", () => { it("seeds schema version", () => {
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
}); });
it("seeds lastModified", () => { it("seeds lastModified", () => {
@@ -135,7 +135,7 @@ describe("Database", () => {
it("is idempotent - calling init() twice does not fail", () => { it("is idempotent - calling init() twice does not fail", () => {
expect(() => db.init()).not.toThrow(); expect(() => db.init()).not.toThrow();
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
}); });
it("does not overwrite existing config on re-init", () => { it("does not overwrite existing config on re-init", () => {
@@ -741,8 +741,8 @@ describe("schema migrations", () => {
// Now run init() which should trigger migration // Now run init() which should trigger migration
db.init(); db.init();
// Verify version bumped to 28 (includes v1→v2 through v26→v28) // Verify version bumped to 29 (includes v1→v2 through v26→v29)
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -767,11 +767,11 @@ describe("schema migrations", () => {
const db = new Database(kbDir); const db = new Database(kbDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
// Re-init should not fail // Re-init should not fail
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
db.close(); db.close();
}); });
@@ -787,7 +787,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'agentRatings'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "agentRatings" }]); expect(tables).toEqual([{ name: "agentRatings" }]);
@@ -811,7 +811,7 @@ describe("schema migrations", () => {
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>; const tables = db.prepare("SELECT name FROM sqlite_master WHERE type='table' AND name = 'mission_events'").all() as Array<{ name: string }>;
expect(tables).toEqual([{ name: "mission_events" }]); expect(tables).toEqual([{ name: "mission_events" }]);
@@ -914,8 +914,8 @@ describe("schema migrations", () => {
// Now run init() which should trigger migrations v2→v3→v4 // Now run init() which should trigger migrations v2→v3→v4
db.init(); db.init();
// Verify version bumped to 28 // Verify version bumped to 29
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
// Verify new columns exist and existing data is intact // Verify new columns exist and existing data is intact
const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>; const cols = db.prepare("PRAGMA table_info(tasks)").all() as Array<{ name: string }>;
@@ -1281,7 +1281,7 @@ describe("createDatabase factory", () => {
const db = createDatabase(kbDir); const db = createDatabase(kbDir);
db.init(); db.init();
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
expect(db.getLastModified()).toBeGreaterThan(0); expect(db.getLastModified()).toBeGreaterThan(0);
db.close(); db.close();

View File

@@ -59,7 +59,7 @@ export function fromJson<T>(json: string | null | undefined): T | undefined {
// ── Schema Definition ──────────────────────────────────────────────── // ── Schema Definition ────────────────────────────────────────────────
const SCHEMA_VERSION = 28; const SCHEMA_VERSION = 29;
function normalizeTaskComments( function normalizeTaskComments(
steeringComments: SteeringComment[] | undefined, steeringComments: SteeringComment[] | undefined,
@@ -1031,6 +1031,55 @@ export class Database {
} }
}); });
} }
// Mission contract assertions (FN-1567)
// Adds explicit validation contract model for milestone behavioral assertions
// with feature linkage tracking and validation state rollup.
if (version < 29) {
this.applyMigration(29, () => {
// Add validationState column to milestones table
this.addColumnIfMissing("milestones", "validationState", "TEXT NOT NULL DEFAULT 'not_started'");
// Create mission_contract_assertions table for milestone validation contracts
this.db.exec(`
CREATE TABLE IF NOT EXISTS mission_contract_assertions (
id TEXT PRIMARY KEY,
milestoneId TEXT NOT NULL,
title TEXT NOT NULL,
assertion TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'pending',
orderIndex INTEGER NOT NULL DEFAULT 0,
createdAt TEXT NOT NULL,
updatedAt TEXT NOT NULL,
FOREIGN KEY (milestoneId) REFERENCES milestones(id) ON DELETE CASCADE
)
`);
// Create mission_feature_assertions link table for many-to-many relationships
this.db.exec(`
CREATE TABLE IF NOT EXISTS mission_feature_assertions (
featureId TEXT NOT NULL,
assertionId TEXT NOT NULL,
createdAt TEXT NOT NULL,
PRIMARY KEY (featureId, assertionId),
FOREIGN KEY (featureId) REFERENCES mission_features(id) ON DELETE CASCADE,
FOREIGN KEY (assertionId) REFERENCES mission_contract_assertions(id) ON DELETE CASCADE
)
`);
// Index for deterministic ordering when listing assertions for a milestone
// Covers: WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC
this.db.exec(`CREATE INDEX IF NOT EXISTS idxContractAssertionsMilestoneOrder ON mission_contract_assertions(milestoneId, orderIndex, createdAt, id)`);
// Index for finding all assertions linked to a feature
// Covers: WHERE featureId = ? (from mission_feature_assertions)
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFeatureAssertionsFeatureId ON mission_feature_assertions(featureId)`);
// Index for finding all features linked to an assertion
// Covers: WHERE assertionId = ? (from mission_feature_assertions)
this.db.exec(`CREATE INDEX IF NOT EXISTS idxFeatureAssertionsAssertionId ON mission_feature_assertions(assertionId)`);
});
}
} }
/** /**

View File

@@ -213,6 +213,22 @@ export type {
FeatureEventPayload, FeatureEventPayload,
FeatureDeletedPayload, FeatureDeletedPayload,
FeatureLinkedPayload, FeatureLinkedPayload,
// Contract assertion types
MISSION_ASSERTION_STATUSES,
MILESTONE_VALIDATION_STATES,
MissionAssertionStatus,
MilestoneValidationState,
MissionContractAssertion,
FeatureAssertionLink,
MilestoneValidationRollup,
ContractAssertionCreateInput,
ContractAssertionUpdateInput,
AssertionCreatedPayload,
AssertionUpdatedPayload,
AssertionDeletedPayload,
AssertionLinkedPayload,
AssertionUnlinkedPayload,
MilestoneValidationUpdatedPayload,
} from "./mission-types.js"; } from "./mission-types.js";
export { MissionStore } from "./mission-store.js"; export { MissionStore } from "./mission-store.js";
export type { MissionStoreEvents, MissionSummary } from "./mission-store.js"; export type { MissionStoreEvents, MissionSummary } from "./mission-store.js";

View File

@@ -1917,6 +1917,542 @@ describe("MissionStore", () => {
expect(updatedF1.taskId).toBeTruthy(); expect(updatedF1.taskId).toBeTruthy();
}); });
}); });
// ── Contract Assertion Tests ────────────────────────────────────────
describe("Contract Assertions", () => {
let mission: ReturnType<typeof store.createMission>;
let milestone: ReturnType<typeof store.addMilestone>;
beforeEach(() => {
mission = store.createMission({ title: "Test Mission" });
milestone = store.addMilestone(mission.id, { title: "Test Milestone" });
});
it("creates an assertion with correct defaults", () => {
const assertion = store.addContractAssertion(milestone.id, {
title: "Auth works",
assertion: "Users can log in and log out",
});
expect(assertion.id).toMatch(/^CA-/);
expect(assertion.milestoneId).toBe(milestone.id);
expect(assertion.title).toBe("Auth works");
expect(assertion.assertion).toBe("Users can log in and log out");
expect(assertion.status).toBe("pending");
expect(assertion.orderIndex).toBe(0);
expect(assertion.createdAt).toBeTruthy();
expect(assertion.updatedAt).toBeTruthy();
});
it("creates assertions with auto-incrementing orderIndex", () => {
const a1 = store.addContractAssertion(milestone.id, {
title: "First",
assertion: "First assertion",
});
const a2 = store.addContractAssertion(milestone.id, {
title: "Second",
assertion: "Second assertion",
});
const a3 = store.addContractAssertion(milestone.id, {
title: "Third",
assertion: "Third assertion",
});
expect(a1.orderIndex).toBe(0);
expect(a2.orderIndex).toBe(1);
expect(a3.orderIndex).toBe(2);
});
it("lists assertions in deterministic order", () => {
store.addContractAssertion(milestone.id, {
title: "First",
assertion: "First assertion",
});
store.addContractAssertion(milestone.id, {
title: "Second",
assertion: "Second assertion",
});
const assertions = store.listContractAssertions(milestone.id);
expect(assertions).toHaveLength(2);
expect(assertions[0].title).toBe("First");
expect(assertions[1].title).toBe("Second");
});
it("gets an assertion by id", () => {
const created = store.addContractAssertion(milestone.id, {
title: "Get Test",
assertion: "Test assertion",
});
const retrieved = store.getContractAssertion(created.id);
expect(retrieved).toBeDefined();
expect(retrieved!.id).toBe(created.id);
expect(retrieved!.title).toBe("Get Test");
});
it("returns undefined for non-existent assertion", () => {
const result = store.getContractAssertion("CA-NONEXISTENT");
expect(result).toBeUndefined();
});
it("updates an assertion", () => {
const assertion = store.addContractAssertion(milestone.id, {
title: "Original",
assertion: "Original assertion",
});
const updated = store.updateContractAssertion(assertion.id, {
title: "Updated",
status: "passed",
});
expect(updated.id).toBe(assertion.id);
expect(updated.title).toBe("Updated");
expect(updated.status).toBe("passed");
expect(updated.assertion).toBe("Original assertion"); // unchanged
});
it("updates assertion status", () => {
const assertion = store.addContractAssertion(milestone.id, {
title: "Status Test",
assertion: "Test",
status: "pending",
});
const passed = store.updateContractAssertion(assertion.id, { status: "passed" });
expect(passed.status).toBe("passed");
const failed = store.updateContractAssertion(assertion.id, { status: "failed" });
expect(failed.status).toBe("failed");
const blocked = store.updateContractAssertion(assertion.id, { status: "blocked" });
expect(blocked.status).toBe("blocked");
});
it("deletes an assertion", () => {
const assertion = store.addContractAssertion(milestone.id, {
title: "Delete Test",
assertion: "Test",
});
store.deleteContractAssertion(assertion.id);
const retrieved = store.getContractAssertion(assertion.id);
expect(retrieved).toBeUndefined();
});
it("reorders assertions", () => {
const a1 = store.addContractAssertion(milestone.id, { title: "A", assertion: "A" });
const a2 = store.addContractAssertion(milestone.id, { title: "B", assertion: "B" });
const a3 = store.addContractAssertion(milestone.id, { title: "C", assertion: "C" });
store.reorderContractAssertions(milestone.id, [a3.id, a1.id, a2.id]);
const assertions = store.listContractAssertions(milestone.id);
expect(assertions[0].id).toBe(a3.id);
expect(assertions[1].id).toBe(a1.id);
expect(assertions[2].id).toBe(a2.id);
});
it("throws when reordering with non-existent assertion", () => {
expect(() =>
store.reorderContractAssertions(milestone.id, ["CA-NONEXISTENT"])
).toThrow("Assertion CA-NONEXISTENT not found");
});
it("throws when reordering assertion from different milestone", async () => {
const milestone2 = store.addMilestone(mission.id, { title: "Milestone 2" });
const a1 = store.addContractAssertion(milestone.id, { title: "A", assertion: "A" });
const a2 = store.addContractAssertion(milestone2.id, { title: "B", assertion: "B" });
expect(() =>
store.reorderContractAssertions(milestone.id, [a1.id, a2.id])
).toThrow(`Assertion ${a2.id} does not belong to milestone ${milestone.id}`);
});
it("emits assertion:created event", () => {
const events: any[] = [];
store.on("assertion:created", (a) => events.push(a));
const assertion = store.addContractAssertion(milestone.id, {
title: "Event Test",
assertion: "Test",
});
expect(events).toHaveLength(1);
expect(events[0].id).toBe(assertion.id);
});
it("emits assertion:updated event", () => {
const events: any[] = [];
store.on("assertion:updated", (a) => events.push(a));
const assertion = store.addContractAssertion(milestone.id, {
title: "Event Test",
assertion: "Test",
});
store.updateContractAssertion(assertion.id, { status: "passed" });
expect(events).toHaveLength(1);
expect(events[0].status).toBe("passed");
});
it("emits assertion:deleted event", () => {
const events: any[] = [];
store.on("assertion:deleted", (id) => events.push(id));
const assertion = store.addContractAssertion(milestone.id, {
title: "Event Test",
assertion: "Test",
});
store.deleteContractAssertion(assertion.id);
expect(events).toHaveLength(1);
expect(events[0]).toBe(assertion.id);
});
it("throws when creating assertion for non-existent milestone", () => {
expect(() =>
store.addContractAssertion("MS-NONEXISTENT", {
title: "Test",
assertion: "Test",
})
).toThrow("Milestone MS-NONEXISTENT not found");
});
});
// ── Feature-Assertion Link Tests ───────────────────────────────────
describe("Feature-Assertion Links", () => {
let mission: ReturnType<typeof store.createMission>;
let milestone: ReturnType<typeof store.addMilestone>;
let slice: ReturnType<typeof store.addSlice>;
let feature: ReturnType<typeof store.addFeature>;
let assertion: ReturnType<typeof store.addContractAssertion>;
beforeEach(() => {
mission = store.createMission({ title: "Test Mission" });
milestone = store.addMilestone(mission.id, { title: "Test Milestone" });
slice = store.addSlice(milestone.id, { title: "Test Slice" });
feature = store.addFeature(slice.id, { title: "Test Feature" });
assertion = store.addContractAssertion(milestone.id, {
title: "Test Assertion",
assertion: "Test assertion content",
});
});
it("links a feature to an assertion", () => {
store.linkFeatureToAssertion(feature.id, assertion.id);
const linkedAssertions = store.listAssertionsForFeature(feature.id);
expect(linkedAssertions).toHaveLength(1);
expect(linkedAssertions[0].id).toBe(assertion.id);
});
it("lists assertions for a feature", () => {
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "A1" });
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "A2" });
store.linkFeatureToAssertion(feature.id, a1.id);
store.linkFeatureToAssertion(feature.id, a2.id);
const linked = store.listAssertionsForFeature(feature.id);
expect(linked).toHaveLength(2);
expect(linked.map((a) => a.title).sort()).toEqual(["A1", "A2"]);
});
it("lists features for an assertion", () => {
const f2 = store.addFeature(slice.id, { title: "Feature 2" });
const f3 = store.addFeature(slice.id, { title: "Feature 3" });
store.linkFeatureToAssertion(feature.id, assertion.id);
store.linkFeatureToAssertion(f2.id, assertion.id);
store.linkFeatureToAssertion(f3.id, assertion.id);
const linked = store.listFeaturesForAssertion(assertion.id);
expect(linked).toHaveLength(3);
});
it("unlinks a feature from an assertion", () => {
store.linkFeatureToAssertion(feature.id, assertion.id);
store.unlinkFeatureFromAssertion(feature.id, assertion.id);
const linked = store.listAssertionsForFeature(feature.id);
expect(linked).toHaveLength(0);
});
it("throws when linking already-linked feature-assertion pair", () => {
store.linkFeatureToAssertion(feature.id, assertion.id);
expect(() =>
store.linkFeatureToAssertion(feature.id, assertion.id)
).toThrow("Feature " + feature.id + " is already linked to assertion " + assertion.id);
});
it("throws when unlinking non-existent link", () => {
expect(() =>
store.unlinkFeatureFromAssertion(feature.id, assertion.id)
).toThrow("Feature " + feature.id + " is not linked to assertion " + assertion.id);
});
it("throws when linking non-existent feature", () => {
expect(() =>
store.linkFeatureToAssertion("F-NONEXISTENT", assertion.id)
).toThrow("Feature F-NONEXISTENT not found");
});
it("throws when linking to non-existent assertion", () => {
expect(() =>
store.linkFeatureToAssertion(feature.id, "CA-NONEXISTENT")
).toThrow("Assertion CA-NONEXISTENT not found");
});
it("emits assertion:linked event", () => {
const events: any[] = [];
store.on("assertion:linked", (e) => events.push(e));
store.linkFeatureToAssertion(feature.id, assertion.id);
expect(events).toHaveLength(1);
expect(events[0].featureId).toBe(feature.id);
expect(events[0].assertionId).toBe(assertion.id);
});
it("emits assertion:unlinked event", () => {
store.linkFeatureToAssertion(feature.id, assertion.id);
const events: any[] = [];
store.on("assertion:unlinked", (e) => events.push(e));
store.unlinkFeatureFromAssertion(feature.id, assertion.id);
expect(events).toHaveLength(1);
expect(events[0].featureId).toBe(feature.id);
expect(events[0].assertionId).toBe(assertion.id);
});
});
// ── Validation Rollup Tests ─────────────────────────────────────────
describe("Validation Rollup", () => {
let mission: ReturnType<typeof store.createMission>;
let milestone: ReturnType<typeof store.addMilestone>;
beforeEach(() => {
mission = store.createMission({ title: "Test Mission" });
milestone = store.addMilestone(mission.id, { title: "Test Milestone" });
});
it("rolls up not_started when no assertions exist", () => {
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.milestoneId).toBe(milestone.id);
expect(rollup.totalAssertions).toBe(0);
expect(rollup.passedAssertions).toBe(0);
expect(rollup.failedAssertions).toBe(0);
expect(rollup.blockedAssertions).toBe(0);
expect(rollup.pendingAssertions).toBe(0);
expect(rollup.unlinkedAssertions).toBe(0);
expect(rollup.state).toBe("not_started");
});
it("rolls up needs_coverage when assertions are not linked", () => {
store.addContractAssertion(milestone.id, {
title: "A1",
assertion: "Test",
});
store.addContractAssertion(milestone.id, {
title: "A2",
assertion: "Test",
});
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.totalAssertions).toBe(2);
expect(rollup.unlinkedAssertions).toBe(2);
expect(rollup.state).toBe("needs_coverage");
});
it("rolls up ready when assertions are linked but not all passed", () => {
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
store.linkFeatureToAssertion(feature.id, a1.id);
store.linkFeatureToAssertion(feature.id, a2.id);
store.updateContractAssertion(a1.id, { status: "passed" });
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.totalAssertions).toBe(2);
expect(rollup.passedAssertions).toBe(1);
expect(rollup.unlinkedAssertions).toBe(0);
expect(rollup.state).toBe("ready");
});
it("rolls up passed when all assertions are passed", () => {
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
store.linkFeatureToAssertion(feature.id, a1.id);
store.linkFeatureToAssertion(feature.id, a2.id);
store.updateContractAssertion(a1.id, { status: "passed" });
store.updateContractAssertion(a2.id, { status: "passed" });
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.state).toBe("passed");
});
it("rolls up failed when any assertion has failed status", () => {
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
const [a1] = store.listContractAssertions(milestone.id);
store.updateContractAssertion(a1.id, { status: "failed" });
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.state).toBe("failed");
expect(rollup.failedAssertions).toBe(1);
});
it("rolls up blocked when any assertion is blocked (before failed)", () => {
const a1 = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
store.updateContractAssertion(a1.id, { status: "failed" });
const a2 = store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
store.updateContractAssertion(a2.id, { status: "blocked" });
// Failed takes precedence over blocked in the precedence order
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.state).toBe("failed");
});
it("rolls up blocked when no failures but has blocked", () => {
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
const [a1] = store.listContractAssertions(milestone.id);
store.updateContractAssertion(a1.id, { status: "blocked" });
const rollup = store.getMilestoneValidationRollup(milestone.id);
expect(rollup.state).toBe("blocked");
expect(rollup.blockedAssertions).toBe(1);
});
it("persists validation state on milestone after assertion change", () => {
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
store.addContractAssertion(milestone.id, { title: "A2", assertion: "T" });
// Initial state should be needs_coverage
let m = store.getMilestone(milestone.id)!;
expect(m.validationState).toBe("needs_coverage");
// Link all assertions
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
const assertions = store.listContractAssertions(milestone.id);
for (const a of assertions) {
store.linkFeatureToAssertion(feature.id, a.id);
}
// After linking, state should be ready
m = store.getMilestone(milestone.id)!;
expect(m.validationState).toBe("ready");
});
it("emits milestone:validation:updated when assertions change", () => {
const events: any[] = [];
store.on("milestone:validation:updated", (e) => events.push(e));
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
expect(events).toHaveLength(1);
expect(events[0].milestoneId).toBe(milestone.id);
expect(events[0].state).toBe("needs_coverage");
expect(events[0].rollup.totalAssertions).toBe(1);
});
it("emits milestone:validation:updated when links change", () => {
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
const assertion = store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
const events: any[] = [];
store.on("milestone:validation:updated", (e) => events.push(e));
store.linkFeatureToAssertion(feature.id, assertion.id);
// Should emit twice: once from assertion add, once from link
expect(events.length).toBeGreaterThanOrEqual(1);
expect(events[events.length - 1].state).toBe("ready"); // linked but not passed
expect(events[events.length - 1].rollup.unlinkedAssertions).toBe(0);
});
});
// ── buildEnrichedDescription with Assertions Tests ────────────────────
describe("buildEnrichedDescription with Assertions", () => {
it("includes linked assertions in enriched description", () => {
const mission = store.createMission({ title: "Auth Mission" });
const milestone = store.addMilestone(mission.id, { title: "Core Auth" });
const slice = store.addSlice(milestone.id, { title: "Login" });
const feature = store.addFeature(slice.id, {
title: "Login Form",
description: "The login form component",
});
const a1 = store.addContractAssertion(milestone.id, {
title: "Validates input",
assertion: "The form must validate email and password fields",
});
const a2 = store.addContractAssertion(milestone.id, {
title: "Shows errors",
assertion: "Invalid credentials must show an error message",
});
store.linkFeatureToAssertion(feature.id, a1.id);
store.linkFeatureToAssertion(feature.id, a2.id);
const description = store.buildEnrichedDescription(feature.id);
expect(description).toContain("## Mission: Auth Mission");
expect(description).toContain("## Milestone: Core Auth");
expect(description).toContain("## Slice: Login");
expect(description).toContain("## Feature: Login Form");
expect(description).toContain("The login form component");
expect(description).toContain("## Contract Assertions");
expect(description).toContain("Validates input");
expect(description).toContain("Shows errors");
expect(description).toContain("The form must validate email and password fields");
});
it("does not include Contract Assertions section when no assertions linked", () => {
const mission = store.createMission({ title: "Test Mission" });
const milestone = store.addMilestone(mission.id, { title: "Milestone" });
const slice = store.addSlice(milestone.id, { title: "Slice" });
const feature = store.addFeature(slice.id, { title: "Feature" });
// Create assertions but don't link them
store.addContractAssertion(milestone.id, { title: "A1", assertion: "T" });
const description = store.buildEnrichedDescription(feature.id);
expect(description).toContain("## Feature: Feature");
expect(description).not.toContain("## Contract Assertions");
});
});
}); });
// vi import for vitest mocking // vi import for vitest mocking

View File

@@ -34,6 +34,12 @@ import type {
MissionEventType, MissionEventType,
MissionHealth, MissionHealth,
SlicePlanState, SlicePlanState,
MissionContractAssertion,
FeatureAssertionLink,
MilestoneValidationRollup,
ContractAssertionCreateInput,
ContractAssertionUpdateInput,
MilestoneValidationState,
} from "./mission-types.js"; } from "./mission-types.js";
// ── Mission Summary Type ───────────────────────────────────────────── // ── Mission Summary Type ─────────────────────────────────────────────
@@ -85,6 +91,18 @@ export interface MissionStoreEvents {
"feature:linked": [{ feature: MissionFeature; taskId: string }]; "feature:linked": [{ feature: MissionFeature; taskId: string }];
/** Emitted when a mission lifecycle event is persisted */ /** Emitted when a mission lifecycle event is persisted */
"mission:event": [MissionEvent]; "mission:event": [MissionEvent];
/** Emitted when a contract assertion is created */
"assertion:created": [MissionContractAssertion];
/** Emitted when a contract assertion is updated */
"assertion:updated": [MissionContractAssertion];
/** Emitted when a contract assertion is deleted */
"assertion:deleted": [string];
/** Emitted when a feature is linked to an assertion */
"assertion:linked": [{ featureId: string; assertionId: string }];
/** Emitted when a feature is unlinked from an assertion */
"assertion:unlinked": [{ featureId: string; assertionId: string }];
/** Emitted when a milestone's validation state is recomputed */
"milestone:validation:updated": [{ milestoneId: string; state: MilestoneValidationState; rollup: MilestoneValidationRollup }];
} }
// ── MissionStore Class ────────────────────────────────────────────── // ── MissionStore Class ──────────────────────────────────────────────
@@ -149,11 +167,39 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
dependencies: fromJson<string[]>(row.dependencies) || [], dependencies: fromJson<string[]>(row.dependencies) || [],
planningNotes: row.planningNotes || undefined, planningNotes: row.planningNotes || undefined,
verification: row.verification || undefined, verification: row.verification || undefined,
validationState: (row.validationState as MilestoneValidationState) || "not_started",
createdAt: row.createdAt, createdAt: row.createdAt,
updatedAt: row.updatedAt, updatedAt: row.updatedAt,
}; };
} }
/**
* Convert a database row to a MissionContractAssertion object.
*/
private rowToAssertion(row: any): MissionContractAssertion {
return {
id: row.id,
milestoneId: row.milestoneId,
title: row.title,
assertion: row.assertion,
status: row.status as import("./mission-types.js").MissionAssertionStatus,
orderIndex: row.orderIndex,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
};
}
/**
* Convert a database row to a FeatureAssertionLink object.
*/
private rowToFeatureAssertionLink(row: any): FeatureAssertionLink {
return {
featureId: row.featureId,
assertionId: row.assertionId,
createdAt: row.createdAt,
};
}
/** /**
* Convert a database row to a Slice object. * Convert a database row to a Slice object.
*/ */
@@ -884,13 +930,14 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
dependencies: input.dependencies || [], dependencies: input.dependencies || [],
planningNotes: input.planningNotes, planningNotes: input.planningNotes,
verification: input.verification, verification: input.verification,
validationState: "not_started",
createdAt: now, createdAt: now,
updatedAt: now, updatedAt: now,
}; };
this.db.prepare(` this.db.prepare(`
INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, createdAt, updatedAt) INSERT INTO milestones (id, missionId, title, description, status, orderIndex, interviewState, dependencies, planningNotes, verification, validationState, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
`).run( `).run(
milestone.id, milestone.id,
milestone.missionId, milestone.missionId,
@@ -902,6 +949,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
toJson(milestone.dependencies), toJson(milestone.dependencies),
milestone.planningNotes ?? null, milestone.planningNotes ?? null,
milestone.verification ?? null, milestone.verification ?? null,
milestone.validationState as string,
milestone.createdAt, milestone.createdAt,
milestone.updatedAt, milestone.updatedAt,
); );
@@ -969,6 +1017,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
dependencies = ?, dependencies = ?,
planningNotes = ?, planningNotes = ?,
verification = ?, verification = ?,
validationState = ?,
updatedAt = ? updatedAt = ?
WHERE id = ? WHERE id = ?
`).run( `).run(
@@ -980,6 +1029,7 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
toJson(updated.dependencies), toJson(updated.dependencies),
updated.planningNotes ?? null, updated.planningNotes ?? null,
updated.verification ?? null, updated.verification ?? null,
updated.validationState || "not_started",
updated.updatedAt, updated.updatedAt,
updated.id, updated.id,
); );
@@ -1617,6 +1667,443 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
return this.rowToFeature(row); return this.rowToFeature(row);
} }
// ── Contract Assertion Operations ─────────────────────────────────
/**
* Add a contract assertion to a milestone.
* Automatically computes the orderIndex (max + 1).
*
* ## Assertion Lifecycle
*
* Assertions transition through these statuses:
* - `pending` — Initial state, assertion has not been validated
* - `passed` — Assertion has been validated and passed
* - `failed` — Assertion has been validated and failed
* - `blocked` — Assertion cannot be validated due to external blockers
*
* Status transitions are managed by calling `updateContractAssertion()` with
* the appropriate status value.
*
* @param milestoneId - Parent milestone ID
* @param input - Assertion creation input
* @returns The created assertion
* @throws Error if milestone not found
*/
addContractAssertion(milestoneId: string, input: ContractAssertionCreateInput): MissionContractAssertion {
const milestone = this.getMilestone(milestoneId);
if (!milestone) {
throw new Error(`Milestone ${milestoneId} not found`);
}
const now = new Date().toISOString();
const id = this.generateAssertionId();
// Compute next orderIndex
const existingAssertions = this.listContractAssertions(milestoneId);
const orderIndex = existingAssertions.length > 0
? Math.max(...existingAssertions.map((a) => a.orderIndex)) + 1
: 0;
const assertion: MissionContractAssertion = {
id,
milestoneId,
title: input.title,
assertion: input.assertion,
status: input.status || "pending",
orderIndex,
createdAt: now,
updatedAt: now,
};
this.db.prepare(`
INSERT INTO mission_contract_assertions (id, milestoneId, title, assertion, status, orderIndex, createdAt, updatedAt)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
`).run(
assertion.id,
assertion.milestoneId,
assertion.title,
assertion.assertion,
assertion.status,
assertion.orderIndex,
assertion.createdAt,
assertion.updatedAt,
);
this.db.bumpLastModified();
this.emit("assertion:created", assertion);
// Recompute milestone validation state
this.recomputeMilestoneValidation(milestoneId);
return assertion;
}
/**
* Get a contract assertion by ID.
*
* @param id - Assertion ID
* @returns The assertion, or undefined if not found
*/
getContractAssertion(id: string): MissionContractAssertion | undefined {
const row = this.db.prepare("SELECT * FROM mission_contract_assertions WHERE id = ?").get(id);
if (!row) return undefined;
return this.rowToAssertion(row);
}
/**
* List contract assertions for a milestone, ordered by orderIndex ASC, createdAt ASC, id ASC.
*
* This ordering is deterministic even when multiple assertions share the same
* orderIndex or createdAt timestamp.
*
* @param milestoneId - Milestone ID
* @returns Array of assertions
*/
listContractAssertions(milestoneId: string): MissionContractAssertion[] {
const rows = this.db.prepare(
"SELECT * FROM mission_contract_assertions WHERE milestoneId = ? ORDER BY orderIndex ASC, createdAt ASC, id ASC"
).all(milestoneId);
return (rows as any[]).map((row) => this.rowToAssertion(row));
}
/**
* Update a contract assertion.
*
* @param id - Assertion ID
* @param updates - Partial assertion updates
* @returns The updated assertion
* @throws Error if assertion not found
*/
updateContractAssertion(id: string, updates: ContractAssertionUpdateInput): MissionContractAssertion {
const assertion = this.getContractAssertion(id);
if (!assertion) {
throw new Error(`Assertion ${id} not found`);
}
const now = new Date().toISOString();
const updated: MissionContractAssertion = {
...assertion,
title: updates.title ?? assertion.title,
assertion: updates.assertion ?? assertion.assertion,
status: updates.status ?? assertion.status,
updatedAt: now,
};
this.db.prepare(`
UPDATE mission_contract_assertions SET
title = ?,
assertion = ?,
status = ?,
updatedAt = ?
WHERE id = ?
`).run(
updated.title,
updated.assertion,
updated.status,
updated.updatedAt,
updated.id,
);
this.db.bumpLastModified();
this.emit("assertion:updated", updated);
// Recompute milestone validation state
this.recomputeMilestoneValidation(updated.milestoneId);
return updated;
}
/**
* Delete a contract assertion.
*
* @param id - Assertion ID
* @throws Error if assertion not found
*/
deleteContractAssertion(id: string): void {
const assertion = this.getContractAssertion(id);
if (!assertion) {
throw new Error(`Assertion ${id} not found`);
}
const milestoneId = assertion.milestoneId;
this.db.prepare("DELETE FROM mission_contract_assertions WHERE id = ?").run(id);
this.db.bumpLastModified();
this.emit("assertion:deleted", id);
// Recompute milestone validation state
this.recomputeMilestoneValidation(milestoneId);
}
/**
* Reorder contract assertions within a milestone.
*
* @param milestoneId - Milestone ID
* @param orderedIds - Assertion IDs in the desired order
* @throws Error if any assertion is not found or belongs to a different milestone
*/
reorderContractAssertions(milestoneId: string, orderedIds: string[]): void {
this.db.transaction(() => {
for (let i = 0; i < orderedIds.length; i++) {
const id = orderedIds[i];
const assertion = this.getContractAssertion(id);
if (!assertion) {
throw new Error(`Assertion ${id} not found`);
}
if (assertion.milestoneId !== milestoneId) {
throw new Error(`Assertion ${id} does not belong to milestone ${milestoneId}`);
}
this.db.prepare(
"UPDATE mission_contract_assertions SET orderIndex = ?, updatedAt = ? WHERE id = ?"
).run(i, new Date().toISOString(), id);
}
});
this.db.bumpLastModified();
}
// ── Feature-Assertion Link Operations ──────────────────────────────
/**
* Link a feature to a contract assertion.
*
* ## Linkage Cardinality
*
* The feature-assertion relationship is many-to-many:
* - One feature can satisfy multiple assertions (e.g., a login feature covers
* "validates input", "shows errors", and "authenticates users")
* - One assertion can be covered by multiple features (e.g., "security check"
* requires both the auth module and the session module)
*
* Links are stored in the `mission_feature_assertions` table with a composite
* primary key of (featureId, assertionId) to prevent duplicate links.
*
* @param featureId - Feature ID
* @param assertionId - Assertion ID
* @throws Error if feature or assertion not found, or if link already exists
*/
linkFeatureToAssertion(featureId: string, assertionId: string): void {
const feature = this.getFeature(featureId);
if (!feature) {
throw new Error(`Feature ${featureId} not found`);
}
const assertion = this.getContractAssertion(assertionId);
if (!assertion) {
throw new Error(`Assertion ${assertionId} not found`);
}
// Check if link already exists
const existing = this.db.prepare(
"SELECT 1 FROM mission_feature_assertions WHERE featureId = ? AND assertionId = ?"
).get(featureId, assertionId);
if (existing) {
throw new Error(`Feature ${featureId} is already linked to assertion ${assertionId}`);
}
const now = new Date().toISOString();
this.db.prepare(
"INSERT INTO mission_feature_assertions (featureId, assertionId, createdAt) VALUES (?, ?, ?)"
).run(featureId, assertionId, now);
this.db.bumpLastModified();
this.emit("assertion:linked", { featureId, assertionId });
// Recompute milestone validation state
this.recomputeMilestoneValidation(assertion.milestoneId);
}
/**
* Unlink a feature from a contract assertion.
*
* @param featureId - Feature ID
* @param assertionId - Assertion ID
* @throws Error if link not found
*/
unlinkFeatureFromAssertion(featureId: string, assertionId: string): void {
const existing = this.db.prepare(
"SELECT 1 FROM mission_feature_assertions WHERE featureId = ? AND assertionId = ?"
).get(featureId, assertionId);
if (!existing) {
throw new Error(`Feature ${featureId} is not linked to assertion ${assertionId}`);
}
this.db.prepare(
"DELETE FROM mission_feature_assertions WHERE featureId = ? AND assertionId = ?"
).run(featureId, assertionId);
this.db.bumpLastModified();
this.emit("assertion:unlinked", { featureId, assertionId });
// Recompute milestone validation state for the assertion's milestone
const assertion = this.getContractAssertion(assertionId);
if (assertion) {
this.recomputeMilestoneValidation(assertion.milestoneId);
}
}
/**
* List all assertions linked to a feature.
*
* @param featureId - Feature ID
* @returns Array of linked assertions
*/
listAssertionsForFeature(featureId: string): MissionContractAssertion[] {
const rows = this.db.prepare(`
SELECT ca.* FROM mission_contract_assertions ca
INNER JOIN mission_feature_assertions fa ON ca.id = fa.assertionId
WHERE fa.featureId = ?
ORDER BY ca.orderIndex ASC, ca.createdAt ASC, ca.id ASC
`).all(featureId);
return (rows as any[]).map((row) => this.rowToAssertion(row));
}
/**
* List all features linked to an assertion.
*
* @param assertionId - Assertion ID
* @returns Array of linked features
*/
listFeaturesForAssertion(assertionId: string): MissionFeature[] {
const rows = this.db.prepare(`
SELECT mf.* FROM mission_features mf
INNER JOIN mission_feature_assertions fa ON mf.id = fa.featureId
WHERE fa.assertionId = ?
ORDER BY mf.createdAt ASC
`).all(assertionId);
return (rows as any[]).map((row) => this.rowToFeature(row));
}
// ── Validation Rollup Operations ───────────────────────────────────
/**
* Get the validation rollup for a milestone.
* This is a denormalized snapshot that includes counts and computed state.
*
* ## Rollup Precedence
*
* The validation state is computed with the following precedence order:
*
* 1. `not_started` — Milestone has no assertions
* 2. `failed` — Any assertion has `failed` status
* 3. `blocked` — Any assertion has `blocked` status (only checked if no failures)
* 4. `needs_coverage` — Assertions exist but some are not linked to features
* 5. `passed` — All assertions have `passed` status
* 6. `ready` — Assertions exist and are linked, but not all have passed
*
* This precedence ensures that:
* - A milestone with no assertions shows `not_started`
* - Failed assertions immediately mark the milestone as `failed`
* - Blocked assertions take precedence over `needs_coverage` but not `failed`
* - Unlinked assertions require attention before validation can complete
* - A milestone only shows `passed` when all assertions pass
*
* The rollup state is automatically persisted to the milestone when assertions
* or links change, via `recomputeMilestoneValidation()`.
*
* @param milestoneId - Milestone ID
* @returns The validation rollup
* @throws Error if milestone not found
*/
getMilestoneValidationRollup(milestoneId: string): MilestoneValidationRollup {
const milestone = this.getMilestone(milestoneId);
if (!milestone) {
throw new Error(`Milestone ${milestoneId} not found`);
}
const assertions = this.listContractAssertions(milestoneId);
const totalAssertions = assertions.length;
// Count by status
let passedAssertions = 0;
let failedAssertions = 0;
let blockedAssertions = 0;
let pendingAssertions = 0;
let unlinkedAssertions = 0;
for (const assertion of assertions) {
switch (assertion.status) {
case "passed":
passedAssertions++;
break;
case "failed":
failedAssertions++;
break;
case "blocked":
blockedAssertions++;
break;
case "pending":
pendingAssertions++;
break;
}
// Check if assertion is linked to any feature
const linkedFeatures = this.listFeaturesForAssertion(assertion.id);
if (linkedFeatures.length === 0) {
unlinkedAssertions++;
}
}
// Compute validation state with exact precedence:
// 1. totalAssertions === 0 → not_started
// 2. failedAssertions > 0 → failed
// 3. blockedAssertions > 0 → blocked
// 4. unlinkedAssertions > 0 → needs_coverage
// 5. passedAssertions === totalAssertions → passed
// 6. otherwise → ready
let state: MilestoneValidationState;
if (totalAssertions === 0) {
state = "not_started";
} else if (failedAssertions > 0) {
state = "failed";
} else if (blockedAssertions > 0) {
state = "blocked";
} else if (unlinkedAssertions > 0) {
state = "needs_coverage";
} else if (passedAssertions === totalAssertions) {
state = "passed";
} else {
state = "ready";
}
return {
milestoneId,
totalAssertions,
passedAssertions,
failedAssertions,
blockedAssertions,
pendingAssertions,
unlinkedAssertions,
state,
};
}
/**
* Recompute and persist the milestone's validation state.
* This is called automatically after assertion or link changes.
*/
private recomputeMilestoneValidation(milestoneId: string): void {
const rollup = this.getMilestoneValidationRollup(milestoneId);
const now = new Date().toISOString();
this.db.prepare(
"UPDATE milestones SET validationState = ?, updatedAt = ? WHERE id = ?"
).run(rollup.state, now, milestoneId);
this.db.bumpLastModified();
this.emit("milestone:validation:updated", {
milestoneId,
state: rollup.state,
rollup,
});
}
// ── Triage Operations ──────────────────────────────────────────────── // ── Triage Operations ────────────────────────────────────────────────
/** /**
@@ -1629,6 +2116,9 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
* - Slice: title, description, verification criteria, planning notes * - Slice: title, description, verification criteria, planning notes
* - Feature: description and acceptance criteria * - Feature: description and acceptance criteria
* *
* When contract assertions are linked to the feature, they are also included
* in the output to provide explicit validation criteria for implementation.
*
* Only non-empty fields are included in the output. This provides AI agents * Only non-empty fields are included in the output. This provides AI agents
* with full context for making informed decisions during task implementation. * with full context for making informed decisions during task implementation.
* *
@@ -1700,6 +2190,20 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
} }
sections.push(featureSections.join("\n")); sections.push(featureSections.join("\n"));
// Contract assertions context (only if linked to this feature)
const linkedAssertions = this.listAssertionsForFeature(featureId);
if (linkedAssertions.length > 0) {
const assertionSections: string[] = [`## Contract Assertions`];
for (const assertion of linkedAssertions) {
const statusIcon = assertion.status === "passed" ? "✅" :
assertion.status === "failed" ? "❌" :
assertion.status === "blocked" ? "🚫" : "⏳";
assertionSections.push(`### ${statusIcon} ${assertion.title}`);
assertionSections.push(assertion.assertion);
}
sections.push(assertionSections.join("\n\n"));
}
return sections.join("\n\n"); return sections.join("\n\n");
} }
@@ -1979,4 +2483,10 @@ export class MissionStore extends EventEmitter<MissionStoreEvents> {
const random = Math.random().toString(36).substring(2, 6).toUpperCase(); const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `ME-${timestamp.toString(36).toUpperCase()}-${random}`; return `ME-${timestamp.toString(36).toUpperCase()}-${random}`;
} }
private generateAssertionId(): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 6).toUpperCase();
return `CA-${timestamp.toString(36).toUpperCase()}-${random}`;
}
} }

View File

@@ -159,6 +159,8 @@ export interface Milestone {
planningNotes?: string; planningNotes?: string;
/** How to verify milestone completion */ /** How to verify milestone completion */
verification?: string; verification?: string;
/** Computed validation state from contract assertions (optional, always populated by MissionStore) */
validationState?: MilestoneValidationState;
/** ISO-8601 timestamp of creation */ /** ISO-8601 timestamp of creation */
createdAt: string; createdAt: string;
/** ISO-8601 timestamp of last update */ /** ISO-8601 timestamp of last update */
@@ -347,3 +349,165 @@ export interface FeatureLinkedPayload {
/** ID of the task it was linked to */ /** ID of the task it was linked to */
taskId: string; taskId: string;
} }
// ── Contract Assertion Types ────────────────────────────────────────
/**
* Status values for a contract assertion's validation state.
*
* Assertions represent explicit behavioral tests or requirements that can be
* validated. They are linked to milestones and optionally to features,
* enabling milestone validation rollup.
*/
export const MISSION_ASSERTION_STATUSES = ["pending", "passed", "failed", "blocked"] as const;
export type MissionAssertionStatus = (typeof MISSION_ASSERTION_STATUSES)[number];
/**
* Validation states for a milestone's contract coverage.
*
* The validation state is computed from the milestone's assertions and is
* persisted on the milestone for efficient querying without rollup recalculation.
*
* Precedence (evaluated in order):
* 1. `not_started` — milestone has no assertions
* 2. `failed` — any assertion has failed
* 3. `blocked` — any assertion is blocked
* 4. `needs_coverage` — assertions exist but some are not linked to features
* 5. `passed` — all assertions have passed
* 6. `ready` — assertions exist and are linked, but not all have passed
*/
export const MILESTONE_VALIDATION_STATES = [
"not_started",
"needs_coverage",
"ready",
"passed",
"failed",
"blocked",
] as const;
export type MilestoneValidationState = (typeof MILESTONE_VALIDATION_STATES)[number];
/**
* A contract assertion represents an explicit behavioral test or requirement
* associated with a milestone. Assertions can be linked to features to track
* coverage and validation status.
*/
export interface MissionContractAssertion {
/** Unique identifier (e.g., "CA-A3B7CD-E9F2") */
id: string;
/** Parent milestone ID */
milestoneId: string;
/** Human-readable title describing the assertion */
title: string;
/** The behavioral specification or acceptance test content */
assertion: string;
/** Current validation status */
status: MissionAssertionStatus;
/** Order index for sorting within the milestone (0-based) */
orderIndex: number;
/** ISO-8601 timestamp of creation */
createdAt: string;
/** ISO-8601 timestamp of last update */
updatedAt: string;
}
/**
* A feature-assertion link represents the association between a feature
* and a contract assertion. This is a many-to-many relationship:
* - One feature can satisfy multiple assertions
* - One assertion can be covered by multiple features
*/
export interface FeatureAssertionLink {
/** The linked feature ID */
featureId: string;
/** The linked assertion ID */
assertionId: string;
/** ISO-8601 timestamp when the link was created */
createdAt: string;
}
/**
* Computed validation rollup for a milestone's contract assertions.
* This is a denormalized snapshot persisted on the milestone.
*/
export interface MilestoneValidationRollup {
/** The milestone this rollup belongs to */
milestoneId: string;
/** Total number of assertions */
totalAssertions: number;
/** Number of assertions in passed status */
passedAssertions: number;
/** Number of assertions in failed status */
failedAssertions: number;
/** Number of assertions in blocked status */
blockedAssertions: number;
/** Number of assertions in pending status */
pendingAssertions: number;
/** Number of assertions not linked to any feature */
unlinkedAssertions: number;
/** The computed validation state */
state: MilestoneValidationState;
}
/**
* Input for creating a new contract assertion.
*/
export interface ContractAssertionCreateInput {
/** Human-readable title (required) */
title: string;
/** The behavioral specification or acceptance test content (required) */
assertion: string;
/** Initial status, defaults to "pending" */
status?: MissionAssertionStatus;
}
/**
* Input for updating a contract assertion.
*/
export interface ContractAssertionUpdateInput {
/** Human-readable title */
title?: string;
/** The behavioral specification */
assertion?: string;
/** Validation status */
status?: MissionAssertionStatus;
}
/** Payload for assertion:created event */
export type AssertionCreatedPayload = MissionContractAssertion;
/** Payload for assertion:updated event */
export type AssertionUpdatedPayload = MissionContractAssertion;
/** Payload for assertion:deleted event */
export interface AssertionDeletedPayload {
/** ID of the deleted assertion */
assertionId: string;
/** Parent milestone ID at time of deletion */
milestoneId: string;
}
/** Payload for assertion:linked event */
export interface AssertionLinkedPayload {
/** The feature ID */
featureId: string;
/** The assertion ID */
assertionId: string;
}
/** Payload for assertion:unlinked event */
export interface AssertionUnlinkedPayload {
/** The feature ID */
featureId: string;
/** The assertion ID */
assertionId: string;
}
/** Payload for milestone:validation:updated event */
export interface MilestoneValidationUpdatedPayload {
/** The milestone ID */
milestoneId: string;
/** The new validation state */
state: MilestoneValidationState;
/** The full validation rollup snapshot */
rollup: MilestoneValidationRollup;
}

View File

@@ -465,7 +465,7 @@ describe("Run Audit", () => {
}); });
it("schema version is bumped to 28", () => { it("schema version is bumped to 28", () => {
expect(db.getSchemaVersion()).toBe(28); expect(db.getSchemaVersion()).toBe(29);
}); });
}); });
}); });