akm-cli 0.9.0-beta.35 → 0.9.0-beta.36

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/CHANGELOG.md CHANGED
@@ -6,6 +6,41 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.9.0-beta.36] — 2026-06-22
10
+
11
+ ### Added
12
+
13
+ - **Stash standards + wiki schemas are surfaced to authoring agents at write
14
+ time.** When an agent edits a page under `wikis/<name>/`, that wiki's
15
+ `schema.md` body is injected into the prompt; when it creates/edits a non-wiki
16
+ asset, the bodies of `category: convention`/`meta` `fact` assets are injected.
17
+ Two mutually-exclusive features selected by target type, sharing one
18
+ `standardsContext` prompt seam. Wired into reflect, propose, and every
19
+ improve authoring pass (distill, consolidate, recombine, procedural, extract,
20
+ schema-repair). (#642)
21
+ - **Unified, validator-sourced authoring-rules seam.** A new
22
+ `authoringRulesForType(type)` injects the hard authoring rules (no
23
+ pseudo-frontmatter in body, exactly two `---` fences, description/`when_to_use`
24
+ length + shape) into every authoring prompt. The numeric bounds live in one
25
+ module that the validators import, so the prompt can no longer drift from what
26
+ the gate enforces. (#645)
27
+
28
+ ### Fixed
29
+
30
+ - **High-salience reflect lane now reflects each asset at most once.** The
31
+ `#608` admission gate lacked the cooldown its sibling high-retrieval gate has,
32
+ so zero-feedback assets were re-selected on every run (auto-accept emits
33
+ `promoted`, not `feedback`), burning LLM calls and churning assets. (#643)
34
+ - **Stuck validation-failing proposals no longer dead-end.** The triage drain no
35
+ longer overwrites an `auto-rejected` gate stamp with a misleading
36
+ `auto-accepted` (the failure stays truthful and visible). A bounded,
37
+ content-preserving auto-repair (strip pseudo-frontmatter / stray `---`, repair
38
+ truncated descriptions) runs at the promote boundary and re-validates — fixable
39
+ proposals promote; genuinely unrepairable ones stay `pending` for manual
40
+ review, with nothing fabricated and validation never bypassed. (#645)
41
+ - Corrected a prompt/validator drift where the distill system prompt asked for an
42
+ 80–200 char description while the gate enforced 20–400. (#645)
43
+
9
44
  ## [0.9.0-beta.35] — 2026-06-21
10
45
 
11
46
  ### Fixed
@@ -15,7 +15,7 @@ when_to_use: <one complete sentence describing the concrete trigger condition>
15
15
  <lesson body — plain markdown, 1–3 short paragraphs of practical guidance>
16
16
 
17
17
  ## description field (MANDATORY)
18
- - A single complete sentence in present tense, 80-200 chars, NO markdown.
18
+ - A single complete sentence in present tense, 20–400 chars, NO markdown.
19
19
  - Self-contained: a reviewer must understand the lesson from this field alone.
20
20
  - DO NOT start with "When ", "If ", or a connector word — that belongs in when_to_use.
21
21
  - DO NOT copy a section heading ("Key takeaways", "For example", "Key pitfalls").
@@ -28,7 +28,7 @@ Things NOT to extract:
28
28
  - Started: {{STARTED_AT}}
29
29
  - Ended: {{ENDED_AT}}
30
30
  - Project hint: {{PROJECT_HINT}}
31
-
31
+ {{STANDARDS}}
32
32
  ## Filtered session transcript
33
33
 
34
34
  The transcript below has already had read-only `akm` meta-ops and platform boilerplate stripped. Only content that might carry signal remains.
@@ -15,6 +15,7 @@ import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
15
15
  import { ConfigError } from "../../core/errors.js";
16
16
  // Note: appendEvent import removed (WS-3a: archive TTL machinery retired)
17
17
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
18
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
18
19
  import { detectTruncatedDescription } from "../../core/text-truncation.js";
19
20
  import { hasHotCaptureMode, hasSupersededStatus, MERGE_ABSOLUTE_FLOOR_CHARS, MERGE_SHRINK_RATIO_MIN, validateProposalFrontmatter, } from "../proposal/validators/proposal-quality-validators.js";
20
21
  import { createProposal, isProposalSkipped, listProposals } from "../proposal/validators/proposals.js";
@@ -377,7 +378,7 @@ async function clusterMemoriesBySimilarity(memories, config, stateDb) {
377
378
  * is precomputed once per run by `loadPendingConsolidateProposalHashes`
378
379
  * so the cost stays O(memories) inside the chunk loop.
379
380
  */
380
- export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set()) {
381
+ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks, bodyTruncation, pendingProposalBodyHashes = new Set(), standardsContext = "") {
381
382
  const start = memories[0] ? `memory:${memories[0].name}` : "";
382
383
  const end = memories[memories.length - 1] ? `memory:${memories[memories.length - 1].name}` : "";
383
384
  const annotationsByIndex = [];
@@ -405,6 +406,11 @@ export function buildChunkPrompt(sourceName, memories, chunkIndex, totalChunks,
405
406
  `Chunk ${chunkIndex + 1} of ${totalChunks}, memories ${start}–${end}:`,
406
407
  "",
407
408
  ];
409
+ if (standardsContext.trim()) {
410
+ lines.push("Standards to follow (the rulebook for this target):");
411
+ lines.push(standardsContext.trim());
412
+ lines.push("");
413
+ }
408
414
  // Top-of-prompt protection block for hot refs. Neutral phrasing — avoid
409
415
  // op-words like "promote", "merge", "contradict" so the model doesn't
410
416
  // accidentally treat the warning as a hint to use that op elsewhere
@@ -1246,6 +1252,10 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, w
1246
1252
  }
1247
1253
  warn(`[consolidate] ${memories.length} memories / ${chunks.length} chunk(s) / chunk_size=${chunkSize}` +
1248
1254
  ` / pending-proposal hashes: ${pendingProposalBodyHashes.size}`);
1255
+ // Consolidate output merges memories (non-wiki) → stash authoring standards.
1256
+ // Resolved ONCE per run and passed to each chunk prompt (facts not re-read
1257
+ // per chunk).
1258
+ const standardsContext = resolveStashStandards(stashDir);
1249
1259
  const chunkOpsArrays = [];
1250
1260
  // Structured skip-reason histogram (2026-05-26): every deterministic
1251
1261
  // post-LLM op rejection site below also calls `pushSkipReason` so the
@@ -1363,7 +1373,7 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, sourceRun, w
1363
1373
  continue;
1364
1374
  }
1365
1375
  warn(`[consolidate] chunk ${chunkIdx + 1}/${chunks.length} (${chunk.length} memories) …`);
1366
- const userPrompt = buildChunkPrompt(sourceName, chunk, chunkIdx, chunks.length, bodyTruncation, pendingProposalBodyHashes);
1376
+ const userPrompt = buildChunkPrompt(sourceName, chunk, chunkIdx, chunks.length, bodyTruncation, pendingProposalBodyHashes, standardsContext);
1367
1377
  let raw = await tryLlmFeature("memory_consolidation", config, async () => {
1368
1378
  if (!llmConfig)
1369
1379
  return { ok: false, error: "No LLM configured for consolidation" };
@@ -58,12 +58,14 @@ import { parseAssetRef } from "../../core/asset/asset-ref.js";
58
58
  import { assembleAssetFromString } from "../../core/asset/asset-serialize.js";
59
59
  import { parseFrontmatter, writeSalienceToFrontmatter } from "../../core/asset/frontmatter.js";
60
60
  import { stripMarkdownFences } from "../../core/asset/markdown.js";
61
+ import { authoringRulesForType } from "../../core/authoring-rules.js";
61
62
  import { resolveStashDir, timestampForFilename } from "../../core/common.js";
62
63
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
63
64
  import { ConfigError, UsageError } from "../../core/errors.js";
64
65
  import { appendEvent, readEvents } from "../../core/events.js";
65
66
  import { lintLessonContent } from "../../core/lesson-lint.js";
66
67
  import { getDbPath } from "../../core/paths.js";
68
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
67
69
  import { openStateDatabase } from "../../core/state-db.js";
68
70
  import { warnVerbose } from "../../core/warn.js";
69
71
  import { closeDatabase, getAllEntries, openDatabase } from "../../indexer/db/db.js";
@@ -307,6 +309,18 @@ export function buildDistillPrompt(input) {
307
309
  const lines = [];
308
310
  lines.push(`Asset ref: ${input.inputRef}`);
309
311
  lines.push("");
312
+ if (input.standardsContext?.trim()) {
313
+ lines.push("Standards to follow (the rulebook for this target):");
314
+ lines.push(input.standardsContext.trim());
315
+ lines.push("");
316
+ }
317
+ {
318
+ const authoringRules = authoringRulesForType(input.proposalKind ?? "lesson");
319
+ if (authoringRules) {
320
+ lines.push(authoringRules);
321
+ lines.push("");
322
+ }
323
+ }
310
324
  lines.push("Asset content:");
311
325
  if (input.assetContent) {
312
326
  const body = input.assetContent.trim().slice(0, 3000);
@@ -939,12 +953,16 @@ export async function akmDistill(options) {
939
953
  // Fail open — CLS is supplemental, never required.
940
954
  }
941
955
  }
956
+ // Distill output is a lesson/knowledge (non-wiki) → stash authoring
957
+ // standards. Resolved once for this single call.
958
+ const standardsContext = resolveStashStandards(stash);
942
959
  const baseUserPrompt = buildDistillPrompt({
943
960
  inputRef,
944
961
  assetContent,
945
962
  feedback,
946
963
  proposalKind: effectiveProposalKind,
947
964
  ...(rejectedForRef.length > 0 ? { rejectedProposals: rejectedForRef } : {}),
965
+ ...(standardsContext.trim() ? { standardsContext } : {}),
948
966
  });
949
967
  const userPrompt = clsContext ? `${baseUserPrompt}${clsContext}` : baseUserPrompt;
950
968
  const messages = [
@@ -149,6 +149,11 @@ export function buildExtractPrompt(input) {
149
149
  const ref = input.data.ref;
150
150
  const startedAt = ref.startedAt ? new Date(ref.startedAt).toISOString() : "unknown";
151
151
  const endedAt = ref.endedAt ? new Date(ref.endedAt).toISOString() : "unknown";
152
+ // Optional standards block — rendered to the lead-in + body when present,
153
+ // or an empty string (no section) when absent. Gated on non-empty.
154
+ const standards = input.standardsContext?.trim()
155
+ ? `\n## Standards to follow (the rulebook for this target)\n\n${input.standardsContext.trim()}\n`
156
+ : "";
152
157
  return promptTemplate
153
158
  .replace("{{HARNESS}}", ref.harness)
154
159
  .replace("{{TITLE}}", ref.title ?? "(no title)")
@@ -156,6 +161,7 @@ export function buildExtractPrompt(input) {
156
161
  .replace("{{ENDED_AT}}", endedAt)
157
162
  .replace("{{PROJECT_HINT}}", ref.projectHint ?? "(no project hint)")
158
163
  .replace("{{ALREADY_PRESERVED}}", formatAlreadyPreserved(input.inlineRefs))
164
+ .replace("{{STANDARDS}}", standards)
159
165
  .replace("{{TRANSCRIPT}}", formatTranscript(input.events));
160
166
  }
161
167
  /**
@@ -31,6 +31,7 @@ import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
31
31
  import { ConfigError, UsageError } from "../../core/errors.js";
32
32
  import { appendEvent } from "../../core/events.js";
33
33
  import { probeLock, releaseLock, tryAcquireLockSync } from "../../core/file-lock.js";
34
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
34
35
  import { getExtractedSessionsMap, getLastExtractRunAt, getStateDbPath, openStateDatabase, shouldSkipAlreadyExtractedSession, upsertExtractedSession, } from "../../core/state-db.js";
35
36
  import { repairTruncatedDescription } from "../../core/text-truncation.js";
36
37
  import { warn } from "../../core/warn.js";
@@ -265,7 +266,11 @@ triage, sessionIndexing, schemaSimilarityCtx,
265
266
  // prior row + bypass flags are threaded in from the caller. Skipping here still
266
267
  // costs ZERO LLM calls (the expensive resource #602 protects); only the cheap
267
268
  // file read is incurred.
268
- prior, force) {
269
+ prior, force,
270
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
271
+ // output. Resolved ONCE per run by the caller and threaded in so facts are
272
+ // not re-read per session. Empty string when none exist.
273
+ standardsContext) {
269
274
  const warnings = [];
270
275
  let data;
271
276
  try {
@@ -360,7 +365,12 @@ prior, force) {
360
365
  };
361
366
  }
362
367
  }
363
- const prompt = buildExtractPrompt({ data, events: filtered.events, inlineRefs: data.inlineRefs });
368
+ const prompt = buildExtractPrompt({
369
+ data,
370
+ events: filtered.events,
371
+ inlineRefs: data.inlineRefs,
372
+ ...(standardsContext.trim() ? { standardsContext } : {}),
373
+ });
364
374
  // #561 — ADDITIVE session indexing. Generate + write the session asset
365
375
  // (`sessions/<harness>/<id>.md`). FAIL-OPEN: any failure only records a
366
376
  // warning; it NEVER changes the proposal/skip outcome of extract. Returns the
@@ -771,6 +781,10 @@ export async function akmExtract(options) {
771
781
  embedFn: options.schemaSimilarityEmbedFn,
772
782
  };
773
783
  }
784
+ // Stash authoring standards (convention/meta fact bodies) for non-wiki
785
+ // extract output. Resolved ONCE per run and threaded into each session's
786
+ // prompt so facts are not re-read per session.
787
+ const extractStandardsContext = resolveStashStandards(stashDir);
774
788
  for (const summary of candidates) {
775
789
  // #602 — the already-extracted skip moved INTO processSession (the content
776
790
  // hash needs the session body, only available after readSession). The prior
@@ -810,7 +824,7 @@ export async function akmExtract(options) {
810
824
  }
811
825
  }
812
826
  try {
813
- const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true);
827
+ const result = await processSession(harness, summary, stashDir, config, llmConfig, chat, options.ctx, sourceRun, dryRun, timeoutMs, maxTotalChars, minContentChars, triage, sessionIndexing, schemaSimilarityCtx, prior, options.force === true, extractStandardsContext);
814
828
  sessions.push(result);
815
829
  // #626 — triage aggregation. A session reached the triage gate only when it
816
830
  // was NOT already preempted by an earlier skip (read_failed / too_short /
@@ -2463,6 +2463,14 @@ async function runImprovePreparationStage(args) {
2463
2463
  // Cap: at most 10% of the effective run limit so the lane cannot crowd out
2464
2464
  // reactive feedback. Requires state.db to have an asset_salience row — refs
2465
2465
  // without a row (pre-#608 assets still on the type-weight stub) are skipped.
2466
+ //
2467
+ // Cooldown: a ref qualifies at most once — when no prior reflect proposal
2468
+ // exists for it (`!lastReflectProposalTs.has`). Without this guard the lane
2469
+ // re-selects the same high-salience refs on EVERY run (auto-accept emits a
2470
+ // `promoted` event, not `feedback`, so the ref never leaves
2471
+ // noFeedbackCandidates), burning LLM calls and churning the asset. This
2472
+ // mirrors the P0-A high-retrieval gate's `!lastReflectProposalTs.has(r.ref)`
2473
+ // guard above so all "rescue" lanes share the same once-per-asset semantics.
2466
2474
  const highSalienceRefs = [];
2467
2475
  const salienceCfg = (options.config ?? loadConfig()).improve?.salience;
2468
2476
  const salienceThreshold = salienceCfg?.salienceThreshold ?? 0.75;
@@ -2478,7 +2486,7 @@ async function runImprovePreparationStage(args) {
2478
2486
  if (highSalienceRefs.length >= highSalienceCap)
2479
2487
  break;
2480
2488
  const row = getAssetSalience(dbForHighSalience, r.ref);
2481
- if (row && row.encoding_salience >= salienceThreshold) {
2489
+ if (row && row.encoding_salience >= salienceThreshold && !lastReflectProposalTs.has(r.ref)) {
2482
2490
  highSalienceRefs.push(r);
2483
2491
  }
2484
2492
  }
@@ -27,6 +27,7 @@ import { resolveStashDir } from "../../core/common.js";
27
27
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
28
28
  import { appendEvent } from "../../core/events.js";
29
29
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
30
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
30
31
  import { warn } from "../../core/warn.js";
31
32
  import { closeDatabase, getAllEntries, openExistingDatabase } from "../../indexer/db/db.js";
32
33
  import { resolveImproveProcessRunnerFromProfile, runnerIsLlm } from "../../integrations/agent/runner.js";
@@ -144,12 +145,17 @@ export function deriveProceduralWorkflowRef(cluster) {
144
145
  }
145
146
  // ── Prompt + parse ──────────────────────────────────────────────────────────────
146
147
  /** Assemble the per-sequence user prompt fed to the procedural LLM. */
147
- export function buildProceduralPrompt(cluster) {
148
+ export function buildProceduralPrompt(cluster, standardsContext = "") {
148
149
  const lines = [
149
150
  `A recurring successful action sequence observed across ${cluster.members.length} sessions.`,
150
151
  "",
151
- "Ordered actions (turn EACH into exactly one step, in this order):",
152
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):");
153
159
  cluster.normalized.forEach((step, i) => {
154
160
  lines.push(`${i + 1}. ${step}`);
155
161
  });
@@ -312,6 +318,9 @@ export async function akmProcedural(opts) {
312
318
  warnings.push("procedural: no LLM configured — skipping");
313
319
  return finish({ sequencesScanned, clustersFormed: 0 });
314
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);
315
324
  for (const cluster of clusters) {
316
325
  if (opts.signal?.aborted) {
317
326
  warnings.push("aborted-mid-run");
@@ -319,7 +328,7 @@ export async function akmProcedural(opts) {
319
328
  }
320
329
  clustersFormed += 1;
321
330
  const workflowRef = deriveProceduralWorkflowRef(cluster);
322
- const prompt = buildProceduralPrompt(cluster);
331
+ const prompt = buildProceduralPrompt(cluster, standardsContext);
323
332
  const raw = await llmFn(prompt);
324
333
  const doc = parseProceduralWorkflow(raw, cluster.normalized.length);
325
334
  if (!doc) {
@@ -45,6 +45,7 @@ import { resolveStashDir } from "../../core/common.js";
45
45
  import { getDefaultLlmConfig, loadConfig } from "../../core/config/config.js";
46
46
  import { appendEvent } from "../../core/events.js";
47
47
  import { parseEmbeddedJsonResponse } from "../../core/parse.js";
48
+ import { resolveStashStandards } from "../../core/standards/resolve-stash-standards.js";
48
49
  import { decayUnseenRecombineHypotheses, findMatchingRecombineHypothesis, getRecombineHypothesis, getStateDbPath, markRecombineHypothesisPromoted, openStateDatabase, recordRecombineInduction, } from "../../core/state-db.js";
49
50
  import { warn } from "../../core/warn.js";
50
51
  import { closeDatabase, getAllEntries, getEntitiesByEntryIds, openExistingDatabase, } from "../../indexer/db/db.js";
@@ -228,12 +229,17 @@ function readBody(entry) {
228
229
  }
229
230
  }
230
231
  /** Assemble the per-cluster user prompt fed to the recombine LLM. */
231
- export function buildClusterPrompt(cluster) {
232
+ export function buildClusterPrompt(cluster, standardsContext = "") {
232
233
  const lines = [
233
234
  `Shared signal: ${cluster.signature}`,
234
235
  `Cluster of ${cluster.members.length} related memories:`,
235
236
  "",
236
237
  ];
238
+ if (standardsContext.trim()) {
239
+ lines.push("Standards to follow (the rulebook for this target):");
240
+ lines.push(standardsContext.trim());
241
+ lines.push("");
242
+ }
237
243
  for (const m of cluster.members) {
238
244
  lines.push(`[memory:${m.entry.name}]`);
239
245
  if (m.entry.description)
@@ -399,6 +405,9 @@ export async function akmRecombine(opts) {
399
405
  // Refs re-induced (defensible generalization passed the quality gate) THIS
400
406
  // run — everything else is decayed after the loop.
401
407
  const seenThisRun = new Set();
408
+ // Recombine output is knowledge/lesson (non-wiki) → stash authoring
409
+ // standards. Resolved ONCE per run and passed to each cluster prompt.
410
+ const standardsContext = resolveStashStandards(stashDir);
402
411
  try {
403
412
  for (const cluster of clusters) {
404
413
  if (opts.signal?.aborted) {
@@ -406,7 +415,7 @@ export async function akmRecombine(opts) {
406
415
  break;
407
416
  }
408
417
  clustersFormed += 1;
409
- const prompt = buildClusterPrompt(cluster);
418
+ const prompt = buildClusterPrompt(cluster, standardsContext);
410
419
  const raw = await llmFn(prompt);
411
420
  const generalization = parseGeneralization(raw);
412
421
  if (!generalization) {
@@ -34,6 +34,7 @@ import { loadConfig } from "../../core/config/config.js";
34
34
  import { ConfigError, UsageError } from "../../core/errors.js";
35
35
  import { appendEvent, readEvents } from "../../core/events.js";
36
36
  import { lintLessonContent } from "../../core/lesson-lint.js";
37
+ import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
37
38
  import { lookup } from "../../indexer/indexer.js";
38
39
  import { runAgent, } from "../../integrations/agent/index.js";
39
40
  import { resolveProcessAgentProfile } from "../../integrations/agent/config.js";
@@ -745,6 +746,9 @@ export async function akmReflect(options = {}) {
745
746
  // Reflexion-style verbal-RL: inject rejected proposals so the agent avoids
746
747
  // reproducing proposals that have already been reviewed and refused.
747
748
  const rejectedProposals = readRejectedProposals(stash, options.ref);
749
+ // Standards "rulebook" for this target — wiki schema (wiki page) or stash
750
+ // convention/meta facts (non-wiki asset); empty when neither fires.
751
+ const standardsContext = resolveStandardsContext(options.ref, stash);
748
752
  // 5. Spawn the agent — with optional Self-Refine loop (R-1 / #372).
749
753
  //
750
754
  // maxRefineIters controls how many agent invocations are made:
@@ -813,6 +817,7 @@ export async function akmReflect(options = {}) {
813
817
  ...(schemaHints.length > 0 ? { schemaHints } : {}),
814
818
  ...(relatedLessons.length > 0 ? { relatedLessons } : {}),
815
819
  ...(options.task ? { task: options.task } : {}),
820
+ ...(standardsContext.trim() ? { standardsContext } : {}),
816
821
  ...(options.avoidPatterns && options.avoidPatterns.length > 0 ? { avoidPatterns: options.avoidPatterns } : {}),
817
822
  ...(rejectedProposals.length > 0 ? { rejectedProposals } : {}),
818
823
  // R-1: inject prior draft as self-critique target on iterations > 0
@@ -370,6 +370,13 @@ export async function drainProposals(opts, promoteFn = akmProposalAccept, reject
370
370
  // — these are re-stamped `no-judge-configured` when no runner resolves them.
371
371
  const needsJudge = new Set();
372
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;
373
380
  const decision = classifyProposal(proposal, opts.policy, opts.maxDiffLines);
374
381
  if (decision === null)
375
382
  continue;
@@ -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() ?? "";
@@ -51,6 +51,7 @@ import { resolveAssetPathFromName, TYPE_DIRS } from "../../../core/asset/asset-s
51
51
  import { NotFoundError, UsageError } from "../../../core/errors.js";
52
52
  import { appendEvent } from "../../../core/events.js";
53
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";
@@ -716,6 +717,99 @@ export function expireStaleProposals(stashDir, config, ctx) {
716
717
  export function validateProposal(proposal) {
717
718
  return runProposalValidators(proposal);
718
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
+ }
719
813
  /**
720
814
  * Validate a proposal, then promote it through the canonical
721
815
  * {@link writeAssetToSource} dispatch (the single place that branches on
@@ -733,12 +827,31 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
733
827
  if (proposal.status !== "pending") {
734
828
  throw new UsageError(`Proposal ${id} is not pending (current status: ${proposal.status}). Only pending proposals can be accepted.`, "INVALID_FLAG_VALUE");
735
829
  }
736
- 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);
737
841
  if (!report.ok) {
738
842
  const message = report.findings.map((f) => `[${f.kind}] ${f.message}`).join("\n");
739
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.");
740
844
  }
741
- 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);
742
855
  if (!TYPE_DIRS[ref.type]) {
743
856
  throw new UsageError(`Proposal ${id} targets unknown asset type "${ref.type}".`, "INVALID_FLAG_VALUE");
744
857
  }
@@ -760,7 +873,7 @@ export async function promoteProposal(stashDir, config, id, options = {}, ctx) {
760
873
  // missing-revert path is visible.
761
874
  warn(`[proposals] promoteProposal: failed to capture backup for ${id}: ${err instanceof Error ? err.message : String(err)}`);
762
875
  }
763
- const written = await writeAssetToSource(target.source, target.config, ref, proposal.payload.content);
876
+ const written = await writeAssetToSource(target.source, target.config, ref, repairedContent);
764
877
  // 0.9.0 (issue #507): single batch commit at the write boundary for git
765
878
  // targets. No-op for filesystem/primary-stash targets.
766
879
  commitWriteTargetBoundary(target, `Update ${formatRefForMessage(ref)}`);
@@ -17,7 +17,9 @@ import path from "node:path";
17
17
  import { parseAssetRef } from "../../core/asset/asset-ref.js";
18
18
  import { assembleAsset } from "../../core/asset/asset-serialize.js";
19
19
  import { parseFrontmatter } from "../../core/asset/frontmatter.js";
20
+ import { authoringRulesForType } from "../../core/authoring-rules.js";
20
21
  import { appendEvent, readEvents } from "../../core/events.js";
22
+ import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
21
23
  import { info, warn } from "../../core/warn.js";
22
24
  import { resolveAssetPath } from "../../indexer/walk/path-resolver.js";
23
25
  import { chatCompletion, parseEmbeddedJsonResponse } from "../../llm/client.js";
@@ -94,6 +96,16 @@ export async function runSchemaRepairPass(failures, options) {
94
96
  const fieldList = missingFields.join(" and ");
95
97
  info(`[improve] schema-repair ${failure.ref} (${fieldList})`);
96
98
  const bodyPreview = (fm.content ?? raw).slice(0, 2000);
99
+ // Standards "rulebook" for this target — wiki schema (wiki page) or stash
100
+ // convention/meta facts (non-wiki asset); empty when neither fires or no
101
+ // stash dir is available. `resolveStandardsContext` dispatches on the ref.
102
+ const standardsContext = stashDir ? resolveStandardsContext(failure.ref, stashDir) : "";
103
+ const standardsSection = standardsContext.trim()
104
+ ? `\n\nStandards to follow (the rulebook for this target):\n${standardsContext.trim()}`
105
+ : "";
106
+ const assetType = parseAssetRef(failure.ref).type;
107
+ const authoringRules = authoringRulesForType(assetType);
108
+ const authoringRulesSection = authoringRules ? `\n\n${authoringRules}` : "";
97
109
  const llmResponse = await chatFn(llmConfig, [
98
110
  {
99
111
  role: "system",
@@ -101,7 +113,7 @@ export async function runSchemaRepairPass(failures, options) {
101
113
  },
102
114
  {
103
115
  role: "user",
104
- content: `Generate the missing frontmatter fields (${fieldList}) for this ${parseAssetRef(failure.ref).type} asset. Return ONLY valid JSON like {"description": "...", "when_to_use": "..."}\n\n${bodyPreview}`,
116
+ content: `Generate the missing frontmatter fields (${fieldList}) for this ${assetType} asset. Return ONLY valid JSON like {"description": "...", "when_to_use": "..."}${standardsSection}${authoringRulesSection}\n\n${bodyPreview}`,
105
117
  },
106
118
  ]);
107
119
  const parsed = parseEmbeddedJsonResponse(llmResponse.trim());
@@ -0,0 +1,83 @@
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
+ * Canonical HARD authoring rules — the single source of truth shared by the
6
+ * proposal validators (which REJECT violations) and the improve/authoring
7
+ * prompts (which must TELL the agent the same rules, in the same words).
8
+ *
9
+ * Why this module exists: authoring rules were duplicated and drifted across
10
+ * prompt templates. `distill-lesson-system.md` told the model "80–200 chars"
11
+ * while the validator enforced 20–400; reflect's prompt omitted the
12
+ * no-pseudo-frontmatter / single-fence rules entirely, so reflect generated
13
+ * proposals that the gate then rejected and that got stuck in the queue.
14
+ *
15
+ * The fix: the numeric bounds live HERE and are imported by both the validators
16
+ * (`isValidDescription` / `isValidWhenToUse` in proposal-quality-validators.ts)
17
+ * and the prompt text (`authoringRulesForType`). The agent-facing rule prose
18
+ * sits next to the bounds it describes, so a developer changing a validator
19
+ * sees the prompt copy that must change with it. `tests/authoring-rules-*`
20
+ * asserts the two representations stay consistent.
21
+ *
22
+ * SCOPE: only HARD rules (a validator rejects the proposal if violated) belong
23
+ * here. Soft/style conventions (voice, paragraph count, "include a # Title")
24
+ * are user-editable and flow through the separate `standardsContext` seam
25
+ * (stash `category: convention` facts) — NOT this module.
26
+ */
27
+ // ── Canonical numeric bounds (imported by the validators — do not duplicate) ──
28
+ /** `description` length bounds (chars). Enforced by `isValidDescription`. */
29
+ export const DESCRIPTION_MIN_CHARS = 20;
30
+ export const DESCRIPTION_MAX_CHARS = 400;
31
+ /** `when_to_use` length bounds (chars). Enforced by `isValidWhenToUse`. */
32
+ export const WHEN_TO_USE_MIN_CHARS = 15;
33
+ export const WHEN_TO_USE_MAX_CHARS = 400;
34
+ // ── Agent-facing rule prose (mirrors the validator checks one-for-one) ────────
35
+ /**
36
+ * Rules that apply to any markdown asset authored with YAML frontmatter + a
37
+ * body. Enforced by `detectDoubleFrontmatter` (currently fires for lesson
38
+ * proposals, but the rules are universally correct, so we state them for every
39
+ * type to prevent the same defect class elsewhere).
40
+ */
41
+ const FRONTMATTER_BODY_RULES = [
42
+ "Emit EXACTLY TWO `---` fence lines — the opening and closing of the YAML frontmatter. Do NOT use `---` as a horizontal rule anywhere in the body.",
43
+ "Do NOT restate `description:` or `when_to_use:` inside the body (no `**description:** …` or `**when_to_use:** …` lines). Those keys belong in the frontmatter ONLY.",
44
+ ];
45
+ /** Rules for the `description` frontmatter field. Enforced by `isValidDescription`. */
46
+ const DESCRIPTION_RULES = [
47
+ `\`description\` must be ${DESCRIPTION_MIN_CHARS}–${DESCRIPTION_MAX_CHARS} characters of plain-prose sentence — no leading digit or markdown marker, balanced backticks, and it must NOT end with \`:\`, \`;\`, or \`,\` (those read as truncation).`,
48
+ '`description` must NOT be a section-heading fragment (e.g. "Overview", "Key points", "Summary"), a code fragment (must not start with `def`/`function`/`class`/`const`/…), or end on a hanging connector word ("a", "the", "and", "to", …).',
49
+ "`description` must NOT merely restate the asset's ref/name; write what the asset actually does.",
50
+ '`description` should NOT start with "When" — that phrasing belongs in `when_to_use`.',
51
+ ];
52
+ /** Rules for the `when_to_use` frontmatter field. Enforced by `isValidWhenToUse`. */
53
+ const WHEN_TO_USE_RULES = [
54
+ `\`when_to_use\` is REQUIRED and must be ${WHEN_TO_USE_MIN_CHARS}–${WHEN_TO_USE_MAX_CHARS} characters describing a concrete trigger. Never write the circular fallback "When working with <name>".`,
55
+ "`description` and `when_to_use` must be different from each other.",
56
+ ];
57
+ /**
58
+ * Asset types that carry a `description` and a body where the
59
+ * frontmatter/body rules apply. (Types without those — if any are added later —
60
+ * simply fall through to the cross-cutting block.)
61
+ */
62
+ const DESCRIPTION_TYPES = new Set(["lesson", "knowledge", "memory", "skill", "command", "agent", "workflow", "fact"]);
63
+ /** Types where `when_to_use` is a HARD requirement (validator rejects if absent). */
64
+ const WHEN_TO_USE_TYPES = new Set(["lesson"]);
65
+ /**
66
+ * Build the hard-rules block for a given asset type, ready to inject as a prompt
67
+ * section. Returns `""` for an unknown type (no over-claiming). The block is
68
+ * deterministic so prompt snapshots stay stable.
69
+ *
70
+ * Inject this VERBATIM into every improve/authoring prompt that creates or edits
71
+ * an asset of `type`, so the agent is told exactly what the gate will reject.
72
+ */
73
+ export function authoringRulesForType(type) {
74
+ const rules = [...FRONTMATTER_BODY_RULES];
75
+ if (DESCRIPTION_TYPES.has(type))
76
+ rules.push(...DESCRIPTION_RULES);
77
+ if (WHEN_TO_USE_TYPES.has(type))
78
+ rules.push(...WHEN_TO_USE_RULES);
79
+ if (rules.length === 0)
80
+ return "";
81
+ const heading = `Hard authoring rules for ${type} assets (the validator REJECTS proposals that violate these):`;
82
+ return [heading, ...rules.map((r) => `- ${r}`)].join("\n");
83
+ }
@@ -0,0 +1,56 @@
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
+ * Dispatch resolver for the standards prompt seam — selects which of the two
6
+ * standards features fires for a given write target, mutually exclusively:
7
+ *
8
+ * - **Feature A — wiki schema**: the target is a wiki page (a ref/path under
9
+ * `wikis/<name>/`, NOT a `raw/` file and NOT a wiki infra file
10
+ * `schema.md`/`index.md`/`log.md`). Returns that wiki's `schema.md` body.
11
+ * - **Feature B — stash standards**: the target is any non-wiki asset.
12
+ * Returns the concatenated `category: convention`/`meta` fact bodies.
13
+ * - **Neither fires**: a wiki `raw/` file or a wiki infra file. Returns `""`.
14
+ *
15
+ * The two NEVER both fire. Both underlying readers degrade to `""` on
16
+ * missing/malformed input and never throw, so this resolver never throws.
17
+ */
18
+ import { extractWikiNameFromRef, INDEX_MD, LOG_MD, loadWikiSchema, SCHEMA_MD } from "../../wiki/wiki.js";
19
+ import { resolveStashStandards } from "./resolve-stash-standards.js";
20
+ /** Wiki infra files that are not authored pages (relative to the wiki root). */
21
+ const WIKI_INFRA_BASENAMES = new Set([SCHEMA_MD, INDEX_MD, LOG_MD]);
22
+ /**
23
+ * Resolve the standards context for a write target identified by its asset ref.
24
+ *
25
+ * @param ref Canonical asset ref of the write target (e.g. `skill:foo`,
26
+ * `wiki:research/topics/x`). When undefined, the target is a
27
+ * non-wiki authoring flow → stash standards.
28
+ * @param stashRoot Stash root directory.
29
+ */
30
+ export function resolveStandardsContext(ref, stashRoot) {
31
+ const wikiName = ref ? extractWikiNameFromRef(ref) : undefined;
32
+ if (!wikiName) {
33
+ // Non-wiki asset target → Feature B (stash authoring standards).
34
+ return resolveStashStandards(stashRoot);
35
+ }
36
+ // Wiki target. Extract the page path after `wiki:<name>/`.
37
+ const prefix = `wiki:${wikiName}/`;
38
+ const pagePath = ref?.startsWith(prefix) ? ref.slice(prefix.length) : "";
39
+ // `wiki:<name>` with no page, a `raw/` file, or a wiki infra file → neither
40
+ // feature fires.
41
+ if (!pagePath)
42
+ return "";
43
+ if (pagePath === "raw" || pagePath.startsWith("raw/"))
44
+ return "";
45
+ // Infra files (`schema`/`index`/`log`) are only special at the WIKI ROOT.
46
+ // A nested page like `wiki:research/analysis/schema` is a genuine page and
47
+ // must NOT be suppressed, so only check when the page is at root depth.
48
+ if (!pagePath.includes("/")) {
49
+ // Refs drop the `.md` extension; compare against both forms defensively.
50
+ if (WIKI_INFRA_BASENAMES.has(pagePath) || WIKI_INFRA_BASENAMES.has(`${pagePath}.md`)) {
51
+ return "";
52
+ }
53
+ }
54
+ // A genuine wiki page → Feature A (that wiki's schema body).
55
+ return loadWikiSchema(stashRoot, wikiName).body;
56
+ }
@@ -0,0 +1,87 @@
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
+ * Resolve the stash-authoring "standards" context (Feature B of the standards
6
+ * plan): gather the bodies of `fact` assets whose `category` frontmatter is
7
+ * `convention` or `meta` so naming / tag / frontmatter conventions are surfaced
8
+ * to the agent when it creates or edits a non-wiki asset.
9
+ *
10
+ * Selection is by **frontmatter `category`**, never by path — flat
11
+ * (`facts/x.md`) and nested (`facts/conventions/x.md`) layouts resolve
12
+ * identically. The MVP does no parsing of fenced blocks, no rule objects, and
13
+ * no warnings: it concatenates the selected facts' bodies in stable enumeration
14
+ * order, each preceded by a one-line `# <ref>` provenance header. Returns `""`
15
+ * when no matching facts exist.
16
+ */
17
+ import fs from "node:fs";
18
+ import path from "node:path";
19
+ import { parseFrontmatter } from "../asset/frontmatter.js";
20
+ /** `category` values that mark a fact as an authoring standard. */
21
+ const STANDARD_CATEGORIES = new Set(["convention", "meta"]);
22
+ /** Directory (under the stash root) where `fact` assets live. */
23
+ const FACTS_SUBDIR = "facts";
24
+ /**
25
+ * Recursively collect `.md` files under `dir` in stable (sorted) enumeration
26
+ * order. Returns absolute paths. Missing dir → `[]`.
27
+ */
28
+ function collectMarkdownFiles(dir) {
29
+ let entries;
30
+ try {
31
+ entries = fs.readdirSync(dir, { withFileTypes: true });
32
+ }
33
+ catch {
34
+ return [];
35
+ }
36
+ const results = [];
37
+ for (const entry of entries.sort((a, b) => a.name.localeCompare(b.name))) {
38
+ const full = path.join(dir, entry.name);
39
+ if (entry.isDirectory()) {
40
+ results.push(...collectMarkdownFiles(full));
41
+ }
42
+ else if (entry.isFile() && entry.name.endsWith(".md")) {
43
+ results.push(full);
44
+ }
45
+ }
46
+ return results;
47
+ }
48
+ /**
49
+ * Derive a fact ref (`fact:conventions/naming`) from an absolute markdown path
50
+ * relative to the facts root. Mirrors the canonical-name derivation in
51
+ * `asset-spec.ts` (POSIX separators, `.md` stripped).
52
+ */
53
+ function toFactRef(factsRoot, absPath) {
54
+ const rel = path.relative(factsRoot, absPath).split(path.sep).join("/");
55
+ const name = rel.endsWith(".md") ? rel.slice(0, -3) : rel;
56
+ return `fact:${name}`;
57
+ }
58
+ export function resolveStashStandards(stashRoot) {
59
+ const factsRoot = path.join(stashRoot, FACTS_SUBDIR);
60
+ const sections = [];
61
+ for (const absPath of collectMarkdownFiles(factsRoot)) {
62
+ let raw;
63
+ try {
64
+ raw = fs.readFileSync(absPath, "utf8");
65
+ }
66
+ catch {
67
+ continue;
68
+ }
69
+ let category = "";
70
+ let body = "";
71
+ try {
72
+ const parsed = parseFrontmatter(raw);
73
+ category = typeof parsed.data.category === "string" ? parsed.data.category.trim() : "";
74
+ body = parsed.content;
75
+ }
76
+ catch {
77
+ continue;
78
+ }
79
+ if (!STANDARD_CATEGORIES.has(category))
80
+ continue;
81
+ const trimmed = body.trim();
82
+ if (!trimmed)
83
+ continue; // skip stub facts with frontmatter but no body
84
+ sections.push(`# ${toFactRef(factsRoot, absPath)}\n${trimmed}`);
85
+ }
86
+ return sections.join("\n\n");
87
+ }
@@ -26,6 +26,7 @@
26
26
  * during validation. We carry it through if the agent supplies it.
27
27
  */
28
28
  import { TYPE_DIRS } from "../../core/asset/asset-spec.js";
29
+ import { authoringRulesForType } from "../../core/authoring-rules.js";
29
30
  import { parseEmbeddedJsonResponse, stripCodeFences, stripThinkBlocks } from "../../core/parse.js";
30
31
  /**
31
32
  * Per-asset-type frontmatter / authoring hints surfaced in the prompt so
@@ -156,6 +157,17 @@ export function buildReflectPrompt(input) {
156
157
  // ref is set but no feedback — explicitly constrain scope to schema compliance
157
158
  sections.push("No usage feedback recorded. Limit your proposal to schema and structural improvements only: missing required frontmatter fields, unclear `when_to_use`, ambiguous description, or broken formatting. Do not speculate about runtime weaknesses you have not observed.");
158
159
  }
160
+ if (input.standardsContext?.trim()) {
161
+ sections.push("Standards to follow (the rulebook for this target):");
162
+ sections.push(input.standardsContext.trim());
163
+ }
164
+ {
165
+ const resolvedType = input.type ?? (input.ref?.includes(":") ? input.ref.split(":")[0] : "");
166
+ const authoringRules = resolvedType ? authoringRulesForType(resolvedType) : "";
167
+ if (authoringRules) {
168
+ sections.push(authoringRules);
169
+ }
170
+ }
159
171
  if (input.assetContent?.trim()) {
160
172
  // Cap at 12 000 chars to stay well under OS ARG_MAX when the prompt is
161
173
  // passed as a CLI argument to opencode/claude. Large assets (wiki snapshots,
@@ -289,6 +301,16 @@ export function buildProposePrompt(input) {
289
301
  for (const line of input.schemaHints)
290
302
  sections.push(`- ${line}`);
291
303
  }
304
+ if (input.standardsContext?.trim()) {
305
+ sections.push("Standards to follow (the rulebook for this target):");
306
+ sections.push(input.standardsContext.trim());
307
+ }
308
+ {
309
+ const authoringRules = authoringRulesForType(input.type);
310
+ if (authoringRules) {
311
+ sections.push(authoringRules);
312
+ }
313
+ }
292
314
  sections.push("Produce a single proposal that, if accepted, would land as the asset described above.");
293
315
  sections.push(input.draftFilePath ? fileWriteContract(input.draftFilePath) : RESPONSE_CONTRACT_JSON);
294
316
  return sections.join("\n\n");
@@ -305,6 +327,16 @@ export function buildSchemaRepairPrompt(input) {
305
327
  `while preserving all existing content.`);
306
328
  sections.push(`Target ref: ${input.ref}`);
307
329
  sections.push(`Schema requirements for ${input.type} assets: ${hintForType(input.type)}`);
330
+ if (input.standardsContext?.trim()) {
331
+ sections.push("Standards to follow (the rulebook for this target):");
332
+ sections.push(input.standardsContext.trim());
333
+ }
334
+ {
335
+ const authoringRules = authoringRulesForType(input.type);
336
+ if (authoringRules) {
337
+ sections.push(authoringRules);
338
+ }
339
+ }
308
340
  const CONTENT_CAP = 3000;
309
341
  const body = input.assetContent.trimEnd();
310
342
  const truncated = body.length > CONTENT_CAP;
package/dist/wiki/wiki.js CHANGED
@@ -224,6 +224,43 @@ function readSchemaDescription(wikiDir) {
224
224
  return undefined;
225
225
  }
226
226
  }
227
+ /**
228
+ * Load a wiki's `schema.md` body and frontmatter.
229
+ *
230
+ * Unlike {@link readSchemaDescription} (which returns only the frontmatter
231
+ * `description` for `listWikis` summaries), this returns the markdown **body**
232
+ * — everything after the closing `---` of the frontmatter — which is where the
233
+ * page contract, operations, and hard rules live. The body is the rulebook
234
+ * injected into the write-time prompt for wiki-page edits.
235
+ *
236
+ * Swallow-and-degrade like the existing reader: a missing file, an unresolvable
237
+ * wiki dir, or malformed content yields `{ body: "", frontmatter: {} }`. Never
238
+ * throws.
239
+ */
240
+ export function loadWikiSchema(stashRoot, name) {
241
+ const empty = { body: "", frontmatter: {} };
242
+ let wikiDir;
243
+ try {
244
+ wikiDir = resolveWikiDir(stashRoot, name);
245
+ }
246
+ catch {
247
+ return empty;
248
+ }
249
+ let raw;
250
+ try {
251
+ raw = fs.readFileSync(path.join(wikiDir, SCHEMA_MD), "utf8");
252
+ }
253
+ catch {
254
+ return empty;
255
+ }
256
+ try {
257
+ const parsed = parseFrontmatter(raw);
258
+ return { body: parsed.content, frontmatter: parsed.data };
259
+ }
260
+ catch {
261
+ return empty;
262
+ }
263
+ }
227
264
  function toIsoDate(ms) {
228
265
  return new Date(ms).toISOString();
229
266
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "akm-cli",
3
- "version": "0.9.0-beta.35",
3
+ "version": "0.9.0-beta.36",
4
4
  "type": "module",
5
5
  "description": "akm (Agent Knowledge Management) — A package manager for AI agent skills, commands, tools, and knowledge. Works with Claude Code, OpenCode, Cursor, and any AI coding assistant.",
6
6
  "keywords": [