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.
- package/CHANGELOG.md +63 -0
- package/dist/assets/prompts/distill-lesson-system.md +1 -1
- package/dist/assets/prompts/extract-session.md +1 -1
- package/dist/commands/improve/consolidate.js +12 -2
- package/dist/commands/improve/distill.js +18 -0
- package/dist/commands/improve/extract-prompt.js +6 -0
- package/dist/commands/improve/extract.js +151 -6
- package/dist/commands/improve/improve.js +18 -2
- package/dist/commands/improve/procedural.js +12 -3
- package/dist/commands/improve/recombine.js +11 -2
- package/dist/commands/improve/reflect.js +5 -0
- package/dist/commands/proposal/drain.js +7 -0
- package/dist/commands/proposal/propose.js +5 -0
- package/dist/commands/proposal/validators/proposal-quality-validators.js +9 -8
- package/dist/commands/proposal/validators/proposals.js +116 -3
- package/dist/commands/sources/schema-repair.js +13 -1
- package/dist/core/authoring-rules.js +83 -0
- package/dist/core/config/config-schema.js +5 -0
- package/dist/core/standards/resolve-standards-context.js +56 -0
- package/dist/core/standards/resolve-stash-standards.js +87 -0
- package/dist/core/state-db.js +17 -0
- package/dist/integrations/agent/prompts.js +32 -0
- package/dist/scripts/migrate-storage.js +8 -0
- package/dist/wiki/wiki.js +37 -0
- package/package.json +1 -1
|
@@ -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 <
|
|
84
|
-
return { ok: false, reason: `description is too short (${v.length} chars; need
|
|
85
|
-
if (v.length >
|
|
86
|
-
return { ok: false, reason: `description is too long (${v.length} chars; max
|
|
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 <
|
|
137
|
-
return { ok: false, reason: `when_to_use is too short (${v.length} chars; need
|
|
138
|
-
if (v.length >
|
|
139
|
-
return { ok: false, reason: `when_to_use is too long (${v.length} chars; max
|
|
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
|
-
|
|
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
|
-
|
|
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,
|
|
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 ${
|
|
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
|
+
}
|
package/dist/core/state-db.js
CHANGED
|
@@ -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.
|
|
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": [
|