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