@akanjs/devkit 2.3.9-rc.2 → 2.3.9-rc.3

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.
package/akanContext.ts CHANGED
@@ -4,7 +4,15 @@ import { capitalize } from "akanjs/common";
4
4
  import { AppExecutor, LibExecutor, type SysExecutor, type WorkspaceExecutor } from "./executors";
5
5
  import { FileSys } from "./fileSys";
6
6
  import type { PackageJson } from "./types";
7
- import type { RepairAction } from "./workflow";
7
+ import {
8
+ type GeneratedSyncState,
9
+ type RepairAction,
10
+ type WorkflowApplyReport,
11
+ type WorkflowPlan,
12
+ type WorkflowRunArtifact,
13
+ workflowRunArtifactPath,
14
+ workflowSyncDir,
15
+ } from "./workflow";
8
16
 
9
17
  export type AkanContextFormat = "json" | "markdown";
10
18
  export type AkanModuleKind = "domain" | "service" | "scalar";
@@ -61,6 +69,29 @@ export interface AkanDiagnostic {
61
69
  message: string;
62
70
  path?: string;
63
71
  repairActions?: RepairAction[];
72
+ scope?: "baseline" | "workflow" | "unknown";
73
+ context?: {
74
+ workflow?: string;
75
+ planPath?: string;
76
+ runId?: string;
77
+ target?: string;
78
+ paths?: string[];
79
+ };
80
+ }
81
+
82
+ export interface GeneratedFilesFreshness {
83
+ status: "fresh" | "stale" | "missing" | "unknown";
84
+ message: string;
85
+ refreshCommand: string;
86
+ verifyingCommands: string[];
87
+ targets?: {
88
+ target: string;
89
+ status: "fresh" | "stale" | "missing" | "unknown";
90
+ lastSyncedAt?: string;
91
+ runId?: string;
92
+ generatedFiles: string[];
93
+ reason: string;
94
+ }[];
64
95
  }
65
96
 
66
97
  export interface AkanDoctorResult {
@@ -71,14 +102,11 @@ export interface AkanDoctorResult {
71
102
  status: "passed" | "failed";
72
103
  diagnostics: AkanDiagnostic[];
73
104
  generatedFiles: string[];
74
- generatedFilesFreshness: {
75
- status: "unknown";
76
- message: string;
77
- refreshCommand: string;
78
- verifyingCommands: string[];
79
- };
105
+ generatedFilesFreshness: GeneratedFilesFreshness;
80
106
  validationCommands: string[];
81
107
  repairActions: RepairAction[];
108
+ baselineDiagnostics?: AkanDiagnostic[];
109
+ workflowDiagnostics?: AkanDiagnostic[];
82
110
  }
83
111
 
84
112
  export type JsonRpcRequest = {
@@ -136,6 +164,7 @@ export const renderDoctorText = (result: AkanDoctorResult) => {
136
164
  lines.push(
137
165
  "",
138
166
  "Generated file freshness:",
167
+ `Status: ${result.generatedFilesFreshness.status}`,
139
168
  result.generatedFilesFreshness.message,
140
169
  `Refresh: ${result.generatedFilesFreshness.refreshCommand}`,
141
170
  "",
@@ -182,7 +211,7 @@ const validationCommands = [
182
211
  "akan doctor --strict --format json",
183
212
  ];
184
213
 
185
- const generatedFilesFreshness = {
214
+ const unknownGeneratedFilesFreshness: GeneratedFilesFreshness = {
186
215
  status: "unknown" as const,
187
216
  message: "Run sync before validation so generated Akan files match the current source conventions.",
188
217
  refreshCommand: "akan sync <app-or-lib>",
@@ -269,6 +298,135 @@ const safeReadJson = async <T>(filePath: string) => {
269
298
  }
270
299
  };
271
300
 
301
+ const isWorkflowPlan = (value: unknown): value is WorkflowPlan =>
302
+ typeof value === "object" &&
303
+ value !== null &&
304
+ "schemaVersion" in value &&
305
+ value.schemaVersion === 1 &&
306
+ "mode" in value &&
307
+ value.mode === "plan";
308
+
309
+ const isWorkflowApplyReport = (value: unknown): value is WorkflowApplyReport =>
310
+ typeof value === "object" &&
311
+ value !== null &&
312
+ "schemaVersion" in value &&
313
+ value.schemaVersion === 1 &&
314
+ "mode" in value &&
315
+ (value.mode === "apply" || value.mode === "dry-run");
316
+
317
+ const isWorkflowRunArtifact = (value: unknown): value is WorkflowRunArtifact =>
318
+ typeof value === "object" && value !== null && "schemaVersion" in value && value.schemaVersion === 1;
319
+
320
+ const planInputString = (plan: WorkflowPlan, key: string) => {
321
+ const value = plan.inputs[key];
322
+ return typeof value === "string" ? value : "";
323
+ };
324
+
325
+ const expandWorkflowTarget = (target: string, plan: WorkflowPlan) => {
326
+ const app = planInputString(plan, "app");
327
+ const module = planInputString(plan, "module");
328
+ const moduleClass = module ? capitalize(module) : "<Module>";
329
+ return target
330
+ .replace(/^\*\//, app ? `apps/${app}/` : "")
331
+ .replaceAll("<module>", module || "<module>")
332
+ .replaceAll("<Module>", moduleClass);
333
+ };
334
+
335
+ const workflowPathsForPlan = (plan: WorkflowPlan) =>
336
+ plan.predictedChanges.map((change) => expandWorkflowTarget(change.target, plan));
337
+
338
+ const workflowPathsForArtifact = (artifact: WorkflowRunArtifact) => {
339
+ if (isWorkflowPlan(artifact)) return workflowPathsForPlan(artifact);
340
+ if (isWorkflowApplyReport(artifact)) {
341
+ return [
342
+ ...artifact.changedFiles.map((file) => file.path),
343
+ ...artifact.generatedFiles.map((file) => file.path),
344
+ ...workflowPathsForPlan(artifact.plan),
345
+ ];
346
+ }
347
+ if ("mode" in artifact && artifact.mode === "validate" && artifact.plan) return workflowPathsForPlan(artifact.plan);
348
+ return [];
349
+ };
350
+
351
+ const loadWorkflowContextPaths = async (
352
+ workspace: WorkspaceExecutor,
353
+ runIdOrPlan: string | null,
354
+ changedFiles: string[],
355
+ ) => {
356
+ const paths = [...changedFiles];
357
+ if (!runIdOrPlan) return paths;
358
+ const inputPath = path.isAbsolute(runIdOrPlan) ? runIdOrPlan : path.join(workspace.workspaceRoot, runIdOrPlan);
359
+ const artifact =
360
+ (await safeReadJson<WorkflowRunArtifact | WorkflowPlan>(inputPath)) ??
361
+ (await safeReadJson<WorkflowRunArtifact>(path.join(workspace.workspaceRoot, workflowRunArtifactPath(runIdOrPlan))));
362
+ if (artifact && isWorkflowRunArtifact(artifact)) paths.push(...workflowPathsForArtifact(artifact));
363
+ return [...new Set(paths.filter(Boolean))];
364
+ };
365
+
366
+ const pathKey = (value: string) =>
367
+ value
368
+ .replaceAll("\\", "/")
369
+ .replaceAll("*", "")
370
+ .replace(/<[^>]+>/g, "")
371
+ .replace(/\/+/g, "/")
372
+ .replace(/^\/|\/$/g, "");
373
+
374
+ const isWorkflowRelatedDiagnostic = (diagnostic: AkanDiagnostic, workflowPaths: string[]) => {
375
+ if (!diagnostic.path) return false;
376
+ const diagnosticPath = pathKey(diagnostic.path);
377
+ return workflowPaths.some((workflowPath) => {
378
+ const candidate = pathKey(workflowPath);
379
+ if (!candidate) return false;
380
+ return (
381
+ diagnosticPath.startsWith(candidate) || candidate.startsWith(diagnosticPath) || diagnosticPath.includes(candidate)
382
+ );
383
+ });
384
+ };
385
+
386
+ const readGeneratedSyncStates = async (workspace: WorkspaceExecutor) => {
387
+ const syncDir = path.join(workspace.workspaceRoot, workflowSyncDir);
388
+ const entries = await safeReadDir(syncDir);
389
+ const states = await Promise.all(
390
+ entries
391
+ .filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
392
+ .map((entry) => safeReadJson<GeneratedSyncState>(path.join(syncDir, entry.name))),
393
+ );
394
+ return states.filter(
395
+ (state): state is GeneratedSyncState =>
396
+ !!state && state.schemaVersion === 1 && typeof state.target === "string" && typeof state.syncedAt === "string",
397
+ );
398
+ };
399
+
400
+ const generatedFreshnessFromStates = async (workspace: WorkspaceExecutor): Promise<GeneratedFilesFreshness> => {
401
+ const states = await readGeneratedSyncStates(workspace);
402
+ if (states.length === 0) return unknownGeneratedFilesFreshness;
403
+ const targets = states
404
+ .sort((a, b) => a.target.localeCompare(b.target))
405
+ .map((state) => ({
406
+ target: state.target,
407
+ status: state.status === "passed" ? ("fresh" as const) : ("stale" as const),
408
+ lastSyncedAt: state.syncedAt,
409
+ runId: state.runId,
410
+ generatedFiles: state.generatedFiles.map((file) => file.path),
411
+ reason:
412
+ state.status === "passed"
413
+ ? `Generated files were refreshed by ${state.command}.`
414
+ : `Last generated repair command failed: ${state.command}.`,
415
+ }));
416
+ const lastFresh = targets
417
+ .filter((target) => target.status === "fresh" && target.lastSyncedAt)
418
+ .sort((a, b) => (b.lastSyncedAt ?? "").localeCompare(a.lastSyncedAt ?? ""))[0];
419
+ return {
420
+ status: targets.some((target) => target.status === "fresh") ? "fresh" : "stale",
421
+ message: lastFresh
422
+ ? `Generated files were refreshed for ${lastFresh.target} at ${lastFresh.lastSyncedAt}.`
423
+ : "Generated sync state exists, but the last recorded repair did not pass.",
424
+ refreshCommand: "akan sync <app-or-lib>",
425
+ verifyingCommands: ["akan lint <app-or-lib-or-pkg>", "akan build <app-name>"],
426
+ targets,
427
+ };
428
+ };
429
+
272
430
  const parseAbstractSummary = (
273
431
  relativePath: string,
274
432
  content: string | null,
@@ -422,9 +580,14 @@ export class AkanContextAnalyzer {
422
580
 
423
581
  static async doctor(
424
582
  workspace: WorkspaceExecutor,
425
- { strict = false }: { strict?: boolean } = {},
583
+ {
584
+ strict = false,
585
+ runIdOrPlan = null,
586
+ changedFiles = [],
587
+ }: { strict?: boolean; runIdOrPlan?: string | null; changedFiles?: string[] } = {},
426
588
  ): Promise<AkanDoctorResult> {
427
589
  const context = await AkanContextAnalyzer.analyze(workspace);
590
+ const workflowPaths = await loadWorkflowContextPaths(workspace, runIdOrPlan, changedFiles);
428
591
  const diagnostics: AkanDiagnostic[] = [];
429
592
  const repairActions: RepairAction[] = [
430
593
  repairAction("generated", "akan repair generated --app <app-or-lib>", "Refresh generated Akan files.", true),
@@ -524,17 +687,29 @@ export class AkanContextAnalyzer {
524
687
  }
525
688
  }
526
689
 
690
+ const scopedDiagnostics = diagnostics.map((diagnostic) => ({
691
+ ...diagnostic,
692
+ scope: workflowPaths.length
693
+ ? isWorkflowRelatedDiagnostic(diagnostic, workflowPaths)
694
+ ? ("workflow" as const)
695
+ : ("baseline" as const)
696
+ : diagnostic.scope,
697
+ context: workflowPaths.length ? { ...diagnostic.context, paths: workflowPaths } : diagnostic.context,
698
+ }));
699
+ const workflowDiagnostics = scopedDiagnostics.filter((diagnostic) => diagnostic.scope === "workflow");
700
+ const baselineDiagnostics = scopedDiagnostics.filter((diagnostic) => diagnostic.scope === "baseline");
527
701
  return {
528
702
  schemaVersion: 1,
529
703
  repoName: context.repoName,
530
704
  root: context.root,
531
705
  strict,
532
- status: diagnostics.some((diagnostic) => diagnostic.severity === "error") ? "failed" : "passed",
533
- diagnostics,
706
+ status: scopedDiagnostics.some((diagnostic) => diagnostic.severity === "error") ? "failed" : "passed",
707
+ diagnostics: scopedDiagnostics,
534
708
  generatedFiles: context.generatedFiles,
535
- generatedFilesFreshness,
709
+ generatedFilesFreshness: await generatedFreshnessFromStates(workspace),
536
710
  validationCommands: context.validationCommands,
537
711
  repairActions,
712
+ ...(workflowPaths.length ? { baselineDiagnostics, workflowDiagnostics } : {}),
538
713
  };
539
714
  }
540
715
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@akanjs/devkit",
3
- "version": "2.3.9-rc.2",
3
+ "version": "2.3.9-rc.3",
4
4
  "sourceType": "module",
5
5
  "type": "module",
6
6
  "publishConfig": {
@@ -32,7 +32,7 @@
32
32
  "@langchain/openai": "^1.4.6",
33
33
  "@tailwindcss/node": "^4.3.0",
34
34
  "@trapezedev/project": "^7.1.4",
35
- "akanjs": "2.3.9-rc.2",
35
+ "akanjs": "2.3.9-rc.3",
36
36
  "chalk": "^5.6.2",
37
37
  "commander": "^14.0.3",
38
38
  "daisyui": "5.5.23",
package/workflow/index.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import { capitalize } from "akanjs/common";
1
2
  import type { Sys, Workspace } from "../commandDecorators";
2
3
  import { AppExecutor, LibExecutor } from "../executors";
3
4
 
@@ -47,6 +48,7 @@ export interface WorkflowPredictedChange {
47
48
  target: string;
48
49
  action: "inspect" | "create" | "modify" | "sync" | "validate";
49
50
  reason: string;
51
+ applyScope?: "auto" | "manual-review" | "generated-sync" | "validation";
50
52
  }
51
53
 
52
54
  export interface WorkflowValidation {
@@ -76,6 +78,23 @@ export interface WorkflowDiagnostic {
76
78
  command?: string;
77
79
  kind?: WorkflowValidationKind;
78
80
  failureScope?: WorkflowFailureScope;
81
+ scope?: "baseline" | "workflow" | "unknown";
82
+ context?: {
83
+ workflow?: string;
84
+ planPath?: string;
85
+ runId?: string;
86
+ target?: string;
87
+ paths?: string[];
88
+ };
89
+ }
90
+
91
+ export interface WorkflowRecommendation {
92
+ code: string;
93
+ message: string;
94
+ kind: "auto-apply" | "validation" | "manual-action" | "import" | "placement" | "ui-component";
95
+ target?: string;
96
+ action?: string;
97
+ confidence?: "high" | "medium" | "low";
79
98
  }
80
99
 
81
100
  export interface WorkflowPlan {
@@ -88,6 +107,7 @@ export interface WorkflowPlan {
88
107
  predictedChanges: readonly WorkflowPredictedChange[];
89
108
  validation: readonly WorkflowValidation[];
90
109
  diagnostics: WorkflowDiagnostic[];
110
+ recommendations: WorkflowRecommendation[];
91
111
  requiresApproval: true;
92
112
  }
93
113
 
@@ -167,6 +187,9 @@ export type PrimitiveFileMap = Record<string, { filename: string; content: strin
167
187
 
168
188
  export interface WorkflowApplyReport {
169
189
  schemaVersion: 1;
190
+ runId?: string;
191
+ applyReportPath?: string;
192
+ validationTarget?: string;
170
193
  workflow: string;
171
194
  mode: "dry-run" | "apply";
172
195
  status: "passed" | "failed";
@@ -176,6 +199,7 @@ export interface WorkflowApplyReport {
176
199
  recommendedValidationCommands: WorkflowApplyCommand[];
177
200
  commands: WorkflowApplyCommand[];
178
201
  diagnostics: WorkflowDiagnostic[];
202
+ recommendations: WorkflowRecommendation[];
179
203
  nextActions: PrimitiveNextAction[];
180
204
  plan: WorkflowPlan;
181
205
  }
@@ -189,6 +213,8 @@ export interface WorkflowValidationRunReport {
189
213
  status: "passed" | "failed";
190
214
  commands: WorkflowValidationCommandResult[];
191
215
  diagnostics: WorkflowDiagnostic[];
216
+ baselineDiagnostics?: WorkflowDiagnostic[];
217
+ workflowDiagnostics?: WorkflowDiagnostic[];
192
218
  repairActions: RepairAction[];
193
219
  nextActions: PrimitiveNextAction[];
194
220
  plan?: WorkflowPlan;
@@ -196,6 +222,8 @@ export interface WorkflowValidationRunReport {
196
222
 
197
223
  export interface RepairReport {
198
224
  schemaVersion: 1;
225
+ runId?: string;
226
+ repairReportPath?: string;
199
227
  command: string;
200
228
  kind: RepairAction["kind"];
201
229
  target: string | null;
@@ -204,6 +232,18 @@ export interface RepairReport {
204
232
  repairActions: RepairAction[];
205
233
  nextActions: PrimitiveNextAction[];
206
234
  commands: WorkflowValidationCommandResult[];
235
+ generatedFiles?: PrimitiveGeneratedFile[];
236
+ syncedAt?: string;
237
+ }
238
+
239
+ export interface GeneratedSyncState {
240
+ schemaVersion: 1;
241
+ target: string;
242
+ status: "passed" | "failed";
243
+ syncedAt: string;
244
+ command: string;
245
+ runId?: string;
246
+ generatedFiles: PrimitiveGeneratedFile[];
207
247
  }
208
248
 
209
249
  export type WorkflowRunArtifact = WorkflowApplyReport | WorkflowValidationRunReport | RepairReport;
@@ -213,6 +253,7 @@ export interface WorkflowStepResult {
213
253
  generatedFiles?: PrimitiveGeneratedFile[];
214
254
  commands?: WorkflowApplyCommand[];
215
255
  diagnostics?: WorkflowDiagnostic[];
256
+ recommendations?: WorkflowRecommendation[];
216
257
  nextActions?: PrimitiveNextAction[];
217
258
  }
218
259
 
@@ -264,6 +305,92 @@ export const getWorkflowSpec = (specs: readonly WorkflowSpec[], name: string) =>
264
305
  export const compactWorkflowInputs = (inputs: WorkflowPlanInputs) =>
265
306
  Object.fromEntries(Object.entries(inputs).filter(([, value]) => value !== null && value !== ""));
266
307
 
308
+ const addFieldComponentForType = (typeName: string) => {
309
+ const normalizedType = normalizeFieldType(typeName);
310
+ if (normalizedType === "Boolean") return "Field.ToggleSelect";
311
+ if (normalizedType === "Date") return "Field.Date";
312
+ if (normalizedType === "Int" || normalizedType === "Float") return "Field.ToggleSelect or Field.Text";
313
+ if (typeName.toLowerCase() === "enum") return "Field.ToggleSelect";
314
+ return "Field.Text";
315
+ };
316
+
317
+ const createAddFieldRecommendations = (inputs: Record<string, WorkflowInputValue>): WorkflowRecommendation[] => {
318
+ const app = typeof inputs.app === "string" ? inputs.app : "<app-or-lib>";
319
+ const module = typeof inputs.module === "string" ? inputs.module : "<module>";
320
+ const field = typeof inputs.field === "string" ? inputs.field : "<field>";
321
+ const typeName = typeof inputs.type === "string" ? inputs.type : null;
322
+ if (!typeName) return [];
323
+
324
+ const normalizedType = typeName.toLowerCase() === "enum" ? "enum" : normalizeFieldType(typeName);
325
+ const constantPath = `*/lib/${module}/${module}.constant.ts`;
326
+ const dictionaryPath = `*/lib/${module}/${module}.dictionary.ts`;
327
+ const templatePath = `*/lib/${module}/${capitalize(module)}.Template.tsx`;
328
+ return [
329
+ ...(normalizedType === "Int" || normalizedType === "Float"
330
+ ? [
331
+ {
332
+ code: "add-field-import",
333
+ kind: "import" as const,
334
+ target: constantPath,
335
+ confidence: "high" as const,
336
+ message: `Import ${normalizedType} from "akanjs/base" before writing field(${normalizedType}).`,
337
+ },
338
+ ]
339
+ : []),
340
+ {
341
+ code: "add-field-placement-constant",
342
+ kind: "placement",
343
+ target: constantPath,
344
+ confidence: "high",
345
+ message: `Insert ${field}: field(${normalizedType}) in ${capitalize(module)}Input.`,
346
+ },
347
+ {
348
+ code: "add-field-placement-dictionary",
349
+ kind: "placement",
350
+ target: dictionaryPath,
351
+ confidence: "high",
352
+ message: `Add dictionary labels for ${module}.${field}.`,
353
+ },
354
+ {
355
+ code: "add-field-component",
356
+ kind: "ui-component",
357
+ target: templatePath,
358
+ confidence: normalizedType === "Int" || normalizedType === "Float" ? "medium" : "high",
359
+ message: `Recommended UI component for ${field} (${normalizedType}): ${addFieldComponentForType(typeName)}.`,
360
+ },
361
+ {
362
+ code: "add-field-ui-manual-review",
363
+ kind: "manual-action",
364
+ target: templatePath,
365
+ action: `Review ${app}:${module} Template/Unit/View/Store surfaces and add ${field} only where the existing pattern is clear.`,
366
+ confidence: "medium",
367
+ message: "UI surface edits are planned as manual-review unless a safe existing field-list pattern is detected.",
368
+ },
369
+ ];
370
+ };
371
+
372
+ const createWorkflowPlanRecommendations = (
373
+ spec: WorkflowSpec,
374
+ inputs: Record<string, WorkflowInputValue>,
375
+ ): WorkflowRecommendation[] => [
376
+ {
377
+ code: "workflow-apply-first",
378
+ kind: "auto-apply",
379
+ confidence: "high",
380
+ action: "Call apply_workflow with the MCP planPath before editing source files directly.",
381
+ message: `Apply the ${spec.name} plan through apply_workflow when MCP returns planPath or next.tool=apply_workflow.`,
382
+ },
383
+ {
384
+ code: "workflow-validate-apply-report",
385
+ kind: "validation",
386
+ confidence: "high",
387
+ action:
388
+ "After apply_workflow, call run_validation with validationTarget when present; otherwise use applyReportPath.",
389
+ message: "Validate the apply report artifact so diagnostics and recommendations follow the actual apply result.",
390
+ },
391
+ ...(spec.name === "add-field" ? createAddFieldRecommendations(inputs) : []),
392
+ ];
393
+
267
394
  export const createWorkflowPlan = (spec: WorkflowSpec, rawInputs: Record<string, unknown>): WorkflowPlan => {
268
395
  const inputs: Record<string, WorkflowInputValue> = {};
269
396
  const diagnostics: WorkflowDiagnostic[] = [];
@@ -328,6 +455,7 @@ export const createWorkflowPlan = (spec: WorkflowSpec, rawInputs: Record<string,
328
455
  predictedChanges: spec.predictedChanges,
329
456
  validation: spec.validation,
330
457
  diagnostics,
458
+ recommendations: createWorkflowPlanRecommendations(spec, inputs),
331
459
  requiresApproval: true,
332
460
  };
333
461
  };
@@ -387,7 +515,10 @@ export const renderWorkflowPlan = (plan: WorkflowPlan) =>
387
515
  ...plan.steps.map((step, index) => `${index + 1}. \`${step.id}\` (${step.tool}): ${step.description}`),
388
516
  "",
389
517
  "## Predicted Changes",
390
- ...plan.predictedChanges.map((change) => `- \`${change.action}\` ${change.target}: ${change.reason}`),
518
+ ...plan.predictedChanges.map((change) => {
519
+ const scope = change.applyScope ? ` (${change.applyScope})` : "";
520
+ return `- \`${change.action}\`${scope} ${change.target}: ${change.reason}`;
521
+ }),
391
522
  "",
392
523
  "## Validation",
393
524
  ...plan.validation.map((validation) => `- \`${validation.command}\`: ${validation.reason}`),
@@ -397,6 +528,11 @@ export const renderWorkflowPlan = (plan: WorkflowPlan) =>
397
528
  ? plan.diagnostics.map((diagnostic) => `- [${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`)
398
529
  : ["- none"]),
399
530
  "",
531
+ "## Recommendations",
532
+ ...(plan.recommendations.length
533
+ ? plan.recommendations.map((recommendation) => `- [${recommendation.kind}] ${recommendation.message}`)
534
+ : ["- none"]),
535
+ "",
400
536
  ].join("\n");
401
537
 
402
538
  const workflowStatus = (diagnostics: readonly WorkflowDiagnostic[]) =>
@@ -424,15 +560,25 @@ export const createWorkflowApplyReport = ({
424
560
  recommendedValidationCommands,
425
561
  commands = [],
426
562
  diagnostics = [],
563
+ recommendations = [],
427
564
  nextActions = [],
428
565
  plan,
429
566
  }: Omit<
430
567
  WorkflowApplyReport,
431
- "schemaVersion" | "status" | "appliedCommands" | "recommendedValidationCommands" | "commands"
568
+ | "schemaVersion"
569
+ | "runId"
570
+ | "applyReportPath"
571
+ | "validationTarget"
572
+ | "status"
573
+ | "appliedCommands"
574
+ | "recommendedValidationCommands"
575
+ | "commands"
576
+ | "recommendations"
432
577
  > & {
433
578
  appliedCommands?: WorkflowApplyCommand[];
434
579
  recommendedValidationCommands?: WorkflowApplyCommand[];
435
580
  commands?: WorkflowApplyCommand[];
581
+ recommendations?: WorkflowRecommendation[];
436
582
  }): WorkflowApplyReport => {
437
583
  const validationCommands = recommendedValidationCommands ?? commands;
438
584
  return {
@@ -446,6 +592,7 @@ export const createWorkflowApplyReport = ({
446
592
  recommendedValidationCommands: uniqueBy(validationCommands, (command) => command.command),
447
593
  commands: uniqueBy(validationCommands, (command) => command.command),
448
594
  diagnostics,
595
+ recommendations: uniqueBy(recommendations, (recommendation) => `${recommendation.kind}:${recommendation.code}`),
449
596
  nextActions: uniqueBy(nextActions, (action) => action.command),
450
597
  plan,
451
598
  };
@@ -475,17 +622,55 @@ export const createWorkflowRunId = (prefix = "run") =>
475
622
  .slice(0, 14)}-${Math.random().toString(36).slice(2, 8)}`;
476
623
 
477
624
  const getRunId = (artifact: WorkflowRunArtifact) => {
478
- if ("runId" in artifact) return artifact.runId;
625
+ if ("runId" in artifact && artifact.runId) return artifact.runId;
479
626
  return createWorkflowRunId("mode" in artifact ? artifact.mode : artifact.kind);
480
627
  };
481
628
 
482
629
  export const workflowRunArtifactPath = (runId: string) => `${workflowRunsDir}/${runId}.json`;
483
630
 
631
+ const withWorkflowRunMetadata = (
632
+ artifact: WorkflowRunArtifact,
633
+ runId: string,
634
+ artifactPath: string,
635
+ ): WorkflowRunArtifact => {
636
+ if ("mode" in artifact && (artifact.mode === "apply" || artifact.mode === "dry-run")) {
637
+ return { ...artifact, runId, applyReportPath: artifactPath, validationTarget: artifactPath };
638
+ }
639
+ if ("kind" in artifact) return { ...artifact, runId, repairReportPath: artifactPath };
640
+ return artifact;
641
+ };
642
+
484
643
  export const writeWorkflowRunArtifact = async (workspace: Workspace, artifact: WorkflowRunArtifact) => {
485
644
  const runId = getRunId(artifact);
486
645
  const artifactPath = workflowRunArtifactPath(runId);
487
- await workspace.writeFile(artifactPath, jsonText(artifact), { silent: true });
488
- return { runId, path: artifactPath };
646
+ const artifactWithMetadata = withWorkflowRunMetadata(artifact, runId, artifactPath);
647
+ await workspace.writeFile(artifactPath, jsonText(artifactWithMetadata), { silent: true });
648
+ return { runId, path: artifactPath, artifact: artifactWithMetadata };
649
+ };
650
+
651
+ export const workflowSyncDir = ".akan/workflows/sync";
652
+
653
+ const syncStateSlug = (target: string) =>
654
+ target
655
+ .trim()
656
+ .replace(/[^a-zA-Z0-9]+/g, "-")
657
+ .replace(/^-+|-+$/g, "")
658
+ .toLowerCase();
659
+
660
+ export const workflowSyncStatePath = (target: string) => `${workflowSyncDir}/${syncStateSlug(target) || "target"}.json`;
661
+
662
+ export const generatedFilePathsForTarget = (targetRoot: string, reason = "Generated files were refreshed by sync.") =>
663
+ [
664
+ { path: `${targetRoot}/lib/cnst.ts`, action: "sync", reason },
665
+ { path: `${targetRoot}/lib/dict.ts`, action: "sync", reason },
666
+ { path: `${targetRoot}/lib/option.ts`, action: "sync", reason },
667
+ { path: `${targetRoot}/lib/index.ts`, action: "sync", reason },
668
+ ] satisfies PrimitiveGeneratedFile[];
669
+
670
+ export const writeGeneratedSyncState = async (workspace: Workspace, state: GeneratedSyncState) => {
671
+ const statePath = workflowSyncStatePath(state.target);
672
+ await workspace.writeFile(statePath, jsonText(state), { silent: true });
673
+ return statePath;
489
674
  };
490
675
 
491
676
  export const readWorkflowRunArtifact = async (workspace: Workspace, runId: string) => {
@@ -506,6 +691,8 @@ export const createWorkflowValidationRunReport = async ({
506
691
  commands,
507
692
  execute,
508
693
  diagnostics = [],
694
+ baselineDiagnostics = [],
695
+ workflowDiagnostics = [],
509
696
  repairActions = [],
510
697
  }: {
511
698
  runId?: string;
@@ -515,6 +702,8 @@ export const createWorkflowValidationRunReport = async ({
515
702
  commands: WorkflowApplyCommand[];
516
703
  execute: WorkflowValidationCommandExecutor;
517
704
  diagnostics?: WorkflowDiagnostic[];
705
+ baselineDiagnostics?: WorkflowDiagnostic[];
706
+ workflowDiagnostics?: WorkflowDiagnostic[];
518
707
  repairActions?: RepairAction[];
519
708
  }): Promise<WorkflowValidationRunReport> => {
520
709
  const results: WorkflowValidationCommandResult[] = [];
@@ -544,6 +733,8 @@ export const createWorkflowValidationRunReport = async ({
544
733
  status: workflowStatus([...diagnostics, ...commandDiagnostics]) === "failed" ? "failed" : commandStatus(results),
545
734
  commands: results,
546
735
  diagnostics: [...diagnostics, ...commandDiagnostics],
736
+ baselineDiagnostics,
737
+ workflowDiagnostics,
547
738
  repairActions: uniqueBy(repairActions, (action) => action.command),
548
739
  nextActions: results
549
740
  .filter((result) => result.status === "failed")
@@ -581,6 +772,7 @@ export const createDryRunWorkflowApplyReport = (plan: WorkflowPlan) => {
581
772
  generatedFiles,
582
773
  commands: workflowCommandsForPlan(plan),
583
774
  diagnostics,
775
+ recommendations: plan.recommendations,
584
776
  nextActions: workflowCommandsForPlan(plan),
585
777
  plan,
586
778
  });
@@ -593,6 +785,7 @@ export const primitiveReportToWorkflowStepResult = (report: PrimitiveWriteReport
593
785
  generatedFiles: report.generatedFiles,
594
786
  commands: report.validationCommands,
595
787
  diagnostics: report.diagnostics,
788
+ recommendations: [],
596
789
  nextActions: report.nextActions,
597
790
  });
598
791
 
@@ -629,6 +822,31 @@ const unsupportedInput = (input: string, message: string): WorkflowDiagnostic =>
629
822
  message,
630
823
  });
631
824
 
825
+ const addFieldUiSurfaceInspection = (plan: WorkflowPlan): WorkflowStepResult => {
826
+ const module = workflowStringInput(plan.inputs.module) ?? "<module>";
827
+ const field = workflowStringInput(plan.inputs.field) ?? "<field>";
828
+ const moduleClassName = capitalize(module);
829
+ const target = `*/lib/${module}/${moduleClassName}.Template.tsx`;
830
+ return {
831
+ recommendations: [
832
+ {
833
+ code: "add-field-ui-surface-review",
834
+ kind: "manual-action",
835
+ target,
836
+ action: `Add ${field} to ${moduleClassName} UI surfaces only when the local Field.* pattern is clear.`,
837
+ confidence: "medium",
838
+ message: `Review Template/Unit/View/Store surfaces for ${module}.${field}; no UI file was modified automatically.`,
839
+ },
840
+ ],
841
+ nextActions: [
842
+ {
843
+ command: `akan workflow explain ${plan.workflow}`,
844
+ reason: "Review UI surface guidance before manually editing ambiguous UI files.",
845
+ },
846
+ ],
847
+ };
848
+ };
849
+
632
850
  export const createWorkflowStepRegistry = ({
633
851
  workspace,
634
852
  createModule,
@@ -699,7 +917,7 @@ export const createWorkflowStepRegistry = ({
699
917
  );
700
918
  },
701
919
  [workflowStepKey("add-field", "update-dictionary")]: inspect,
702
- [workflowStepKey("add-field", "update-ui-surfaces")]: inspect,
920
+ [workflowStepKey("add-field", "update-ui-surfaces")]: async (_step, plan) => addFieldUiSurfaceInspection(plan),
703
921
  [workflowStepKey("add-enum-field", "update-constant")]: async (_step, plan) =>
704
922
  primitiveReportToWorkflowStepResult(
705
923
  await addEnumField({
@@ -756,6 +974,11 @@ export const renderWorkflowApplyReport = (report: WorkflowApplyReport) =>
756
974
  ? report.diagnostics.map((diagnostic) => `- [${diagnostic.severity}] ${diagnostic.code}: ${diagnostic.message}`)
757
975
  : ["- none"]),
758
976
  "",
977
+ "## Recommendations",
978
+ ...(report.recommendations.length
979
+ ? report.recommendations.map((recommendation) => `- [${recommendation.kind}] ${recommendation.message}`)
980
+ : ["- none"]),
981
+ "",
759
982
  "## Next Actions",
760
983
  ...(report.nextActions.length
761
984
  ? report.nextActions.map((action) => `- \`${action.command}\`: ${action.reason}`)
@@ -818,12 +1041,25 @@ export const createRepairReport = ({
818
1041
  repairActions = [],
819
1042
  nextActions = [],
820
1043
  commands = [],
1044
+ generatedFiles = [],
1045
+ syncedAt,
821
1046
  }: Omit<
822
1047
  RepairReport,
823
- "schemaVersion" | "status" | "target" | "diagnostics" | "repairActions" | "nextActions" | "commands"
1048
+ | "schemaVersion"
1049
+ | "runId"
1050
+ | "repairReportPath"
1051
+ | "status"
1052
+ | "target"
1053
+ | "diagnostics"
1054
+ | "repairActions"
1055
+ | "nextActions"
1056
+ | "commands"
824
1057
  > &
825
1058
  Partial<
826
- Pick<RepairReport, "target" | "diagnostics" | "repairActions" | "nextActions" | "commands">
1059
+ Pick<
1060
+ RepairReport,
1061
+ "target" | "diagnostics" | "repairActions" | "nextActions" | "commands" | "generatedFiles" | "syncedAt"
1062
+ >
827
1063
  >): RepairReport => ({
828
1064
  schemaVersion: 1,
829
1065
  command,
@@ -834,6 +1070,8 @@ export const createRepairReport = ({
834
1070
  repairActions: uniqueBy(repairActions, (action) => action.command),
835
1071
  nextActions: uniqueBy(nextActions, (action) => action.command),
836
1072
  commands,
1073
+ generatedFiles,
1074
+ ...(syncedAt ? { syncedAt } : {}),
837
1075
  });
838
1076
 
839
1077
  export const renderRepairReportMarkdown = (report: RepairReport) =>
@@ -871,6 +1109,7 @@ export class WorkflowExecutor {
871
1109
  const generatedFiles: PrimitiveGeneratedFile[] = [];
872
1110
  const recommendedValidationCommands: WorkflowApplyCommand[] = [];
873
1111
  const diagnostics: WorkflowDiagnostic[] = [...plan.diagnostics];
1112
+ const recommendations: WorkflowRecommendation[] = [...plan.recommendations];
874
1113
  const nextActions: PrimitiveNextAction[] = [];
875
1114
 
876
1115
  if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) {
@@ -881,6 +1120,7 @@ export class WorkflowExecutor {
881
1120
  generatedFiles,
882
1121
  recommendedValidationCommands,
883
1122
  diagnostics,
1123
+ recommendations,
884
1124
  nextActions,
885
1125
  plan,
886
1126
  });
@@ -911,6 +1151,7 @@ export class WorkflowExecutor {
911
1151
  generatedFiles.push(...(result.generatedFiles ?? []));
912
1152
  recommendedValidationCommands.push(...(result.commands ?? []));
913
1153
  diagnostics.push(...(result.diagnostics ?? []));
1154
+ recommendations.push(...(result.recommendations ?? []));
914
1155
  nextActions.push(...(result.nextActions ?? []));
915
1156
  if (diagnostics.some((diagnostic) => diagnostic.severity === "error")) break;
916
1157
  }
@@ -922,6 +1163,7 @@ export class WorkflowExecutor {
922
1163
  generatedFiles,
923
1164
  recommendedValidationCommands,
924
1165
  diagnostics,
1166
+ recommendations,
925
1167
  nextActions,
926
1168
  plan,
927
1169
  });
@@ -994,12 +1236,7 @@ export const sourceFile = (sys: Sys, path: string, action: PrimitiveChangedFile[
994
1236
  });
995
1237
 
996
1238
  export const generatedFilesForSync = (sys: Sys, reason = "Generated files may change after sync.") =>
997
- [
998
- { path: `${getSysRoot(sys)}/lib/cnst.ts`, action: "sync", reason },
999
- { path: `${getSysRoot(sys)}/lib/dict.ts`, action: "sync", reason },
1000
- { path: `${getSysRoot(sys)}/lib/option.ts`, action: "sync", reason },
1001
- { path: `${getSysRoot(sys)}/lib/index.ts`, action: "sync", reason },
1002
- ] satisfies PrimitiveGeneratedFile[];
1239
+ generatedFilePathsForTarget(getSysRoot(sys), reason);
1003
1240
 
1004
1241
  export const validationCommandsForTarget = (target: string) =>
1005
1242
  [