akm-cli 0.9.0-beta.34 → 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.
@@ -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
+ }
@@ -208,6 +208,11 @@ export const ImproveProcessConfigSchema = z
208
208
  maxSessionsPerRun: z.number().int().min(0).optional(),
209
209
  // #561 — index agent sessions as a searchable `session` asset (extract
210
210
  // process). Absent = on-when-an-LLM-is-available (fail-open when offline).
211
+ // COST: when on, each processed session makes a SECOND LLM call (the session
212
+ // summary) on top of the extraction call — i.e. ~2 LLM calls/session. Set to
213
+ // false to halve per-session extract cost at the price of unsearchable
214
+ // sessions. (Unchanged/skip sessions still cost zero — the content-hash
215
+ // ledger gates both calls upstream.)
211
216
  indexSessions: z.boolean().optional(),
212
217
  // #561 — minimum session duration in minutes for session indexing. 0
213
218
  // disables the gate. Absent = default 5. Only meaningful on `extract`.
@@ -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
+ }
@@ -1520,6 +1520,23 @@ export function getExtractedSessionsMap(db, harness, sessionIds) {
1520
1520
  }
1521
1521
  return out;
1522
1522
  }
1523
+ /**
1524
+ * The most recent extract-run time for a harness — `MAX(processed_at)` across
1525
+ * its ledger rows, as ms epoch — or `null` when the harness has never been
1526
+ * extracted. Used to default the discovery window to "since the last run" so an
1527
+ * intermittently-online host that was off for days still rediscovers sessions
1528
+ * that ended during the gap (the content-hash ledger keeps the widened window
1529
+ * free of redundant LLM cost).
1530
+ */
1531
+ export function getLastExtractRunAt(db, harness) {
1532
+ const row = db
1533
+ .prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?")
1534
+ .get(harness);
1535
+ if (!row?.last)
1536
+ return null;
1537
+ const ms = Date.parse(row.last);
1538
+ return Number.isFinite(ms) ? ms : null;
1539
+ }
1523
1540
  /**
1524
1541
  * Decide whether a session should be skipped because the extractor has already
1525
1542
  * processed BYTE-IDENTICAL content (#602). The skip authority is the content
@@ -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;
@@ -8998,6 +8998,7 @@ __export(exports_state_db, {
8998
8998
  getStateDbPath: () => getStateDbPath,
8999
8999
  getRecombineHypothesis: () => getRecombineHypothesis,
9000
9000
  getPhaseThreshold: () => getPhaseThreshold,
9001
+ getLastExtractRunAt: () => getLastExtractRunAt,
9001
9002
  getExtractedSessionsMap: () => getExtractedSessionsMap,
9002
9003
  getExtractedSession: () => getExtractedSession,
9003
9004
  getConsolidationJudgedMap: () => getConsolidationJudgedMap,
@@ -9437,6 +9438,13 @@ function getExtractedSessionsMap(db, harness, sessionIds) {
9437
9438
  }
9438
9439
  return out;
9439
9440
  }
9441
+ function getLastExtractRunAt(db, harness) {
9442
+ const row = db.prepare("SELECT MAX(processed_at) AS last FROM extract_sessions_seen WHERE harness = ?").get(harness);
9443
+ if (!row?.last)
9444
+ return null;
9445
+ const ms = Date.parse(row.last);
9446
+ return Number.isFinite(ms) ? ms : null;
9447
+ }
9440
9448
  function shouldSkipAlreadyExtractedSession(prior, currentContentHash) {
9441
9449
  if (!prior)
9442
9450
  return false;
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.34",
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": [