akm-cli 0.9.0-beta.4 → 0.9.0-beta.40

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.
Files changed (131) hide show
  1. package/CHANGELOG.md +626 -0
  2. package/dist/assets/prompts/consolidate-system.md +23 -0
  3. package/dist/assets/prompts/contradiction-judge.md +33 -0
  4. package/dist/assets/prompts/distill-knowledge-system.md +22 -0
  5. package/dist/assets/prompts/distill-lesson-system.md +36 -0
  6. package/dist/assets/prompts/extract-session.md +6 -2
  7. package/dist/assets/prompts/graph-extract-system.md +1 -0
  8. package/dist/assets/prompts/memory-infer-system.md +1 -0
  9. package/dist/assets/prompts/memory-infer-user.md +5 -0
  10. package/dist/assets/prompts/metadata-enhance-system.md +1 -0
  11. package/dist/assets/prompts/procedural-system.md +44 -0
  12. package/dist/assets/prompts/recombine-system.md +40 -0
  13. package/dist/assets/prompts/staleness-detect-system.md +6 -0
  14. package/dist/assets/prompts/validate-summary-judge.md +1 -0
  15. package/dist/assets/stash-skeleton/facts/conventions/assets/agent.md +22 -0
  16. package/dist/assets/stash-skeleton/facts/conventions/assets/command.md +22 -0
  17. package/dist/assets/stash-skeleton/facts/conventions/assets/fact.md +24 -0
  18. package/dist/assets/stash-skeleton/facts/conventions/assets/knowledge.md +22 -0
  19. package/dist/assets/stash-skeleton/facts/conventions/assets/lesson.md +25 -0
  20. package/dist/assets/stash-skeleton/facts/conventions/assets/memory.md +21 -0
  21. package/dist/assets/stash-skeleton/facts/conventions/assets/script.md +21 -0
  22. package/dist/assets/stash-skeleton/facts/conventions/assets/skill.md +23 -0
  23. package/dist/assets/stash-skeleton/facts/conventions/assets/workflow.md +22 -0
  24. package/dist/assets/templates/html/health.html +281 -111
  25. package/dist/cli.js +14 -3
  26. package/dist/commands/agent/contribute-cli.js +16 -3
  27. package/dist/commands/feedback-cli.js +15 -6
  28. package/dist/commands/graph/graph.js +75 -71
  29. package/dist/commands/health/checks.js +48 -0
  30. package/dist/commands/health/html-report.js +422 -80
  31. package/dist/commands/health.js +381 -9
  32. package/dist/commands/improve/calibration.js +161 -0
  33. package/dist/commands/improve/consolidate.js +631 -111
  34. package/dist/commands/improve/dedup.js +482 -0
  35. package/dist/commands/improve/distill.js +163 -69
  36. package/dist/commands/improve/encoding-salience.js +205 -0
  37. package/dist/commands/improve/extract-cli.js +115 -1
  38. package/dist/commands/improve/extract-prompt.js +39 -2
  39. package/dist/commands/improve/extract-watch.js +140 -0
  40. package/dist/commands/improve/extract.js +403 -40
  41. package/dist/commands/improve/feedback-valence.js +54 -0
  42. package/dist/commands/improve/homeostatic.js +467 -0
  43. package/dist/commands/improve/improve-auto-accept.js +113 -6
  44. package/dist/commands/improve/improve-profiles.js +12 -0
  45. package/dist/commands/improve/improve.js +2042 -612
  46. package/dist/commands/improve/memory/memory-contradiction-detect.js +23 -28
  47. package/dist/commands/improve/outcome-loop.js +256 -0
  48. package/dist/commands/improve/proactive-maintenance.js +115 -0
  49. package/dist/commands/improve/procedural.js +418 -0
  50. package/dist/commands/improve/recombine.js +602 -0
  51. package/dist/commands/improve/reflect-noise.js +0 -0
  52. package/dist/commands/improve/reflect.js +46 -4
  53. package/dist/commands/improve/related-sessions.js +120 -0
  54. package/dist/commands/improve/salience.js +438 -0
  55. package/dist/commands/improve/triage.js +93 -0
  56. package/dist/commands/lint/agent-linter.js +19 -24
  57. package/dist/commands/lint/base-linter.js +173 -60
  58. package/dist/commands/lint/command-linter.js +19 -24
  59. package/dist/commands/lint/env-key-rules.js +34 -1
  60. package/dist/commands/lint/fact-linter.js +39 -0
  61. package/dist/commands/lint/index.js +31 -13
  62. package/dist/commands/lint/memory-linter.js +1 -1
  63. package/dist/commands/lint/registry.js +7 -2
  64. package/dist/commands/lint/task-linter.js +3 -3
  65. package/dist/commands/lint/workflow-linter.js +26 -1
  66. package/dist/commands/proposal/drain-policies.js +5 -0
  67. package/dist/commands/proposal/drain.js +17 -1
  68. package/dist/commands/proposal/proposal.js +5 -0
  69. package/dist/commands/proposal/propose.js +5 -0
  70. package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
  71. package/dist/commands/proposal/validators/proposals.js +187 -57
  72. package/dist/commands/read/curate.js +344 -80
  73. package/dist/commands/read/search-cli.js +7 -0
  74. package/dist/commands/read/search.js +1 -0
  75. package/dist/commands/read/show.js +67 -2
  76. package/dist/commands/sources/init.js +36 -9
  77. package/dist/commands/sources/installed-stashes.js +5 -1
  78. package/dist/commands/sources/schema-repair.js +13 -1
  79. package/dist/commands/sources/stash-cli.js +19 -3
  80. package/dist/commands/sources/stash-skeleton.js +23 -8
  81. package/dist/core/asset/asset-registry.js +2 -0
  82. package/dist/core/asset/asset-spec.js +14 -0
  83. package/dist/core/asset/frontmatter.js +166 -167
  84. package/dist/core/asset/markdown.js +8 -0
  85. package/dist/core/authoring-rules.js +83 -0
  86. package/dist/core/config/config-schema.js +274 -2
  87. package/dist/core/config/config.js +2 -2
  88. package/dist/core/logs-db.js +4 -3
  89. package/dist/core/paths.js +3 -0
  90. package/dist/core/standards/resolve-standards-context.js +87 -0
  91. package/dist/core/standards/resolve-stash-standards.js +99 -0
  92. package/dist/core/standards/resolve-type-conventions.js +66 -0
  93. package/dist/core/state-db.js +691 -30
  94. package/dist/indexer/db/db.js +364 -38
  95. package/dist/indexer/db/graph-db.js +129 -86
  96. package/dist/indexer/ensure-index.js +152 -17
  97. package/dist/indexer/graph/graph-boost.js +51 -41
  98. package/dist/indexer/graph/graph-extraction.js +203 -3
  99. package/dist/indexer/index-writer-lock.js +99 -0
  100. package/dist/indexer/indexer.js +114 -111
  101. package/dist/indexer/passes/memory-inference.js +10 -3
  102. package/dist/indexer/passes/staleness-detect.js +2 -5
  103. package/dist/indexer/search/db-search.js +15 -4
  104. package/dist/indexer/search/ranking-contributors.js +22 -0
  105. package/dist/indexer/search/ranking.js +4 -0
  106. package/dist/indexer/walk/matchers.js +9 -0
  107. package/dist/integrations/agent/prompts.js +33 -0
  108. package/dist/integrations/harnesses/claude/session-log.js +11 -1
  109. package/dist/integrations/harnesses/opencode/session-log.js +173 -3
  110. package/dist/integrations/session-logs/index.js +16 -0
  111. package/dist/llm/client.js +23 -4
  112. package/dist/llm/embedder.js +27 -3
  113. package/dist/llm/embedders/local.js +66 -2
  114. package/dist/llm/feature-gate.js +8 -4
  115. package/dist/llm/graph-extract.js +2 -1
  116. package/dist/llm/memory-infer.js +4 -8
  117. package/dist/llm/metadata-enhance.js +9 -1
  118. package/dist/output/renderers.js +73 -1
  119. package/dist/output/shapes/curate.js +14 -2
  120. package/dist/output/text/helpers.js +16 -1
  121. package/dist/runtime.js +25 -1
  122. package/dist/scripts/migrate-storage.js +1378 -599
  123. package/dist/scripts/migrations/import-fs-improve-runs-to-db.js +479 -270
  124. package/dist/setup/setup.js +3 -3
  125. package/dist/sources/providers/tar-utils.js +16 -8
  126. package/dist/storage/sqlite-pragmas.js +146 -0
  127. package/dist/wiki/wiki.js +37 -0
  128. package/dist/workflows/db.js +3 -4
  129. package/dist/workflows/validate-summary.js +2 -7
  130. package/docs/data-and-telemetry.md +1 -0
  131. package/package.json +8 -6
@@ -0,0 +1,418 @@
1
+ // This Source Code Form is subject to the terms of the Mozilla Public
2
+ // License, v. 2.0. If a copy of the MPL was not distributed with this
3
+ // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
+ /**
5
+ * #615 — procedural-compilation pass.
6
+ *
7
+ * An OPT-IN post-loop improve stage (default disabled via
8
+ * `IMPROVE_PROCESS_DEFAULTS.procedural`). It reads assets that carry an
9
+ * `orderedActions` frontmatter list (captured by #619), detects RECURRING
10
+ * successful action sequences across sessions (the SAME normalized ordered step
11
+ * list appearing >= `minRecurrence` times with a non-failure `outcomeData`), and
12
+ * emits ONE normal `type: workflow` proposal per recurring sequence through the
13
+ * existing proposal queue + quality gate.
14
+ *
15
+ * The ordered step list — NOT the LLM — is the source of truth. The bounded LLM
16
+ * call only NAMES the workflow and per-step titles/instructions; the parser
17
+ * rejects any output whose step count / order drifts from the deterministic
18
+ * sequence, and the assembled workflow markdown is re-parsed locally before it
19
+ * is ever queued. A justified null (the LLM determines the sequence is not a
20
+ * coherent procedure) is an acceptable outcome and produces no proposal.
21
+ */
22
+ import { createHash } from "node:crypto";
23
+ import fs from "node:fs";
24
+ import proceduralSystemPrompt from "../../assets/prompts/procedural-system.md" with { type: "text" };
25
+ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
26
+ import { resolveStashDir } from "../../core/common.js";
27
+ import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
28
+ import { appendEvent } from "../../core/events.js";
29
+ import { parseEmbeddedJsonResponse } from "../../core/parse.js";
30
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
31
+ import { warn } from "../../core/warn.js";
32
+ import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
33
+ import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
34
+ import { chatCompletion } from "../../llm/client.js";
35
+ import { parseWorkflow } from "../../workflows/parser.js";
36
+ import { validateProposalFrontmatter } from "../proposal/validators/proposal-quality-validators.js";
37
+ import { createProposal, isProposalSkipped } from "../proposal/validators/proposals.js";
38
+ const PROCEDURAL_SYSTEM_PROMPT = proceduralSystemPrompt;
39
+ const DEFAULT_MIN_RECURRENCE = 3;
40
+ const DEFAULT_MAX_PROPOSALS_PER_RUN = 3;
41
+ /** Failure-signal heuristic: an outcome matching this is NOT counted as success. */
42
+ const FAILURE_SIGNAL = /\b(fail|failed|failure|error|errored|abort|aborted|rollback|reverted)\b/i;
43
+ // ── Normalization + recurrence model (deterministic, no LLM) ────────────────────
44
+ /** Normalize a single action string: trim, lowercase, collapse whitespace, strip trailing punctuation. */
45
+ function normalizeStep(s) {
46
+ return s
47
+ .trim()
48
+ .toLowerCase()
49
+ .replace(/\s+/g, " ")
50
+ .replace(/[.;,:]+$/, "")
51
+ .trim();
52
+ }
53
+ /**
54
+ * Normalize an ordered action list: each step token-normalized (case /
55
+ * whitespace / trailing-punctuation insensitive), with empties dropped. The
56
+ * result is ORDER-SENSITIVE — reordered sequences normalize distinctly.
57
+ */
58
+ export function normalizeSequence(actions) {
59
+ return actions.map(normalizeStep).filter((s) => s.length > 0);
60
+ }
61
+ /** A member counts toward recurrence only when its outcome is present, non-empty, and not a failure signal. */
62
+ function isSuccessfulOutcome(outcome) {
63
+ if (!outcome)
64
+ return false;
65
+ const trimmed = outcome.trim();
66
+ if (!trimmed)
67
+ return false;
68
+ return !FAILURE_SIGNAL.test(trimmed);
69
+ }
70
+ /** Recover the ordered-action sequence + outcome from an asset's frontmatter (NOT a DB column). */
71
+ function readOrderedSequence(entry) {
72
+ let data;
73
+ try {
74
+ const raw = fs.readFileSync(entry.filePath, "utf8");
75
+ data = parseFrontmatter(raw).data;
76
+ }
77
+ catch {
78
+ return null;
79
+ }
80
+ const rawActions = data.orderedActions;
81
+ if (!Array.isArray(rawActions))
82
+ return null;
83
+ const actions = rawActions.filter((a) => typeof a === "string");
84
+ if (actions.length === 0)
85
+ return null;
86
+ const outcome = typeof data.outcomeData === "string" ? data.outcomeData : undefined;
87
+ const ref = `${entry.entry.type}:${entry.entry.name}`;
88
+ return { actions, ...(outcome !== undefined ? { outcome } : {}), ref, entryKey: entry.entryKey };
89
+ }
90
+ /**
91
+ * Group entries by their normalized ordered sequence. Only successful members
92
+ * (non-failure outcome) count toward recurrence. Keep groups whose successful
93
+ * member count >= `minRecurrence`, sort deterministically (member-count desc
94
+ * then groupKey asc), and cap to `maxProposalsPerRun`. Exported for unit tests.
95
+ */
96
+ export function buildSequenceClusters(entries, opts) {
97
+ const groups = new Map();
98
+ for (const entry of entries) {
99
+ const seq = readOrderedSequence(entry);
100
+ if (!seq)
101
+ continue;
102
+ const normalized = normalizeSequence(seq.actions);
103
+ if (normalized.length === 0)
104
+ continue;
105
+ if (!isSuccessfulOutcome(seq.outcome))
106
+ continue;
107
+ const groupKey = JSON.stringify(normalized);
108
+ const existing = groups.get(groupKey);
109
+ const member = { ref: seq.ref, entryKey: seq.entryKey, outcome: seq.outcome ?? "" };
110
+ if (existing) {
111
+ if (!existing.members.some((m) => m.entryKey === member.entryKey))
112
+ existing.members.push(member);
113
+ }
114
+ else {
115
+ groups.set(groupKey, { groupKey, normalized, members: [member] });
116
+ }
117
+ }
118
+ const clusters = [...groups.values()].filter((c) => c.members.length >= opts.minRecurrence);
119
+ clusters.sort((a, b) => b.members.length - a.members.length || a.groupKey.localeCompare(b.groupKey));
120
+ return clusters.slice(0, Math.max(0, opts.maxProposalsPerRun));
121
+ }
122
+ /**
123
+ * Stable workflow ref for a sequence cluster. The hash of the sorted member keys
124
+ * + the normalized sequence keeps the ref deterministic across runs, so
125
+ * re-detection maps to the same ref and the content-hash dedup in createProposal
126
+ * suppresses queue churn.
127
+ */
128
+ export function deriveProceduralWorkflowRef(cluster) {
129
+ const slug = cluster.normalized
130
+ .slice(0, 3)
131
+ .join("-")
132
+ .toLowerCase()
133
+ .replace(/[^a-z0-9-]+/g, "-")
134
+ .replace(/-+/g, "-")
135
+ .replace(/^-|-$/g, "");
136
+ const memberKey = cluster.members
137
+ .map((m) => m.entryKey)
138
+ .sort()
139
+ .join("|");
140
+ const hash = createHash("sha256")
141
+ .update(`${memberKey} ${JSON.stringify(cluster.normalized)}`, "utf8")
142
+ .digest("hex")
143
+ .slice(0, 8);
144
+ return `workflow:compiled/${slug || "sequence"}-${hash}`;
145
+ }
146
+ // ── Prompt + parse ──────────────────────────────────────────────────────────────
147
+ /** Assemble the per-sequence user prompt fed to the procedural LLM. */
148
+ export function buildProceduralPrompt(cluster, standardsContext = "") {
149
+ const lines = [
150
+ `A recurring successful action sequence observed across ${cluster.members.length} sessions.`,
151
+ "",
152
+ ];
153
+ if (standardsContext.trim()) {
154
+ lines.push("Standards to follow (the rulebook for this target):");
155
+ lines.push(standardsContext.trim());
156
+ lines.push("");
157
+ }
158
+ lines.push("Ordered actions (turn EACH into exactly one step, in this order):");
159
+ cluster.normalized.forEach((step, i) => {
160
+ lines.push(`${i + 1}. ${step}`);
161
+ });
162
+ lines.push("", "Sample successful outcomes:");
163
+ for (const m of cluster.members.slice(0, 5)) {
164
+ if (m.outcome)
165
+ lines.push(`- ${m.outcome}`);
166
+ }
167
+ lines.push("", `Return strict JSON with EXACTLY ${cluster.normalized.length} steps in the same order, or an explicit null.`);
168
+ return lines.join("\n");
169
+ }
170
+ /** Parse the raw LLM output into a workflow, or `null` for the justified-null path. */
171
+ function parseProceduralWorkflow(raw, expectedSteps) {
172
+ if (raw === null)
173
+ return null;
174
+ const trimmed = raw.trim();
175
+ if (!trimmed || trimmed.toLowerCase() === "null")
176
+ return null;
177
+ const parsed = parseEmbeddedJsonResponse(trimmed);
178
+ if (parsed === undefined || parsed === null || typeof parsed !== "object")
179
+ return null;
180
+ const obj = parsed;
181
+ const title = typeof obj.title === "string" ? obj.title.trim() : "";
182
+ const description = typeof obj.description === "string" ? obj.description : "";
183
+ if (!title && !description)
184
+ return null;
185
+ if (!Array.isArray(obj.steps) || obj.steps.length !== expectedSteps)
186
+ return null;
187
+ const steps = [];
188
+ for (const rawStep of obj.steps) {
189
+ if (typeof rawStep !== "object" || rawStep === null)
190
+ return null;
191
+ const s = rawStep;
192
+ const stepTitle = typeof s.title === "string" ? s.title.trim() : "";
193
+ const instructions = typeof s.instructions === "string" ? s.instructions.trim() : "";
194
+ if (!stepTitle || !instructions)
195
+ return null;
196
+ const completionCriteria = Array.isArray(s.completionCriteria)
197
+ ? s.completionCriteria.filter((c) => typeof c === "string" && c.trim().length > 0)
198
+ : undefined;
199
+ steps.push({
200
+ title: stepTitle,
201
+ instructions,
202
+ ...(completionCriteria && completionCriteria.length > 0 ? { completionCriteria } : {}),
203
+ });
204
+ }
205
+ return { title: title || "Compiled Workflow", description, steps };
206
+ }
207
+ // ── Workflow markdown assembly ────────────────────────────────────────────────
208
+ /** Convert a step title into a kebab-case step id. */
209
+ function kebab(s) {
210
+ return (s
211
+ .toLowerCase()
212
+ .replace(/[^a-z0-9]+/g, "-")
213
+ .replace(/-+/g, "-")
214
+ .replace(/^-|-$/g, "") || "step");
215
+ }
216
+ /** Build the exact workflow markdown the parser accepts. */
217
+ export function assembleWorkflowMarkdown(doc) {
218
+ const lines = [
219
+ "---",
220
+ `description: ${JSON.stringify(doc.description)}`,
221
+ "---",
222
+ "",
223
+ `# Workflow: ${doc.title}`,
224
+ "",
225
+ ];
226
+ const usedIds = new Set();
227
+ doc.steps.forEach((step, idx) => {
228
+ let id = kebab(step.title);
229
+ if (usedIds.has(id))
230
+ id = `${id}-${idx + 1}`;
231
+ usedIds.add(id);
232
+ lines.push(`## Step: ${step.title}`, `Step ID: ${id}`, "", "### Instructions", step.instructions, "");
233
+ if (step.completionCriteria && step.completionCriteria.length > 0) {
234
+ lines.push("### Completion Criteria");
235
+ for (const c of step.completionCriteria)
236
+ lines.push(`- ${c}`);
237
+ lines.push("");
238
+ }
239
+ });
240
+ return lines.join("\n");
241
+ }
242
+ // ── Production LLM seam ───────────────────────────────────────────────────────
243
+ /**
244
+ * Resolve the production LLM seam from the active improve profile. Returns a
245
+ * `ProceduralLlmFn` that issues one bounded chatCompletion per call, or
246
+ * `undefined` when no LLM is configured (the pass then makes no calls).
247
+ */
248
+ function resolveProductionLlmFn(config, signal) {
249
+ const proceduralProcess = config.profiles?.improve?.default?.processes?.procedural;
250
+ const runnerSpec = resolveImproveProcessRunnerFromProfile(proceduralProcess, config);
251
+ const llmConfig = runnerSpec && runnerIsLlm(runnerSpec) ? runnerSpec.connection : getDefaultLlmConfig(config);
252
+ if (!llmConfig)
253
+ return undefined;
254
+ return async (prompt) => {
255
+ const messages = [
256
+ { role: "system", content: PROCEDURAL_SYSTEM_PROMPT },
257
+ { role: "user", content: prompt },
258
+ ];
259
+ try {
260
+ return await chatCompletion(llmConfig, messages, { signal, enableThinking: false });
261
+ }
262
+ catch (e) {
263
+ warn(`[procedural] LLM call failed: ${String(e)}`);
264
+ return null;
265
+ }
266
+ };
267
+ }
268
+ // ── Main entry point ───────────────────────────────────────────────────────────
269
+ export async function akmProcedural(opts) {
270
+ const startMs = Date.now();
271
+ const config = opts.config ?? loadConfig();
272
+ const stashDir = opts.stashDir ?? resolveStashDir();
273
+ const sourceRun = opts.sourceRun ?? `procedural-${startMs}`;
274
+ const eligibilitySource = opts.eligibilitySource ?? "procedural";
275
+ const minRecurrence = opts.minRecurrence ?? DEFAULT_MIN_RECURRENCE;
276
+ const maxProposalsPerRun = opts.maxProposalsPerRun ?? DEFAULT_MAX_PROPOSALS_PER_RUN;
277
+ const warnings = [];
278
+ const finish = (over) => ({
279
+ schemaVersion: 1,
280
+ ok: true,
281
+ sequencesScanned: 0,
282
+ clustersFormed: 0,
283
+ proposalsEmitted: 0,
284
+ nullsReturned: 0,
285
+ durationMs: Date.now() - startMs,
286
+ warnings,
287
+ ...over,
288
+ });
289
+ // Budget guard: an already-aborted signal short-circuits before any LLM call.
290
+ if (opts.signal?.aborted) {
291
+ return finish({ ok: false, warnings: [...warnings, "aborted-before-start"] });
292
+ }
293
+ // Load all entries from the index (orderedActions can ride any asset type).
294
+ let entries = [];
295
+ let db;
296
+ try {
297
+ db = openExistingDatabase();
298
+ entries = getAllEntries(db);
299
+ }
300
+ catch (e) {
301
+ warnings.push(`procedural: failed to open index — ${String(e)}`);
302
+ return finish({ ok: false });
303
+ }
304
+ finally {
305
+ if (db)
306
+ closeDatabase(db);
307
+ }
308
+ const clusters = buildSequenceClusters(entries, { minRecurrence, maxProposalsPerRun });
309
+ const sequencesScanned = entries.length;
310
+ let clustersFormed = 0;
311
+ let proposalsEmitted = 0;
312
+ let nullsReturned = 0;
313
+ if (clusters.length === 0) {
314
+ return finish({ sequencesScanned, clustersFormed: 0 });
315
+ }
316
+ const llmFn = opts.proceduralLlmFn ?? resolveProductionLlmFn(config, opts.signal);
317
+ if (!llmFn) {
318
+ warnings.push("procedural: no LLM configured — skipping");
319
+ return finish({ sequencesScanned, clustersFormed: 0 });
320
+ }
321
+ // Procedural output is a workflow (non-wiki) → stash authoring standards.
322
+ // Resolved ONCE per run and passed to each sequence prompt.
323
+ const standardsContext = resolveStashStandards(stashDir);
324
+ for (const cluster of clusters) {
325
+ if (opts.signal?.aborted) {
326
+ warnings.push("aborted-mid-run");
327
+ break;
328
+ }
329
+ clustersFormed += 1;
330
+ const workflowRef = deriveProceduralWorkflowRef(cluster);
331
+ const prompt = buildProceduralPrompt(cluster, standardsContext);
332
+ const raw = await llmFn(prompt);
333
+ const doc = parseProceduralWorkflow(raw, cluster.normalized.length);
334
+ if (!doc) {
335
+ nullsReturned += 1;
336
+ appendEvent({
337
+ eventType: "procedural_compiled",
338
+ ref: workflowRef,
339
+ metadata: {
340
+ groupKey: cluster.groupKey,
341
+ memberCount: cluster.members.length,
342
+ outcome: "null_returned",
343
+ sourceRun,
344
+ },
345
+ }, opts.ctx);
346
+ continue;
347
+ }
348
+ // Quality gate (always-run, never bypassed): the description must be present
349
+ // and non-truncated. Runs BEFORE createProposal.
350
+ const fmCheck = validateProposalFrontmatter({ description: doc.description });
351
+ if (!fmCheck.ok) {
352
+ appendEvent({
353
+ eventType: "procedural_compiled",
354
+ ref: workflowRef,
355
+ metadata: {
356
+ groupKey: cluster.groupKey,
357
+ memberCount: cluster.members.length,
358
+ outcome: "quality_rejected",
359
+ reason: fmCheck.reason,
360
+ sourceRun,
361
+ },
362
+ }, opts.ctx);
363
+ continue;
364
+ }
365
+ // Assemble + locally validate the workflow markdown. Never queue an
366
+ // unparseable workflow.
367
+ const content = assembleWorkflowMarkdown(doc);
368
+ const parsed = parseWorkflow(content, { path: workflowRef });
369
+ if (!parsed.ok) {
370
+ appendEvent({
371
+ eventType: "procedural_compiled",
372
+ ref: workflowRef,
373
+ metadata: {
374
+ groupKey: cluster.groupKey,
375
+ memberCount: cluster.members.length,
376
+ outcome: "invalid_workflow",
377
+ reason: parsed.errors[0]?.message,
378
+ sourceRun,
379
+ },
380
+ }, opts.ctx);
381
+ continue;
382
+ }
383
+ const proposalResult = createProposal(stashDir, {
384
+ ref: workflowRef,
385
+ source: "procedural",
386
+ sourceRun,
387
+ payload: { content, frontmatter: { description: doc.description } },
388
+ eligibilitySource,
389
+ }, opts.ctx);
390
+ if (isProposalSkipped(proposalResult)) {
391
+ appendEvent({
392
+ eventType: "procedural_compiled",
393
+ ref: workflowRef,
394
+ metadata: {
395
+ groupKey: cluster.groupKey,
396
+ memberCount: cluster.members.length,
397
+ outcome: "skipped",
398
+ skipReason: proposalResult.reason,
399
+ sourceRun,
400
+ },
401
+ }, opts.ctx);
402
+ continue;
403
+ }
404
+ proposalsEmitted += 1;
405
+ appendEvent({
406
+ eventType: "procedural_compiled",
407
+ ref: workflowRef,
408
+ metadata: {
409
+ groupKey: cluster.groupKey,
410
+ memberCount: cluster.members.length,
411
+ outcome: "queued",
412
+ proposalId: proposalResult.id,
413
+ sourceRun,
414
+ },
415
+ }, opts.ctx);
416
+ }
417
+ return finish({ sequencesScanned, clustersFormed, proposalsEmitted, nullsReturned });
418
+ }