@coordiation/agent 0.1.0 → 1.0.0-rc.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1751 @@
1
+ import { createHash } from "node:crypto";
2
+ import { constants } from "node:fs";
3
+ import { access, mkdir, readFile, readdir, writeFile } from "node:fs/promises";
4
+ import { basename, dirname, join, relative, resolve, sep } from "node:path";
5
+
6
+ export const LIFECYCLE_SCHEMA_VERSION = "1.0";
7
+ export const LIFECYCLE_STAGES = ["specification", "prd", "ux", "prototype", "development", "qa", "release", "production"];
8
+ export const LIFECYCLE_GATES = {
9
+ specification: "GATE-SPEC",
10
+ prd: "GATE-PRD",
11
+ ux: "GATE-UX",
12
+ prototype: "GATE-PROTOTYPE",
13
+ development: "GATE-DEVELOPMENT",
14
+ qa: "GATE-QA",
15
+ release: "GATE-RELEASE",
16
+ production: "GATE-PRODUCTION",
17
+ };
18
+
19
+ const COORDIATION_DIRECTORY = ".coordiation";
20
+ const ARTIFACT_DIRECTORY = "artifacts";
21
+ const PROJECT_PATH = `${COORDIATION_DIRECTORY}/project.json`;
22
+ const LIFECYCLE_PATH = `${COORDIATION_DIRECTORY}/lifecycle.json`;
23
+ const TRACEABILITY_PATH = `${COORDIATION_DIRECTORY}/traceability.json`;
24
+ const APPROVALS_PATH = `${COORDIATION_DIRECTORY}/approvals.json`;
25
+ const EVIDENCE_PATH = `${COORDIATION_DIRECTORY}/evidence.json`;
26
+
27
+ const loginQuestions = [
28
+ {
29
+ id: "audience",
30
+ priority: "blocking",
31
+ question: "Who will use this login?",
32
+ why: "The audience determines account rules, language, security expectations, and where users go next.",
33
+ choices: [
34
+ { value: "customers", label: "Existing customers", effect: "Use a customer-facing flow and familiar recovery language.", recommended: true },
35
+ { value: "staff", label: "Internal staff", effect: "Expect organization-managed accounts and stricter access controls." },
36
+ { value: "both", label: "Customers and staff", effect: "The product may need separate entry paths or role-aware routing." },
37
+ ],
38
+ recommendedValue: "customers",
39
+ example: "Existing customers who already have an account.",
40
+ },
41
+ {
42
+ id: "method",
43
+ priority: "blocking",
44
+ question: "Which sign-in method should be available?",
45
+ why: "This changes the form, backend integration, recovery flow, and security behavior.",
46
+ choices: [
47
+ { value: "email-password", label: "Email and password", effect: "Familiar flow that requires password recovery.", recommended: true },
48
+ { value: "magic-link", label: "Magic link", effect: "No password field, but reliable email delivery is required." },
49
+ { value: "social", label: "Google or social login", effect: "Requires a third-party identity provider." },
50
+ { value: "existing-provider", label: "Existing authentication provider", effect: "Coordiation follows the provider contract already used by the project." },
51
+ ],
52
+ recommendedValue: "email-password",
53
+ example: "Email and password through our existing authentication API.",
54
+ },
55
+ {
56
+ id: "destination",
57
+ priority: "blocking",
58
+ question: "Where should users go after a successful login?",
59
+ why: "A clear destination makes success behavior testable and prevents ambiguous redirects.",
60
+ choices: [
61
+ { value: "/dashboard", label: "Main dashboard", effect: "Every successful login opens the primary workspace.", recommended: true },
62
+ { value: "original-page", label: "Originally requested page", effect: "Users continue the task that first required authentication." },
63
+ { value: "/account", label: "Account page", effect: "Users land on their profile or account overview." },
64
+ ],
65
+ recommendedValue: "/dashboard",
66
+ example: "Take successful users to /dashboard.",
67
+ },
68
+ {
69
+ id: "recovery",
70
+ priority: "recommended",
71
+ question: "Should the login include account recovery?",
72
+ why: "Password-based users need a safe way to regain access without support intervention.",
73
+ choices: [
74
+ { value: "forgot-password", label: "Forgot-password link", effect: "Adds a visible recovery path.", recommended: true },
75
+ { value: "support-only", label: "Contact support", effect: "Recovery is handled manually outside this page." },
76
+ { value: "not-needed", label: "No recovery", effect: "Suitable only when the selected authentication method has no password." },
77
+ ],
78
+ recommendedValue: "forgot-password",
79
+ example: "Show a forgot-password link below the password field.",
80
+ },
81
+ {
82
+ id: "registration",
83
+ priority: "recommended",
84
+ question: "Can new users create an account from this page?",
85
+ why: "Registration changes scope, fields, verification, legal consent, and navigation.",
86
+ choices: [
87
+ { value: "out-of-scope", label: "No, login only", effect: "Keeps this release focused on existing users.", recommended: true },
88
+ { value: "link-only", label: "Link to registration", effect: "The login page links to a separate registration flow." },
89
+ { value: "same-flow", label: "Login and registration together", effect: "Requires a larger combined authentication flow." },
90
+ ],
91
+ recommendedValue: "out-of-scope",
92
+ example: "Registration is out of scope for this page.",
93
+ },
94
+ {
95
+ id: "mfa",
96
+ priority: "recommended",
97
+ question: "Is multi-factor authentication required in this release?",
98
+ why: "MFA adds a second verification step and additional success, error, recovery, and trusted-device states.",
99
+ choices: [
100
+ { value: "not-in-first-release", label: "Not in the first release", effect: "Keep the initial login flow smaller and record MFA as future scope.", recommended: true },
101
+ { value: "required", label: "Required for everyone", effect: "Every successful credential check continues to a second factor." },
102
+ { value: "role-based", label: "Required for selected roles", effect: "The backend must return a role-aware MFA requirement." },
103
+ ],
104
+ recommendedValue: "not-in-first-release",
105
+ example: "MFA is not required in the first release.",
106
+ },
107
+ {
108
+ id: "auth-service",
109
+ priority: "blocking",
110
+ question: "What authentication service or API should the page use?",
111
+ why: "Development cannot safely invent endpoints, session rules, or provider configuration.",
112
+ choices: [
113
+ { value: "existing-api", label: "Existing project API", effect: "The implementation uses the current backend contract.", recommended: true },
114
+ { value: "provider-sdk", label: "Existing provider SDK", effect: "The implementation follows the installed identity provider." },
115
+ { value: "not-decided", label: "Not decided yet", effect: "The PRD remains draft and development cannot pass its gate." },
116
+ ],
117
+ recommendedValue: "existing-api",
118
+ example: "Use the existing POST /api/auth/login endpoint and current session cookie.",
119
+ },
120
+ {
121
+ id: "brand-tone",
122
+ priority: "optional",
123
+ question: "What visual tone should the login use?",
124
+ why: "A visual direction helps the UX and prototype stages choose suitable typography, spacing, and imagery.",
125
+ choices: [
126
+ { value: "current-system", label: "Current product design system", effect: "Reuse existing tokens and components.", recommended: true },
127
+ { value: "minimal", label: "Minimal and neutral", effect: "Use a quiet, focused form with little decoration." },
128
+ { value: "brand-led", label: "Strong brand presence", effect: "Use recognizable brand color, message, and imagery." },
129
+ ],
130
+ recommendedValue: "current-system",
131
+ example: "Use our current monochrome Coordiation design system.",
132
+ },
133
+ {
134
+ id: "success-check",
135
+ priority: "blocking",
136
+ question: "How will the team know the login works correctly?",
137
+ why: "A concrete success check becomes acceptance criteria and QA evidence.",
138
+ choices: [
139
+ { value: "redirect-and-session", label: "Redirect plus authenticated session", effect: "QA verifies both navigation and persisted authenticated state.", recommended: true },
140
+ { value: "redirect-only", label: "Successful redirect", effect: "QA checks navigation but does not prove session persistence." },
141
+ { value: "api-success", label: "Successful API response", effect: "QA validates the backend response but still needs a UI outcome." },
142
+ ],
143
+ recommendedValue: "redirect-and-session",
144
+ example: "Valid credentials create a session and redirect to /dashboard.",
145
+ },
146
+ ];
147
+
148
+ function genericFeatureQuestions(capability) {
149
+ return [
150
+ {
151
+ id: "outcome",
152
+ priority: "blocking",
153
+ question: `What should people accomplish with this ${capability}?`,
154
+ why: "The user outcome keeps the product contract focused on a useful result instead of a collection of UI elements.",
155
+ choices: [
156
+ { value: "complete-primary-task", label: "Complete one primary task", effect: "The experience is optimized around one clear action.", recommended: true },
157
+ { value: "review-and-decide", label: "Review information and decide", effect: "The experience prioritizes comparison, context, and decision support." },
158
+ { value: "manage-existing-data", label: "Manage existing information", effect: "The experience prioritizes finding, editing, and organizing records." },
159
+ ],
160
+ recommendedValue: "complete-primary-task",
161
+ example: `People can complete the main ${capability} task without needing support.`,
162
+ },
163
+ {
164
+ id: "audience",
165
+ priority: "blocking",
166
+ question: `Who is the primary user of this ${capability}?`,
167
+ why: "The primary user determines language, permissions, complexity, and accessibility expectations.",
168
+ choices: [
169
+ { value: "customers", label: "Customers or public users", effect: "Use approachable language and avoid internal assumptions.", recommended: true },
170
+ { value: "staff", label: "Internal staff", effect: "The flow may use organization roles and operational terminology." },
171
+ { value: "administrators", label: "Administrators", effect: "The contract must include elevated permissions and safer destructive actions." },
172
+ { value: "multiple-roles", label: "Several user roles", effect: "Role-specific visibility and permissions become explicit requirements." },
173
+ ],
174
+ recommendedValue: "customers",
175
+ example: "Existing customers using the product on mobile and desktop.",
176
+ },
177
+ {
178
+ id: "primary-action",
179
+ priority: "blocking",
180
+ question: "What is the single most important action on this experience?",
181
+ why: "Naming one primary action creates a testable happy path and prevents competing calls to action.",
182
+ choices: [
183
+ { value: "submit", label: "Submit information", effect: "The contract centers on form completion and confirmation.", recommended: true },
184
+ { value: "select", label: "Find and select something", effect: "Search, filtering, comparison, and selection become central." },
185
+ { value: "review", label: "Review status or information", effect: "Hierarchy, freshness, and next steps become central." },
186
+ { value: "contact", label: "Start a conversation", effect: "The contract centers on a contact, booking, or inquiry path." },
187
+ ],
188
+ recommendedValue: "submit",
189
+ example: "The user submits the form and receives a clear confirmation.",
190
+ },
191
+ {
192
+ id: "entry-point",
193
+ priority: "recommended",
194
+ question: "How do users arrive at this experience?",
195
+ why: "Entry context determines navigation, prerequisite state, and what the page must explain.",
196
+ choices: [
197
+ { value: "main-navigation", label: "Main product navigation", effect: "The experience can rely on the product shell and signed-in context.", recommended: true },
198
+ { value: "direct-link", label: "Direct or shared link", effect: "The experience must stand on its own and handle missing context." },
199
+ { value: "previous-step", label: "Previous workflow step", effect: "The contract preserves data and progress from an earlier step." },
200
+ ],
201
+ recommendedValue: "main-navigation",
202
+ example: "Users open it from the main navigation after signing in.",
203
+ },
204
+ {
205
+ id: "data-source",
206
+ priority: "blocking",
207
+ question: "Where does the experience read or save its data?",
208
+ why: "An implementation cannot responsibly invent API fields, persistence rules, or ownership of sensitive data.",
209
+ choices: [
210
+ { value: "existing-api", label: "Existing project API", effect: "Development follows the current backend contract.", recommended: true },
211
+ { value: "local-static", label: "Local or static content", effect: "No remote persistence is required for the first release." },
212
+ { value: "third-party", label: "Third-party service", effect: "Credentials, limits, errors, and provider ownership must be documented." },
213
+ { value: "not-decided", label: "Not decided yet", effect: "The specification remains draft and cannot pass its gate." },
214
+ ],
215
+ recommendedValue: "existing-api",
216
+ example: "Use the existing project API and authenticated session.",
217
+ },
218
+ {
219
+ id: "success-behavior",
220
+ priority: "blocking",
221
+ question: "What should users see immediately after the primary action succeeds?",
222
+ why: "A visible success outcome becomes part of the acceptance and QA contract.",
223
+ choices: [
224
+ { value: "confirmation", label: "Confirmation in the same experience", effect: "Show a clear success state without changing location.", recommended: true },
225
+ { value: "detail-view", label: "Open the resulting detail view", effect: "Navigate to the created or selected record." },
226
+ { value: "next-step", label: "Continue to the next step", effect: "Move forward in a multi-step workflow." },
227
+ ],
228
+ recommendedValue: "confirmation",
229
+ example: "Show a confirmation with the saved result and the safest next action.",
230
+ },
231
+ {
232
+ id: "edge-states",
233
+ priority: "recommended",
234
+ question: "Which non-happy states must the first release handle?",
235
+ why: "Loading, empty, invalid, denied, and unavailable states are part of a complete product—not optional polish.",
236
+ choices: [
237
+ { value: "standard-complete", label: "Loading, empty, invalid, and unavailable", effect: "Covers the minimum complete state set.", recommended: true },
238
+ { value: "permission-aware", label: "Standard states plus permission denied", effect: "Adds role and access handling." },
239
+ { value: "offline-aware", label: "Standard states plus offline recovery", effect: "Adds connection-loss and retry behavior." },
240
+ ],
241
+ recommendedValue: "standard-complete",
242
+ example: "Handle loading, no data, validation failure, and service unavailable.",
243
+ },
244
+ {
245
+ id: "experience-constraints",
246
+ priority: "recommended",
247
+ question: "Which experience constraints are required?",
248
+ why: "Responsive and accessible behavior must be contractual so they survive design and implementation changes.",
249
+ choices: [
250
+ { value: "responsive-accessible", label: "Responsive and accessible", effect: "Require 320px support, keyboard completion, visible focus, labels, and announced status.", recommended: true },
251
+ { value: "desktop-accessible", label: "Desktop-first and accessible", effect: "Mobile optimization is deferred but accessibility remains required." },
252
+ { value: "project-contract", label: "Use the existing project contract", effect: "Inherit documented platform, localization, and accessibility requirements." },
253
+ ],
254
+ recommendedValue: "responsive-accessible",
255
+ example: "Support 320px upward, keyboard use, screen readers, and reduced motion.",
256
+ },
257
+ {
258
+ id: "verification",
259
+ priority: "blocking",
260
+ question: "How will the team verify that this feature is useful and working?",
261
+ why: "Verification turns an intention into acceptance criteria and concrete QA evidence.",
262
+ choices: [
263
+ { value: "outcome-and-state", label: "Primary outcome plus persisted state", effect: "QA verifies both the visible result and the underlying saved state.", recommended: true },
264
+ { value: "task-completion", label: "Successful task completion", effect: "QA verifies the happy path and required error recovery." },
265
+ { value: "analytics-event", label: "Task completion plus analytics event", effect: "QA also verifies a measurable product signal." },
266
+ ],
267
+ recommendedValue: "outcome-and-state",
268
+ example: "The primary task completes, the result persists after refresh, and required error states are recoverable.",
269
+ },
270
+ ];
271
+ }
272
+
273
+ function defineBuiltinTemplate({ id, intent, capability, aliases = [] }) {
274
+ return {
275
+ id,
276
+ intent,
277
+ capability,
278
+ aliases,
279
+ artifactStrategy: "generic-feature",
280
+ questions: genericFeatureQuestions(capability),
281
+ maxQuestionsPerTurn: 3,
282
+ };
283
+ }
284
+
285
+ const capabilityTemplateRegistry = new Map();
286
+
287
+ export function registerCapabilityTemplate(template, { overwrite = false } = {}) {
288
+ if (!template || typeof template !== "object") throw new Error("A capability template object is required.");
289
+ const id = slug(template.id);
290
+ if (!id || !template.intent || !Array.isArray(template.questions) || template.questions.length === 0) throw new Error("A capability template requires id, intent, and questions.");
291
+ const maxQuestionsPerTurn = template.maxQuestionsPerTurn ?? 3;
292
+ if (!Number.isInteger(maxQuestionsPerTurn) || maxQuestionsPerTurn < 1 || maxQuestionsPerTurn > 3) throw new Error("Capability templates may ask between one and three questions per turn.");
293
+ const questionIds = new Set();
294
+ for (const question of template.questions) {
295
+ if (!question.id || !question.question || !question.why || !question.example || !question.recommendedValue) throw new Error(`Every ${id} question requires id, question, why, example, and recommendedValue.`);
296
+ if (questionIds.has(question.id)) throw new Error(`Duplicate ${id} question id: ${question.id}.`);
297
+ questionIds.add(question.id);
298
+ if (!Array.isArray(question.choices) || question.choices.length < 2 || question.choices.length > 4) throw new Error(`${id}.${question.id} requires two to four answer choices.`);
299
+ if (!question.choices.some(({ value }) => value === question.recommendedValue)) throw new Error(`${id}.${question.id} recommendation must match one of its choices.`);
300
+ }
301
+ const strategy = template.artifactStrategy ?? "generic-feature";
302
+ const requiredQuestionIds = strategy === "login"
303
+ ? ["audience", "method", "destination", "recovery", "registration", "mfa", "auth-service", "brand-tone", "success-check"]
304
+ : ["outcome", "audience", "primary-action", "entry-point", "data-source", "success-behavior", "edge-states", "experience-constraints", "verification"];
305
+ const missing = requiredQuestionIds.filter((questionId) => !questionIds.has(questionId));
306
+ if (missing.length) throw new Error(`${id} is missing required ${strategy} questions: ${missing.join(", ")}.`);
307
+ if (capabilityTemplateRegistry.has(id) && !overwrite) throw new Error(`Capability template already registered: ${id}.`);
308
+ const normalized = { ...template, id, artifactStrategy: strategy, aliases: [...new Set((template.aliases ?? []).map(slug))], maxQuestionsPerTurn };
309
+ capabilityTemplateRegistry.set(id, normalized);
310
+ return normalized;
311
+ }
312
+
313
+ registerCapabilityTemplate({
314
+ id: "login",
315
+ intent: "Create a complete login experience",
316
+ capability: "login experience",
317
+ aliases: ["login-page", "sign-in", "signin"],
318
+ artifactStrategy: "login",
319
+ questions: loginQuestions,
320
+ maxQuestionsPerTurn: 3,
321
+ });
322
+
323
+ for (const template of [
324
+ { id: "custom-feature", intent: "Define a custom product feature", capability: "product feature", aliases: ["feature", "custom"] },
325
+ { id: "dashboard", intent: "Create a useful dashboard", capability: "dashboard", aliases: ["overview", "analytics-dashboard"] },
326
+ { id: "landing-page", intent: "Create a focused landing page", capability: "landing page", aliases: ["landing", "marketing-page"] },
327
+ { id: "checkout", intent: "Create a recoverable checkout", capability: "checkout", aliases: ["payment", "purchase"] },
328
+ { id: "profile", intent: "Create a profile experience", capability: "profile experience", aliases: ["account-profile"] },
329
+ { id: "settings", intent: "Create a safe settings experience", capability: "settings experience", aliases: ["preferences"] },
330
+ { id: "search", intent: "Create a useful search experience", capability: "search experience", aliases: ["find"] },
331
+ { id: "upload", intent: "Create a reliable upload experience", capability: "upload experience", aliases: ["file-upload"] },
332
+ { id: "notifications", intent: "Create a manageable notification experience", capability: "notification experience", aliases: ["notification"] },
333
+ ]) registerCapabilityTemplate(defineBuiltinTemplate(template));
334
+
335
+ export const interviewTemplates = Object.fromEntries([...capabilityTemplateRegistry.values()].flatMap((template) => [[template.id, template], ...template.aliases.map((alias) => [alias, template])]));
336
+
337
+ export function listCapabilityTemplates() {
338
+ return [...capabilityTemplateRegistry.values()].map(({ id, intent, capability, aliases, questions, maxQuestionsPerTurn }) => ({ id, intent, capability, aliases, questionCount: questions.length, maxQuestionsPerTurn }));
339
+ }
340
+
341
+ export function resolveCapabilityTemplate(intent) {
342
+ const requested = String(intent ?? "").trim();
343
+ if (!requested) throw new Error("A capability intent is required.");
344
+ const normalized = slug(requested);
345
+ for (const template of capabilityTemplateRegistry.values()) {
346
+ if (template.id === normalized || template.aliases.includes(normalized)) return template;
347
+ }
348
+ const detected = [...capabilityTemplateRegistry.values()].find((template) => [template.id, ...template.aliases].some((candidate) => normalized.includes(candidate)));
349
+ if (detected) return detected;
350
+ return defineBuiltinTemplate({ id: normalized, intent: `Define ${requested}`, capability: requested, aliases: [] });
351
+ }
352
+
353
+ async function exists(pathname) {
354
+ try { await access(pathname, constants.F_OK); return true; } catch { return false; }
355
+ }
356
+
357
+ function projectPath(cwd, pathname) {
358
+ const root = resolve(cwd);
359
+ const destination = resolve(root, pathname);
360
+ if (destination !== root && !destination.startsWith(`${root}${sep}`)) throw new Error(`Lifecycle path leaves the project: ${pathname}`);
361
+ return destination;
362
+ }
363
+
364
+ async function readJson(filename) {
365
+ return JSON.parse(await readFile(filename, "utf8"));
366
+ }
367
+
368
+ async function writeJson(filename, value) {
369
+ await mkdir(dirname(filename), { recursive: true });
370
+ await writeFile(filename, `${JSON.stringify(value, null, 2)}\n`);
371
+ }
372
+
373
+ function now(clock) {
374
+ return (clock?.() ?? new Date()).toISOString();
375
+ }
376
+
377
+ function slug(value) {
378
+ return String(value ?? "coordiation-product").trim().toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || "coordiation-product";
379
+ }
380
+
381
+ function relativePath(cwd, filename) {
382
+ return relative(resolve(cwd), filename).split(sep).join("/");
383
+ }
384
+
385
+ function stageDirectory(stage) {
386
+ return stage === "specification" ? "specification" : stage;
387
+ }
388
+
389
+ export async function initializeLifecycleProject({ cwd = process.cwd(), name, ownerName, ownerEmail, clock } = {}) {
390
+ const root = resolve(cwd);
391
+ const projectFile = projectPath(root, PROJECT_PATH);
392
+ if (await exists(projectFile)) {
393
+ const evidenceFile = projectPath(root, EVIDENCE_PATH);
394
+ if (!(await exists(evidenceFile))) await writeJson(evidenceFile, { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-evidence", records: [], updatedAt: now(clock) });
395
+ else {
396
+ const ledger = await readJson(evidenceFile);
397
+ let migrated = false;
398
+ for (const record of ledger.records ?? []) {
399
+ if (!Number.isInteger(record.artifactRevision)) {
400
+ record.artifactRevision = 1;
401
+ migrated = true;
402
+ }
403
+ }
404
+ if (migrated) {
405
+ ledger.updatedAt = now(clock);
406
+ await writeJson(evidenceFile, ledger);
407
+ }
408
+ }
409
+ return { created: false, project: await readJson(projectFile), paths: [PROJECT_PATH, LIFECYCLE_PATH, TRACEABILITY_PATH, APPROVALS_PATH, EVIDENCE_PATH] };
410
+ }
411
+ const timestamp = now(clock);
412
+ const projectName = name || basename(root);
413
+ const owner = { name: ownerName || "Project owner", email: ownerEmail || null, role: "owner" };
414
+ const project = {
415
+ schemaVersion: LIFECYCLE_SCHEMA_VERSION,
416
+ kind: "coordiation-product-project",
417
+ id: slug(projectName),
418
+ name: projectName,
419
+ artifactFormat: "canonical-json-with-generated-markdown",
420
+ owners: [owner],
421
+ createdAt: timestamp,
422
+ updatedAt: timestamp,
423
+ };
424
+ const lifecycle = {
425
+ schemaVersion: LIFECYCLE_SCHEMA_VERSION,
426
+ kind: "coordiation-lifecycle",
427
+ currentStage: "specification",
428
+ stages: LIFECYCLE_STAGES.map((stage, index) => ({ id: stage, order: index + 1, status: index === 0 ? "active" : "pending", gate: LIFECYCLE_GATES[stage] })),
429
+ gates: LIFECYCLE_STAGES.map((stage) => ({ id: LIFECYCLE_GATES[stage], stage, status: "pending", approvalId: null })),
430
+ updatedAt: timestamp,
431
+ };
432
+ const traceability = { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-traceability", nodes: [], edges: [], updatedAt: timestamp };
433
+ const approvals = { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-approvals", approvals: [], updatedAt: timestamp };
434
+ const evidence = { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-evidence", records: [], updatedAt: timestamp };
435
+ await Promise.all([
436
+ writeJson(projectFile, project),
437
+ writeJson(projectPath(root, LIFECYCLE_PATH), lifecycle),
438
+ writeJson(projectPath(root, TRACEABILITY_PATH), traceability),
439
+ writeJson(projectPath(root, APPROVALS_PATH), approvals),
440
+ writeJson(projectPath(root, EVIDENCE_PATH), evidence),
441
+ ...LIFECYCLE_STAGES.map((stage) => mkdir(projectPath(root, join(ARTIFACT_DIRECTORY, stageDirectory(stage))), { recursive: true })),
442
+ mkdir(projectPath(root, join(COORDIATION_DIRECTORY, "interviews")), { recursive: true }),
443
+ mkdir(projectPath(root, join(COORDIATION_DIRECTORY, "context")), { recursive: true }),
444
+ ]);
445
+ return { created: true, project, paths: [PROJECT_PATH, LIFECYCLE_PATH, TRACEABILITY_PATH, APPROVALS_PATH, EVIDENCE_PATH] };
446
+ }
447
+
448
+ async function requireInitialized(cwd) {
449
+ const projectFile = projectPath(cwd, PROJECT_PATH);
450
+ if (!(await exists(projectFile))) throw new Error("Coordiation lifecycle is not initialized. Run `coordiation init` first.");
451
+ }
452
+
453
+ async function artifactFiles(cwd) {
454
+ const files = [];
455
+ for (const stage of LIFECYCLE_STAGES) {
456
+ const directory = projectPath(cwd, join(ARTIFACT_DIRECTORY, stageDirectory(stage)));
457
+ let entries = [];
458
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch { continue; }
459
+ for (const entry of entries) if (entry.isFile() && entry.name.endsWith(".json")) files.push(join(directory, entry.name));
460
+ }
461
+ return files.sort();
462
+ }
463
+
464
+ export async function getLifecycleStatus({ cwd = process.cwd() } = {}) {
465
+ await requireInitialized(cwd);
466
+ const [project, lifecycle, approvals, files] = await Promise.all([
467
+ readJson(projectPath(cwd, PROJECT_PATH)),
468
+ readJson(projectPath(cwd, LIFECYCLE_PATH)),
469
+ readJson(projectPath(cwd, APPROVALS_PATH)),
470
+ artifactFiles(cwd),
471
+ ]);
472
+ const artifacts = await Promise.all(files.map((file) => readJson(file)));
473
+ return {
474
+ schemaVersion: LIFECYCLE_SCHEMA_VERSION,
475
+ kind: "coordiation-lifecycle-status",
476
+ project: { id: project.id, name: project.name },
477
+ currentStage: lifecycle.currentStage,
478
+ stages: lifecycle.stages,
479
+ gates: lifecycle.gates,
480
+ artifactCount: artifacts.length,
481
+ artifacts: artifacts.map(({ id, kind, stage, status, revision }) => ({ id, kind, stage, status, revision })),
482
+ approvalCount: approvals.approvals.length,
483
+ };
484
+ }
485
+
486
+ function publicQuestion(question) {
487
+ return {
488
+ id: question.id,
489
+ priority: question.priority,
490
+ question: question.question,
491
+ why: question.why,
492
+ choices: question.choices,
493
+ recommendedValue: question.recommendedValue,
494
+ example: question.example,
495
+ customAnswerAllowed: true,
496
+ unknownUsesRecommendation: true,
497
+ };
498
+ }
499
+
500
+ function normalizeAnswer(question, answer) {
501
+ const value = String(answer ?? "").trim();
502
+ if (!value) throw new Error(`Answer for ${question.id} cannot be empty.`);
503
+ if (["recommended", "recommendation", "default", "i don't know", "i dont know", "tidak tahu", "gunakan rekomendasi"].includes(value.toLowerCase())) {
504
+ return { value: question.recommendedValue, source: "user-confirmed-recommendation" };
505
+ }
506
+ return { value, source: "user" };
507
+ }
508
+
509
+ function answerValue(session, id) {
510
+ return session.answers[id]?.value;
511
+ }
512
+
513
+ function artifactEnvelope({ id, kind, title, stage, timestamp, body, derivedFrom = [], acceptanceCriteria = [] }) {
514
+ return {
515
+ schemaVersion: LIFECYCLE_SCHEMA_VERSION,
516
+ id,
517
+ kind,
518
+ title,
519
+ stage,
520
+ status: "draft",
521
+ revision: 1,
522
+ owners: ["product"],
523
+ derivedFrom,
524
+ acceptanceCriteria,
525
+ dependencies: [],
526
+ risks: [],
527
+ evidence: [],
528
+ approvals: [],
529
+ body,
530
+ createdAt: timestamp,
531
+ updatedAt: timestamp,
532
+ };
533
+ }
534
+
535
+ function markdownArtifact(artifact) {
536
+ const lines = [
537
+ `# ${artifact.id} — ${artifact.title}`,
538
+ "",
539
+ `Status: ${artifact.status}`,
540
+ "",
541
+ `Revision: ${artifact.revision}`,
542
+ "",
543
+ `Stage: ${artifact.stage}`,
544
+ "",
545
+ ];
546
+ for (const [key, value] of Object.entries(artifact.body)) {
547
+ lines.push(`## ${key.replace(/([A-Z])/g, " $1").replace(/^./, (character) => character.toUpperCase())}`, "");
548
+ if (Array.isArray(value)) for (const item of value) lines.push(`- ${typeof item === "string" ? item : JSON.stringify(item)}`);
549
+ else lines.push(String(value));
550
+ lines.push("");
551
+ }
552
+ if (artifact.acceptanceCriteria.length) {
553
+ lines.push("## Acceptance criteria", "");
554
+ for (const criterion of artifact.acceptanceCriteria) lines.push(`- ${criterion}`);
555
+ lines.push("");
556
+ }
557
+ return `${lines.join("\n").trim()}\n`;
558
+ }
559
+
560
+ async function writeArtifact(cwd, artifact) {
561
+ const base = projectPath(cwd, join(ARTIFACT_DIRECTORY, stageDirectory(artifact.stage), artifact.id));
562
+ await writeJson(`${base}.json`, artifact);
563
+ await writeFile(`${base}.md`, markdownArtifact(artifact));
564
+ return { json: relativePath(cwd, `${base}.json`), markdown: relativePath(cwd, `${base}.md`) };
565
+ }
566
+
567
+ function artifactId(prefix, number) {
568
+ return `${prefix}-${String(number).padStart(3, "0")}`;
569
+ }
570
+
571
+ async function allocateArtifactIds(cwd, acceptanceCriterionCount = 6) {
572
+ const artifacts = await Promise.all((await artifactFiles(cwd)).map((file) => readJson(file)));
573
+ const numbers = (prefix) => artifacts
574
+ .map(({ id }) => new RegExp(`^${prefix}-(\\d+)$`).exec(id)?.[1])
575
+ .filter(Boolean)
576
+ .map(Number);
577
+ const contractNumber = Math.max(0, ...numbers("SPEC"), ...numbers("PRD")) + 1;
578
+ const firstCriterion = Math.max(0, ...numbers("AC")) + 1;
579
+ return {
580
+ specification: artifactId("SPEC", contractNumber),
581
+ prd: artifactId("PRD", contractNumber),
582
+ acceptanceCriteria: Array.from({ length: acceptanceCriterionCount }, (_, index) => artifactId("AC", firstCriterion + index)),
583
+ };
584
+ }
585
+
586
+ function createLoginArtifacts(session, timestamp, ids) {
587
+ const audience = answerValue(session, "audience");
588
+ const method = answerValue(session, "method");
589
+ const destination = answerValue(session, "destination");
590
+ const recovery = answerValue(session, "recovery");
591
+ const registration = answerValue(session, "registration");
592
+ const mfa = answerValue(session, "mfa");
593
+ const authService = answerValue(session, "auth-service");
594
+ const brandTone = answerValue(session, "brand-tone");
595
+ const successCheck = answerValue(session, "success-check");
596
+ const openDecisions = authService === "not-decided"
597
+ ? ["Choose and document the authentication service or API contract."]
598
+ : [];
599
+ const acceptanceCriteria = ids.acceptanceCriteria;
600
+ const specification = artifactEnvelope({
601
+ id: ids.specification,
602
+ kind: "product-specification",
603
+ title: "Provide a clear and secure login experience",
604
+ stage: "specification",
605
+ timestamp,
606
+ body: {
607
+ problem: `${audience} need a clear and secure way to access the authenticated product area.`,
608
+ desiredOutcome: `A successful login establishes authenticated state and continues to ${destination}.`,
609
+ authenticationMethod: method,
610
+ constraints: [`Authentication integration: ${authService}`, "Responsive and keyboard-accessible behavior is required.", "Errors must not reveal whether a specific account exists."],
611
+ nonGoals: [`Registration: ${registration}`, `Multi-factor authentication: ${mfa}`],
612
+ successSignal: successCheck,
613
+ visualDirection: brandTone,
614
+ openDecisions,
615
+ },
616
+ });
617
+ const prd = artifactEnvelope({
618
+ id: ids.prd,
619
+ kind: "product-requirement-document",
620
+ title: "Login page product contract",
621
+ stage: "prd",
622
+ timestamp,
623
+ derivedFrom: [ids.specification],
624
+ acceptanceCriteria,
625
+ body: {
626
+ scope: [`Authenticate ${audience} using ${method}.`, `Continue successful users to ${destination}.`, `Provide recovery behavior: ${recovery}.`, "Cover idle, submitting, success, invalid input, invalid credentials, lockout or rate limit, and service unavailable states."],
627
+ nonScope: [`Registration behavior: ${registration}.`, `MFA behavior: ${mfa}.`],
628
+ integration: authService,
629
+ responsive: "Required from 320px upward without horizontal overflow.",
630
+ accessibility: "Keyboard completion, visible focus, associated labels, announced errors, and non-color-only status are required.",
631
+ security: "Use the existing authentication contract, avoid account enumeration, preserve secure session handling, and never log credentials.",
632
+ openDecisions,
633
+ },
634
+ });
635
+ const statements = [
636
+ `Valid credentials create authenticated state and continue to ${destination}.`,
637
+ "Invalid credentials show a non-enumerating inline error without clearing the identifier.",
638
+ "Keyboard and screen-reader users can identify, complete, submit, and recover from errors in the form.",
639
+ `The login exposes the confirmed recovery behavior: ${recovery}.`,
640
+ "Submitting, locked or rate-limited, and service-unavailable states prevent duplicate submission and explain the next action.",
641
+ "The page remains readable and free of horizontal overflow from 320px through desktop layouts.",
642
+ ];
643
+ const criteria = statements.map((statement, index) => artifactEnvelope({ id: acceptanceCriteria[index], kind: "acceptance-criterion", title: statement, stage: "prd", timestamp, derivedFrom: [ids.prd], body: { statement } }));
644
+ return [specification, prd, ...criteria];
645
+ }
646
+
647
+ function createGenericFeatureArtifacts(session, template, timestamp, ids) {
648
+ const outcome = answerValue(session, "outcome");
649
+ const audience = answerValue(session, "audience");
650
+ const primaryAction = answerValue(session, "primary-action");
651
+ const entryPoint = answerValue(session, "entry-point");
652
+ const dataSource = answerValue(session, "data-source");
653
+ const successBehavior = answerValue(session, "success-behavior");
654
+ const edgeStates = answerValue(session, "edge-states");
655
+ const experienceConstraints = answerValue(session, "experience-constraints");
656
+ const verification = answerValue(session, "verification");
657
+ const capability = template.capability;
658
+ const openDecisions = dataSource === "not-decided" ? ["Choose and document the data source or persistence contract."] : [];
659
+ const specification = artifactEnvelope({
660
+ id: ids.specification,
661
+ kind: "product-specification",
662
+ title: `Enable a useful ${capability}`,
663
+ stage: "specification",
664
+ timestamp,
665
+ body: {
666
+ capability,
667
+ problem: `${audience} need a clear way to use the ${capability} and achieve ${outcome}.`,
668
+ desiredOutcome: `Users can ${primaryAction} and receive ${successBehavior}.`,
669
+ entryPoint,
670
+ constraints: [`Data contract: ${dataSource}`, `Experience contract: ${experienceConstraints}`],
671
+ requiredStates: edgeStates,
672
+ successSignal: verification,
673
+ nonGoals: ["Capabilities not confirmed by this interview are outside the first release."],
674
+ openDecisions,
675
+ },
676
+ });
677
+ const prd = artifactEnvelope({
678
+ id: ids.prd,
679
+ kind: "product-requirement-document",
680
+ title: `${capability} product contract`,
681
+ stage: "prd",
682
+ timestamp,
683
+ derivedFrom: [ids.specification],
684
+ acceptanceCriteria: ids.acceptanceCriteria,
685
+ body: {
686
+ capability,
687
+ scope: [`Serve ${audience}.`, `Support the primary action: ${primaryAction}.`, `Enter through: ${entryPoint}.`, `Show success behavior: ${successBehavior}.`, `Handle states: ${edgeStates}.`],
688
+ integration: dataSource,
689
+ responsive: experienceConstraints === "desktop-accessible" ? "Desktop-first; smaller viewports are explicitly deferred." : "Required from 320px upward without horizontal overflow.",
690
+ accessibility: "Keyboard completion, visible focus, associated labels, announced status, and non-color-only feedback are required.",
691
+ verification,
692
+ nonScope: ["Unconfirmed secondary workflows and integrations."],
693
+ openDecisions,
694
+ },
695
+ });
696
+ const statements = [
697
+ `${audience} can complete ${primaryAction} and receive ${successBehavior}.`,
698
+ `The experience handles ${edgeStates} with a clear explanation and next action.`,
699
+ `Data reads and writes follow the confirmed contract: ${dataSource}.`,
700
+ "Keyboard and screen-reader users can understand, complete, and recover from the primary workflow.",
701
+ experienceConstraints === "desktop-accessible" ? "The supported desktop layout remains readable without clipped content." : "The experience remains readable and free of horizontal overflow from 320px through desktop layouts.",
702
+ `QA records evidence for the confirmed verification method: ${verification}.`,
703
+ ];
704
+ const criteria = statements.map((statement, index) => artifactEnvelope({ id: ids.acceptanceCriteria[index], kind: "acceptance-criterion", title: statement, stage: "prd", timestamp, derivedFrom: [ids.prd], body: { statement } }));
705
+ return [specification, prd, ...criteria];
706
+ }
707
+
708
+ async function finalizeInterview(cwd, session, template, timestamp) {
709
+ const ids = await allocateArtifactIds(cwd);
710
+ const artifacts = template.artifactStrategy === "login"
711
+ ? createLoginArtifacts(session, timestamp, ids)
712
+ : createGenericFeatureArtifacts(session, template, timestamp, ids);
713
+ const paths = [];
714
+ for (const artifact of artifacts) paths.push({ id: artifact.id, ...(await writeArtifact(cwd, artifact)) });
715
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
716
+ const trace = await readJson(traceFile);
717
+ const nodes = artifacts.map((artifact) => ({ id: artifact.id, kind: artifact.kind, stage: artifact.stage, status: artifact.status, revision: artifact.revision, path: paths.find((entry) => entry.id === artifact.id).json }));
718
+ const edges = [
719
+ { from: ids.prd, to: ids.specification, relation: "derives_from" },
720
+ ...artifacts.filter((artifact) => artifact.kind === "acceptance-criterion").map((artifact) => ({ from: artifact.id, to: ids.prd, relation: "derives_from" })),
721
+ ];
722
+ trace.nodes = [...trace.nodes.filter((node) => !nodes.some((candidate) => candidate.id === node.id)), ...nodes];
723
+ trace.edges = [...trace.edges.filter((edge) => !edges.some((candidate) => candidate.from === edge.from && candidate.to === edge.to && candidate.relation === edge.relation)), ...edges];
724
+ trace.updatedAt = timestamp;
725
+ await writeJson(traceFile, trace);
726
+ session.status = "completed";
727
+ session.completedAt = timestamp;
728
+ session.generatedArtifacts = paths;
729
+ return paths;
730
+ }
731
+
732
+ export async function runRequirementInterview({ cwd = process.cwd(), intent = "login", answers = {}, clock } = {}) {
733
+ await requireInitialized(cwd);
734
+ const template = resolveCapabilityTemplate(intent);
735
+ const timestamp = now(clock);
736
+ const sessionFile = projectPath(cwd, join(COORDIATION_DIRECTORY, "interviews", `${template.id}.json`));
737
+ const session = await exists(sessionFile) ? await readJson(sessionFile) : {
738
+ schemaVersion: LIFECYCLE_SCHEMA_VERSION,
739
+ kind: "coordiation-requirement-interview",
740
+ id: `INTERVIEW-${template.id.toUpperCase()}-001`,
741
+ intent: template.id,
742
+ status: "active",
743
+ maxQuestionsPerTurn: template.maxQuestionsPerTurn,
744
+ answers: {},
745
+ generatedArtifacts: [],
746
+ createdAt: timestamp,
747
+ updatedAt: timestamp,
748
+ };
749
+ if (session.status === "completed" && Object.keys(answers).length) throw new Error(`The ${template.id} interview is already completed. Start a new artifact revision before changing confirmed answers.`);
750
+ for (const [id, answer] of Object.entries(answers)) {
751
+ const question = template.questions.find((entry) => entry.id === id);
752
+ if (!question) throw new Error(`Unknown ${template.id} interview question: ${id}.`);
753
+ session.answers[id] = { ...normalizeAnswer(question, answer), answeredAt: timestamp };
754
+ }
755
+ session.updatedAt = timestamp;
756
+ const unanswered = template.questions.filter((question) => !session.answers[question.id]);
757
+ if (unanswered.length === 0 && session.status !== "completed") await finalizeInterview(cwd, session, template, timestamp);
758
+ await writeJson(sessionFile, session);
759
+ return {
760
+ schemaVersion: LIFECYCLE_SCHEMA_VERSION,
761
+ kind: "coordiation-interview-result",
762
+ id: session.id,
763
+ intent: session.intent,
764
+ status: session.status,
765
+ answered: Object.keys(session.answers).length,
766
+ totalQuestions: template.questions.length,
767
+ nextQuestions: session.status === "completed" ? [] : unanswered.slice(0, template.maxQuestionsPerTurn).map(publicQuestion),
768
+ generatedArtifacts: session.generatedArtifacts,
769
+ sessionPath: relativePath(cwd, sessionFile),
770
+ };
771
+ }
772
+
773
+ async function loadArtifact(cwd, artifactId) {
774
+ const normalized = String(artifactId).trim().toUpperCase();
775
+ for (const file of await artifactFiles(cwd)) {
776
+ const artifact = await readJson(file);
777
+ if (artifact.id === normalized) return { artifact, file };
778
+ }
779
+ throw new Error(`Unknown lifecycle artifact: ${artifactId}.`);
780
+ }
781
+
782
+ function uxScreenStates(prd) {
783
+ const capability = prd.body.capability ?? "login experience";
784
+ if (capability === "login experience" || /login/i.test(prd.title)) {
785
+ return [
786
+ { id: "idle", purpose: "Explain the login and accept credentials.", content: ["Identifier field", "Password field", "Primary sign-in action", "Recovery path"], actions: ["Enter credentials", "Submit", "Open recovery"] },
787
+ { id: "submitting", purpose: "Prevent duplicate submission while authentication is in progress.", content: ["Progress status", "Preserved identifier"], actions: ["Wait"] },
788
+ { id: "invalid-input", purpose: "Explain correctable field-level problems.", content: ["Associated field errors", "Summary when several fields fail"], actions: ["Correct input", "Submit again"] },
789
+ { id: "invalid-credentials", purpose: "Explain authentication failure without revealing whether an account exists.", content: ["Non-enumerating error", "Recovery path"], actions: ["Try again", "Open recovery"] },
790
+ { id: "rate-limited", purpose: "Protect the account and explain when another attempt is safe.", content: ["Temporary restriction", "Safe next step"], actions: ["Wait", "Open support or recovery"] },
791
+ { id: "service-unavailable", purpose: "Preserve user input and provide a recoverable service failure.", content: ["Availability message", "Retry guidance"], actions: ["Retry"] },
792
+ { id: "success", purpose: "Confirm authenticated state before continuing.", content: ["Authenticated status"], actions: ["Continue to the confirmed destination"] },
793
+ ];
794
+ }
795
+ const states = [
796
+ { id: "idle", purpose: `Orient the user within the ${capability}.`, content: ["Purpose", "Current data", "Primary action"], actions: ["Begin the primary task"] },
797
+ { id: "loading", purpose: "Explain that required information is being retrieved.", content: ["Progress status", "Stable layout skeleton"], actions: ["Wait"] },
798
+ { id: "empty", purpose: "Explain why no information is available and how to continue.", content: ["Empty-state reason", "Recommended next action"], actions: ["Start the primary task"] },
799
+ { id: "invalid", purpose: "Explain correctable validation or business-rule failures.", content: ["Associated errors", "Preserved user input"], actions: ["Correct input", "Try again"] },
800
+ { id: "service-unavailable", purpose: "Make a temporary dependency failure recoverable.", content: ["Availability message", "Retry guidance"], actions: ["Retry"] },
801
+ { id: "success", purpose: "Confirm the completed outcome and resulting state.", content: ["Result summary", "Safe next action"], actions: ["Review result", "Continue"] },
802
+ ];
803
+ const requiredStates = String(prd.body.scope?.find((entry) => entry.startsWith("Handle states:")) ?? "").toLowerCase();
804
+ if (requiredStates.includes("permission")) states.splice(-1, 0, { id: "permission-denied", purpose: "Explain missing access without exposing restricted information.", content: ["Access status", "Safe escalation path"], actions: ["Return", "Request access"] });
805
+ if (requiredStates.includes("offline")) states.splice(-1, 0, { id: "offline", purpose: "Preserve progress while the connection is unavailable.", content: ["Connection status", "Preserved work"], actions: ["Retry when online"] });
806
+ return states;
807
+ }
808
+
809
+ function uxPrimaryFlow(prd) {
810
+ const capability = prd.body.capability ?? "login experience";
811
+ if (capability === "login experience" || /login/i.test(prd.title)) {
812
+ return [
813
+ { step: 1, actor: "user", action: "Open the login experience", outcome: "The idle state explains the required credentials and recovery path." },
814
+ { step: 2, actor: "user", action: "Enter and submit credentials", outcome: "Input is validated and duplicate submission is prevented." },
815
+ { step: 3, actor: "system", action: "Authenticate through the confirmed integration", outcome: "The system returns success or a safe, recoverable failure." },
816
+ { step: 4, actor: "system", action: "Establish authenticated state", outcome: "The user continues to the PRD destination." },
817
+ ];
818
+ }
819
+ return [
820
+ { step: 1, actor: "user", action: "Enter from the confirmed entry point", outcome: `The ${capability} explains context and the primary action.` },
821
+ { step: 2, actor: "system", action: "Load required data and permissions", outcome: "Loading, empty, denied, or ready state is explicit." },
822
+ { step: 3, actor: "user", action: "Complete the primary action", outcome: "Input and business rules are validated without losing work." },
823
+ { step: 4, actor: "system", action: "Persist or apply the confirmed outcome", outcome: "The system uses the PRD data contract." },
824
+ { step: 5, actor: "system", action: "Present success and the safest next action", outcome: "The user can verify the result and continue." },
825
+ ];
826
+ }
827
+
828
+ function uxNumberFor(prd, artifacts) {
829
+ const preferred = Number(/-(\d+)$/.exec(prd.id)?.[1]);
830
+ const occupied = new Set(artifacts.filter(({ id }) => id.startsWith("UX-")).map(({ id }) => Number(id.slice(3))));
831
+ if (preferred && !occupied.has(preferred)) return preferred;
832
+ return Math.max(0, ...occupied) + 1;
833
+ }
834
+
835
+ export async function createUxContract({ cwd = process.cwd(), prdId, clock } = {}) {
836
+ await requireInitialized(cwd);
837
+ const { artifact: prd } = await loadArtifact(cwd, prdId);
838
+ if (prd.stage !== "prd" || prd.kind !== "product-requirement-document") throw new Error(`${prd.id} is not a PRD artifact.`);
839
+ if (prd.status !== "approved") throw new Error(`${prd.id} must be approved before deriving UX.`);
840
+ const files = await artifactFiles(cwd);
841
+ const artifacts = await Promise.all(files.map((file) => readJson(file)));
842
+ const existing = artifacts.find((artifact) => artifact.stage === "ux" && artifact.status !== "superseded" && artifact.derivedFrom.includes(prd.id));
843
+ if (existing) return { created: false, artifact: existing, paths: { json: `artifacts/ux/${existing.id}.json`, markdown: `artifacts/ux/${existing.id}.md` } };
844
+ const timestamp = now(clock);
845
+ const id = artifactId("UX", uxNumberFor(prd, artifacts));
846
+ const screenStates = uxScreenStates(prd);
847
+ const ux = artifactEnvelope({
848
+ id,
849
+ kind: "ux-contract",
850
+ title: `${prd.body.capability ?? "Login experience"} UX contract`,
851
+ stage: "ux",
852
+ timestamp,
853
+ derivedFrom: [prd.id],
854
+ acceptanceCriteria: prd.acceptanceCriteria,
855
+ body: {
856
+ capability: prd.body.capability ?? "login experience",
857
+ primaryFlow: uxPrimaryFlow(prd),
858
+ screenStates,
859
+ informationArchitecture: { primaryRegion: "Task context and primary action", secondaryRegion: "Supporting information and safe next actions", navigation: "Preserve the confirmed entry and return path." },
860
+ interactionContract: ["One visually dominant primary action per state.", "Submitting actions expose progress and prevent accidental duplication.", "Errors preserve recoverable input and remain adjacent to their source.", "Success communicates the resulting state and safest next action."],
861
+ responsiveContract: { minimumViewport: "320px", requirements: ["No horizontal page overflow.", "Touch targets remain operable.", "Reading and focus order remain logical.", "Primary actions remain visible without covering content."] },
862
+ accessibilityContract: { keyboard: "The complete primary flow works without a pointer.", focus: "Focus is visible, ordered, and moved only when context changes materially.", semantics: "Landmarks, headings, labels, descriptions, and status messages expose their purpose.", motion: "Non-essential motion respects prefers-reduced-motion.", errors: "Errors are associated, announced, and never communicated by color alone." },
863
+ contentContract: ["Use plain, action-oriented language.", "Explain what happened and what the user can do next.", "Do not expose sensitive existence, permission, or system details."],
864
+ verificationPlan: prd.acceptanceCriteria.map((criterionId) => ({ criterionId, evidence: "UX state or flow reference plus prototype and QA evidence in downstream stages." })),
865
+ openDecisions: [...(prd.body.openDecisions ?? [])],
866
+ },
867
+ });
868
+ const paths = await writeArtifact(cwd, ux);
869
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
870
+ const trace = await readJson(traceFile);
871
+ trace.nodes.push({ id: ux.id, kind: ux.kind, stage: ux.stage, status: ux.status, revision: ux.revision, path: paths.json });
872
+ trace.edges.push(
873
+ { from: ux.id, to: prd.id, relation: "derives_from" },
874
+ ...ux.acceptanceCriteria.map((criterionId) => ({ from: ux.id, to: criterionId, relation: "satisfies" })),
875
+ );
876
+ trace.updatedAt = timestamp;
877
+ await writeJson(traceFile, trace);
878
+ return { created: true, artifact: ux, paths };
879
+ }
880
+
881
+ function prototypeComponentPlan(ux) {
882
+ const capability = ux.body.capability;
883
+ const common = [
884
+ { registryId: "typography", role: "Semantic headings, instructions, metadata, and status copy." },
885
+ { registryId: "button", role: "Primary, secondary, retry, and safe navigation actions." },
886
+ { registryId: "alert", role: "Associated error, warning, unavailable, and success feedback." },
887
+ { registryId: "skeleton", role: "Stable loading placeholders without layout shift." },
888
+ ];
889
+ if (capability === "login experience") return [
890
+ { registryId: "card", role: "Bounded authentication surface and supporting content." },
891
+ { registryId: "field", role: "Label, description, control, and associated validation message." },
892
+ { registryId: "input", role: "Identifier and password entry using native input semantics." },
893
+ { registryId: "spinner", role: "Submitting status inside the primary action." },
894
+ ...common,
895
+ ];
896
+ if (/dashboard/i.test(capability)) return [
897
+ { registryId: "sidebar", role: "Responsive product navigation and current-location context." },
898
+ { registryId: "navigation-menu", role: "Primary destinations and keyboard navigation." },
899
+ { registryId: "card", role: "Summary metrics and bounded information groups." },
900
+ { registryId: "data-table", role: "Structured records with semantic headers and actions." },
901
+ { registryId: "chart", role: "Data visualization with an equivalent textual summary." },
902
+ { registryId: "badge", role: "Compact status with text, never color alone." },
903
+ { registryId: "select", role: "Explicit time range or view selection." },
904
+ { registryId: "empty", role: "No-data explanation and recommended next action." },
905
+ ...common,
906
+ ];
907
+ return [
908
+ { registryId: "card", role: "Primary task region and supporting information." },
909
+ { registryId: "field", role: "Label, description, input, and associated error composition." },
910
+ { registryId: "input", role: "Native text or data entry where required by the PRD." },
911
+ { registryId: "empty", role: "No-data state with a useful next action." },
912
+ { registryId: "spinner", role: "Compact progress status for actions." },
913
+ ...common,
914
+ ];
915
+ }
916
+
917
+ function prototypeLayout(ux) {
918
+ const dashboard = /dashboard/i.test(ux.body.capability);
919
+ return {
920
+ shell: dashboard
921
+ ? ["co-min-h-screen", "co-grid", "co-grid-cols-1", "lg:co-grid-cols-4"]
922
+ : ["co-min-h-screen", "co-flex", "co-items-center", "co-justify-center", "co-p-4"],
923
+ content: dashboard
924
+ ? ["co-w-full", "co-grid", "co-gap-6", "co-p-4", "md:co-grid-cols-2", "lg:co-col-span-3"]
925
+ : ["co-w-full", "co-grid", "co-gap-4", "md:co-max-w-lg"],
926
+ rules: [
927
+ "Keep every Coordiation utility candidate literal; do not construct co-* classes dynamically.",
928
+ "Preserve DOM order as the reading and keyboard order at every viewport.",
929
+ "Use intrinsic sizing and wrapping before introducing fixed dimensions.",
930
+ "Do not allow page-level horizontal overflow at 320px.",
931
+ ],
932
+ };
933
+ }
934
+
935
+ function prototypeNumberFor(ux, artifacts) {
936
+ const preferred = Number(/-(\d+)$/.exec(ux.id)?.[1]);
937
+ const occupied = new Set(artifacts.filter(({ id }) => id.startsWith("PROTO-")).map(({ id }) => Number(id.slice(6))));
938
+ if (preferred && !occupied.has(preferred)) return preferred;
939
+ return Math.max(0, ...occupied) + 1;
940
+ }
941
+
942
+ export async function createPrototypeContract({ cwd = process.cwd(), uxId, clock } = {}) {
943
+ await requireInitialized(cwd);
944
+ const { artifact: ux } = await loadArtifact(cwd, uxId);
945
+ if (ux.stage !== "ux" || ux.kind !== "ux-contract") throw new Error(`${ux.id} is not a UX contract.`);
946
+ if (ux.status !== "approved") throw new Error(`${ux.id} must be approved before deriving a prototype.`);
947
+ const files = await artifactFiles(cwd);
948
+ const artifacts = await Promise.all(files.map((file) => readJson(file)));
949
+ const existing = artifacts.find((artifact) => artifact.stage === "prototype" && artifact.status !== "superseded" && artifact.derivedFrom.includes(ux.id));
950
+ if (existing) return { created: false, artifact: existing, paths: { json: `artifacts/prototype/${existing.id}.json`, markdown: `artifacts/prototype/${existing.id}.md` } };
951
+ const timestamp = now(clock);
952
+ const id = artifactId("PROTO", prototypeNumberFor(ux, artifacts));
953
+ const componentPlan = prototypeComponentPlan(ux);
954
+ const prototype = artifactEnvelope({
955
+ id,
956
+ kind: "prototype-contract",
957
+ title: `${ux.body.capability} interactive prototype contract`,
958
+ stage: "prototype",
959
+ timestamp,
960
+ derivedFrom: [ux.id],
961
+ acceptanceCriteria: ux.acceptanceCriteria,
962
+ body: {
963
+ capability: ux.body.capability,
964
+ fidelity: "Interactive, state-complete, implementation-oriented prototype using mock adapters.",
965
+ frameworkContract: { css: "@coordiation/css with literal co-* candidates", components: "Owned source installed from @coordiation/ui", icons: "Use @coordiation/icons components; raw inline SVG and Unicode arrow substitutes are not allowed.", runtime: "Use the target framework only for state and interaction; Coordiation CSS adds no browser runtime." },
966
+ componentPlan,
967
+ layoutContract: prototypeLayout(ux),
968
+ statePreviews: ux.body.screenStates.map((state) => ({ id: state.id, fixture: `Deterministic ${state.id} fixture`, purpose: state.purpose, visibleContent: state.content, availableActions: state.actions, componentIds: componentPlan.map(({ registryId }) => registryId) })),
969
+ interactionMap: ux.body.primaryFlow.map((flowStep) => ({ step: flowStep.step, trigger: flowStep.actor === "user" ? flowStep.action : `system:${flowStep.action}`, expectedOutcome: flowStep.outcome, keyboardRequired: true })),
970
+ viewportMatrix: [
971
+ { width: 320, label: "compact", requirement: "Single-column, no page overflow, operable touch and keyboard targets." },
972
+ { width: 768, label: "medium", requirement: "Use available space without changing reading or focus order." },
973
+ { width: 1280, label: "wide", requirement: "Apply the confirmed multi-region layout without excessive line length." },
974
+ ],
975
+ motionContract: { classes: ["co-transition", "co-duration-200", "co-ease-out", "motion-reduce:co-duration-0"], rules: ["Animate only opacity and transform when possible.", "Motion must explain state or spatial change, not delay task completion.", "Reduced-motion mode removes non-essential movement while preserving status feedback."] },
976
+ mockAdapter: { policy: "No production credentials or undocumented endpoints.", fixtures: ux.body.screenStates.map(({ id }) => id), reset: "Every state preview can be opened directly and reset deterministically." },
977
+ evidenceRequirements: { screenshots: ux.body.screenStates.flatMap(({ id: stateId }) => [320, 768, 1280].map((width) => ({ stateId, width }))), keyboardPath: "Record focus order and successful keyboard-only completion of the primary flow.", accessibility: "Record automated checks plus manual labels, status announcement, contrast, zoom, and reduced-motion observations.", acceptanceCriteria: ux.acceptanceCriteria.map((criterionId) => ({ criterionId, requiredEvidence: "Linked state preview, viewport evidence, or interaction recording." })) },
978
+ openDecisions: [...(ux.body.openDecisions ?? [])],
979
+ },
980
+ });
981
+ const paths = await writeArtifact(cwd, prototype);
982
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
983
+ const trace = await readJson(traceFile);
984
+ trace.nodes.push({ id: prototype.id, kind: prototype.kind, stage: prototype.stage, status: prototype.status, revision: prototype.revision, path: paths.json });
985
+ trace.edges.push(
986
+ { from: prototype.id, to: ux.id, relation: "derives_from" },
987
+ ...prototype.acceptanceCriteria.map((criterionId) => ({ from: prototype.id, to: criterionId, relation: "satisfies" })),
988
+ );
989
+ trace.updatedAt = timestamp;
990
+ await writeJson(traceFile, trace);
991
+ return { created: true, artifact: prototype, paths };
992
+ }
993
+
994
+ async function detectDevelopmentTarget(cwd) {
995
+ const packageFile = projectPath(cwd, "package.json");
996
+ let dependencies = {};
997
+ if (await exists(packageFile)) {
998
+ const packageJson = await readJson(packageFile);
999
+ dependencies = { ...(packageJson.dependencies ?? {}), ...(packageJson.devDependencies ?? {}) };
1000
+ }
1001
+ if (dependencies.next) return { framework: "Next.js", language: "TypeScript/React", extension: "tsx", sourceRoot: "src/features", nativeComponentModel: "React Server and Client Components as required by interaction." };
1002
+ if (dependencies.react) return { framework: "React", language: "TypeScript/React", extension: "tsx", sourceRoot: "src/features", nativeComponentModel: "React components and hooks only where state is required." };
1003
+ if (dependencies.svelte || dependencies["@sveltejs/kit"]) return { framework: "Svelte", language: "Svelte/TypeScript", extension: "svelte", sourceRoot: "src/lib/features", nativeComponentModel: "Native Svelte components and stores." };
1004
+ if (dependencies.astro) return { framework: "Astro", language: "Astro/TypeScript", extension: "astro", sourceRoot: "src/components", nativeComponentModel: "Astro components with isolated interactive islands." };
1005
+ if (await exists(projectPath(cwd, "composer.json"))) return { framework: "Laravel/PHP", language: "Blade/PHP", extension: "blade.php", sourceRoot: "resources/views", nativeComponentModel: "Blade views and project-native controllers or Livewire only when already installed." };
1006
+ if (await exists(projectPath(cwd, "wp-content"))) return { framework: "WordPress", language: "PHP", extension: "php", sourceRoot: "wp-content/themes/coordiation", nativeComponentModel: "Theme templates, blocks, and WordPress APIs." };
1007
+ return { framework: "HTML", language: "HTML/CSS/JavaScript", extension: "html", sourceRoot: "src", nativeComponentModel: "Standards-first HTML with progressive enhancement." };
1008
+ }
1009
+
1010
+ function developmentNumberFor(prototype, artifacts) {
1011
+ const preferred = Number(/-(\d+)$/.exec(prototype.id)?.[1]);
1012
+ const occupied = new Set(artifacts.filter(({ id }) => id.startsWith("DEV-")).map(({ id }) => Number(id.slice(4))));
1013
+ if (preferred && !occupied.has(preferred)) return preferred;
1014
+ return Math.max(0, ...occupied) + 1;
1015
+ }
1016
+
1017
+ export async function createDevelopmentContract({ cwd = process.cwd(), prototypeId, clock } = {}) {
1018
+ await requireInitialized(cwd);
1019
+ const { artifact: prototype } = await loadArtifact(cwd, prototypeId);
1020
+ if (prototype.stage !== "prototype" || prototype.kind !== "prototype-contract") throw new Error(`${prototype.id} is not a prototype contract.`);
1021
+ if (prototype.status !== "approved") throw new Error(`${prototype.id} must be approved before deriving development work.`);
1022
+ const files = await artifactFiles(cwd);
1023
+ const artifacts = await Promise.all(files.map((file) => readJson(file)));
1024
+ const existing = artifacts.find((artifact) => artifact.stage === "development" && artifact.status !== "superseded" && artifact.derivedFrom.includes(prototype.id));
1025
+ if (existing) return { created: false, artifact: existing, paths: { json: `artifacts/development/${existing.id}.json`, markdown: `artifacts/development/${existing.id}.md` } };
1026
+ const timestamp = now(clock);
1027
+ const id = artifactId("DEV", developmentNumberFor(prototype, artifacts));
1028
+ const target = await detectDevelopmentTarget(cwd);
1029
+ const featureSlug = slug(prototype.body.capability);
1030
+ const featureRoot = `${target.sourceRoot}/${featureSlug}`;
1031
+ const utilityCandidates = [...new Set([
1032
+ ...prototype.body.layoutContract.shell,
1033
+ ...prototype.body.layoutContract.content,
1034
+ ...prototype.body.motionContract.classes,
1035
+ ])];
1036
+ const workItems = [
1037
+ { id: `${id}-WORK-001`, title: "Install or reuse approved Coordiation primitives", outputs: prototype.body.componentPlan.map(({ registryId }) => `@coordiation/ui:${registryId}`), dependsOn: [] },
1038
+ { id: `${id}-WORK-002`, title: "Implement semantic layout with literal Coordiation utilities", outputs: [`${featureRoot}/${featureSlug}.${target.extension}`], dependsOn: [`${id}-WORK-001`] },
1039
+ { id: `${id}-WORK-003`, title: "Implement every prototype state and transition", outputs: [`${featureRoot}/${featureSlug}-state.${target.extension}`], dependsOn: [`${id}-WORK-002`] },
1040
+ { id: `${id}-WORK-004`, title: "Connect the documented data adapter without embedding credentials", outputs: [`${featureRoot}/${featureSlug}-adapter.ts`], dependsOn: [`${id}-WORK-003`] },
1041
+ { id: `${id}-WORK-005`, title: "Add acceptance, accessibility, responsive, and failure-path tests", outputs: [`tests/${featureSlug}.test.js`], dependsOn: [`${id}-WORK-003`, `${id}-WORK-004`] },
1042
+ ];
1043
+ const development = artifactEnvelope({
1044
+ id,
1045
+ kind: "development-contract",
1046
+ title: `${prototype.body.capability} development contract`,
1047
+ stage: "development",
1048
+ timestamp,
1049
+ derivedFrom: [prototype.id],
1050
+ acceptanceCriteria: prototype.acceptanceCriteria,
1051
+ body: {
1052
+ capability: prototype.body.capability,
1053
+ target,
1054
+ sourcePlan: { root: featureRoot, files: [...new Set(workItems.flatMap(({ outputs }) => outputs).filter((output) => !output.startsWith("@coordiation/")))], ownership: "Project-owned open code; generated source may be edited without a Coordiation runtime dependency." },
1055
+ dependencies: { components: prototype.body.componentPlan.map(({ registryId }) => registryId), icons: "@coordiation/icons only", css: "@coordiation/css", external: "No new third-party UI system may be introduced by generation." },
1056
+ utilityCandidates,
1057
+ stateImplementation: prototype.body.statePreviews.map(({ id: stateId, fixture, availableActions }) => ({ stateId, fixture, availableActions, requirement: "Reachable through a deterministic adapter or explicit preview control." })),
1058
+ interactionImplementation: prototype.body.interactionMap,
1059
+ workItems,
1060
+ dataBoundary: { adapter: `${featureRoot}/${featureSlug}-adapter.ts`, rule: "UI components depend on a typed feature adapter, not undocumented endpoints.", secrets: "Environment or server configuration only; never generated into source, fixtures, logs, or artifacts.", failures: "Map provider failures into the approved UX states." },
1061
+ testPlan: prototype.acceptanceCriteria.map((criterionId, index) => ({ id: `${id}-TEST-${String(index + 1).padStart(3, "0")}`, criterionId, levels: ["component", "integration"], requiredEvidence: "Passing test command plus the hashed test source." })),
1062
+ qualityCommands: { test: "npm test", build: "npm run build", accessibility: "npm run test:a11y", note: "Projects may record equivalent native commands when these scripts are not present." },
1063
+ definitionOfDone: ["All planned project-owned source files exist.", "Every acceptance criterion maps to a test plan entry.", "Tests and production build exit successfully.", "Keyboard, accessibility, responsive, and reduced-motion checks pass.", "No dynamic co-* class construction or non-Coordiation icon substitute is introduced."],
1064
+ requiredEvidence: ["source", "test", "build", "accessibility"],
1065
+ openDecisions: [...(prototype.body.openDecisions ?? [])],
1066
+ },
1067
+ });
1068
+ const paths = await writeArtifact(cwd, development);
1069
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
1070
+ const trace = await readJson(traceFile);
1071
+ trace.nodes.push({ id: development.id, kind: development.kind, stage: development.stage, status: development.status, revision: development.revision, path: paths.json });
1072
+ trace.edges.push(
1073
+ { from: development.id, to: prototype.id, relation: "derives_from" },
1074
+ ...development.acceptanceCriteria.map((criterionId) => ({ from: development.id, to: criterionId, relation: "satisfies" })),
1075
+ );
1076
+ trace.updatedAt = timestamp;
1077
+ await writeJson(traceFile, trace);
1078
+ return { created: true, artifact: development, paths };
1079
+ }
1080
+
1081
+ function nextEvidenceId(records) {
1082
+ return `EVIDENCE-${String(records.length + 1).padStart(3, "0")}`;
1083
+ }
1084
+
1085
+ function qaNumberFor(development, artifacts) {
1086
+ const preferred = Number(/-(\d+)$/.exec(development.id)?.[1]);
1087
+ const occupied = new Set(artifacts.filter(({ id }) => id.startsWith("QA-")).map(({ id }) => Number(id.slice(3))));
1088
+ if (preferred && !occupied.has(preferred)) return preferred;
1089
+ return Math.max(0, ...occupied) + 1;
1090
+ }
1091
+
1092
+ export async function createQaReport({ cwd = process.cwd(), developmentId, clock } = {}) {
1093
+ await requireInitialized(cwd);
1094
+ const { artifact: development } = await loadArtifact(cwd, developmentId);
1095
+ if (development.stage !== "development" || development.kind !== "development-contract") throw new Error(`${development.id} is not a development contract.`);
1096
+ if (development.status !== "approved") throw new Error(`${development.id} must be approved before deriving QA.`);
1097
+ const files = await artifactFiles(cwd);
1098
+ const artifacts = await Promise.all(files.map((file) => readJson(file)));
1099
+ const existing = artifacts.find((artifact) => artifact.stage === "qa" && artifact.status !== "superseded" && artifact.derivedFrom.includes(development.id));
1100
+ if (existing) return { created: false, artifact: existing, paths: { json: `artifacts/qa/${existing.id}.json`, markdown: `artifacts/qa/${existing.id}.md` } };
1101
+ const prototype = artifacts.find((artifact) => artifact.stage === "prototype" && development.derivedFrom.includes(artifact.id));
1102
+ const criteria = new Map(artifacts.filter((artifact) => artifact.kind === "acceptance-criterion").map((artifact) => [artifact.id, artifact]));
1103
+ const timestamp = now(clock);
1104
+ const id = artifactId("QA", qaNumberFor(development, artifacts));
1105
+ const qa = artifactEnvelope({
1106
+ id,
1107
+ kind: "qa-report",
1108
+ title: `${development.body.capability} QA contract and report`,
1109
+ stage: "qa",
1110
+ timestamp,
1111
+ derivedFrom: [development.id],
1112
+ acceptanceCriteria: development.acceptanceCriteria,
1113
+ body: {
1114
+ capability: development.body.capability,
1115
+ strategy: "Risk-based verification of every acceptance criterion, prototype state, supported viewport, accessibility behavior, security boundary, and regression surface.",
1116
+ testCases: development.body.testPlan.map((plannedTest, index) => ({ id: `${id}-CASE-${String(index + 1).padStart(3, "0")}`, criterionId: plannedTest.criterionId, statement: criteria.get(plannedTest.criterionId)?.body?.statement ?? "Verify the linked acceptance criterion.", preconditions: ["Approved development contract", "Deterministic test data or fixture"], steps: ["Open the linked feature state.", "Perform the criterion action using the supported input method.", "Observe the visible outcome and persisted state."], expected: "The acceptance criterion passes without introducing an accessibility, security, responsive, or regression failure.", evidenceTypes: ["functional", "regression"] })),
1117
+ stateCoverage: development.body.stateImplementation.map(({ stateId, fixture }) => ({ stateId, fixture, checks: ["Content and actions match the UX contract.", "Keyboard and announced status are correct.", "State is resettable and does not leak production data."] })),
1118
+ viewportCoverage: prototype?.body?.viewportMatrix ?? [
1119
+ { width: 320, label: "compact", requirement: "No overflow and operable controls." },
1120
+ { width: 768, label: "medium", requirement: "Logical reading and focus order." },
1121
+ { width: 1280, label: "wide", requirement: "Confirmed multi-region layout." },
1122
+ ],
1123
+ accessibilityChecklist: ["Keyboard-only completion", "Visible and logical focus", "Labels, descriptions, landmarks, and headings", "Status and errors announced", "Contrast and non-color-only meaning", "200% zoom and reflow", "Reduced motion"],
1124
+ securityChecklist: ["No credentials, tokens, or private records in source, fixtures, logs, or artifacts", "Authorization is enforced outside visual hiding", "Errors do not reveal sensitive account or system details", "User-controlled content is safely handled", "Dependencies and data adapters match the approved boundary"],
1125
+ regressionScope: { components: development.body.dependencies.components, utilities: development.body.utilityCandidates, upstreamArtifacts: development.derivedFrom, requirement: "Existing consumers of shared components and utilities remain functional." },
1126
+ requiredEvidence: ["functional", "visual", "accessibility", "security", "regression"],
1127
+ releaseBlockers: [],
1128
+ openDecisions: [...(development.body.openDecisions ?? [])],
1129
+ },
1130
+ });
1131
+ const paths = await writeArtifact(cwd, qa);
1132
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
1133
+ const trace = await readJson(traceFile);
1134
+ trace.nodes.push({ id: qa.id, kind: qa.kind, stage: qa.stage, status: qa.status, revision: qa.revision, path: paths.json });
1135
+ trace.edges.push(
1136
+ { from: qa.id, to: development.id, relation: "verifies" },
1137
+ ...qa.acceptanceCriteria.map((criterionId) => ({ from: qa.id, to: criterionId, relation: "verifies" })),
1138
+ );
1139
+ trace.updatedAt = timestamp;
1140
+ await writeJson(traceFile, trace);
1141
+ return { created: true, artifact: qa, paths };
1142
+ }
1143
+
1144
+ function releaseNumberFor(qa, artifacts) {
1145
+ const preferred = Number(/-(\d+)$/.exec(qa.id)?.[1]);
1146
+ const occupied = new Set(artifacts.filter(({ id }) => id.startsWith("RELEASE-")).map(({ id }) => Number(id.slice(8))));
1147
+ if (preferred && !occupied.has(preferred)) return preferred;
1148
+ return Math.max(0, ...occupied) + 1;
1149
+ }
1150
+
1151
+ async function defaultReleaseVersion(cwd) {
1152
+ const packageFile = projectPath(cwd, "package.json");
1153
+ if (!(await exists(packageFile))) return "0.1.0";
1154
+ const packageJson = await readJson(packageFile);
1155
+ return typeof packageJson.version === "string" && packageJson.version.trim() ? packageJson.version.trim() : "0.1.0";
1156
+ }
1157
+
1158
+ export async function createReleaseRecord({ cwd = process.cwd(), qaId, version, clock } = {}) {
1159
+ await requireInitialized(cwd);
1160
+ const { artifact: qa } = await loadArtifact(cwd, qaId);
1161
+ if (qa.stage !== "qa" || qa.kind !== "qa-report") throw new Error(`${qa.id} is not a QA report.`);
1162
+ if (qa.status !== "approved") throw new Error(`${qa.id} must be approved before deriving a release.`);
1163
+ const normalizedVersion = String(version ?? await defaultReleaseVersion(cwd)).trim();
1164
+ if (!/^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(normalizedVersion)) throw new Error("Release version must be semantic versioning such as 1.0.0 or 1.0.0-rc.1.");
1165
+ const files = await artifactFiles(cwd);
1166
+ const artifacts = await Promise.all(files.map((file) => readJson(file)));
1167
+ const existing = artifacts.find((artifact) => artifact.stage === "release" && artifact.status !== "superseded" && artifact.derivedFrom.includes(qa.id));
1168
+ if (existing) {
1169
+ if (existing.body.version !== normalizedVersion) throw new Error(`${existing.id} already releases ${qa.id} as version ${existing.body.version}. Create a new QA revision before changing the release version.`);
1170
+ return { created: false, artifact: existing, paths: { json: `artifacts/release/${existing.id}.json`, markdown: `artifacts/release/${existing.id}.md` } };
1171
+ }
1172
+ const development = artifacts.find((artifact) => artifact.stage === "development" && qa.derivedFrom.includes(artifact.id));
1173
+ const evidenceFile = projectPath(cwd, EVIDENCE_PATH);
1174
+ const ledger = await exists(evidenceFile) ? await readJson(evidenceFile) : { records: [] };
1175
+ const qaEvidence = ledger.records
1176
+ .filter((record) => record.artifactId === qa.id && record.status === "passed" && record.verified)
1177
+ .map(({ id, type, sha256 }) => ({ id, type, sha256 }));
1178
+ const timestamp = now(clock);
1179
+ const id = artifactId("RELEASE", releaseNumberFor(qa, artifacts));
1180
+ const snapshot = {
1181
+ version: normalizedVersion,
1182
+ qa: { id: qa.id, revision: qa.revision },
1183
+ development: development ? { id: development.id, revision: development.revision } : null,
1184
+ acceptanceCriteria: qa.acceptanceCriteria,
1185
+ qaEvidence,
1186
+ };
1187
+ const snapshotSha256 = createHash("sha256").update(JSON.stringify(snapshot)).digest("hex");
1188
+ const release = artifactEnvelope({
1189
+ id,
1190
+ kind: "release-record",
1191
+ title: `${qa.body.capability} release ${normalizedVersion}`,
1192
+ stage: "release",
1193
+ timestamp,
1194
+ derivedFrom: [qa.id],
1195
+ acceptanceCriteria: qa.acceptanceCriteria,
1196
+ body: {
1197
+ capability: qa.body.capability,
1198
+ version: normalizedVersion,
1199
+ candidate: { id: `${slug(qa.body.capability)}-v${normalizedVersion}`, snapshotSha256, sourceQa: { id: qa.id, revision: qa.revision }, acceptanceCriteria: qa.acceptanceCriteria },
1200
+ changelog: [{ type: "feature", summary: `Deliver the approved ${qa.body.capability} contract.`, requirementIds: qa.acceptanceCriteria }],
1201
+ compatibility: { framework: development?.body?.target?.framework ?? "project-native", coordiation: ["@coordiation/css", "@coordiation/ui", "@coordiation/icons"], breakingChanges: [], support: "No compatibility claim beyond the approved development target and recorded candidate evidence." },
1202
+ migration: { required: false, steps: ["No data or configuration migration is required by the current contract."], reversibility: "The previous immutable release candidate remains deployable." },
1203
+ environment: { variableNames: [], secretValuesStored: false, validation: "Validate required configuration names in the deployment environment; never store secret values in lifecycle artifacts." },
1204
+ rollout: { strategy: "progressive", batches: [{ name: "internal", audience: "Release operators and internal verification", percentage: 0 }, { name: "limited", audience: "A controlled production cohort", percentage: 10 }, { name: "general", audience: "All intended users after health confirmation", percentage: 100 }], pauseConditions: ["Any health check fails.", "A security, privacy, accessibility, or data-integrity regression is reported."] },
1205
+ healthChecks: [{ id: `${id}-HEALTH-001`, signal: "availability", successCondition: "The deployed feature and required dependencies respond successfully." }, { id: `${id}-HEALTH-002`, signal: "critical user journey", successCondition: "The primary acceptance path completes in the production-like environment." }, { id: `${id}-HEALTH-003`, signal: "errors", successCondition: "No material increase in client, server, authorization, or data-integrity errors." }],
1206
+ rollback: { triggers: ["A required health check fails or degrades after rollout.", "A blocker security, privacy, accessibility, or data-integrity defect is confirmed."], steps: ["Pause further rollout and preserve diagnostic evidence.", "Restore the previous immutable candidate and compatible configuration.", "Run availability and critical-journey health checks, then communicate status."], owner: "Named release approver", maximumDecisionTime: "15 minutes" },
1207
+ requiredEvidence: ["candidate", "release-notes", "rollback-validation"],
1208
+ releaseBlockers: [],
1209
+ openDecisions: [...(qa.body.openDecisions ?? [])],
1210
+ },
1211
+ });
1212
+ const paths = await writeArtifact(cwd, release);
1213
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
1214
+ const trace = await readJson(traceFile);
1215
+ trace.nodes.push({ id: release.id, kind: release.kind, stage: release.stage, status: release.status, revision: release.revision, path: paths.json });
1216
+ trace.edges.push(
1217
+ { from: release.id, to: qa.id, relation: "releases" },
1218
+ ...release.acceptanceCriteria.map((criterionId) => ({ from: release.id, to: criterionId, relation: "includes" })),
1219
+ );
1220
+ trace.updatedAt = timestamp;
1221
+ await writeJson(traceFile, trace);
1222
+ return { created: true, artifact: release, paths };
1223
+ }
1224
+
1225
+ function productionNumberFor(releaseRecord, artifacts) {
1226
+ const preferred = Number(/-(\d+)$/.exec(releaseRecord.id)?.[1]);
1227
+ const occupied = new Set(artifacts.filter(({ id }) => id.startsWith("DEPLOY-")).map(({ id }) => Number(id.slice(7))));
1228
+ if (preferred && !occupied.has(preferred)) return preferred;
1229
+ return Math.max(0, ...occupied) + 1;
1230
+ }
1231
+
1232
+ export async function createProductionRecord({ cwd = process.cwd(), releaseId, environment = "production", sourceRevision, clock } = {}) {
1233
+ await requireInitialized(cwd);
1234
+ const { artifact: releaseRecord } = await loadArtifact(cwd, releaseId);
1235
+ if (releaseRecord.stage !== "release" || releaseRecord.kind !== "release-record") throw new Error(`${releaseRecord.id} is not a release record.`);
1236
+ if (releaseRecord.status !== "approved") throw new Error(`${releaseRecord.id} must be approved before deriving Production.`);
1237
+ const normalizedEnvironment = String(environment ?? "production").trim().toLowerCase();
1238
+ if (!/^[a-z0-9][a-z0-9-]*$/.test(normalizedEnvironment)) throw new Error("Production environment must use lowercase letters, numbers, and hyphens.");
1239
+ const normalizedRevision = String(sourceRevision ?? releaseRecord.body.candidate.snapshotSha256).trim();
1240
+ if (!/^[0-9A-Za-z][0-9A-Za-z._/-]{6,127}$/.test(normalizedRevision)) throw new Error("Source revision must be a commit, digest, or immutable candidate identifier of at least 7 characters.");
1241
+ const files = await artifactFiles(cwd);
1242
+ const artifacts = await Promise.all(files.map((file) => readJson(file)));
1243
+ const existing = artifacts.find((artifact) => artifact.stage === "production" && artifact.status !== "superseded" && artifact.derivedFrom.includes(releaseRecord.id));
1244
+ if (existing) {
1245
+ if (existing.body.deployment.environment !== normalizedEnvironment || existing.body.deployment.sourceRevision !== normalizedRevision) throw new Error(`${existing.id} already binds ${releaseRecord.id} to ${existing.body.deployment.environment} at ${existing.body.deployment.sourceRevision}. Create a new approved release before changing the deployment target.`);
1246
+ return { created: false, artifact: existing, paths: { json: `artifacts/production/${existing.id}.json`, markdown: `artifacts/production/${existing.id}.md` } };
1247
+ }
1248
+ const timestamp = now(clock);
1249
+ const id = artifactId("DEPLOY", productionNumberFor(releaseRecord, artifacts));
1250
+ const production = artifactEnvelope({
1251
+ id,
1252
+ kind: "production-record",
1253
+ title: `${releaseRecord.body.capability} ${releaseRecord.body.version} production deployment`,
1254
+ stage: "production",
1255
+ timestamp,
1256
+ derivedFrom: [releaseRecord.id],
1257
+ acceptanceCriteria: releaseRecord.acceptanceCriteria,
1258
+ body: {
1259
+ capability: releaseRecord.body.capability,
1260
+ deployment: { id: `${releaseRecord.body.candidate.id}-${normalizedEnvironment}`, environment: normalizedEnvironment, releaseId: releaseRecord.id, version: releaseRecord.body.version, candidateId: releaseRecord.body.candidate.id, sourceRevision: normalizedRevision, status: "planned", completedAt: null },
1261
+ authority: { releaseApprovalRequired: true, releaseId: releaseRecord.id, rule: "Only the exact approved release candidate may be deployed; deriving this record does not execute deployment." },
1262
+ configuration: { requiredVariableNames: releaseRecord.body.environment.variableNames, secretValuesStored: false, validation: "Resolve secret values from the authorized environment at deployment time and record only validation evidence." },
1263
+ rollout: { strategy: releaseRecord.body.rollout.strategy, batches: releaseRecord.body.rollout.batches, currentBatch: "not-started", pauseConditions: releaseRecord.body.rollout.pauseConditions },
1264
+ healthChecks: releaseRecord.body.healthChecks,
1265
+ healthStatus: "pending",
1266
+ monitoring: { signals: ["availability", "critical user journey", "error rate", "performance"], alerts: [{ signal: "availability or critical journey", condition: "A required health check fails.", owner: "Production operator" }, { signal: "security, privacy, accessibility, or data integrity", condition: "A blocker regression is detected.", owner: "Release approver" }], observationWindow: "Observe every rollout batch before advancing." },
1267
+ monitoringStatus: "pending",
1268
+ rollback: { candidate: releaseRecord.body.migration.reversibility, triggers: releaseRecord.body.rollback.triggers, steps: releaseRecord.body.rollback.steps, owner: releaseRecord.body.rollback.owner, readiness: "pending" },
1269
+ requiredEvidence: ["deployment", "health", "monitoring", "rollback-readiness"],
1270
+ incidents: [],
1271
+ feedback: [],
1272
+ productionBlockers: [],
1273
+ openDecisions: [...(releaseRecord.body.openDecisions ?? [])],
1274
+ },
1275
+ });
1276
+ const paths = await writeArtifact(cwd, production);
1277
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
1278
+ const trace = await readJson(traceFile);
1279
+ trace.nodes.push({ id: production.id, kind: production.kind, stage: production.stage, status: production.status, revision: production.revision, path: paths.json });
1280
+ trace.edges.push(
1281
+ { from: production.id, to: releaseRecord.id, relation: "deploys" },
1282
+ ...production.acceptanceCriteria.map((criterionId) => ({ from: production.id, to: criterionId, relation: "operationalizes" })),
1283
+ );
1284
+ trace.updatedAt = timestamp;
1285
+ await writeJson(traceFile, trace);
1286
+ return { created: true, artifact: production, paths };
1287
+ }
1288
+
1289
+ export async function recordLifecycleEvidence({ cwd = process.cwd(), artifactId, developmentId, qaId, type, status = "passed", path: evidencePath, command, exitCode, summary, recordedBy = "agent", clock } = {}) {
1290
+ await requireInitialized(cwd);
1291
+ const targetId = artifactId ?? developmentId ?? qaId;
1292
+ const { artifact, file } = await loadArtifact(cwd, targetId);
1293
+ const developmentTypes = ["source", "test", "build", "accessibility"];
1294
+ const qaTypes = ["functional", "visual", "accessibility", "security", "regression"];
1295
+ const releaseTypes = ["candidate", "release-notes", "rollback-validation"];
1296
+ const productionTypes = ["deployment", "health", "monitoring", "rollback-readiness"];
1297
+ const allowedTypes = artifact.stage === "development" ? developmentTypes : artifact.stage === "qa" ? qaTypes : artifact.stage === "release" ? releaseTypes : artifact.stage === "production" ? productionTypes : null;
1298
+ if (!allowedTypes) throw new Error(`${artifact.id} does not accept implementation or QA evidence.`);
1299
+ const normalizedType = String(type ?? "").trim().toLowerCase();
1300
+ if (!allowedTypes.includes(normalizedType)) throw new Error(`${artifact.stage} evidence type must be one of: ${allowedTypes.join(", ")}.`);
1301
+ if (!["passed", "failed"].includes(status)) throw new Error("Evidence status must be passed or failed.");
1302
+ let normalizedPath = null;
1303
+ let sha256 = null;
1304
+ if (evidencePath) {
1305
+ const filename = projectPath(cwd, evidencePath);
1306
+ if (!(await exists(filename))) throw new Error(`Evidence file does not exist: ${evidencePath}.`);
1307
+ const bytes = await readFile(filename);
1308
+ normalizedPath = relativePath(cwd, filename);
1309
+ sha256 = createHash("sha256").update(bytes).digest("hex");
1310
+ }
1311
+ if (!normalizedPath) throw new Error(`${normalizedType} evidence requires --path to an existing project file or report.`);
1312
+ const normalizedExitCode = exitCode === undefined || exitCode === null ? null : Number(exitCode);
1313
+ const commandRequired = artifact.stage === "development" ? normalizedType !== "source" : artifact.stage === "qa" ? normalizedType !== "visual" : artifact.stage === "release" ? normalizedType !== "release-notes" : normalizedType !== "monitoring";
1314
+ if (commandRequired && (!command || !Number.isInteger(normalizedExitCode))) throw new Error(`${normalizedType} evidence requires --command and --exit-code.`);
1315
+ if (status === "passed" && normalizedExitCode !== null && normalizedExitCode !== 0) throw new Error("Passed command evidence must have exit code 0.");
1316
+ const evidenceFile = projectPath(cwd, EVIDENCE_PATH);
1317
+ const timestamp = now(clock);
1318
+ const ledger = await exists(evidenceFile) ? await readJson(evidenceFile) : { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-evidence", records: [], updatedAt: timestamp };
1319
+ const comparable = { artifactId: artifact.id, artifactRevision: artifact.revision, type: normalizedType, status, path: normalizedPath, sha256, command: command ?? null, exitCode: normalizedExitCode };
1320
+ const existing = ledger.records.find((record) => Object.entries(comparable).every(([key, value]) => record[key] === value));
1321
+ if (existing) return { created: false, evidence: existing };
1322
+ const verified = commandRequired ? Boolean(sha256 && command && normalizedExitCode === 0) : Boolean(sha256);
1323
+ const evidence = { id: nextEvidenceId(ledger.records), ...comparable, verified, summary: summary ?? `${normalizedType} evidence recorded`, recordedBy, createdAt: timestamp };
1324
+ ledger.records.push(evidence);
1325
+ ledger.updatedAt = timestamp;
1326
+ artifact.evidence.push(evidence.id);
1327
+ artifact.updatedAt = timestamp;
1328
+ if (artifact.stage === "production") {
1329
+ if (normalizedType === "deployment") {
1330
+ artifact.body.deployment.status = status === "passed" && verified ? "deployed" : "failed";
1331
+ artifact.body.deployment.completedAt = timestamp;
1332
+ }
1333
+ if (normalizedType === "health") artifact.body.healthStatus = status === "passed" && verified ? "healthy" : "unhealthy";
1334
+ if (normalizedType === "monitoring") artifact.body.monitoringStatus = status === "passed" && verified ? "active" : "degraded";
1335
+ if (normalizedType === "rollback-readiness") artifact.body.rollback.readiness = status === "passed" && verified ? "ready" : "not-ready";
1336
+ }
1337
+ await Promise.all([writeJson(evidenceFile, ledger), writeJson(file, artifact), writeFile(file.replace(/\.json$/, ".md"), markdownArtifact(artifact))]);
1338
+ return { created: true, evidence };
1339
+ }
1340
+
1341
+ export function recordDevelopmentEvidence(options = {}) {
1342
+ return recordLifecycleEvidence({ ...options, artifactId: options.developmentId });
1343
+ }
1344
+
1345
+ export function recordQaEvidence(options = {}) {
1346
+ return recordLifecycleEvidence({ ...options, artifactId: options.qaId });
1347
+ }
1348
+
1349
+ export function recordReleaseEvidence(options = {}) {
1350
+ return recordLifecycleEvidence({ ...options, artifactId: options.releaseId });
1351
+ }
1352
+
1353
+ export function recordProductionEvidence(options = {}) {
1354
+ return recordLifecycleEvidence({ ...options, artifactId: options.productionId });
1355
+ }
1356
+
1357
+ export async function getTraceability({ cwd = process.cwd(), id } = {}) {
1358
+ await requireInitialized(cwd);
1359
+ const trace = await readJson(projectPath(cwd, TRACEABILITY_PATH));
1360
+ if (!id) return trace;
1361
+ const normalized = String(id).trim().toUpperCase();
1362
+ const edges = trace.edges.filter((edge) => edge.from === normalized || edge.to === normalized);
1363
+ const related = new Set([normalized, ...edges.flatMap((edge) => [edge.from, edge.to])]);
1364
+ return { ...trace, focus: normalized, nodes: trace.nodes.filter((node) => related.has(node.id)), edges };
1365
+ }
1366
+
1367
+ function isPlainObject(value) {
1368
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
1369
+ const prototype = Object.getPrototypeOf(value);
1370
+ return prototype === Object.prototype || prototype === null;
1371
+ }
1372
+
1373
+ export async function reviseLifecycleArtifact({ cwd = process.cwd(), artifactId, reason, bodyPatch = {}, clock } = {}) {
1374
+ await requireInitialized(cwd);
1375
+ const normalizedReason = String(reason ?? "").trim();
1376
+ if (!normalizedReason) throw new Error("Artifact revision requires a reason.");
1377
+ if (!isPlainObject(bodyPatch) || ["__proto__", "prototype", "constructor"].some((key) => Object.hasOwn(bodyPatch, key))) throw new Error("Artifact body patch must be a safe JSON object.");
1378
+ const files = await artifactFiles(cwd);
1379
+ const entries = await Promise.all(files.map(async (file) => ({ file, artifact: await readJson(file) })));
1380
+ const target = entries.find(({ artifact }) => artifact.id === String(artifactId ?? "").trim().toUpperCase());
1381
+ if (!target) throw new Error(`Unknown lifecycle artifact: ${artifactId}.`);
1382
+ if (target.artifact.status === "superseded") throw new Error(`${target.artifact.id} is superseded and cannot be revised in place.`);
1383
+ const unknownPatchKeys = Object.keys(bodyPatch).filter((key) => !Object.hasOwn(target.artifact.body, key));
1384
+ if (unknownPatchKeys.length) throw new Error(`Revision patch contains unknown ${target.artifact.kind} body fields: ${unknownPatchKeys.join(", ")}.`);
1385
+ const timestamp = now(clock);
1386
+ const priorRevision = structuredClone(target.artifact);
1387
+ const archive = projectPath(cwd, join(COORDIATION_DIRECTORY, "revisions", target.artifact.id, `revision-${target.artifact.revision}.json`));
1388
+ await writeJson(archive, { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-artifact-revision-history", reason: normalizedReason, archivedAt: timestamp, artifact: priorRevision });
1389
+
1390
+ const invalidated = new Set();
1391
+ const frontier = [target.artifact.id];
1392
+ while (frontier.length) {
1393
+ const upstreamId = frontier.shift();
1394
+ for (const entry of entries) {
1395
+ if (entry.artifact.id === target.artifact.id || invalidated.has(entry.artifact.id)) continue;
1396
+ if (entry.artifact.derivedFrom.includes(upstreamId) || entry.artifact.acceptanceCriteria.includes(upstreamId)) {
1397
+ invalidated.add(entry.artifact.id);
1398
+ frontier.push(entry.artifact.id);
1399
+ }
1400
+ }
1401
+ }
1402
+
1403
+ target.artifact.revision += 1;
1404
+ target.artifact.status = "draft";
1405
+ target.artifact.body = { ...target.artifact.body, ...structuredClone(bodyPatch) };
1406
+ target.artifact.approvals = [];
1407
+ target.artifact.evidence = [];
1408
+ target.artifact.risks = [...target.artifact.risks, { type: "revision", reason: normalizedReason, priorRevision: priorRevision.revision }];
1409
+ target.artifact.updatedAt = timestamp;
1410
+ const writes = [writeJson(target.file, target.artifact), writeFile(target.file.replace(/\.json$/, ".md"), markdownArtifact(target.artifact))];
1411
+ for (const entry of entries) {
1412
+ if (!invalidated.has(entry.artifact.id)) continue;
1413
+ entry.artifact.status = "superseded";
1414
+ entry.artifact.updatedAt = timestamp;
1415
+ entry.artifact.risks = [...entry.artifact.risks, { type: "upstream-revision", upstreamId: target.artifact.id, upstreamRevision: target.artifact.revision }];
1416
+ writes.push(writeJson(entry.file, entry.artifact), writeFile(entry.file.replace(/\.json$/, ".md"), markdownArtifact(entry.artifact)));
1417
+ }
1418
+ await Promise.all(writes);
1419
+
1420
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
1421
+ const trace = await readJson(traceFile);
1422
+ for (const node of trace.nodes) {
1423
+ if (node.id === target.artifact.id) {
1424
+ node.revision = target.artifact.revision;
1425
+ node.status = "draft";
1426
+ } else if (invalidated.has(node.id)) node.status = "superseded";
1427
+ }
1428
+ trace.updatedAt = timestamp;
1429
+ await writeJson(traceFile, trace);
1430
+
1431
+ const lifecycleFile = projectPath(cwd, LIFECYCLE_PATH);
1432
+ const lifecycle = await readJson(lifecycleFile);
1433
+ const stageIndex = lifecycle.stages.findIndex(({ id }) => id === target.artifact.stage);
1434
+ lifecycle.currentStage = target.artifact.stage;
1435
+ for (let index = stageIndex; index < lifecycle.stages.length; index += 1) lifecycle.stages[index].status = index === stageIndex ? "active" : "pending";
1436
+ for (let index = stageIndex; index < lifecycle.gates.length; index += 1) {
1437
+ lifecycle.gates[index].status = "pending";
1438
+ lifecycle.gates[index].approvalId = null;
1439
+ }
1440
+ lifecycle.updatedAt = timestamp;
1441
+ await writeJson(lifecycleFile, lifecycle);
1442
+ return { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-revision-result", artifact: { id: target.artifact.id, revision: target.artifact.revision, status: target.artifact.status }, invalidated: [...invalidated].sort(), reason: normalizedReason, archive: relativePath(cwd, archive), lifecycle: { currentStage: lifecycle.currentStage } };
1443
+ }
1444
+
1445
+ function check(id, passed, message) {
1446
+ return { id, passed, message };
1447
+ }
1448
+
1449
+ async function interviewSessions(cwd) {
1450
+ const directory = projectPath(cwd, join(COORDIATION_DIRECTORY, "interviews"));
1451
+ let entries = [];
1452
+ try { entries = await readdir(directory, { withFileTypes: true }); } catch { return []; }
1453
+ return Promise.all(entries.filter((entry) => entry.isFile() && entry.name.endsWith(".json")).map((entry) => readJson(join(directory, entry.name))));
1454
+ }
1455
+
1456
+ function latestArtifact(artifacts, stage) {
1457
+ return artifacts.filter((artifact) => artifact.stage === stage).sort((left, right) => left.id.localeCompare(right.id, undefined, { numeric: true })).at(-1);
1458
+ }
1459
+
1460
+ export async function checkLifecycleGate({ cwd = process.cwd(), gate, artifactId: requestedArtifactId } = {}) {
1461
+ await requireInitialized(cwd);
1462
+ const normalizedGate = String(gate ?? "").trim().toUpperCase();
1463
+ const stage = Object.entries(LIFECYCLE_GATES).find(([, value]) => value === normalizedGate)?.[0];
1464
+ if (!stage) throw new Error(`Unknown lifecycle gate: ${gate}.`);
1465
+ const files = await artifactFiles(cwd);
1466
+ const allArtifacts = await Promise.all(files.map((file) => readJson(file)));
1467
+ const requestedArtifact = requestedArtifactId ? allArtifacts.find((artifact) => artifact.id === String(requestedArtifactId).trim().toUpperCase()) : null;
1468
+ if (requestedArtifactId && !requestedArtifact) throw new Error(`Unknown lifecycle artifact: ${requestedArtifactId}.`);
1469
+ if (requestedArtifact?.status === "superseded") return { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-gate-report", gate: normalizedGate, stage, artifactId: requestedArtifact.id, ready: false, checks: [check("artifact-current", false, `${requestedArtifact.id} is superseded and cannot authorize a gate.`)] };
1470
+ const artifacts = allArtifacts.filter((artifact) => artifact.status !== "superseded");
1471
+ let specification;
1472
+ let prd;
1473
+ let ux;
1474
+ let prototype;
1475
+ let development;
1476
+ let qa;
1477
+ let releaseRecord;
1478
+ let productionRecord;
1479
+ if (requestedArtifact?.stage === "specification") {
1480
+ specification = requestedArtifact;
1481
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && artifact.derivedFrom.includes(specification.id));
1482
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && artifact.derivedFrom.includes(prd?.id));
1483
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && artifact.derivedFrom.includes(ux?.id));
1484
+ } else if (requestedArtifact?.stage === "prd") {
1485
+ prd = requestedArtifact;
1486
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd.derivedFrom.includes(artifact.id));
1487
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && artifact.derivedFrom.includes(prd.id));
1488
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && artifact.derivedFrom.includes(ux?.id));
1489
+ } else if (requestedArtifact?.stage === "ux") {
1490
+ ux = requestedArtifact;
1491
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux.derivedFrom.includes(artifact.id));
1492
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1493
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && artifact.derivedFrom.includes(ux.id));
1494
+ } else if (requestedArtifact?.stage === "prototype") {
1495
+ prototype = requestedArtifact;
1496
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && prototype.derivedFrom.includes(artifact.id));
1497
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux?.derivedFrom.includes(artifact.id));
1498
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1499
+ } else if (requestedArtifact?.stage === "development") {
1500
+ development = requestedArtifact;
1501
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && development.derivedFrom.includes(artifact.id));
1502
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && prototype?.derivedFrom.includes(artifact.id));
1503
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux?.derivedFrom.includes(artifact.id));
1504
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1505
+ } else if (requestedArtifact?.stage === "qa") {
1506
+ qa = requestedArtifact;
1507
+ development = artifacts.find((artifact) => artifact.stage === "development" && qa.derivedFrom.includes(artifact.id));
1508
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && development?.derivedFrom.includes(artifact.id));
1509
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && prototype?.derivedFrom.includes(artifact.id));
1510
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux?.derivedFrom.includes(artifact.id));
1511
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1512
+ } else if (requestedArtifact?.stage === "release") {
1513
+ releaseRecord = requestedArtifact;
1514
+ qa = artifacts.find((artifact) => artifact.stage === "qa" && releaseRecord.derivedFrom.includes(artifact.id));
1515
+ development = artifacts.find((artifact) => artifact.stage === "development" && qa?.derivedFrom.includes(artifact.id));
1516
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && development?.derivedFrom.includes(artifact.id));
1517
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && prototype?.derivedFrom.includes(artifact.id));
1518
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux?.derivedFrom.includes(artifact.id));
1519
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1520
+ } else if (requestedArtifact?.stage === "production") {
1521
+ productionRecord = requestedArtifact;
1522
+ releaseRecord = artifacts.find((artifact) => artifact.stage === "release" && productionRecord.derivedFrom.includes(artifact.id));
1523
+ qa = artifacts.find((artifact) => artifact.stage === "qa" && releaseRecord?.derivedFrom.includes(artifact.id));
1524
+ development = artifacts.find((artifact) => artifact.stage === "development" && qa?.derivedFrom.includes(artifact.id));
1525
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && development?.derivedFrom.includes(artifact.id));
1526
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && prototype?.derivedFrom.includes(artifact.id));
1527
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux?.derivedFrom.includes(artifact.id));
1528
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1529
+ } else if (normalizedGate === "GATE-SPEC") {
1530
+ specification = latestArtifact(artifacts, "specification");
1531
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && artifact.derivedFrom.includes(specification?.id));
1532
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && artifact.derivedFrom.includes(prd?.id));
1533
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && artifact.derivedFrom.includes(ux?.id));
1534
+ } else if (normalizedGate === "GATE-PRD") {
1535
+ prd = latestArtifact(artifacts, "prd");
1536
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1537
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && artifact.derivedFrom.includes(prd?.id));
1538
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && artifact.derivedFrom.includes(ux?.id));
1539
+ } else if (normalizedGate === "GATE-UX") {
1540
+ ux = latestArtifact(artifacts, "ux");
1541
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux?.derivedFrom.includes(artifact.id));
1542
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1543
+ prototype = artifacts.find((artifact) => artifact.stage === "prototype" && artifact.derivedFrom.includes(ux?.id));
1544
+ } else {
1545
+ prototype = latestArtifact(artifacts, "prototype");
1546
+ ux = artifacts.find((artifact) => artifact.stage === "ux" && prototype?.derivedFrom.includes(artifact.id));
1547
+ prd = artifacts.find((artifact) => artifact.stage === "prd" && ux?.derivedFrom.includes(artifact.id));
1548
+ specification = artifacts.find((artifact) => artifact.stage === "specification" && prd?.derivedFrom.includes(artifact.id));
1549
+ }
1550
+ development ??= artifacts.find((artifact) => artifact.stage === "development" && artifact.derivedFrom.includes(prototype?.id));
1551
+ qa ??= artifacts.find((artifact) => artifact.stage === "qa" && artifact.derivedFrom.includes(development?.id));
1552
+ releaseRecord ??= artifacts.find((artifact) => artifact.stage === "release" && artifact.derivedFrom.includes(qa?.id));
1553
+ productionRecord ??= artifacts.find((artifact) => artifact.stage === "production" && artifact.derivedFrom.includes(releaseRecord?.id));
1554
+ const sessions = await interviewSessions(cwd);
1555
+ const interview = sessions.find((session) => session.generatedArtifacts?.some(({ id }) => id === specification?.id));
1556
+ const evidenceFile = projectPath(cwd, EVIDENCE_PATH);
1557
+ const evidenceLedger = await exists(evidenceFile) ? await readJson(evidenceFile) : { records: [] };
1558
+ const developmentEvidence = evidenceLedger.records.filter((record) => record.artifactId === development?.id && record.artifactRevision === development?.revision);
1559
+ const qaEvidence = evidenceLedger.records.filter((record) => record.artifactId === qa?.id && record.artifactRevision === qa?.revision);
1560
+ const releaseEvidence = evidenceLedger.records.filter((record) => record.artifactId === releaseRecord?.id && record.artifactRevision === releaseRecord?.revision);
1561
+ const productionEvidence = evidenceLedger.records.filter((record) => record.artifactId === productionRecord?.id && record.artifactRevision === productionRecord?.revision);
1562
+ let checks;
1563
+ if (normalizedGate === "GATE-SPEC") {
1564
+ checks = [
1565
+ check("interview-complete", interview?.status === "completed", "The requirement interview is complete."),
1566
+ check("specification-exists", Boolean(specification), `${specification?.id ?? "A specification"} exists.`),
1567
+ check("no-blocking-open-decisions", Array.isArray(specification?.body?.openDecisions) && specification.body.openDecisions.length === 0, "The specification has no blocking open decisions."),
1568
+ ];
1569
+ } else if (normalizedGate === "GATE-PRD") {
1570
+ checks = [
1571
+ check("specification-approved", specification?.status === "approved", `${specification?.id ?? "The linked specification"} is approved.`),
1572
+ check("prd-exists", Boolean(prd), `${prd?.id ?? "A PRD"} exists.`),
1573
+ check("acceptance-criteria-linked", (prd?.acceptanceCriteria?.length ?? 0) >= 4, "The PRD links testable acceptance criteria."),
1574
+ check("no-blocking-open-decisions", Array.isArray(prd?.body?.openDecisions) && prd.body.openDecisions.length === 0, "The PRD has no blocking open decisions."),
1575
+ ];
1576
+ } else if (normalizedGate === "GATE-UX") {
1577
+ checks = [
1578
+ check("prd-approved", prd?.status === "approved", `${prd?.id ?? "The linked PRD"} is approved.`),
1579
+ check("ux-contract-exists", Boolean(ux), `${ux?.id ?? "A UX contract"} exists.`),
1580
+ check("primary-flow-complete", (ux?.body?.primaryFlow?.length ?? 0) >= 4, "The UX contract contains a complete primary flow."),
1581
+ check("screen-states-complete", (ux?.body?.screenStates?.length ?? 0) >= 5, "The UX contract defines happy, loading, empty or invalid, unavailable, and success behavior."),
1582
+ check("acceptance-criteria-linked", (ux?.acceptanceCriteria?.length ?? 0) >= 4, "The UX contract links the PRD acceptance criteria."),
1583
+ check("responsive-contract", Boolean(ux?.body?.responsiveContract?.minimumViewport), "Responsive behavior is explicit."),
1584
+ check("accessibility-contract", Boolean(ux?.body?.accessibilityContract?.keyboard && ux?.body?.accessibilityContract?.errors), "Keyboard, focus, semantics, motion, and error behavior are explicit."),
1585
+ check("no-blocking-open-decisions", Array.isArray(ux?.body?.openDecisions) && ux.body.openDecisions.length === 0, "The UX contract has no blocking open decisions."),
1586
+ ];
1587
+ } else if (normalizedGate === "GATE-PROTOTYPE") {
1588
+ const previewIds = new Set(prototype?.body?.statePreviews?.map(({ id }) => id) ?? []);
1589
+ const uxStateIds = ux?.body?.screenStates?.map(({ id }) => id) ?? [];
1590
+ const viewports = prototype?.body?.viewportMatrix?.map(({ width }) => width) ?? [];
1591
+ const layoutClasses = [...(prototype?.body?.layoutContract?.shell ?? []), ...(prototype?.body?.layoutContract?.content ?? [])];
1592
+ checks = [
1593
+ check("ux-approved", ux?.status === "approved", `${ux?.id ?? "The linked UX contract"} is approved.`),
1594
+ check("prototype-contract-exists", Boolean(prototype), `${prototype?.id ?? "A prototype contract"} exists.`),
1595
+ check("coordiation-components", (prototype?.body?.componentPlan?.length ?? 0) >= 5 && prototype?.body?.frameworkContract?.components?.includes("@coordiation/ui"), "The prototype uses a concrete Coordiation component plan."),
1596
+ check("coordiation-icons", prototype?.body?.frameworkContract?.icons?.includes("@coordiation/icons"), "Icon use is constrained to Coordiation icon components."),
1597
+ check("literal-co-classes", layoutClasses.length >= 6 && layoutClasses.every((candidate) => candidate.includes("co-")), "Layout utilities are explicit literal co-* candidates."),
1598
+ check("all-states-previewable", uxStateIds.length >= 5 && uxStateIds.every((id) => previewIds.has(id)), "Every UX state has a deterministic prototype preview."),
1599
+ check("viewport-matrix", [320, 768, 1280].every((width) => viewports.includes(width)), "Compact, medium, and wide prototype viewports are defined."),
1600
+ check("reduced-motion", prototype?.body?.motionContract?.classes?.some((candidate) => candidate.startsWith("motion-reduce:")), "The prototype defines a reduced-motion path."),
1601
+ check("evidence-plan", (prototype?.body?.evidenceRequirements?.screenshots?.length ?? 0) >= uxStateIds.length * 3 && (prototype?.body?.evidenceRequirements?.acceptanceCriteria?.length ?? 0) === (prototype?.acceptanceCriteria?.length ?? -1), "Screenshot, keyboard, accessibility, and acceptance evidence are planned."),
1602
+ check("no-blocking-open-decisions", Array.isArray(prototype?.body?.openDecisions) && prototype.body.openDecisions.length === 0, "The prototype contract has no blocking open decisions."),
1603
+ ];
1604
+ } else if (normalizedGate === "GATE-DEVELOPMENT") {
1605
+ const requiredEvidence = ["source", "test", "build", "accessibility"];
1606
+ const stateIds = new Set(development?.body?.stateImplementation?.map(({ stateId }) => stateId) ?? []);
1607
+ const prototypeStateIds = prototype?.body?.statePreviews?.map(({ id }) => id) ?? [];
1608
+ checks = [
1609
+ check("prototype-approved", prototype?.status === "approved", `${prototype?.id ?? "The linked prototype contract"} is approved.`),
1610
+ check("development-contract-exists", Boolean(development), `${development?.id ?? "A development contract"} exists.`),
1611
+ check("framework-target", Boolean(development?.body?.target?.framework && development?.body?.target?.nativeComponentModel), "The implementation target and native component model are explicit."),
1612
+ check("work-items-complete", (development?.body?.workItems?.length ?? 0) >= 5, "Source, states, adapter, components, and tests have implementation work items."),
1613
+ check("state-coverage", prototypeStateIds.length >= 5 && prototypeStateIds.every((stateId) => stateIds.has(stateId)), "Every prototype state is covered by the development plan."),
1614
+ check("acceptance-test-coverage", (development?.body?.testPlan?.length ?? 0) === (development?.acceptanceCriteria?.length ?? -1), "Every acceptance criterion maps to a development test."),
1615
+ check("literal-utilities", (development?.body?.utilityCandidates?.length ?? 0) >= 6 && development.body.utilityCandidates.every((candidate) => candidate.includes("co-")), "The development contract contains only explicit literal Coordiation utility candidates."),
1616
+ ...requiredEvidence.map((type) => check(`evidence-${type}`, developmentEvidence.some((record) => record.type === type && record.status === "passed" && record.verified), `Verified passing ${type} evidence is recorded.`)),
1617
+ check("no-blocking-open-decisions", Array.isArray(development?.body?.openDecisions) && development.body.openDecisions.length === 0, "The development contract has no blocking open decisions."),
1618
+ ];
1619
+ } else if (normalizedGate === "GATE-QA") {
1620
+ const requiredEvidence = ["functional", "visual", "accessibility", "security", "regression"];
1621
+ const latestEvidence = new Map();
1622
+ for (const record of qaEvidence) latestEvidence.set(record.type, record);
1623
+ const qaStateIds = new Set(qa?.body?.stateCoverage?.map(({ stateId }) => stateId) ?? []);
1624
+ const developmentStateIds = development?.body?.stateImplementation?.map(({ stateId }) => stateId) ?? [];
1625
+ const viewports = qa?.body?.viewportCoverage?.map(({ width }) => width) ?? [];
1626
+ checks = [
1627
+ check("development-approved", development?.status === "approved", `${development?.id ?? "The linked development contract"} is approved.`),
1628
+ check("qa-report-exists", Boolean(qa), `${qa?.id ?? "A QA report"} exists.`),
1629
+ check("acceptance-cases", (qa?.body?.testCases?.length ?? 0) === (qa?.acceptanceCriteria?.length ?? -1), "Every acceptance criterion has an executable QA case."),
1630
+ check("state-coverage", developmentStateIds.length >= 5 && developmentStateIds.every((stateId) => qaStateIds.has(stateId)), "Every implemented state is covered by QA."),
1631
+ check("viewport-coverage", [320, 768, 1280].every((width) => viewports.includes(width)), "QA covers compact, medium, and wide viewports."),
1632
+ check("accessibility-checklist", (qa?.body?.accessibilityChecklist?.length ?? 0) >= 7, "Keyboard, focus, semantics, errors, contrast, zoom, and reduced motion are covered."),
1633
+ check("security-checklist", (qa?.body?.securityChecklist?.length ?? 0) >= 5, "Secrets, authorization, disclosure, input handling, and dependency boundaries are covered."),
1634
+ ...requiredEvidence.map((type) => {
1635
+ const record = latestEvidence.get(type);
1636
+ return check(`evidence-${type}`, record?.status === "passed" && record?.verified, `Latest ${type} evidence is verified and passing.`);
1637
+ }),
1638
+ check("no-release-blockers", Array.isArray(qa?.body?.releaseBlockers) && qa.body.releaseBlockers.length === 0, "QA has no unresolved release blockers."),
1639
+ check("no-blocking-open-decisions", Array.isArray(qa?.body?.openDecisions) && qa.body.openDecisions.length === 0, "The QA report has no blocking open decisions."),
1640
+ ];
1641
+ } else if (normalizedGate === "GATE-RELEASE") {
1642
+ const requiredEvidence = ["candidate", "release-notes", "rollback-validation"];
1643
+ const latestEvidence = new Map();
1644
+ for (const record of releaseEvidence) latestEvidence.set(record.type, record);
1645
+ const rolloutPercentages = releaseRecord?.body?.rollout?.batches?.map(({ percentage }) => percentage) ?? [];
1646
+ checks = [
1647
+ check("qa-approved", qa?.status === "approved", `${qa?.id ?? "The linked QA report"} is approved.`),
1648
+ check("release-record-exists", Boolean(releaseRecord), `${releaseRecord?.id ?? "A release record"} exists.`),
1649
+ check("semantic-version", /^\d+\.\d+\.\d+(?:-[0-9A-Za-z.-]+)?$/.test(releaseRecord?.body?.version ?? ""), "The release has a semantic version."),
1650
+ check("immutable-candidate", Boolean(releaseRecord?.body?.candidate?.id) && /^[a-f0-9]{64}$/.test(releaseRecord?.body?.candidate?.snapshotSha256 ?? ""), "The release candidate has a stable identity and SHA-256 snapshot."),
1651
+ check("acceptance-coverage", (releaseRecord?.body?.candidate?.acceptanceCriteria?.length ?? 0) === (releaseRecord?.acceptanceCriteria?.length ?? -1), "The candidate includes every approved acceptance criterion."),
1652
+ check("release-notes", (releaseRecord?.body?.changelog?.length ?? 0) >= 1, "The release contains requirement-linked changelog entries."),
1653
+ check("compatibility-migration", Boolean(releaseRecord?.body?.compatibility?.support) && (releaseRecord?.body?.migration?.steps?.length ?? 0) >= 1, "Compatibility and migration behavior are explicit."),
1654
+ check("secret-safe-environment", releaseRecord?.body?.environment?.secretValuesStored === false && Array.isArray(releaseRecord?.body?.environment?.variableNames), "Environment configuration stores names only, never secret values."),
1655
+ check("progressive-rollout", rolloutPercentages.length >= 2 && rolloutPercentages.includes(100) && (releaseRecord?.body?.rollout?.pauseConditions?.length ?? 0) >= 2, "The rollout is staged, reaches general availability, and has pause conditions."),
1656
+ check("health-checks", (releaseRecord?.body?.healthChecks?.length ?? 0) >= 3, "Availability, critical journey, and error health checks are defined."),
1657
+ check("rollback-ready", (releaseRecord?.body?.rollback?.triggers?.length ?? 0) >= 2 && (releaseRecord?.body?.rollback?.steps?.length ?? 0) >= 3 && Boolean(releaseRecord?.body?.rollback?.owner), "Rollback triggers, ordered steps, and ownership are explicit."),
1658
+ ...requiredEvidence.map((type) => {
1659
+ const record = latestEvidence.get(type);
1660
+ return check(`evidence-${type}`, record?.status === "passed" && record?.verified, `Latest ${type} evidence is verified and passing.`);
1661
+ }),
1662
+ check("no-release-blockers", Array.isArray(releaseRecord?.body?.releaseBlockers) && releaseRecord.body.releaseBlockers.length === 0, "The release has no unresolved blockers."),
1663
+ check("no-blocking-open-decisions", Array.isArray(releaseRecord?.body?.openDecisions) && releaseRecord.body.openDecisions.length === 0, "The release has no blocking open decisions."),
1664
+ ];
1665
+ } else if (normalizedGate === "GATE-PRODUCTION") {
1666
+ const requiredEvidence = ["deployment", "health", "monitoring", "rollback-readiness"];
1667
+ const latestEvidence = new Map();
1668
+ for (const record of productionEvidence) latestEvidence.set(record.type, record);
1669
+ const rolloutPercentages = productionRecord?.body?.rollout?.batches?.map(({ percentage }) => percentage) ?? [];
1670
+ const unresolvedIncidents = productionRecord?.body?.incidents?.filter((incident) => incident.status !== "resolved") ?? [];
1671
+ checks = [
1672
+ check("release-approved", releaseRecord?.status === "approved", `${releaseRecord?.id ?? "The linked release"} is approved.`),
1673
+ check("production-record-exists", Boolean(productionRecord), `${productionRecord?.id ?? "A Production record"} exists.`),
1674
+ check("immutable-deployment-identity", Boolean(productionRecord?.body?.deployment?.id && productionRecord?.body?.deployment?.sourceRevision) && productionRecord?.body?.deployment?.releaseId === releaseRecord?.id, "Deployment identity binds the approved release, environment, candidate, and source revision."),
1675
+ check("deployment-succeeded", productionRecord?.body?.deployment?.status === "deployed" && Boolean(productionRecord?.body?.deployment?.completedAt), "The deployment completed successfully."),
1676
+ check("secret-safe-configuration", productionRecord?.body?.configuration?.secretValuesStored === false && Array.isArray(productionRecord?.body?.configuration?.requiredVariableNames), "Production records configuration names but never secret values."),
1677
+ check("controlled-rollout", rolloutPercentages.length >= 2 && rolloutPercentages.includes(100) && (productionRecord?.body?.rollout?.pauseConditions?.length ?? 0) >= 2, "The Production rollout has staged cohorts and pause conditions."),
1678
+ check("health-plan", (productionRecord?.body?.healthChecks?.length ?? 0) >= 3, "Availability, critical journey, and error health checks are defined."),
1679
+ check("healthy", productionRecord?.body?.healthStatus === "healthy", "The latest Production health result is healthy."),
1680
+ check("monitoring-active", productionRecord?.body?.monitoringStatus === "active" && (productionRecord?.body?.monitoring?.signals?.length ?? 0) >= 4 && (productionRecord?.body?.monitoring?.alerts?.length ?? 0) >= 2, "Monitoring and owned alerts are active."),
1681
+ check("rollback-ready", productionRecord?.body?.rollback?.readiness === "ready" && (productionRecord?.body?.rollback?.triggers?.length ?? 0) >= 2 && (productionRecord?.body?.rollback?.steps?.length ?? 0) >= 3, "The exact rollback path has been validated."),
1682
+ ...requiredEvidence.map((type) => {
1683
+ const record = latestEvidence.get(type);
1684
+ return check(`evidence-${type}`, record?.status === "passed" && record?.verified, `Latest ${type} evidence is verified and passing.`);
1685
+ }),
1686
+ check("no-unresolved-incidents", unresolvedIncidents.length === 0, "Production has no unresolved incidents."),
1687
+ check("no-production-blockers", Array.isArray(productionRecord?.body?.productionBlockers) && productionRecord.body.productionBlockers.length === 0, "Production has no unresolved blockers."),
1688
+ check("no-blocking-open-decisions", Array.isArray(productionRecord?.body?.openDecisions) && productionRecord.body.openDecisions.length === 0, "The Production record has no blocking open decisions."),
1689
+ ];
1690
+ } else {
1691
+ checks = [check("stage-implementation", false, `${normalizedGate} is defined by the blueprint but is not implemented in the first lifecycle slice.`)];
1692
+ }
1693
+ const gateArtifact = stage === "specification" ? specification : stage === "prd" ? prd : stage === "ux" ? ux : stage === "prototype" ? prototype : stage === "development" ? development : stage === "qa" ? qa : stage === "release" ? releaseRecord : stage === "production" ? productionRecord : requestedArtifact;
1694
+ return { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-gate-report", gate: normalizedGate, stage, artifactId: requestedArtifact?.id ?? gateArtifact?.id ?? null, ready: checks.every((entry) => entry.passed), checks };
1695
+ }
1696
+
1697
+ function nextApprovalId(approvals) {
1698
+ return `APPROVAL-${String(approvals.length + 1).padStart(3, "0")}`;
1699
+ }
1700
+
1701
+ export async function approveLifecycleArtifact({ cwd = process.cwd(), artifactId, gate, approverName, approverEmail, comment = "Approved", clock } = {}) {
1702
+ await requireInitialized(cwd);
1703
+ if (!approverName) throw new Error("Approval requires --approver-name.");
1704
+ const normalizedGate = String(gate ?? "").trim().toUpperCase();
1705
+ const { artifact, file } = await loadArtifact(cwd, artifactId);
1706
+ const expectedGate = LIFECYCLE_GATES[artifact.stage];
1707
+ if (normalizedGate !== expectedGate) throw new Error(`${artifact.id} belongs to ${expectedGate}, not ${normalizedGate || "an unspecified gate"}.`);
1708
+ if (artifact.status === "approved") throw new Error(`${artifact.id} revision ${artifact.revision} is already approved.`);
1709
+ const report = await checkLifecycleGate({ cwd, gate: normalizedGate, artifactId: artifact.id });
1710
+ if (!report.ready) throw new Error(`${normalizedGate} is not ready: ${report.checks.filter((entry) => !entry.passed).map((entry) => entry.id).join(", ")}`);
1711
+ const timestamp = now(clock);
1712
+ const approvalsFile = projectPath(cwd, APPROVALS_PATH);
1713
+ const approvals = await readJson(approvalsFile);
1714
+ const approval = {
1715
+ id: nextApprovalId(approvals.approvals),
1716
+ artifactId: artifact.id,
1717
+ revision: artifact.revision,
1718
+ gate: normalizedGate,
1719
+ decision: "approved",
1720
+ approver: { name: approverName, email: approverEmail || null, role: "human-approver" },
1721
+ comment,
1722
+ createdAt: timestamp,
1723
+ };
1724
+ approvals.approvals.push(approval);
1725
+ approvals.updatedAt = timestamp;
1726
+ artifact.status = "approved";
1727
+ artifact.approvals.push(approval.id);
1728
+ artifact.updatedAt = timestamp;
1729
+ await Promise.all([writeJson(file, artifact), writeFile(file.replace(/\.json$/, ".md"), markdownArtifact(artifact)), writeJson(approvalsFile, approvals)]);
1730
+ const traceFile = projectPath(cwd, TRACEABILITY_PATH);
1731
+ const trace = await readJson(traceFile);
1732
+ const node = trace.nodes.find((entry) => entry.id === artifact.id);
1733
+ if (node) node.status = "approved";
1734
+ trace.updatedAt = timestamp;
1735
+ await writeJson(traceFile, trace);
1736
+ const lifecycleFile = projectPath(cwd, LIFECYCLE_PATH);
1737
+ const lifecycle = await readJson(lifecycleFile);
1738
+ const gateEntry = lifecycle.gates.find((entry) => entry.id === normalizedGate);
1739
+ gateEntry.status = "approved";
1740
+ gateEntry.approvalId = approval.id;
1741
+ const stageIndex = lifecycle.stages.findIndex((entry) => entry.id === artifact.stage);
1742
+ const currentStageIndex = lifecycle.stages.findIndex((entry) => entry.id === lifecycle.currentStage);
1743
+ lifecycle.stages[stageIndex].status = "approved";
1744
+ if (stageIndex >= currentStageIndex && stageIndex + 1 < lifecycle.stages.length) {
1745
+ lifecycle.stages[stageIndex + 1].status = "active";
1746
+ lifecycle.currentStage = lifecycle.stages[stageIndex + 1].id;
1747
+ }
1748
+ lifecycle.updatedAt = timestamp;
1749
+ await writeJson(lifecycleFile, lifecycle);
1750
+ return { schemaVersion: LIFECYCLE_SCHEMA_VERSION, kind: "coordiation-approval-result", approval, artifact: { id: artifact.id, revision: artifact.revision, status: artifact.status }, lifecycle: { currentStage: lifecycle.currentStage } };
1751
+ }