akm-cli 0.9.1-beta.1 → 0.9.1-beta.3
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 +34 -1
- package/dist/cli/parse-args.js +7 -1
- package/dist/commands/env/child-env.js +14 -0
- package/dist/commands/health/advisories.js +5 -5
- package/dist/commands/health/html-report.js +2 -2
- package/dist/commands/health/metrics.js +38 -22
- package/dist/commands/health/report-view-model.js +1 -1
- package/dist/commands/improve/consolidate.js +61 -9
- package/dist/commands/improve/eval-cases.js +2 -0
- package/dist/commands/improve/memory/memory-improve.js +1 -0
- package/dist/commands/lint/base-linter.js +93 -20
- package/dist/commands/lint/index.js +5 -1
- package/dist/commands/sources/add-cli.js +8 -2
- package/dist/commands/sources/migration-help.js +12 -3
- package/dist/commands/sources/self-update.js +9 -1
- package/dist/core/adapter/adapters/agent-skills-adapter.js +32 -16
- package/dist/core/adapter/adapters/akm-lint.js +6 -2
- package/dist/core/adapter/adapters/akm-task-adapter.js +4 -2
- package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
- package/dist/core/asset/frontmatter.js +6 -1
- package/dist/core/common.js +81 -3
- package/dist/core/config/config-io.js +5 -45
- package/dist/core/config/schema/engines.js +14 -3
- package/dist/core/extra-params.js +11 -0
- package/dist/core/fs-txn.js +15 -2
- package/dist/core/json-schema.js +19 -2
- package/dist/core/paths.js +16 -2
- package/dist/core/redaction.js +22 -1
- package/dist/core/state-db.js +1 -0
- package/dist/core/write-source.js +26 -2
- package/dist/indexer/indexer.js +48 -9
- package/dist/indexer/search/db-search.js +17 -2
- package/dist/indexer/walk/walker.js +6 -1
- package/dist/integrations/agent/detect.js +13 -1
- package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
- package/dist/integrations/lockfile.js +10 -0
- package/dist/llm/client.js +14 -19
- package/dist/llm/embedder.js +23 -3
- package/dist/llm/embedders/remote.js +27 -2
- package/dist/output/html-render.js +40 -1
- package/dist/runtime.js +23 -1
- package/dist/scripts/akm-migrate-node.js +303 -107
- package/dist/scripts/akm-migrate.js +303 -107
- package/dist/setup/setup.js +22 -7
- package/dist/sources/providers/git-install.js +25 -2
- package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
- package/dist/storage/database.js +71 -12
- package/dist/storage/engines/sqlite-migrations.js +61 -2
- package/dist/storage/repositories/index-connection.js +11 -1
- package/dist/storage/repositories/index-meta-repository.js +11 -0
- package/dist/storage/repositories/index-schema.js +17 -2
- package/dist/storage/repositories/index-vec-repository.js +43 -5
- package/dist/storage/repositories/salience-repository.js +13 -12
- package/dist/storage/sqlite-pragmas.js +12 -1
- package/dist/tasks/runner.js +84 -7
- package/dist/tasks/scheduler-invocation.js +19 -0
- package/dist/tasks/schema.js +21 -1
- package/dist/text-import-hook.mjs +1 -1
- package/dist/workflows/exec/native-executor.js +8 -0
- package/dist/workflows/exec/step-work.js +10 -2
- package/dist/workflows/parser.js +26 -1
- package/package.json +1 -1
- package/schemas/akm-config.json +10 -5
- package/schemas/akm-workflow.json +7 -3
package/CHANGELOG.md
CHANGED
|
@@ -6,7 +6,23 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
-
## [0.9.1-beta.
|
|
9
|
+
## [0.9.1-beta.3] - 2026-08-18
|
|
10
|
+
|
|
11
|
+
### Fixed
|
|
12
|
+
|
|
13
|
+
- Preserve multiline frontmatter descriptions when `akm lint --fix` quotes
|
|
14
|
+
colons, recover already-malformed quoted descriptions, and report a fix only
|
|
15
|
+
when the file actually changes.
|
|
16
|
+
- Skip consolidation promotion proposals whose body already exists in a live
|
|
17
|
+
knowledge asset, preventing exact-content duplicates from recurring in the
|
|
18
|
+
proposal backlog.
|
|
19
|
+
- Derive utility `last_used_at` values only from real user retrieval events
|
|
20
|
+
(`search`, `show`, and `curate`) instead of stamping assets with index time.
|
|
21
|
+
- Compute the salience-distribution health metric over every positive,
|
|
22
|
+
non-missing salience value rather than a top-ranked 100-row slice, and report
|
|
23
|
+
the evaluated sample size.
|
|
24
|
+
|
|
25
|
+
## [0.9.1-beta.2] - 2026-08-17
|
|
10
26
|
|
|
11
27
|
### Breaking changes & migration
|
|
12
28
|
|
|
@@ -361,6 +377,23 @@ what an upgrader reads first.
|
|
|
361
377
|
|
|
362
378
|
### Fixed
|
|
363
379
|
|
|
380
|
+
- **Upgrading Node after installing akm now explains itself.** A native binding
|
|
381
|
+
is built for the Node ABI present at install time, so upgrading Node major
|
|
382
|
+
versions afterwards leaves akm reporting a bare Node internals message —
|
|
383
|
+
*"The module … was compiled against a different Node.js version"*, or on a
|
|
384
|
+
second attempt the even less helpful *"Module did not self-register"*. akm now
|
|
385
|
+
recognises that failure and answers with the one command that fixes it
|
|
386
|
+
(`npm rebuild better-sqlite3`), names the ABI actually running, and says
|
|
387
|
+
plainly that this is not a broken install.
|
|
388
|
+
|
|
389
|
+
The diagnostic had to move to do this. It wrapped the `require`, but
|
|
390
|
+
`require("better-sqlite3")` **succeeds** against a mismatched binding — the
|
|
391
|
+
package resolves its `.node` file lazily — so the error lands at
|
|
392
|
+
`new Database(...)` and the loader's handler never saw it. The previous text
|
|
393
|
+
telling the user to look for a version mismatch "in the error below" was
|
|
394
|
+
unreachable. Found by installing the published build under Node 22 and running
|
|
395
|
+
it under Node 24.
|
|
396
|
+
|
|
364
397
|
- **akm's Node fallback no longer aborts at teardown on Node 24.** On Node
|
|
365
398
|
24.19.0 and later, any command that opened a database could intermittently
|
|
366
399
|
die with `node::RemoveEnvironmentCleanupHook … Assertion (env) != nullptr`
|
package/dist/cli/parse-args.js
CHANGED
|
@@ -45,8 +45,14 @@ export function parsePositiveIntFlag(raw, flagName = "--limit") {
|
|
|
45
45
|
const trimmed = raw.trim();
|
|
46
46
|
if (!trimmed)
|
|
47
47
|
return undefined;
|
|
48
|
+
// Strict digits, matching parseNonNegativeIntFlag below. parseInt stops at the
|
|
49
|
+
// first non-digit, so "10x" silently became 10, "3.5" became 3, and
|
|
50
|
+
// "5 apples" became 5 — accepted rather than rejected as invalid.
|
|
51
|
+
if (!/^\d+$/.test(trimmed)) {
|
|
52
|
+
throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
|
|
53
|
+
}
|
|
48
54
|
const parsed = parseInt(trimmed, 10);
|
|
49
|
-
if (
|
|
55
|
+
if (parsed <= 0) {
|
|
50
56
|
throw new UsageError(`Invalid ${flagName} value: "${raw}". Must be a positive integer.`, "INVALID_FLAG_VALUE");
|
|
51
57
|
}
|
|
52
58
|
return parsed;
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
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
|
+
import { WIN32_SPAWN_ENV_FLOOR } from "../../core/spawn-env.js";
|
|
4
5
|
const CLEAN_ENV_ALLOWLIST = [
|
|
5
6
|
"HOME",
|
|
6
7
|
"PATH",
|
|
@@ -38,6 +39,19 @@ export function buildChildEnv(parentEnv, options) {
|
|
|
38
39
|
if (parentEnv[key] !== undefined)
|
|
39
40
|
base[key] = parentEnv[key];
|
|
40
41
|
}
|
|
42
|
+
// The allowlist above is POSIX-shaped. On Windows a child started without
|
|
43
|
+
// SystemRoot/COMSPEC/PATHEXT and friends frequently cannot start at all —
|
|
44
|
+
// which is why every other spawn path in the codebase applies this floor
|
|
45
|
+
// (see spawnEnvNamesFor). `env run --clean` / `secret run --clean` did not,
|
|
46
|
+
// so clean-mode injection was unusable there. The floor is names the OS
|
|
47
|
+
// requires of any child, not user configuration, so it does not weaken what
|
|
48
|
+
// "clean" means about inherited secrets.
|
|
49
|
+
if (process.platform === "win32") {
|
|
50
|
+
for (const key of WIN32_SPAWN_ENV_FLOOR) {
|
|
51
|
+
if (parentEnv[key] !== undefined)
|
|
52
|
+
base[key] = parentEnv[key];
|
|
53
|
+
}
|
|
54
|
+
}
|
|
41
55
|
}
|
|
42
56
|
for (const key of options.inherit) {
|
|
43
57
|
if (parentEnv[key] !== undefined)
|
|
@@ -53,17 +53,17 @@ export function collectImproveAdvisories(db, stateDbPath, since, improveSummary)
|
|
|
53
53
|
"treat outcome-derived rank contributions as noise until a real usage/outcome signal lands.",
|
|
54
54
|
});
|
|
55
55
|
}
|
|
56
|
-
// Salience-distribution collapse
|
|
57
|
-
// ranking no longer discriminates between assets.
|
|
56
|
+
// Salience-distribution collapse across all assets with retrieval evidence.
|
|
58
57
|
if (improveSummary.degradation?.salienceUniformityFlagged) {
|
|
58
|
+
const sampleSize = improveSummary.degradation.retrievalSalienceSampleSize;
|
|
59
59
|
advisories.push({
|
|
60
60
|
name: "salience-uniformity-collapse",
|
|
61
61
|
status: "warn",
|
|
62
62
|
kind: "deterministic",
|
|
63
63
|
confidence: "high",
|
|
64
|
-
message: `Salience distribution collapsed toward uniform:
|
|
65
|
-
`${improveSummary.degradation.corpusCentroidDistance} < 0.08
|
|
66
|
-
"Ranking
|
|
64
|
+
message: `Salience distribution collapsed toward uniform: retrieval_salience Gini = ` +
|
|
65
|
+
`${improveSummary.degradation.corpusCentroidDistance} < 0.08 across ${sampleSize} ` +
|
|
66
|
+
"observed, resolvable assets. Ranking carries little discrimination among assets with retrieval evidence.",
|
|
67
67
|
});
|
|
68
68
|
}
|
|
69
69
|
// Enrichment-vs-minting policy: enrichment lanes edit existing assets;
|
|
@@ -351,8 +351,8 @@ function renderActionItems(vm) {
|
|
|
351
351
|
prio: "P2",
|
|
352
352
|
cls: "warn",
|
|
353
353
|
title: "Salience distribution collapsed: retrieval_salience Gini < 0.08",
|
|
354
|
-
descHtml:
|
|
355
|
-
"ranking
|
|
354
|
+
descHtml: `The ${vm.degradation.retrievalSalienceSampleSize} observed, resolvable salience scores are near-uniform — ` +
|
|
355
|
+
"ranking carries little discrimination among assets with retrieval evidence. " +
|
|
356
356
|
`Corpus diversity proxy: ${esc(String(vm.degradation.corpusCentroidDistance))}.`,
|
|
357
357
|
remedy: "akm health --format json | jq '.improve.degradation'",
|
|
358
358
|
});
|
|
@@ -12,11 +12,23 @@ import { withStateDb } from "../../core/state-db.js";
|
|
|
12
12
|
import { insertEvent } from "../../storage/repositories/events-repository.js";
|
|
13
13
|
import { queryImproveRuns } from "../../storage/repositories/improve-runs-repository.js";
|
|
14
14
|
import { listStateProposals } from "../../storage/repositories/proposals-repository.js";
|
|
15
|
-
import {
|
|
15
|
+
import { getObservedRetrievalSalience } from "../../storage/repositories/salience-repository.js";
|
|
16
16
|
import { roundRate, toFiniteNumber } from "./improve-metrics.js";
|
|
17
17
|
import { ENRICHMENT_LANES, } from "./types.js";
|
|
18
18
|
/** Event type appended + read back by the state.db round-trip probe. */
|
|
19
19
|
const HEALTH_PROBE_EVENT = "health_probe";
|
|
20
|
+
/**
|
|
21
|
+
* Retrieval-salience Gini guardrails.
|
|
22
|
+
*
|
|
23
|
+
* These are distribution-shape thresholds, not quantiles tied to a corpus
|
|
24
|
+
* size. Synthetic anchors pinned in monitor-liveness.test.ts are ~0.01 for a
|
|
25
|
+
* near-uniform two-band distribution, 0.25 for a balanced 0.25/0.75 spread,
|
|
26
|
+
* and ~0.82 for one dominant value among nine 0.01 values. Full-observation
|
|
27
|
+
* production snapshots also sit stably in the neutral band: 0.2613 at n=1,209
|
|
28
|
+
* (2026-07-12) and 0.2506 at n=1,454 (2026-08-17, missing rows excluded).
|
|
29
|
+
*/
|
|
30
|
+
const SALIENCE_UNIFORMITY_GINI_THRESHOLD = 0.08;
|
|
31
|
+
const SALIENCE_ENTRENCHMENT_GINI_THRESHOLD = 0.35;
|
|
20
32
|
/** Synthetic sentinel ref (ref-grammar decision D-R3): a colon-free
|
|
21
33
|
* `<subsystem>/_<marker>` label. `health` has no asset stash-subdir, so
|
|
22
34
|
* `health/_probe` names the subsystem. */
|
|
@@ -204,38 +216,41 @@ export function computeEnrichmentMintingRollup(db, since, until) {
|
|
|
204
216
|
* @param until - Window end (ISO-8601).
|
|
205
217
|
*/
|
|
206
218
|
export function computeDegradationMetrics(db, since, until) {
|
|
207
|
-
// (a) Corpus diversity —
|
|
208
|
-
//
|
|
209
|
-
//
|
|
210
|
-
//
|
|
211
|
-
//
|
|
219
|
+
// (a) Corpus diversity — distribution of every observed retrieval-salience
|
|
220
|
+
// value for a currently resolvable asset. Zero is the no-observation floor
|
|
221
|
+
// and is excluded: the diagnostic measures discrimination among assets that
|
|
222
|
+
// have a retrieval signal, not corpus coverage.
|
|
223
|
+
//
|
|
224
|
+
// Do not preselect by rank_score here. The old top-100 sample truncated the
|
|
225
|
+
// distribution before measuring it, producing a low Gini even when the full
|
|
226
|
+
// observed corpus had a healthy spread.
|
|
212
227
|
let corpusCentroidDistance = Number.NaN;
|
|
228
|
+
let retrievalSalienceSampleSize = 0;
|
|
213
229
|
let entrenchmentFlagged;
|
|
214
230
|
let salienceUniformityFlagged;
|
|
215
231
|
try {
|
|
216
232
|
// Fail-open: the asset_salience table may not exist yet (pre-WS-1 install).
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
const rows = getTopRetrievalSalience(db, 100);
|
|
233
|
+
const rows = getObservedRetrievalSalience(db);
|
|
234
|
+
retrievalSalienceSampleSize = rows.length;
|
|
220
235
|
if (rows.length >= 5) {
|
|
236
|
+
// The repository returns ascending values. Keep a defensive sort so the
|
|
237
|
+
// O(n log n) closed-form Gini remains correct if that contract changes.
|
|
221
238
|
const vals = rows.map((r) => r.retrieval_salience).sort((a, b) => a - b);
|
|
222
239
|
const n = vals.length;
|
|
223
|
-
const
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
//
|
|
228
|
-
|
|
229
|
-
const gini = mean > 0 ? sumAbsDiff / (n * n * mean) : 0;
|
|
240
|
+
const sum = vals.reduce((acc, value) => acc + value, 0);
|
|
241
|
+
const weightedSum = vals.reduce((acc, value, index) => acc + (index + 1) * value, 0);
|
|
242
|
+
// Closed-form Gini for sorted non-negative values. This is O(n log n)
|
|
243
|
+
// including the defensive sort; the previous pairwise implementation
|
|
244
|
+
// was O(n²) and only safe because the sample was capped at 100.
|
|
245
|
+
const gini = sum > 0 ? (2 * weightedSum) / (n * sum) - (n + 1) / n : 0;
|
|
230
246
|
// Re-express as a diversity proxy in [0,1]: high gini = low diversity.
|
|
231
247
|
// corpusCentroidDistance approximation: gini is "distance from uniform".
|
|
232
|
-
// Two-tailed:
|
|
233
|
-
//
|
|
234
|
-
//
|
|
235
|
-
// in this tail under the old one-tailed check).
|
|
248
|
+
// Two-tailed: high concentration flags entrenchment; very low spread
|
|
249
|
+
// flags near-uniformity. Calibration provenance is documented with the
|
|
250
|
+
// constants above and pinned by synthetic distribution tests.
|
|
236
251
|
corpusCentroidDistance = roundRate(gini);
|
|
237
|
-
entrenchmentFlagged = gini >
|
|
238
|
-
salienceUniformityFlagged = gini <
|
|
252
|
+
entrenchmentFlagged = gini > SALIENCE_ENTRENCHMENT_GINI_THRESHOLD;
|
|
253
|
+
salienceUniformityFlagged = gini < SALIENCE_UNIFORMITY_GINI_THRESHOLD;
|
|
239
254
|
}
|
|
240
255
|
}
|
|
241
256
|
catch {
|
|
@@ -305,6 +320,7 @@ export function computeDegradationMetrics(db, since, until) {
|
|
|
305
320
|
}
|
|
306
321
|
return {
|
|
307
322
|
corpusCentroidDistance,
|
|
323
|
+
retrievalSalienceSampleSize,
|
|
308
324
|
entrenchmentFlagged,
|
|
309
325
|
salienceUniformityFlagged,
|
|
310
326
|
mergeFidelityContradictionRate,
|
|
@@ -455,7 +455,7 @@ function buildSummaryRows(aggregates, trend) {
|
|
|
455
455
|
"Corpus diversity (Gini)",
|
|
456
456
|
num(degradation.corpusCentroidDistance),
|
|
457
457
|
degradation.entrenchmentFlagged || degradation.salienceUniformityFlagged ? "down" : "flat",
|
|
458
|
-
|
|
458
|
+
`Gini coefficient of positive retrieval_salience values across ${degradation.retrievalSalienceSampleSize} resolvable assets. Two-tailed: >0.35 = entrenchment risk; <0.08 = collapsed toward uniform.`,
|
|
459
459
|
], [
|
|
460
460
|
"Merge fidelity contradiction rate",
|
|
461
461
|
pct(degradation.mergeFidelityContradictionRate, 1),
|
|
@@ -286,6 +286,46 @@ function loadPendingConsolidateProposalHashes(stashDir) {
|
|
|
286
286
|
}
|
|
287
287
|
return hashes;
|
|
288
288
|
}
|
|
289
|
+
/**
|
|
290
|
+
* Hash the bodies of live knowledge assets once per consolidation run.
|
|
291
|
+
*
|
|
292
|
+
* Pending-proposal dedup prevents repeated queue entries, but accepted
|
|
293
|
+
* proposals leave that set. Without a live-asset guard, the next run can copy
|
|
294
|
+
* the same memory body into a new knowledge slug indefinitely. Scan the target
|
|
295
|
+
* tree directly (rather than trusting the asynchronously refreshed index) so
|
|
296
|
+
* an already-written asset suppresses recurrence immediately.
|
|
297
|
+
*/
|
|
298
|
+
export function loadExistingKnowledgeBodyHashes(targetRoot) {
|
|
299
|
+
const hashes = new Set();
|
|
300
|
+
const knowledgeRoot = path.join(targetRoot, "knowledge");
|
|
301
|
+
if (!fs.existsSync(knowledgeRoot))
|
|
302
|
+
return hashes;
|
|
303
|
+
const visit = (dir) => {
|
|
304
|
+
let entries;
|
|
305
|
+
try {
|
|
306
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
307
|
+
}
|
|
308
|
+
catch {
|
|
309
|
+
return;
|
|
310
|
+
}
|
|
311
|
+
for (const entry of entries) {
|
|
312
|
+
const entryPath = path.join(dir, entry.name);
|
|
313
|
+
if (entry.isDirectory()) {
|
|
314
|
+
visit(entryPath);
|
|
315
|
+
}
|
|
316
|
+
else if (entry.isFile() && entry.name.endsWith(".md")) {
|
|
317
|
+
try {
|
|
318
|
+
hashes.add(cacheHash(fs.readFileSync(entryPath, "utf8")));
|
|
319
|
+
}
|
|
320
|
+
catch {
|
|
321
|
+
// An unreadable asset cannot provide reliable duplicate evidence.
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
}
|
|
325
|
+
};
|
|
326
|
+
visit(knowledgeRoot);
|
|
327
|
+
return hashes;
|
|
328
|
+
}
|
|
289
329
|
/** Parse a stored provenance ref and emit its canonical D-R5 display spelling. */
|
|
290
330
|
function canonicalStoredXref(ref) {
|
|
291
331
|
try {
|
|
@@ -976,6 +1016,7 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, warnings, sh
|
|
|
976
1016
|
memoryByRef,
|
|
977
1017
|
promoted,
|
|
978
1018
|
promotedSourceRefs: new Set(),
|
|
1019
|
+
existingKnowledgeBodyHashes: loadExistingKnowledgeBodyHashes(opts.writeTarget.source.path),
|
|
979
1020
|
promotionFailures,
|
|
980
1021
|
warnings,
|
|
981
1022
|
pushSkipReason: accounting.pushSkipReason,
|
|
@@ -1011,8 +1052,26 @@ async function akmConsolidateInner(opts, config, stashDir, startMs, warnings, sh
|
|
|
1011
1052
|
},
|
|
1012
1053
|
});
|
|
1013
1054
|
}
|
|
1055
|
+
/** Reject a promotion when its body already exists in knowledge or the queue. */
|
|
1056
|
+
function shouldSkipPromotionBodyDuplicate(args) {
|
|
1057
|
+
const { bodyHash, op, knowledgeRef, ctx } = args;
|
|
1058
|
+
if (ctx.existingKnowledgeBodyHashes.has(bodyHash)) {
|
|
1059
|
+
ctx.warnings.push(`Skipping promote: identical body already exists in knowledge; skipping duplicate for ${op.ref} → ${knowledgeRef}`);
|
|
1060
|
+
ctx.pushSkipReason("promote", op.ref, "dedup_existing_knowledge");
|
|
1061
|
+
return true;
|
|
1062
|
+
}
|
|
1063
|
+
const contentDupProposal = listProposals(ctx.stashDir, { status: "pending" })
|
|
1064
|
+
.filter((proposal) => proposal.source === "consolidate")
|
|
1065
|
+
.find((proposal) => cacheHash(proposalContent(proposal)) === bodyHash);
|
|
1066
|
+
if (!contentDupProposal)
|
|
1067
|
+
return false;
|
|
1068
|
+
ctx.warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
|
|
1069
|
+
ctx.pushSkipReason("promote", op.ref, "dedup_pending_proposal");
|
|
1070
|
+
return true;
|
|
1071
|
+
}
|
|
1014
1072
|
/** Execute one reconciled promotion by emitting a reviewable proposal. */
|
|
1015
|
-
|
|
1073
|
+
/** @internal Executes the real proposal-emission path for one promote operation. */
|
|
1074
|
+
export async function emitPromotionProposal(op, ctx) {
|
|
1016
1075
|
const { config, stashDir, sourceRun, target, memoryByRef, warnings, pushSkipReason, promoted, promotedSourceRefs } = ctx;
|
|
1017
1076
|
const entry = memoryByRef.get(op.ref);
|
|
1018
1077
|
if (!entry) {
|
|
@@ -1111,15 +1170,8 @@ async function emitPromotionProposal(op, ctx) {
|
|
|
1111
1170
|
// Use cacheHash (case-preserving stripped body) to match the canonical
|
|
1112
1171
|
// hash domain used by the body-embedding cache and pending-proposal set.
|
|
1113
1172
|
const bodyHash = cacheHash(sourceBody);
|
|
1114
|
-
|
|
1115
|
-
const contentDupProposal = allPendingConsolidateProposals.find((p) => {
|
|
1116
|
-
return cacheHash(proposalContent(p)) === bodyHash;
|
|
1117
|
-
});
|
|
1118
|
-
if (contentDupProposal) {
|
|
1119
|
-
warnings.push(`Skipping promote: identical body already pending as proposal ${contentDupProposal.id} (ref: ${contentDupProposal.ref}); skipping duplicate for ${op.ref} → ${knowledgeRef}`);
|
|
1120
|
-
pushSkipReason("promote", op.ref, "dedup_pending_proposal");
|
|
1173
|
+
if (shouldSkipPromotionBodyDuplicate({ bodyHash, op, knowledgeRef, ctx }))
|
|
1121
1174
|
return;
|
|
1122
|
-
}
|
|
1123
1175
|
try {
|
|
1124
1176
|
// Use LLM-provided description; fall back to memory's own description
|
|
1125
1177
|
// (post-sanitization frontmatter is authoritative).
|
|
@@ -4,6 +4,7 @@
|
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
6
|
import { writeFileAtomic } from "../../core/common.js";
|
|
7
|
+
import { recordWrittenPath } from "../../core/write-provenance.js";
|
|
7
8
|
export function writeEvalCase(stashDir, evalCase) {
|
|
8
9
|
const evalDir = path.join(stashDir, ".akm", "eval-cases");
|
|
9
10
|
fs.mkdirSync(evalDir, { recursive: true });
|
|
@@ -28,6 +29,7 @@ Use it as a regression test: future improve runs on this ref should not produce
|
|
|
28
29
|
output that would be rejected for the same reason.
|
|
29
30
|
`;
|
|
30
31
|
writeFileAtomic(filePath, content);
|
|
32
|
+
recordWrittenPath(filePath);
|
|
31
33
|
return filePath;
|
|
32
34
|
}
|
|
33
35
|
export function countEvalCases(stashDir) {
|
|
@@ -543,6 +543,7 @@ function appendBeliefStateTransitionLog(stashDir, transitions) {
|
|
|
543
543
|
}))
|
|
544
544
|
.join("\n");
|
|
545
545
|
fs.appendFileSync(logPath, `${lines}\n`, "utf8");
|
|
546
|
+
recordWrittenPath(logPath);
|
|
546
547
|
return logPath;
|
|
547
548
|
}
|
|
548
549
|
function priorBeliefStateForArchive(candidate) {
|
|
@@ -33,6 +33,7 @@
|
|
|
33
33
|
// ----------------------------------------------------------------------------
|
|
34
34
|
import fs from "node:fs";
|
|
35
35
|
import path from "node:path";
|
|
36
|
+
import { isScalar, parseDocument } from "yaml";
|
|
36
37
|
import { assetPathForName, stashDirFor } from "../../core/asset/asset-placement.js";
|
|
37
38
|
import { BUNDLE_REF_RE } from "../../core/asset/asset-ref.js";
|
|
38
39
|
import { spliceFrontmatterLine } from "../../core/asset/frontmatter.js";
|
|
@@ -41,26 +42,87 @@ import { typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
|
|
|
41
42
|
import { localDateStamp } from "../../core/common.js";
|
|
42
43
|
import { findFenceRegions } from "./markdown-insertion.js";
|
|
43
44
|
// ── Helpers ───────────────────────────────────────────────────────────────────
|
|
45
|
+
/** Fold physically wrapped prose the same way a YAML plain scalar does. */
|
|
46
|
+
function foldDescriptionLines(lines) {
|
|
47
|
+
let value = "";
|
|
48
|
+
let blankLines = 0;
|
|
49
|
+
for (const line of lines) {
|
|
50
|
+
const trimmed = line.trim();
|
|
51
|
+
if (!trimmed) {
|
|
52
|
+
blankLines++;
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
if (value)
|
|
56
|
+
value += blankLines > 0 ? "\n".repeat(blankLines) : " ";
|
|
57
|
+
value += trimmed;
|
|
58
|
+
blankLines = 0;
|
|
59
|
+
}
|
|
60
|
+
return value;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Recover a description from malformed wrapped YAML.
|
|
64
|
+
*
|
|
65
|
+
* Older producers emitted a valid quoted first physical line followed by
|
|
66
|
+
* indented prose outside the quote. The full document cannot be parsed, but
|
|
67
|
+
* the first line can still be decoded independently and the continuation can
|
|
68
|
+
* be folded without guessing at escape sequences in that first segment.
|
|
69
|
+
*/
|
|
70
|
+
function recoverMalformedDescription(firstSegment, continuation) {
|
|
71
|
+
const firstLine = parseDocument(`description: ${firstSegment}`);
|
|
72
|
+
const firstValue = firstLine.get("description", true);
|
|
73
|
+
const decodedFirst = firstLine.errors.length === 0 && isScalar(firstValue) && typeof firstValue.value === "string"
|
|
74
|
+
? firstValue.value
|
|
75
|
+
: firstSegment.trim();
|
|
76
|
+
const value = foldDescriptionLines([decodedFirst, ...continuation]);
|
|
77
|
+
return value || null;
|
|
78
|
+
}
|
|
79
|
+
/**
|
|
80
|
+
* Quote the complete description scalar, including physical continuation
|
|
81
|
+
* lines, and verify that the replacement is valid YAML before returning it.
|
|
82
|
+
*/
|
|
44
83
|
function fixUnquotedColon(raw) {
|
|
84
|
+
const eol = raw.includes("\r\n") ? "\r\n" : "\n";
|
|
45
85
|
const lines = raw.split(/\r?\n/);
|
|
46
86
|
if (lines[0]?.trim() !== "---")
|
|
47
|
-
return
|
|
87
|
+
return null;
|
|
48
88
|
const closeIdx = lines.findIndex((l, i) => i > 0 && l.trim() === "---");
|
|
49
89
|
if (closeIdx === -1)
|
|
50
|
-
return
|
|
90
|
+
return null;
|
|
51
91
|
for (let i = 1; i < closeIdx; i++) {
|
|
52
|
-
const m = lines[i]
|
|
92
|
+
const m = lines[i]?.match(/^(description:\s*)(.*)/);
|
|
53
93
|
if (!m)
|
|
54
94
|
continue;
|
|
55
|
-
const prefix = m
|
|
56
|
-
|
|
57
|
-
if ((value.startsWith('"') && value.endsWith('"') && value.length >= 2) ||
|
|
58
|
-
(value.startsWith("'") && value.endsWith("'") && value.length >= 2))
|
|
95
|
+
const [, prefix, firstSegment] = m;
|
|
96
|
+
if (prefix === undefined || firstSegment === undefined)
|
|
59
97
|
continue;
|
|
60
|
-
|
|
61
|
-
|
|
98
|
+
let continuationEnd = i + 1;
|
|
99
|
+
while (continuationEnd < closeIdx) {
|
|
100
|
+
const continuationLine = lines[continuationEnd];
|
|
101
|
+
if (continuationLine === undefined || (!/^[ \t]/.test(continuationLine) && continuationLine.trim()))
|
|
102
|
+
break;
|
|
103
|
+
continuationEnd++;
|
|
104
|
+
}
|
|
105
|
+
const frontmatter = lines.slice(1, closeIdx).join("\n");
|
|
106
|
+
const document = parseDocument(frontmatter);
|
|
107
|
+
const description = document.get("description", true);
|
|
108
|
+
const value = document.errors.length === 0 && isScalar(description) && typeof description.value === "string"
|
|
109
|
+
? description.value
|
|
110
|
+
: recoverMalformedDescription(firstSegment.trim(), lines.slice(i + 1, continuationEnd));
|
|
111
|
+
if (value === null)
|
|
112
|
+
return null;
|
|
113
|
+
lines.splice(i, continuationEnd - i, `${prefix}${JSON.stringify(value)}`);
|
|
114
|
+
const candidate = lines.join(eol);
|
|
115
|
+
const candidateCloseIdx = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
|
|
116
|
+
const candidateDocument = parseDocument(lines.slice(1, candidateCloseIdx).join("\n"));
|
|
117
|
+
const candidateDescription = candidateDocument.get("description", true);
|
|
118
|
+
if (candidateDocument.errors.length > 0 ||
|
|
119
|
+
!isScalar(candidateDescription) ||
|
|
120
|
+
candidateDescription.value !== value) {
|
|
121
|
+
return null;
|
|
122
|
+
}
|
|
123
|
+
return candidate;
|
|
62
124
|
}
|
|
63
|
-
return
|
|
125
|
+
return null;
|
|
64
126
|
}
|
|
65
127
|
function checkMissingUpdated(data, frontmatterText) {
|
|
66
128
|
return frontmatterText !== null && !("updated" in data);
|
|
@@ -473,16 +535,27 @@ export function runBaseChecks(ctx) {
|
|
|
473
535
|
const unquotedColonDetail = checkUnquotedDescriptionColon(ctx.frontmatter);
|
|
474
536
|
if (unquotedColonDetail) {
|
|
475
537
|
if (ctx.fix) {
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
538
|
+
const fixedRaw = fixUnquotedColon(currentRaw);
|
|
539
|
+
if (fixedRaw === null) {
|
|
540
|
+
issues.push({
|
|
541
|
+
file: ctx.relPath,
|
|
542
|
+
issue: "unquoted-colon",
|
|
543
|
+
detail: `${unquotedColonDetail} — could not construct a valid YAML replacement`,
|
|
544
|
+
fixed: "failed",
|
|
545
|
+
});
|
|
546
|
+
}
|
|
547
|
+
else {
|
|
548
|
+
currentRaw = fixedRaw;
|
|
549
|
+
modified = true;
|
|
550
|
+
const issue = {
|
|
551
|
+
file: ctx.relPath,
|
|
552
|
+
issue: "unquoted-colon",
|
|
553
|
+
detail: unquotedColonDetail,
|
|
554
|
+
fixed: true,
|
|
555
|
+
};
|
|
556
|
+
issues.push(issue);
|
|
557
|
+
pendingFixes.push(issue);
|
|
558
|
+
}
|
|
486
559
|
}
|
|
487
560
|
else {
|
|
488
561
|
issues.push({
|
|
@@ -502,7 +502,11 @@ function lintAkmSweep(stashRoot, extraStashRoots, cfg, sources, options) {
|
|
|
502
502
|
}
|
|
503
503
|
for (const filePath of assetFiles) {
|
|
504
504
|
// Skip registry-cached read-only files — --fix must not mutate them.
|
|
505
|
-
|
|
505
|
+
// Compare on a separator-normalized copy: on Windows these paths carry
|
|
506
|
+
// backslashes, so the forward-slash substring never matched and --fix
|
|
507
|
+
// rewrote files inside the registry cache.
|
|
508
|
+
const posixPath = filePath.replace(/\\/g, "/");
|
|
509
|
+
if (posixPath.includes("/.cache/") || posixPath.includes("/registry/"))
|
|
506
510
|
continue;
|
|
507
511
|
const relPath = path.relative(stashRoot, filePath);
|
|
508
512
|
let raw;
|
|
@@ -10,6 +10,7 @@ import { decideDangerousKeyInstall } from "../../core/activation-policy.js";
|
|
|
10
10
|
import { UsageError } from "../../core/errors.js";
|
|
11
11
|
import { appendEvent } from "../../core/events.js";
|
|
12
12
|
import { warn } from "../../core/warn.js";
|
|
13
|
+
import { sanitizeString } from "../../sources/providers/provider-utils.js";
|
|
13
14
|
import { akmRemove } from "./installed-stashes.js";
|
|
14
15
|
import { akmAdd } from "./source-add.js";
|
|
15
16
|
import { addStash } from "./source-manage.js";
|
|
@@ -180,9 +181,14 @@ export async function auditInstalledStashForDangerousKeys(opts) {
|
|
|
180
181
|
groupedByEnv.set(f.envRef, existing);
|
|
181
182
|
}
|
|
182
183
|
for (const [envRef, keys] of groupedByEnv) {
|
|
183
|
-
|
|
184
|
+
// envRef and keys come from filenames and KEY names inside a downloaded or
|
|
185
|
+
// cloned bundle, i.e. attacker-controllable. Tar validation rejects NUL but
|
|
186
|
+
// not ESC/CSI, and git checkout allows them in filenames on Linux/macOS —
|
|
187
|
+
// so printing them raw let a crafted bundle rewrite this security prompt
|
|
188
|
+
// with terminal escapes right before an "Install anyway?" confirmation.
|
|
189
|
+
warn(`[warn] Env "${sanitizeString(envRef)}" in stash "${sanitizeString(stashLabel)}" contains potentially dangerous keys:`);
|
|
184
190
|
for (const key of keys) {
|
|
185
|
-
warn(` - ${key}: can hijack process execution via \`akm env run\``);
|
|
191
|
+
warn(` - ${sanitizeString(key)}: can hijack process execution via \`akm env run\``);
|
|
186
192
|
}
|
|
187
193
|
}
|
|
188
194
|
const confirmed = await p.confirm({
|
|
@@ -3,6 +3,7 @@
|
|
|
3
3
|
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
4
|
import fs from "node:fs";
|
|
5
5
|
import path from "node:path";
|
|
6
|
+
import embeddedChangelog from "../../../CHANGELOG.md" with { type: "text" };
|
|
6
7
|
import { getDirname } from "../../runtime.js";
|
|
7
8
|
const CHANGELOG_URL = "https://github.com/itlackey/akm/blob/main/CHANGELOG.md";
|
|
8
9
|
const MIGRATION_DOC_URL = "https://github.com/itlackey/akm/blob/main/docs/migration/v0.5-to-v0.6.md";
|
|
@@ -24,9 +25,13 @@ function loadChangelog() {
|
|
|
24
25
|
}
|
|
25
26
|
}
|
|
26
27
|
catch {
|
|
27
|
-
// fall through to
|
|
28
|
+
// fall through to the embedded copy
|
|
28
29
|
}
|
|
29
|
-
|
|
30
|
+
// In the `bun build --compile` standalone binary, import.meta.url points into
|
|
31
|
+
// the virtual /$bunfs tree and every existsSync above misses, so `akm help
|
|
32
|
+
// migrate <version>` degraded to the generic "no dedicated note" message for
|
|
33
|
+
// EVERY version. Only assets imported `with { type: "text" }` are embedded.
|
|
34
|
+
return embeddedChangelog.length > 0 ? embeddedChangelog : undefined;
|
|
30
35
|
}
|
|
31
36
|
/**
|
|
32
37
|
* Load the bundled migration note for a specific version, if one exists.
|
|
@@ -87,7 +92,11 @@ function resolveLatestVersion(changelog) {
|
|
|
87
92
|
return undefined;
|
|
88
93
|
}
|
|
89
94
|
function extractChangelogSection(changelog, version) {
|
|
90
|
-
|
|
95
|
+
// `\Z` is not a JavaScript anchor — it matches a literal "Z", which truncated
|
|
96
|
+
// the section at the first capital Z in the body (and failed outright for the
|
|
97
|
+
// last entry). `$` with the `m` flag would stop at the first line end, so the
|
|
98
|
+
// end-of-input alternative has to be an explicit lookahead for the input end.
|
|
99
|
+
const pattern = new RegExp(`^## \\[${escapeRegexString(version)}\\][^\\n]*\\n([\\s\\S]*?)(?=^## \\[|$(?![\\s\\S]))`, "m");
|
|
91
100
|
const match = changelog.match(pattern);
|
|
92
101
|
if (!match)
|
|
93
102
|
return undefined;
|
|
@@ -10,6 +10,7 @@ import { ConfigError } from "../../core/errors.js";
|
|
|
10
10
|
import { warn } from "../../core/warn.js";
|
|
11
11
|
import { githubHeaders } from "../../integrations/github.js";
|
|
12
12
|
import { getDirname, mainPath, semverOrder } from "../../runtime.js";
|
|
13
|
+
import { resolveAkmInvocation } from "../../tasks/resolve-akm-bin.js";
|
|
13
14
|
const REPO = "itlackey/akm";
|
|
14
15
|
const DEFAULT_PACKAGE_NAME = "akm-cli";
|
|
15
16
|
const NODE_MODULES_SEGMENT = "/node_modules/";
|
|
@@ -474,7 +475,14 @@ function readInstalledCliVersion(akmBin) {
|
|
|
474
475
|
return match?.[0];
|
|
475
476
|
}
|
|
476
477
|
function runRequiredCommand(akmBin, args, label) {
|
|
477
|
-
|
|
478
|
+
// A bare "akm" is not spawnable on Windows: npm/pnpm/yarn install a global CLI
|
|
479
|
+
// as akm.cmd / akm.ps1 shims, and spawnSync without a shell does not apply
|
|
480
|
+
// PATHEXT — so the package-manager upgrade arm died with ENOENT before it ever
|
|
481
|
+
// ran. resolveAkmInvocation returns a concrete argv (launcher, runtime + main
|
|
482
|
+
// script, or a standalone binary) for however this install actually runs.
|
|
483
|
+
// An explicit path (the standalone arm passes one) is used as given.
|
|
484
|
+
const [command, ...prefixArgs] = path.isAbsolute(akmBin) ? [akmBin] : resolveAkmInvocation().argv;
|
|
485
|
+
const result = childProcess.spawnSync(command ?? akmBin, [...prefixArgs, ...args], {
|
|
478
486
|
encoding: "utf8",
|
|
479
487
|
env: process.env,
|
|
480
488
|
stdio: "pipe",
|