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

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 (132) hide show
  1. package/CHANGELOG.md +646 -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/git.js +71 -61
  126. package/dist/sources/providers/tar-utils.js +16 -8
  127. package/dist/storage/sqlite-pragmas.js +146 -0
  128. package/dist/wiki/wiki.js +37 -0
  129. package/dist/workflows/db.js +3 -4
  130. package/dist/workflows/validate-summary.js +2 -7
  131. package/docs/data-and-telemetry.md +1 -0
  132. package/package.json +8 -6
@@ -20,6 +20,7 @@ const STASH_SUBDIRS = [
20
20
  "lessons",
21
21
  "tasks",
22
22
  "knowledge",
23
+ "facts",
23
24
  ];
24
25
  // ── Helpers ───────────────────────────────────────────────────────────────────
25
26
  function collectYamlFiles(dir) {
@@ -85,7 +86,8 @@ export function akmLint(options = {}) {
85
86
  const fix = options.fix ?? false;
86
87
  const fixed = [];
87
88
  const flagged = [];
88
- for (const subdir of STASH_SUBDIRS) {
89
+ const dirsToScan = options.typeFilter ? STASH_SUBDIRS.filter((d) => d === options.typeFilter) : STASH_SUBDIRS;
90
+ for (const subdir of dirsToScan) {
89
91
  const dirPath = path.join(stashRoot, subdir);
90
92
  // Tasks are .yml files; everything else is .md
91
93
  const files = subdir === "tasks" ? collectYamlFiles(dirPath) : collectMarkdownFiles(dirPath);
@@ -97,7 +99,9 @@ export function akmLint(options = {}) {
97
99
  if (entry.isDirectory()) {
98
100
  const subdirIssues = linter.lintDirectory(path.join(dirPath, entry.name), stashRoot);
99
101
  for (const issue of subdirIssues) {
100
- if (issue.fixed) {
102
+ // Tristate-safe: only `true` counts as fixed; `false` and "failed"
103
+ // are both flagged.
104
+ if (issue.fixed === true) {
101
105
  fixed.push(issue);
102
106
  }
103
107
  else {
@@ -108,6 +112,9 @@ export function akmLint(options = {}) {
108
112
  }
109
113
  }
110
114
  for (const filePath of files) {
115
+ // Skip registry-cached read-only files — --fix must not mutate them.
116
+ if (filePath.includes("/.cache/") || filePath.includes("/registry/"))
117
+ continue;
111
118
  const relPath = path.relative(stashRoot, filePath);
112
119
  let raw;
113
120
  try {
@@ -142,10 +149,11 @@ export function akmLint(options = {}) {
142
149
  fileDeleted = true;
143
150
  fixed.push(issue);
144
151
  }
145
- else if (issue.fixed) {
152
+ else if (issue.fixed === true) {
146
153
  fixed.push(issue);
147
154
  }
148
155
  else {
156
+ // fixed === false (not fixable / no fix requested) or "failed" (fix attempted but threw)
149
157
  flagged.push(issue);
150
158
  }
151
159
  }
@@ -159,16 +167,26 @@ export function akmLint(options = {}) {
159
167
  // findings go into `flagged`, never `fixed`.
160
168
  const envRoots = [stashRoot, ...extraStashRoots];
161
169
  for (const root of envRoots) {
162
- const dir = path.join(root, "env");
163
- if (!fs.existsSync(dir))
164
- continue;
165
- for (const envPath of collectEnvFiles(dir)) {
166
- const baseName = path.basename(envPath, ".env");
167
- // "default" (or empty) maps to ".env" → env:default
168
- const ref = baseName === "" ? "env:default" : `env:${baseName}`;
169
- const relPath = path.relative(root, envPath);
170
- for (const issue of checkEnvForDangerousKeys(envPath, relPath, ref)) {
171
- flagged.push(issue);
170
+ // The `env` assets live under `env/` (ref prefix `env:`); whole-file
171
+ // `secret` assets live under `secrets/` (canonical ref prefix `secret:`,
172
+ // singular). Map the scan directory to its canonical ref prefix so the
173
+ // finding's `Ref:` field matches what `akm show`/`akm secret` accept.
174
+ for (const { scanSubdir, refPrefix } of [
175
+ { scanSubdir: "env", refPrefix: "env" },
176
+ { scanSubdir: "secrets", refPrefix: "secret" },
177
+ ]) {
178
+ const dir = path.join(root, scanSubdir);
179
+ if (!fs.existsSync(dir))
180
+ continue;
181
+ for (const envPath of collectEnvFiles(dir)) {
182
+ const baseName = path.basename(envPath, ".env");
183
+ // A dotfile literally named `.env` has an empty baseName — use the full
184
+ // basename so it doesn't collide with `default.env` → refPrefix:default.
185
+ const ref = baseName === "" ? `${refPrefix}:.env` : `${refPrefix}:${baseName}`;
186
+ const relPath = path.relative(root, envPath);
187
+ for (const issue of checkEnvForDangerousKeys(envPath, relPath, ref)) {
188
+ flagged.push(issue);
189
+ }
172
190
  }
173
191
  }
174
192
  }
@@ -33,7 +33,7 @@ export class MemoryLinter extends BaseLinter {
33
33
  file: ctx.relPath,
34
34
  issue: "orphaned-stub",
35
35
  detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
36
- fixed: false,
36
+ fixed: "failed",
37
37
  });
38
38
  }
39
39
  // Signal caller to skip remaining checks via a sentinel issue
@@ -4,6 +4,7 @@
4
4
  import { AgentLinter } from "./agent-linter.js";
5
5
  import { CommandLinter } from "./command-linter.js";
6
6
  import { DefaultLinter } from "./default-linter.js";
7
+ import { FactLinter } from "./fact-linter.js";
7
8
  import { KnowledgeLinter } from "./knowledge-linter.js";
8
9
  import { MemoryLinter } from "./memory-linter.js";
9
10
  import { SkillLinter } from "./skill-linter.js";
@@ -18,15 +19,19 @@ const LINTERS = [
18
19
  new KnowledgeLinter(),
19
20
  new SkillLinter(),
20
21
  new TaskLinter(),
21
- new DefaultLinter(),
22
+ new FactLinter(),
22
23
  ];
24
+ // Single shared DefaultLinter instance — used both as the explicit "lessons"
25
+ // handler and as the fallback for any unrecognised asset type.
26
+ const DEFAULT_LINTER = new DefaultLinter();
23
27
  const LINTER_MAP = new Map();
24
28
  for (const linter of LINTERS) {
25
29
  for (const t of linter.types) {
26
30
  LINTER_MAP.set(t, linter);
27
31
  }
28
32
  }
29
- const DEFAULT_LINTER = new DefaultLinter();
33
+ // Register "lessons" explicitly so there is only one DefaultLinter instance.
34
+ LINTER_MAP.set("lessons", DEFAULT_LINTER);
30
35
  /**
31
36
  * Return the appropriate linter for the given stash subdirectory name.
32
37
  * Falls back to `DefaultLinter` for unknown types.
@@ -28,9 +28,9 @@ export class TaskLinter extends BaseLinter {
28
28
  if (!("schedule" in ctx.data) || typeof ctx.data.schedule !== "string" || ctx.data.schedule.trim() === "") {
29
29
  missing.push("schedule");
30
30
  }
31
- // enabled: must be present (boolean value of false is valid)
32
- if (!("enabled" in ctx.data)) {
33
- missing.push("enabled");
31
+ // enabled: must be present AND a boolean (value of false is valid, but "yes" / 1 are not)
32
+ if (!("enabled" in ctx.data) || typeof ctx.data.enabled !== "boolean") {
33
+ missing.push("enabled (must be a boolean)");
34
34
  }
35
35
  // At least one of: prompt, workflow, or command
36
36
  const hasTarget = "prompt" in ctx.data || "workflow" in ctx.data || "command" in ctx.data;
@@ -2,6 +2,7 @@
2
2
  // License, v. 2.0. If a copy of the MPL was not distributed with this
3
3
  // file, You can obtain one at https://mozilla.org/MPL/2.0/.
4
4
  import fs from "node:fs";
5
+ import { parseWorkflow } from "../../workflows/parser.js";
5
6
  import { BaseLinter } from "./base-linter.js";
6
7
  const PLACEHOLDER_STRINGS = ["Describe what this workflow accomplishes", "Example Workflow"];
7
8
  /**
@@ -32,7 +33,7 @@ export class WorkflowLinter extends BaseLinter {
32
33
  file: ctx.relPath,
33
34
  issue: "placeholder-stub",
34
35
  detail: `could not delete: ${e instanceof Error ? e.message : String(e)}`,
35
- fixed: false,
36
+ fixed: "failed",
36
37
  });
37
38
  }
38
39
  return issues;
@@ -44,6 +45,30 @@ export class WorkflowLinter extends BaseLinter {
44
45
  fixed: false,
45
46
  });
46
47
  }
48
+ const isReadOnly = ctx.filePath.includes("/.cache/") || ctx.filePath.includes("/registry/");
49
+ if (!isReadOnly) {
50
+ try {
51
+ const result = parseWorkflow(ctx.raw, { path: ctx.filePath });
52
+ if (!result.ok) {
53
+ for (const err of result.errors ?? []) {
54
+ issues.push({
55
+ file: ctx.relPath,
56
+ issue: "invalid-workflow-structure",
57
+ detail: err.message ?? String(err),
58
+ fixed: false,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ catch (e) {
64
+ issues.push({
65
+ file: ctx.relPath,
66
+ issue: "invalid-workflow-structure",
67
+ detail: `workflow parser error: ${e instanceof Error ? e.message : String(e)}`,
68
+ fixed: false,
69
+ });
70
+ }
71
+ }
47
72
  return issues;
48
73
  }
49
74
  #checkPlaceholderStub(body) {
@@ -45,6 +45,10 @@ export const PERSONAL_STASH = {
45
45
  { generator: "reflect", maxDiffLines: 80 },
46
46
  // Consolidate within the diff band; mid-band lands in `defer` below.
47
47
  { generator: "consolidate", maxDiffLines: 200 },
48
+ // Recombine: accept only confirmed type:lesson proposals (promoted by the
49
+ // recombine confidence gate) within the diff band. type:hypothesis proposals
50
+ // have no matching rule here and stay pending for manual review.
51
+ { generator: "recombine", requireType: "lesson", maxDiffLines: 200 },
48
52
  ],
49
53
  rejectEmpty: true,
50
54
  // Mid-band consolidate, distill duplicates, and contradiction escalations are
@@ -83,6 +87,7 @@ const DrainAcceptRuleSchema = z
83
87
  generator: GeneratorSchema,
84
88
  maxDiffLines: z.number().int().positive().optional(),
85
89
  minContentLines: z.number().int().nonnegative().optional(),
90
+ requireType: z.string().optional(),
86
91
  })
87
92
  .strict();
88
93
  const DrainPolicySchema = z
@@ -80,7 +80,16 @@ export function classifyProposal(proposal, policy, maxDiffLines) {
80
80
  if (policy.rejectEmpty && isEmptyDiff(proposal)) {
81
81
  return { verdict: "reject", reason: "empty diff", gate: { reason: "empty-diff" } };
82
82
  }
83
- const rule = policy.accept.find((r) => r.generator === proposal.source);
83
+ const rule = policy.accept.find((r) => {
84
+ if (r.generator !== proposal.source)
85
+ return false;
86
+ if (r.requireType !== undefined) {
87
+ const fm = parseFrontmatter(proposal.payload.content ?? "").data;
88
+ if (typeof fm.type !== "string" || fm.type !== r.requireType)
89
+ return false;
90
+ }
91
+ return true;
92
+ });
84
93
  if (rule) {
85
94
  const lines = contentLineCount(content);
86
95
  const body = contentBodyLineCount(content);
@@ -361,6 +370,13 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
361
370
  // — these are re-stamped `no-judge-configured` when no runner resolves them.
362
371
  const needsJudge = new Set();
363
372
  for (const proposal of pending) {
373
+ // Do NOT reclassify a proposal that was already conclusively stamped
374
+ // `auto-rejected` by a prior gate run (e.g. the improve confidence gate).
375
+ // Overwriting an authoritative rejection with `auto-accepted` would corrupt
376
+ // the audit trail and silently promote content the gate explicitly rejected.
377
+ // Such proposals remain pending for manual review (or TTL expiry).
378
+ if (proposal.gateDecision?.outcome === "auto-rejected")
379
+ continue;
364
380
  const decision = classifyProposal(proposal, opts.policy, opts.maxDiffLines);
365
381
  if (decision === null)
366
382
  continue;
@@ -75,6 +75,11 @@ export async function akmProposalAccept(options) {
75
75
  source: result.proposal.source,
76
76
  ...(result.proposal.sourceRun !== undefined ? { sourceRun: result.proposal.sourceRun } : {}),
77
77
  assetPath: result.assetPath,
78
+ // Attribution tagging: carry the eligibility lane from the proposal record
79
+ // onto the promoted event so accept outcomes can be sliced by lane.
80
+ ...(result.proposal.eligibilitySource !== undefined
81
+ ? { eligibilitySource: result.proposal.eligibilitySource }
82
+ : {}),
78
83
  },
79
84
  });
80
85
  return {
@@ -18,6 +18,7 @@ import { TYPE_DIRS } from "../../core/asset/asset-spec.js";
18
18
  import { resolveStashDir } from "../../core/common.js";
19
19
  import { ConfigError, UsageError } from "../../core/errors.js";
20
20
  import { appendEvent } from "../../core/events.js";
21
+ import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
21
22
  import { runAgent, } from "../../integrations/agent/index.js";
22
23
  import { resolveProcessAgentProfile } from "../../integrations/agent/config.js";
23
24
  import { buildProposePrompt, parseAgentProposalPayload } from "../../integrations/agent/prompts.js";
@@ -93,10 +94,14 @@ export async function akmPropose(options) {
93
94
  // directly using its file tools rather than returning JSON via stdout.
94
95
  const draftFilePath = import("node:os").then((os) => import("node:path").then((path) => path.join(os.tmpdir(), `akm-propose-${options.type}-${options.name.replace(/[^a-z0-9_-]/gi, "_")}-${Date.now()}.md`)));
95
96
  const resolvedDraftPath = await draftFilePath;
97
+ // Standards "rulebook" for this target — wiki schema (wiki page) or stash
98
+ // convention/meta facts (non-wiki asset); empty when neither fires.
99
+ const standardsContext = resolveStandardsContext(`${options.type}:${options.name}`, stash);
96
100
  const prompt = buildProposePrompt({
97
101
  type: options.type,
98
102
  name: options.name,
99
103
  task: options.task,
104
+ ...(standardsContext.trim() ? { standardsContext } : {}),
100
105
  draftFilePath: resolvedDraftPath,
101
106
  });
102
107
  // 4. Spawn the agent.
@@ -59,6 +59,7 @@
59
59
  */
60
60
  // ── Reflect-size guard ───────────────────────────────────────────────────────
61
61
  import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
62
+ import { DESCRIPTION_MAX_CHARS, DESCRIPTION_MIN_CHARS, WHEN_TO_USE_MAX_CHARS, WHEN_TO_USE_MIN_CHARS, } from "../../../core/authoring-rules.js";
62
63
  import { detectTruncatedDescription, TRUNCATION_TRAILING_WORDS } from "../../../core/text-truncation.js";
63
64
  // ── Description / when_to_use shape ─────────────────────────────────────────
64
65
  export const HEADING_FRAGMENT_PATTERNS = [
@@ -80,10 +81,10 @@ export function isValidDescription(value, inputRef, options = {}) {
80
81
  const v = value.trim();
81
82
  if (!v)
82
83
  return { ok: false, reason: "description is empty" };
83
- if (v.length < 20)
84
- return { ok: false, reason: `description is too short (${v.length} chars; need ≥20)` };
85
- if (v.length > 400)
86
- return { ok: false, reason: `description is too long (${v.length} chars; max 400)` };
84
+ if (v.length < DESCRIPTION_MIN_CHARS)
85
+ return { ok: false, reason: `description is too short (${v.length} chars; need ≥${DESCRIPTION_MIN_CHARS})` };
86
+ if (v.length > DESCRIPTION_MAX_CHARS)
87
+ return { ok: false, reason: `description is too long (${v.length} chars; max ${DESCRIPTION_MAX_CHARS})` };
87
88
  if (/^\s*[\d#*\->`]/.test(v))
88
89
  return { ok: false, reason: "description starts with a digit or markdown marker" };
89
90
  const last = v.slice(-1);
@@ -133,10 +134,10 @@ export function isValidWhenToUse(value, inputRef) {
133
134
  const v = value.trim();
134
135
  if (!v)
135
136
  return { ok: false, reason: "when_to_use is empty" };
136
- if (v.length < 15)
137
- return { ok: false, reason: `when_to_use is too short (${v.length} chars; need ≥15)` };
138
- if (v.length > 400)
139
- return { ok: false, reason: `when_to_use is too long (${v.length} chars; max 400)` };
137
+ if (v.length < WHEN_TO_USE_MIN_CHARS)
138
+ return { ok: false, reason: `when_to_use is too short (${v.length} chars; need ≥${WHEN_TO_USE_MIN_CHARS})` };
139
+ if (v.length > WHEN_TO_USE_MAX_CHARS)
140
+ return { ok: false, reason: `when_to_use is too long (${v.length} chars; max ${WHEN_TO_USE_MAX_CHARS})` };
140
141
  if (/^when working with\b/i.test(v))
141
142
  return { ok: false, reason: "when_to_use is the circular 'When working with ...' fallback" };
142
143
  const refTail = inputRef.split(":").pop()?.toLowerCase() ?? "";
@@ -50,7 +50,8 @@ import { makeAssetRef, parseAssetRef } from "../../../core/asset/asset-ref.js";
50
50
  import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-spec.js";
51
51
  import { NotFoundError, UsageError } from "../../../core/errors.js";
52
52
  import { appendEvent } from "../../../core/events.js";
53
- import { getStateDbPath, getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, openStateDatabase, recordFsProposalsImport, upsertProposal, } from "../../../core/state-db.js";
53
+ import { getStateDbPath, getStateProposal, hasImportedFsProposals, insertProposalIfAbsent, listStateProposalIdsByPrefix, listStateProposals, openStateDatabase, recordFsProposalsImport, upsertProposal, withImmediateTransaction, } from "../../../core/state-db.js";
54
+ import { repairTruncatedDescription } from "../../../core/text-truncation.js";
54
55
  import { warn } from "../../../core/warn.js";
55
56
  import { commitWriteTargetBoundary, formatRefForMessage, resolveWriteTarget, writeAssetToSource, } from "../../../core/write-source.js";
56
57
  import { runProposalValidators } from "./proposal-validators.js";
@@ -74,6 +75,8 @@ export const PROPOSAL_SOURCES = [
74
75
  "consolidate",
75
76
  "extract",
76
77
  "improve",
78
+ "recombine",
79
+ "procedural",
77
80
  // Semi-automated / tool-driven.
78
81
  "feedback",
79
82
  // Human-initiated / CLI-driven.
@@ -91,6 +94,8 @@ export const AUTOMATED_PROPOSAL_SOURCES = [
91
94
  "consolidate",
92
95
  "extract",
93
96
  "improve",
97
+ "recombine",
98
+ "procedural",
94
99
  "schema-repair",
95
100
  ];
96
101
  /**
@@ -334,37 +339,42 @@ export function createProposal(stashDir, input, ctx) {
334
339
  }
335
340
  const normalizedRef = makeAssetRef(parsedRef.type, parsedRef.name, parsedRef.origin);
336
341
  return withProposalsDb(stashDir, ctx, (db) => {
337
- if (!input.force) {
338
- const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
339
- if (skip)
340
- return skip;
341
- }
342
- const created = nowIso(ctx);
343
- // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
344
- // is dropped silently we never store NaN, Infinity, or out-of-range values.
345
- // Callers that mis-report confidence should not poison the auto-accept gate.
346
- const sanitizedConfidence = typeof input.confidence === "number" &&
347
- Number.isFinite(input.confidence) &&
348
- input.confidence >= 0 &&
349
- input.confidence <= 1
350
- ? input.confidence
351
- : undefined;
352
- const proposal = {
353
- id: newId(ctx),
354
- ref: normalizedRef,
355
- status: "pending",
356
- source: input.source,
357
- ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
358
- createdAt: created,
359
- updatedAt: created,
360
- payload: {
361
- content: input.payload.content,
362
- ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
363
- },
364
- ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
365
- };
366
- upsertProposal(db, proposal, stashDir);
367
- return proposal;
342
+ return withImmediateTransaction(db, () => {
343
+ if (!input.force) {
344
+ const skip = checkDedupAndCooldown(db, stashDir, normalizedRef, input, ctx);
345
+ if (skip)
346
+ return skip;
347
+ }
348
+ const created = nowIso(ctx);
349
+ // Phase 6A: validate confidence is a finite number in [0, 1]. Anything else
350
+ // is dropped silently we never store NaN, Infinity, or out-of-range values.
351
+ // Callers that mis-report confidence should not poison the auto-accept gate.
352
+ const sanitizedConfidence = typeof input.confidence === "number" &&
353
+ Number.isFinite(input.confidence) &&
354
+ input.confidence >= 0 &&
355
+ input.confidence <= 1
356
+ ? input.confidence
357
+ : undefined;
358
+ const proposal = {
359
+ id: newId(ctx),
360
+ ref: normalizedRef,
361
+ status: "pending",
362
+ source: input.source,
363
+ ...(input.sourceRun !== undefined ? { sourceRun: input.sourceRun } : {}),
364
+ createdAt: created,
365
+ updatedAt: created,
366
+ payload: {
367
+ content: input.payload.content,
368
+ ...(input.payload.frontmatter !== undefined ? { frontmatter: input.payload.frontmatter } : {}),
369
+ },
370
+ ...(sanitizedConfidence !== undefined ? { confidence: sanitizedConfidence } : {}),
371
+ // Attribution tagging: persist the eligibility lane so it survives to
372
+ // accept/reject/revert time. See EligibilitySource.
373
+ ...(input.eligibilitySource !== undefined ? { eligibilitySource: input.eligibilitySource } : {}),
374
+ };
375
+ upsertProposal(db, proposal, stashDir);
376
+ return proposal;
377
+ });
368
378
  });
369
379
  }
370
380
  /**
@@ -517,19 +527,25 @@ export function resolveProposalId(stashDir, idOrRef, ctx) {
517
527
  */
518
528
  export function archiveProposal(stashDir, id, status, reason, ctx) {
519
529
  return withProposalsDb(stashDir, ctx, (db) => {
520
- const existing = requireProposal(db, stashDir, id);
521
- const updated = {
522
- ...existing,
523
- status,
524
- updatedAt: nowIso(ctx),
525
- review: {
526
- outcome: status,
527
- ...(reason !== undefined ? { reason } : {}),
528
- decidedAt: nowIso(ctx),
529
- },
530
- };
531
- upsertProposal(db, updated, stashDir);
532
- return updated;
530
+ return withImmediateTransaction(db, () => {
531
+ const existing = requireProposal(db, stashDir, id);
532
+ if (existing.status !== "pending") {
533
+ throw new UsageError(`Proposal ${id} is not pending (current status: ${existing.status}). Only pending proposals can be ${status}.`, "INVALID_FLAG_VALUE");
534
+ }
535
+ const decidedAt = nowIso(ctx);
536
+ const updated = {
537
+ ...existing,
538
+ status,
539
+ updatedAt: decidedAt,
540
+ review: {
541
+ outcome: status,
542
+ ...(reason !== undefined ? { reason } : {}),
543
+ decidedAt,
544
+ },
545
+ };
546
+ upsertProposal(db, updated, stashDir);
547
+ return updated;
548
+ });
533
549
  });
534
550
  }
535
551
  /**
@@ -548,15 +564,17 @@ export function archiveProposal(stashDir, id, status, reason, ctx) {
548
564
  */
549
565
  export function recordGateDecision(stashDir, id, decision, ctx) {
550
566
  return withProposalsDb(stashDir, ctx, (db) => {
551
- const existing = getStateProposal(db, id, stashDir);
552
- if (!existing)
553
- return undefined;
554
- const updated = {
555
- ...existing,
556
- gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
557
- };
558
- upsertProposal(db, updated, stashDir);
559
- return updated;
567
+ return withImmediateTransaction(db, () => {
568
+ const existing = getStateProposal(db, id, stashDir);
569
+ if (!existing || existing.status !== "pending")
570
+ return undefined;
571
+ const updated = {
572
+ ...existing,
573
+ gateDecision: { ...decision, decidedAt: decision.decidedAt ?? nowIso(ctx) },
574
+ };
575
+ upsertProposal(db, updated, stashDir);
576
+ return updated;
577
+ });
560
578
  });
561
579
  }
562
580
  /**
@@ -699,6 +717,99 @@ export function expireStaleProposals(stashDir, config, ctx) {
699
717
  export function validateProposal(proposal) {
700
718
  return runProposalValidators(proposal);
701
719
  }
720
+ // ── Content repair ──────────────────────────────────────────────────────────
721
+ /**
722
+ * Attempt bounded, deterministic repair of mechanically-fixable defects in a
723
+ * proposal's markdown content. NEVER fabricates text — only strips known-bad
724
+ * structure and applies {@link repairTruncatedDescription} to a truncated
725
+ * description when one is detected.
726
+ *
727
+ * Repairs performed (in order):
728
+ * 1. Strip body lines that restate frontmatter fields as pseudo-frontmatter
729
+ * (e.g. `**description**: …` or `when_to_use: …` in the body).
730
+ * 2. Remove stray body `---` horizontal-rule lines (leaving exactly the two
731
+ * frontmatter fences when the content has a valid frontmatter block).
732
+ * 3. Apply {@link repairTruncatedDescription} to a truncated/hanging
733
+ * `description` field in the frontmatter.
734
+ *
735
+ * Returns the repaired content string. When no repairs apply the input is
736
+ * returned byte-identical so callers can use strict equality to detect
737
+ * whether a repair actually happened.
738
+ *
739
+ * CRITICAL: This function is CONTENT-PRESERVING. Callers MUST re-validate the
740
+ * repaired output via {@link validateProposal} / {@link runProposalValidators}
741
+ * before promotion — a repair that makes things *worse* (or is simply
742
+ * insufficient) must be caught by the existing gate.
743
+ */
744
+ export function repairProposalContent(content) {
745
+ if (typeof content !== "string" || content.trim() === "")
746
+ return content;
747
+ // Determine whether the content has a frontmatter block so we know how
748
+ // many `---` fence lines are expected.
749
+ const hasFrontmatter = /^---\r?\n[\s\S]*?\r?\n---/.test(content);
750
+ // Split into lines for structural repairs.
751
+ const lines = content.split(/\r?\n/);
752
+ // Track whether we are inside the opening frontmatter block so we can
753
+ // leave it untouched and only repair the body.
754
+ let inFrontmatter = false;
755
+ // Frontmatter fence index tracking: first fence opens FM, second closes it.
756
+ let fmOpenSeen = false;
757
+ let fmCloseSeen = false;
758
+ const repairedLines = [];
759
+ for (const line of lines) {
760
+ const isFence = /^---\s*$/.test(line);
761
+ // Track frontmatter fences (first two `---` fences delimit the FM block).
762
+ if (isFence && !fmCloseSeen) {
763
+ if (!fmOpenSeen) {
764
+ fmOpenSeen = true;
765
+ inFrontmatter = true;
766
+ repairedLines.push(line);
767
+ continue;
768
+ }
769
+ if (inFrontmatter) {
770
+ fmCloseSeen = true;
771
+ inFrontmatter = false;
772
+ repairedLines.push(line);
773
+ continue;
774
+ }
775
+ }
776
+ // We are now in the body (past the frontmatter or no frontmatter).
777
+ if (inFrontmatter) {
778
+ // Still inside the frontmatter — keep as-is.
779
+ repairedLines.push(line);
780
+ continue;
781
+ }
782
+ // Repair 1: Strip pseudo-frontmatter restatements in the body.
783
+ // Matches lines like `**description**: …` or `when_to_use: …`.
784
+ if (/^\s*(\*\*|__)?\s*(description|when_to_use)\s*(\*\*|__)?\s*:/i.test(line)) {
785
+ // Drop the line — it is a structural defect, not user content.
786
+ continue;
787
+ }
788
+ // Repair 2: Remove stray `---` horizontal-rule lines in the body.
789
+ // We keep these only when the content has NO frontmatter (in that case
790
+ // `---` is a legitimate thematic break in plain-body content).
791
+ if (isFence && hasFrontmatter) {
792
+ // Drop: these are extra `---` fences beyond the two frontmatter delimiters.
793
+ continue;
794
+ }
795
+ repairedLines.push(line);
796
+ }
797
+ let repaired = repairedLines.join("\n");
798
+ // Repair 3: Apply repairTruncatedDescription to the description field.
799
+ // We operate on the raw text rather than re-parsing YAML to avoid
800
+ // reformatting unrelated frontmatter keys.
801
+ if (hasFrontmatter) {
802
+ // Extract the body text (after the second `---`) so we can pass it to
803
+ // repairTruncatedDescription as context for the swap-in heuristic.
804
+ const bodyMatch = repaired.match(/^---\r?\n[\s\S]*?\r?\n---\r?\n?([\s\S]*)$/);
805
+ const bodyText = bodyMatch?.[1] ?? "";
806
+ repaired = repaired.replace(/^(description:\s*)(.*?)(\r?\n)/m, (_match, prefix, rawDesc, nl) => {
807
+ const fixed = repairTruncatedDescription(rawDesc.trim(), bodyText);
808
+ return `${prefix}${fixed}${nl}`;
809
+ });
810
+ }
811
+ return repaired;
812
+ }
702
813
  /**
703
814
  * Validate a proposal, then promote it through the canonical
704
815
  * {@link writeAssetToSource} dispatch (the single place that branches on
@@ -716,12 +827,31 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
716
827
  if (proposal.status !== "pending") {
717
828
  throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
718
829
  }
719
- const report = validateProposal(proposal);
830
+ // Attempt bounded auto-repair of mechanically-fixable structural defects
831
+ // (pseudo-frontmatter-in-body, stray `---` fences, truncated description)
832
+ // BEFORE running validation. If the repair produces valid content, we
833
+ // promote the repaired version; if validation still fails, the original
834
+ // error path throws as before. The repair is content-preserving and
835
+ // deterministic — it never invents text.
836
+ const repairedContent = repairProposalContent(proposal.payload.content);
837
+ const proposalToValidate = repairedContent !== proposal.payload.content
838
+ ? { ...proposal, payload: { ...proposal.payload, content: repairedContent } }
839
+ : proposal;
840
+ const report = validateProposal(proposalToValidate);
720
841
  if (!report.ok) {
721
842
  const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
722
843
  throw new UsageError(`Proposal ${id} failed validation:\n${message}`, "MISSING_REQUIRED_ARGUMENT", "Fix the proposal payload (frontmatter / content) and try again, or reject the proposal with a reason.");
723
844
  }
724
- const ref = parseAssetRef(proposal.ref);
845
+ // Use the (possibly repaired) payload for the promotion write. Persist the
846
+ // repaired content back onto the DB row so the audit trail reflects the
847
+ // final promoted payload (not the defective original).
848
+ if (repairedContent !== proposal.payload.content) {
849
+ withProposalsDb(stashDir, ctx, (db) => {
850
+ const updated = { ...proposal, payload: { ...proposal.payload, content: repairedContent } };
851
+ upsertProposal(db, updated, stashDir);
852
+ });
853
+ }
854
+ const ref = parseAssetRef(proposalToValidate.ref);
725
855
  if (!TYPE_DIRS[ref.type]) {
726
856
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
727
857
  }
@@ -743,7 +873,7 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
743
873
  // missing-revert path is visible.
744
874
  warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
745
875
  }
746
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
876
+ const written = await writeAssetToSource(target.source, target.config, ref, repairedContent);
747
877
  // 0.9.0 (issue #507): single batch commit at the write boundary for git
748
878
  // targets. No-op for filesystem/primary-stash targets.
749
879
  commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);