akm-cli 0.9.3 → 0.9.5

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.
Files changed (56) hide show
  1. package/CHANGELOG.md +233 -1
  2. package/README.md +1 -1
  3. package/SECURITY.md +1 -1
  4. package/STABILITY.md +1 -1
  5. package/dist/akm +2 -2
  6. package/dist/akm-migrate +2 -2
  7. package/dist/cli.js +5 -5
  8. package/dist/commands/health/improve-metrics.js +17 -0
  9. package/dist/commands/health/windows.js +2 -2
  10. package/dist/commands/health.js +2 -2
  11. package/dist/commands/improve/anti-collapse.js +4 -91
  12. package/dist/commands/improve/preparation.js +8 -1
  13. package/dist/commands/lint/index.js +3 -7
  14. package/dist/commands/proposal/validators/proposal-validators.js +12 -0
  15. package/dist/commands/read/search.js +14 -24
  16. package/dist/commands/tasks/tasks-cli.js +81 -3
  17. package/dist/commands/tasks/tasks.js +117 -2
  18. package/dist/core/adapter/adapters/akm-adapter.js +23 -14
  19. package/dist/core/adapter/adapters/akm-lint.js +3 -2
  20. package/dist/core/adapter/adapters/akm-task-adapter.js +9 -6
  21. package/dist/core/adapter/adapters/dotenv-adapter.js +13 -11
  22. package/dist/core/adapter/recognize-match.js +1 -20
  23. package/dist/core/asset/asset-placement.js +21 -2
  24. package/dist/core/common.js +21 -1
  25. package/dist/core/config/config-version-shim.js +101 -0
  26. package/dist/core/config/config.js +6 -6
  27. package/dist/core/improve-result.js +35 -14
  28. package/dist/execution/guarded-source.js +0 -10
  29. package/dist/indexer/lookup/adapter-concept-owner.js +6 -89
  30. package/dist/indexer/passes/metadata.js +12 -4
  31. package/dist/indexer/scan/doc-to-entry.js +2 -0
  32. package/dist/indexer/search/db-search.js +6 -0
  33. package/dist/indexer/search/search-fields.js +16 -1
  34. package/dist/indexer/walk/matchers.js +0 -22
  35. package/dist/output/shapes/helpers.js +19 -1
  36. package/dist/output/shapes/passthrough.js +18 -5
  37. package/dist/output/text/command-format.js +4 -0
  38. package/dist/registry/pinned-request-helper.js +2 -2
  39. package/dist/registry/pinned-transport.js +6 -6
  40. package/dist/scripts/akm-migrate-node.js +12678 -12601
  41. package/dist/scripts/akm-migrate.js +12678 -12601
  42. package/dist/storage/repositories/proposals-repository.js +65 -7
  43. package/dist/storage/repositories/task-history-repository.js +22 -10
  44. package/dist/tasks/run/task-history.js +23 -3
  45. package/dist/tasks/scheduler-binding.js +15 -5
  46. package/dist/tasks/scheduler-sync-preview.js +45 -0
  47. package/dist/tasks/scheduler-sync.js +77 -41
  48. package/dist/tasks/source/bounded-document.js +1 -1
  49. package/dist/tasks/source/parse-task-source.js +77 -11
  50. package/dist/tasks/source/task-source-v3-frozen.js +428 -0
  51. package/dist/tasks/source/task-to-v3.js +512 -0
  52. package/dist/tasks/source/task-to-v4.js +457 -0
  53. package/docs/reference/cli.md +33 -6
  54. package/docs/reference/configuration.md +27 -6
  55. package/docs/reference/tasks.md +10 -0
  56. package/package.json +4 -4
@@ -14,13 +14,10 @@
14
14
  import { getSources, loadConfig } from "../../core/config/config.js";
15
15
  import { rethrowIfTestIsolationError, UsageError } from "../../core/errors.js";
16
16
  import { appendEvent } from "../../core/events.js";
17
- import { isTransientStashPath } from "../../core/paths.js";
18
17
  import { resolveReadSources } from "../../indexer/read-preflight.js";
19
18
  import { searchLocal } from "../../indexer/search/db-search.js";
20
19
  import { getSearchHitAttribution, usageEventAttributionMetadata, } from "../../indexer/search/search-attribution.js";
21
20
  import { getEntryIdByFilePath, getItemRefById } from "../../storage/repositories/index-entries-repository.js";
22
- import { bumpUtilityScoresBatch } from "../../storage/repositories/index-utility-repository.js";
23
- import { getCurrentWorkflowScopeKey } from "../../workflows/authoring/scope-key.js";
24
21
  // Eagerly import source providers to trigger self-registration before the
25
22
  // indexer or path-resolution code runs.
26
23
  import "../../sources/providers/index.js";
@@ -188,7 +185,7 @@ function usageSearchMode(mode) {
188
185
  function maybeLogSearchEvent(input, query, response, mode) {
189
186
  if (input.skipLogging)
190
187
  return;
191
- logSearchEvent(query, response, mode, input.eventSource, input.disableScopedUtility === true, input.attributionProjection);
188
+ logSearchEvent(query, response, mode, input.eventSource, input.attributionProjection);
192
189
  }
193
190
  /**
194
191
  * Resolve entry IDs by file_path lookup (exact match, not LIKE).
@@ -229,7 +226,7 @@ function resolveEntryIds(db, hits) {
229
226
  * Per-entry events are recorded only for stash hits because registry hits
230
227
  * have no local entry_id to reference.
231
228
  */
232
- function logSearchEvent(query, response, mode = "keyword", eventSource = "user", disableScopedUtility = false, attributionProjection = "full") {
229
+ function logSearchEvent(query, response, mode = "keyword", eventSource = "user", attributionProjection = "full") {
233
230
  // Emit a structured event to events.jsonl so workflow-trace consumers
234
231
  // detect akm search invocations without relying on stdout scraping.
235
232
  const stashHits = response.hits.filter((h) => h.type !== "registry");
@@ -276,25 +273,18 @@ function logSearchEvent(query, response, mode = "keyword", eventSource = "user",
276
273
  source: eventSource,
277
274
  });
278
275
  }, TELEMETRY_BUSY_TIMEOUT_MS);
279
- // Bump utility scores for all resolved entries (MemRL retrieval signal).
280
- // The indexer overwrites these at next reindex; bumps are temporary hints.
281
- // Gated to user-sourced events: pipeline searches (improve probes, task
282
- // runner) must not feed the utility signal (meta-review 05 DRIFT-6 —
283
- // the bump previously fired unconditionally, so even correctly-tagged
284
- // machine traffic inflated utility). utility_scores stays in index.db.
285
- const resolvedIds = eventSource === "user" ? resolved.map((r) => r.entryId).filter((id) => id !== undefined) : [];
286
- if (resolvedIds.length > 0) {
287
- let scopeKey;
288
- try {
289
- const stashPath = response.bundleDir;
290
- const disabled = disableScopedUtility || (stashPath && isTransientStashPath(stashPath));
291
- scopeKey = disabled ? undefined : getCurrentWorkflowScopeKey();
292
- }
293
- catch {
294
- // Non-fatal — fall back to global-only bumps on any error.
295
- }
296
- bumpUtilityScoresBatch(db, resolvedIds, 1.0, 0.1, scopeKey);
297
- }
276
+ // No live utility_scores/utility_scores_scoped write here (#862): a
277
+ // search result is an impression, not a signal that the asset was
278
+ // useful. Rewarding every returned hit created a feedback loop where
279
+ // merely appearing in results inflated future ranking — assets
280
+ // surfaced because they'd surfaced before, not because a user acted
281
+ // on them. Retrieval counts are still recorded above via
282
+ // insertUsageEvent (search_count) and rolled into utility_scores by
283
+ // the offline `recomputeUtilityScores` pass (`akm index`), which uses
284
+ // the show/search *select rate* — a ratio that requires an actual
285
+ // `show`/select event, not raw impressions. Explicit signal comes
286
+ // from `akm feedback` (applyFeedbackToUtilityScore) and from
287
+ // selection (recordShowUsage / the `select` event derived from it).
298
288
  }, { busyTimeoutMs: TELEMETRY_BUSY_TIMEOUT_MS });
299
289
  }
300
290
  catch (err) {
@@ -27,11 +27,11 @@
27
27
  import { defineCommand } from "citty";
28
28
  import { getParsedInvocation } from "../../cli/invocation.js";
29
29
  import { parsePositiveIntFlag } from "../../cli/parse-args.js";
30
- import { defineGroupCommand, defineJsonCommand, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors } from "../../cli/shared.js";
30
+ import { defineGroupCommand, defineJsonCommand, EXIT_CODES, GLOBAL_OUTPUT_ARGS, output, runWithJsonErrors, } from "../../cli/shared.js";
31
31
  import { UsageError } from "../../core/errors.js";
32
32
  import { TASK_RUN_BOOLEAN_FLAGS, TASK_RUN_VALUE_FLAGS } from "../../tasks/task-run-reserved-flags.js";
33
33
  import { akmTaskExplain } from "./explain.js";
34
- import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksRun, akmTasksSync } from "./tasks.js";
34
+ import { akmTasksAdd, akmTasksDoctor, akmTasksHistory, akmTasksPrune, akmTasksRun, akmTasksSync, akmTasksSyncPlan, } from "./tasks.js";
35
35
  /** Shared `--bundle <bundle>` arg wired onto every task subcommand. */
36
36
  const bundleArg = {
37
37
  bundle: {
@@ -303,6 +303,18 @@ const tasksHistoryCommand = defineJsonCommand({
303
303
  output("task-history", result);
304
304
  },
305
305
  });
306
+ /**
307
+ * #849: `task sync --dry-run`'s exit-code contract, "non-zero when the plan
308
+ * contains removals" — factored out as a pure function (rather than left
309
+ * inline in the command's `run()`) so it's directly unit-testable without
310
+ * driving the whole CLI through a real scheduler backend. #867: also
311
+ * non-zero when any source failed to parse/prepare — sync degrades (still
312
+ * reconciles the tasks/workflows that DID parse) rather than rejecting the
313
+ * whole set, but a dropped source must still surface as a failing exit.
314
+ */
315
+ export function taskSyncDryRunExitCode(preview) {
316
+ return preview.hasRemovals || (preview.failures?.length ?? 0) > 0 ? EXIT_CODES.GENERAL : undefined;
317
+ }
306
318
  const tasksSyncCommand = defineJsonCommand({
307
319
  meta: {
308
320
  name: "sync",
@@ -315,11 +327,33 @@ const tasksSyncCommand = defineJsonCommand({
315
327
  description: "Replace installed bindings with the current invocation",
316
328
  default: false,
317
329
  },
330
+ "dry-run": {
331
+ type: "boolean",
332
+ description: "Compute and print the full reconcile plan (adds/updates/removes, with owning bundle on every " +
333
+ "removal) without touching the OS scheduler. Zero durable writes. Exits non-zero when the plan " +
334
+ "contains removals.",
335
+ default: false,
336
+ },
318
337
  },
319
338
  async run({ args }) {
320
339
  rejectRetiredTaskTargetFlag();
321
- const result = await akmTasksSync({}, args.bundle, { rebind: args.rebind === true });
340
+ const rebind = args.rebind === true;
341
+ if (args["dry-run"] === true) {
342
+ const preview = await akmTasksSyncPlan({}, args.bundle, { rebind });
343
+ output("task-sync-dry-run", preview);
344
+ const exitCode = taskSyncDryRunExitCode(preview);
345
+ if (exitCode !== undefined)
346
+ process.exitCode = exitCode;
347
+ return;
348
+ }
349
+ const result = await akmTasksSync({}, args.bundle, { rebind });
322
350
  output("task-sync", result);
351
+ // #867: sync degrades — sources that failed to parse/prepare are
352
+ // excluded from reconciliation and reported in `result.failed` rather
353
+ // than poisoning the whole sync, but their presence must still fail
354
+ // the command's exit code so the breakage stays visible.
355
+ if (result.failed.length > 0)
356
+ process.exitCode = EXIT_CODES.GENERAL;
323
357
  },
324
358
  });
325
359
  // ── `akm task explain` — read-only introspection (P2b Lane B, spec
@@ -364,6 +398,49 @@ const tasksDoctorCommand = defineJsonCommand({
364
398
  output("task-doctor", result);
365
399
  },
366
400
  });
401
+ /**
402
+ * #851: `akm task prune`'s exit-code contract mirrors `task sync --dry-run`'s
403
+ * (`taskSyncDryRunExitCode` above) — non-zero whenever the preview lists
404
+ * removals the invocation didn't (or couldn't, without `--yes`) execute, so
405
+ * `akm task prune` is usable as a CI/health check the same way `sync
406
+ * --dry-run` is.
407
+ */
408
+ export function taskPruneExitCode(result) {
409
+ return result.dryRun && result.preview.hasRemovals ? EXIT_CODES.GENERAL : undefined;
410
+ }
411
+ const tasksPruneCommand = defineJsonCommand({
412
+ meta: {
413
+ name: "prune",
414
+ description: "Remove installed scheduler entries `sync` can never reclaim because their own descriptor no longer " +
415
+ "resolves to a live bundle (corrupt/missing --scheduler-context, or the owning bundle directory is " +
416
+ "gone). Defaults to a dry-run preview — zero scheduler writes. Requires --yes to remove anything; " +
417
+ "--id narrows removal to specific binding ids (comma-separated).",
418
+ },
419
+ args: {
420
+ yes: {
421
+ type: "boolean",
422
+ description: "Execute removal of every currently-computed orphan (the plan is still printed first)",
423
+ default: false,
424
+ },
425
+ id: {
426
+ type: "string",
427
+ description: "Comma-separated scheduler binding id(s) to limit pruning to (must already be orphan candidates)",
428
+ },
429
+ },
430
+ async run({ args }) {
431
+ const id = args.id
432
+ ? args.id
433
+ .split(",")
434
+ .map((value) => value.trim())
435
+ .filter(Boolean)
436
+ : undefined;
437
+ const result = await akmTasksPrune({}, { yes: args.yes === true, id });
438
+ output("task-prune", result);
439
+ const exitCode = taskPruneExitCode(result);
440
+ if (exitCode !== undefined)
441
+ process.exitCode = exitCode;
442
+ },
443
+ });
367
444
  export const taskCommand = defineGroupCommand({
368
445
  meta: {
369
446
  name: "task",
@@ -375,6 +452,7 @@ export const taskCommand = defineGroupCommand({
375
452
  explain: tasksExplainCommand,
376
453
  history: tasksHistoryCommand,
377
454
  sync: tasksSyncCommand,
455
+ prune: tasksPruneCommand,
378
456
  doctor: tasksDoctorCommand,
379
457
  },
380
458
  // Bare `akm task` reports scheduler diagnostics. Inspection of individual
@@ -35,7 +35,8 @@ import { exitCodeForStatus } from "../../tasks/run/task-result.js";
35
35
  import { parseSchedule, SCHEDULE_SUPPORTED_SUBSET_HINT } from "../../tasks/schedule.js";
36
36
  import { assertSchedulerMutationArtifact, assertSchedulerNativeArtifactCardinality, compileTaskSchedulerBindings, schedulerBindingNativeId, schedulerBindingOrdinal, schedulerNativeArtifactKey, schedulerNativeBindingId, } from "../../tasks/scheduler-binding.js";
37
37
  import { schedulerContextDescriptor, schedulerContextPath, validateSchedulerContextDescriptor, writeSchedulerContextDescriptor, } from "../../tasks/scheduler-invocation.js";
38
- import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
38
+ import { assertSchedulerNativeArtifactOwnership, assertSchedulerSourceSnapshot, buildSchedulerRemoveOperation, finalizeSchedulerSyncPlan, prepareSchedulerSyncSourceSet, } from "../../tasks/scheduler-sync.js";
39
+ import { renderSchedulerPlanPreview, renderSchedulerSyncPlanPreview, } from "../../tasks/scheduler-sync-preview.js";
39
40
  import { parseTaskSource } from "../../tasks/source/parse-task-source.js";
40
41
  import { projectTaskSourceV4 } from "../../tasks/source/project-v4.js";
41
42
  import { TASK_V3_MAX_SOURCE_BYTES } from "../../tasks/source-v3.js";
@@ -326,7 +327,16 @@ export async function akmTasksHistory(input) {
326
327
  * scans all bundles. Activation happens only through explicit `add --bundle`
327
328
  * (or `sync --bundle` on a bundle whose task files are already present).
328
329
  */
329
- export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
330
+ /**
331
+ * Compute (but never apply) a scheduler sync plan: everything through
332
+ * `finalizeSchedulerSyncPlan`'s final call, stopping strictly before
333
+ * `applySchedulerSyncPlan`. Shared by `akmTasksSync` (applies the plan) and
334
+ * `akmTasksSyncPlan` (#849 `--dry-run`, never applies it) so the two paths
335
+ * can never drift on what "the plan" means. `prepared?.publish`, the one
336
+ * deferred write-producing closure in this pipeline, is returned but never
337
+ * invoked here — only `applySchedulerSyncPlan` may call it.
338
+ */
339
+ async function buildSchedulerSyncPlan(deps, bundleTarget, options) {
330
340
  const resolved = resolveTaskReadBundle(undefined, bundleTarget);
331
341
  const config = loadConfig();
332
342
  const stashDir = resolved.source.path;
@@ -393,6 +403,10 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
393
403
  }
394
404
  : {}),
395
405
  }, preparedSources);
406
+ return { sched, plan, prepared, warnings };
407
+ }
408
+ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
409
+ const { sched, plan, prepared, warnings } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
396
410
  await applySchedulerSyncPlan(sched, plan, prepared?.publish && plan.operations.some((operation) => operation.kind !== "remove")
397
411
  ? prepared.publish
398
412
  : undefined);
@@ -403,9 +417,110 @@ export async function akmTasksSync(deps = {}, bundleTarget, options = {}) {
403
417
  unchanged: [...plan.unchanged],
404
418
  skipped: [],
405
419
  backend: sched.name,
420
+ failed: plan.failures.map((failure) => ({ ...failure })),
406
421
  ...(warnings.length > 0 ? { warnings } : {}),
407
422
  };
408
423
  }
424
+ /**
425
+ * `akm task sync --dry-run` (#849): compute the exact same plan
426
+ * `akmTasksSync` would apply, then return a non-mutating preview instead of
427
+ * calling `applySchedulerSyncPlan`. `buildSchedulerSyncPlan` is shared with
428
+ * the real sync path specifically so this can never see a different plan
429
+ * than the one a real sync would apply — and specifically so this function
430
+ * never even holds a reference to a callable `publish` closure past this
431
+ * point: `prepared.publish`, if any, is dropped on the floor here, never
432
+ * invoked. Zero durable writes, mirroring `akm workflow plan`.
433
+ */
434
+ export async function akmTasksSyncPlan(deps = {}, bundleTarget, options = {}) {
435
+ const { sched, plan } = await buildSchedulerSyncPlan(deps, bundleTarget, options);
436
+ return renderSchedulerSyncPlanPreview(sched.name, plan);
437
+ }
438
+ /**
439
+ * Classify one installed scheduler binding as a prune candidate (#851), using
440
+ * the same two signals `doctor`'s `inspectInstalledBinding` already computes
441
+ * — deliberately narrower than that function's full `status` set. Only an
442
+ * entry whose ownership can NEVER be resolved (`invalid-context`) or whose
443
+ * resolved owner no longer exists on disk (`dead-bundle-path`) is a
444
+ * candidate; `missing-path` (e.g. the akm binary itself moved) is a
445
+ * different failure mode and is intentionally NOT folded in here, per the
446
+ * scoping in #851 — an entry that still resolves to a live bundle is never a
447
+ * candidate, full stop.
448
+ */
449
+ function classifyPruneCandidate(entry) {
450
+ let ownerBundlePath;
451
+ try {
452
+ ownerBundlePath = validateSchedulerContextDescriptor(entry.contextPath).environment.AKM_BUNDLE_DIR;
453
+ }
454
+ catch {
455
+ return "invalid-context";
456
+ }
457
+ if (ownerBundlePath !== undefined && !fs.existsSync(ownerBundlePath))
458
+ return "dead-bundle-path";
459
+ return undefined;
460
+ }
461
+ async function buildTaskPrunePlan(deps = {}, options = {}) {
462
+ const sched = deps.backend ?? selectBackend();
463
+ if (!sched.inspectBindings) {
464
+ throw new ConfigError(`Scheduler backend "${sched.name}" cannot provide one coherent inspection for prune.`, "INVALID_CONFIG_FILE");
465
+ }
466
+ const inspection = await sched.inspectBindings({});
467
+ const candidates = new Map();
468
+ for (const entry of inspection.installed) {
469
+ const reason = classifyPruneCandidate(entry);
470
+ if (reason)
471
+ candidates.set(entry.id, reason);
472
+ }
473
+ const requestedIds = options.id?.filter((id) => id.length > 0) ?? [];
474
+ for (const id of requestedIds) {
475
+ if (!candidates.has(id)) {
476
+ throw new UsageError(`Scheduler binding ${JSON.stringify(id)} is not an orphaned prune candidate ` +
477
+ "(either not installed, or it still resolves to a live bundle) — refusing to prune it.", "INVALID_FLAG_VALUE");
478
+ }
479
+ }
480
+ const idFilter = requestedIds.length > 0 ? new Set(requestedIds) : undefined;
481
+ const resolved = resolveTaskReadBundle(undefined, undefined);
482
+ const bundleContext = {
483
+ adapterId: resolved.source.adapterId ?? detectAdapterId(resolved.source.path),
484
+ bundleName: resolved.source.name,
485
+ };
486
+ const operations = [];
487
+ for (const entry of inspection.installed) {
488
+ const reason = candidates.get(entry.id);
489
+ if (!reason)
490
+ continue;
491
+ if (idFilter && !idFilter.has(entry.id))
492
+ continue;
493
+ const operation = buildSchedulerRemoveOperation(entry.id, entry, inspection.artifacts, bundleContext);
494
+ operations.push(Object.freeze({ ...operation, reason }));
495
+ }
496
+ return { sched, operations: Object.freeze(operations) };
497
+ }
498
+ /**
499
+ * `akm task prune` (#851): remove installed scheduler bindings `sync` can
500
+ * never reclaim because their own `--scheduler-context` descriptor doesn't
501
+ * resolve to a live bundle. Defaults to dry-run — no `--yes` and no `--id`
502
+ * means zero backend calls that could mutate anything, matching
503
+ * `akmTasksSyncPlan`'s zero-write guarantee. `--id` (one or more) narrows
504
+ * execution to exactly those bindings; `--yes` alone executes every
505
+ * currently-computed candidate. Both still return the full preview so the
506
+ * plan is never silent about what it did.
507
+ */
508
+ export async function akmTasksPrune(deps = {}, options = {}) {
509
+ const { sched, operations } = await buildTaskPrunePlan(deps, options);
510
+ const preview = renderSchedulerPlanPreview(sched.name, operations);
511
+ if (!options.yes) {
512
+ return { backend: sched.name, dryRun: true, preview, removed: [] };
513
+ }
514
+ await applySchedulerTransaction(sched, operations, {
515
+ initialExpectations: operations.map((operation) => operation.expected),
516
+ });
517
+ return {
518
+ backend: sched.name,
519
+ dryRun: false,
520
+ preview,
521
+ removed: operations.map((operation) => operation.id),
522
+ };
523
+ }
409
524
  export async function akmTasksDoctor(deps = {}) {
410
525
  const warnings = [];
411
526
  let invocation = {
@@ -82,10 +82,10 @@
82
82
  import fs from "node:fs";
83
83
  import path from "node:path";
84
84
  import { applyPostContributorFields, applyPreContributorFields, extractPackageMetadata, } from "../../../indexer/passes/metadata.js";
85
- import { assetPathForName, deriveCanonicalAssetNameFromStashRoot, placementTypes, stashDirFor, stashDirNames, } from "../../asset/asset-placement.js";
85
+ import { assetPathCandidatesForName, assetPathForName, deriveCanonicalAssetNameFromStashRoot, placementTypes, stashDirFor, stashDirNames, } from "../../asset/asset-placement.js";
86
86
  import { parseFrontmatter } from "../../asset/frontmatter.js";
87
87
  import { executionDefaultsFromFrontmatter, renderMarkdownExecutionSource } from "../execution-source.js";
88
- import { recognizeMatch, recognizePathCandidateMatches } from "../recognize-match.js";
88
+ import { recognizeMatch } from "../recognize-match.js";
89
89
  import { perTypeValidateChecks, skillDirectoryDiagnostics, workflowYamlSourceDiagnostics } from "./akm-lint.js";
90
90
  import { applyFoldedMetadata, foldRecognizedMetadata } from "./akm-metadata.js";
91
91
  import { hashContent, runBaseValidateChecks } from "./shared.js";
@@ -223,6 +223,8 @@ function indexDocumentFromEntry(entry, base, rendererName) {
223
223
  doc.tags = entry.tags;
224
224
  if (entry.content !== undefined)
225
225
  doc.content = entry.content;
226
+ if (entry.contentTruncated !== undefined)
227
+ doc.contentTruncated = entry.contentTruncated;
226
228
  if (entry.aliases !== undefined)
227
229
  doc.aliases = entry.aliases;
228
230
  if (entry.searchHints !== undefined)
@@ -252,14 +254,6 @@ function conceptIdForRecognizedType(root, filePath, type) {
252
254
  const stashDir = stashDirFor(type);
253
255
  return stashDir !== undefined ? `${stashDir}/${canonicalName}` : canonicalName;
254
256
  }
255
- function recognizePathCandidates(c, file) {
256
- if (isReservedFileName(file.fileName) || akmStashAbstains(c.root, file.absPath))
257
- return [];
258
- return recognizePathCandidateMatches(file).flatMap((match) => {
259
- const conceptId = conceptIdForRecognizedType(c.root, file.absPath, match.type);
260
- return conceptId === undefined ? [] : [conceptId];
261
- });
262
- }
263
257
  function recognize(c, file) {
264
258
  // D-R6 (spec §5.1): `index.md` / `log.md` are OKF reserved structural files at
265
259
  // every depth — never items. Excluded BEFORE classification so a directory
@@ -481,9 +475,19 @@ export const akmAdapter = {
481
475
  ".kts",
482
476
  ],
483
477
  recognize,
484
- recognizePathCandidates,
485
478
  renderExecutionSource,
486
479
  validate,
480
+ /**
481
+ * Closed-form owner candidates (#857): every physical path spelling that
482
+ * could claim `conceptId` without walking the bundle. `deriveCanonicalAssetNameFromStashRoot`
483
+ * (recognize's own conceptId derivation) is a pure function of (type,
484
+ * filePath); this is its inverse, enumerated for the two placements that
485
+ * function can produce for one conceptId — CANONICAL (authored under the
486
+ * type's own stash subdir) and the LOOSE FALLBACK (authored anywhere else
487
+ * in the bundle, so the canonical name is the file's full path relative to
488
+ * the bundle root instead of the stash subdir). `assetPathCandidatesForName`
489
+ * additionally expands `env`'s `.env`/`<name>.env` duality on each.
490
+ */
487
491
  readCandidates(c, conceptId) {
488
492
  const posix = conceptId.replace(/\\/g, "/");
489
493
  const slash = posix.indexOf("/");
@@ -492,9 +496,14 @@ export const akmAdapter = {
492
496
  const head = posix.slice(0, slash);
493
497
  const rest = posix.slice(slash + 1);
494
498
  const type = stashDirToType(head);
495
- return type === undefined || rest.length === 0
496
- ? []
497
- : [{ path: assetPathForName(type, path.join(c.root, head), rest), conceptId: posix }];
499
+ if (type === undefined || rest.length === 0)
500
+ return [];
501
+ const canonical = assetPathCandidatesForName(type, path.join(c.root, head), rest);
502
+ const loose = assetPathCandidatesForName(type, c.root, rest);
503
+ return [...new Set([...canonical, ...loose])].map((candidatePath) => ({
504
+ path: candidatePath,
505
+ conceptId: posix,
506
+ }));
498
507
  },
499
508
  /**
500
509
  * Type-driven placement (§5.1), reproducing `path-resolver.ts#buildDiskCandidates`:
@@ -57,6 +57,7 @@ import { taskSourceErrorDetail } from "../../../tasks/source-v3.js";
57
57
  import { compileWorkflowPlan } from "../../../workflows/ir/compile.js";
58
58
  import { compileWorkflowSource } from "../../../workflows/source-ir/compile.js";
59
59
  import { conceptIdForStashFile } from "../../asset/resolve-ref.js";
60
+ import { isAkmRegistryCachePath } from "../../common.js";
60
61
  /** Recommended `category` values for facts — `commands/lint/fact-linter.ts:9`. */
61
62
  const KNOWN_CATEGORIES = new Set(["personal", "team", "project", "convention", "meta"]);
62
63
  /** Placeholder markers a workflow stub carries — `commands/lint/workflow-linter.ts:10`. */
@@ -336,7 +337,7 @@ function lineOf(err) {
336
337
  * like the established Markdown frontend without inventing another parser.
337
338
  */
338
339
  export function workflowYamlSourceDiagnostics(relPath, raw, parsePath, workspaceRoot) {
339
- if (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
340
+ if (isAkmRegistryCachePath(parsePath))
340
341
  return { errors: [], warnings: [] };
341
342
  const compiled = compileWorkflowSource(raw, { path: parsePath, workspaceRoot });
342
343
  if (compiled.ok)
@@ -369,7 +370,7 @@ export function workflowYamlSourceDiagnostics(relPath, raw, parsePath, workspace
369
370
  */
370
371
  export function workflowFrontendDiagnostics(relPath, raw, parsePath) {
371
372
  const none = { errors: [], warnings: [] };
372
- if (parsePath.includes("/.cache/") || parsePath.includes("/registry/"))
373
+ if (isAkmRegistryCachePath(parsePath))
373
374
  return none;
374
375
  const errors = [];
375
376
  const warnings = [];
@@ -17,12 +17,15 @@
17
17
  * ── validate (spec §6 task validation column) ──
18
18
  *
19
19
  * Validation enters the canonical task source parser (`parseTaskSource`,
20
- * task source v4 only as of P4 — a `version: 3` or `version: 2` document now
21
- * fails closed with `TASK_SCHEMA_VERSION_UNSUPPORTED`). That parser owns the
22
- * closed key sets, the executable-selector XOR, hostile YAML policy, the
23
- * `akm/command` builtin, bounds, and physical `working-directory`
24
- * containment. The adapter only translates a parser failure into the
25
- * format-family diagnostic shape.
20
+ * task source v4 native as of P4 — a `version: 3` or `version: 2` document
21
+ * is auto-read through the in-memory migration shim in
22
+ * `parse-task-source.ts`; only a version the shim's deterministic planners
23
+ * cannot convert, or any other unsupported number, fails closed with
24
+ * `TASK_SCHEMA_VERSION_UNSUPPORTED`). That parser owns the closed key sets,
25
+ * the executable-selector XOR, hostile YAML policy, the `akm/command`
26
+ * builtin, bounds, and physical `working-directory` containment. The
27
+ * adapter only translates a parser failure into the format-family
28
+ * diagnostic shape.
26
29
  *
27
30
  * Conformance oracle (authored, DO NOT modify): fixture
28
31
  * `tests/fixtures/bundles/akm-task/` + goldens
@@ -29,7 +29,7 @@
29
29
  */
30
30
  import fs from "node:fs";
31
31
  import path from "node:path";
32
- import { assetPathForName, typeForStashDir } from "../../asset/asset-placement.js";
32
+ import { assetPathCandidatesForName, assetPathForName, typeForStashDir } from "../../asset/asset-placement.js";
33
33
  import { dangerousEnvKeyDiagnostics } from "./akm-lint.js";
34
34
  import { hashContent } from "./shared.js";
35
35
  /** A dotenv bundle is single-component; its one component is `main`. */
@@ -103,12 +103,6 @@ function conceptIdForPath(type, relativePath) {
103
103
  const stripped = posix.replace(/\.env$/i, "");
104
104
  return stripped.endsWith("/") ? `${stripped}default` : stripped;
105
105
  }
106
- function recognizePathCandidates(_c, file) {
107
- const type = classify(file.relPath);
108
- if (type === null || hasSensitiveMarker(file.absPath, type))
109
- return [];
110
- return [conceptIdForPath(type, file.relPath)];
111
- }
112
106
  function recognize(c, file) {
113
107
  const type = classify(file.relPath);
114
108
  if (type === null)
@@ -172,8 +166,15 @@ export const dotenvAdapter = {
172
166
  version: "0.9.0",
173
167
  extensions: [".env"],
174
168
  recognize,
175
- recognizePathCandidates,
176
169
  validate,
170
+ /**
171
+ * Closed-form owner candidates (#857): `env`/`secret` are always authored
172
+ * directly under their own stash subdir (`classify` requires it — no
173
+ * off-canonical loose placement exists for this adapter), so there is no
174
+ * loose-fallback class to enumerate here, unlike `akm-adapter`.
175
+ * `assetPathCandidatesForName` expands `env`'s `.env`/`<name>.env` duality;
176
+ * the sensitive-marker sibling spellings are then layered on each.
177
+ */
177
178
  readCandidates(c, conceptId) {
178
179
  const posix = toPosix(conceptId);
179
180
  const slash = posix.indexOf("/");
@@ -184,10 +185,11 @@ export const dotenvAdapter = {
184
185
  const type = typeForStashDir(head);
185
186
  if ((type !== "env" && type !== "secret") || rest.length === 0)
186
187
  return [];
187
- const primary = assetPathForName(type, path.join(c.root, head), rest);
188
- return (type === "env"
188
+ const primaries = assetPathCandidatesForName(type, path.join(c.root, head), rest);
189
+ const expanded = primaries.flatMap((primary) => type === "env"
189
190
  ? [primary, primary.replace(/\.env$/i, ".sensitive")]
190
- : [primary, `${primary}.sensitive`, `${primary}.lock`]).map((candidatePath) => ({ path: candidatePath, conceptId: posix }));
191
+ : [primary, `${primary}.sensitive`, `${primary}.lock`]);
192
+ return expanded.map((candidatePath) => ({ path: candidatePath, conceptId: posix }));
191
193
  },
192
194
  /**
193
195
  * env places to `env/<name>.env`, secret to `secrets/<name>` (identity join,
@@ -1,7 +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 { directoryMatcher, extensionMatcher, parentDirHintMatcher, smartMdMatcher, smartMdPathCandidates, } from "../../indexer/walk/matchers.js";
4
+ import { directoryMatcher, extensionMatcher, parentDirHintMatcher, smartMdMatcher } from "../../indexer/walk/matchers.js";
5
5
  /**
6
6
  * The four builtin matchers, in registration order. The array index IS the
7
7
  * registration index `runMatchers` uses for tie-breaking. (The `wiki` matcher
@@ -46,22 +46,3 @@ export function recognizeMatch(file) {
46
46
  }
47
47
  return winningMatch(hits);
48
48
  }
49
- /**
50
- * Every AKM matcher winner possible from path fields alone. The three
51
- * path-only matchers run exactly as production does; each possible result of
52
- * the shared smart-Markdown fact table is then arbitrated at its real index.
53
- */
54
- export function recognizePathCandidateMatches(file) {
55
- const fileContext = file;
56
- const pathHits = [extensionMatcher, directoryMatcher, parentDirHintMatcher].flatMap((matcher, index) => {
57
- const result = matcher(fileContext);
58
- return result ? [{ result, index }] : [];
59
- });
60
- const smartCandidates = smartMdPathCandidates(file);
61
- const variants = smartCandidates.length > 0 ? smartCandidates : [undefined];
62
- const winners = variants.flatMap((smart) => {
63
- const winner = winningMatch(smart ? [...pathHits, { result: smart, index: 3 }] : [...pathHits]);
64
- return winner ? [winner] : [];
65
- });
66
- return [...new Map(winners.map((winner) => [`${winner.type}\0${winner.renderer}`, winner])).values()];
67
- }
@@ -151,10 +151,10 @@ const BUILTIN_PLACEMENT_SPECS = {
151
151
  isRelevantFile: (fileName) => path.extname(fileName).toLowerCase() === ".yml",
152
152
  toCanonicalName: (typeRoot, filePath) => {
153
153
  const rel = toPosix(path.relative(typeRoot, filePath));
154
- return rel.endsWith(".yml") ? rel.slice(0, -4) : rel;
154
+ return rel.toLowerCase().endsWith(".yml") ? rel.slice(0, -4) : rel;
155
155
  },
156
156
  toAssetPath: (typeRoot, name) => {
157
- const withExt = name.endsWith(".yml") ? name : `${name}.yml`;
157
+ const withExt = name.toLowerCase().endsWith(".yml") ? name : `${name}.yml`;
158
158
  return path.join(typeRoot, withExt);
159
159
  },
160
160
  },
@@ -241,3 +241,22 @@ export function assetPathForName(assetType, typeRoot, name) {
241
241
  throw new Error(`Unknown asset type: "${assetType}"`);
242
242
  return spec.toAssetPath(typeRoot, name);
243
243
  }
244
+ /**
245
+ * Every physical path spelling that could own `name`, closed-form (#857).
246
+ * `toAssetPath` is a function, so it can only pick ONE spelling — but `env`'s
247
+ * "default" alias is genuinely dual-owned: both `<dir>/.env` and
248
+ * `<dir>/default.env` derive the same canonical name (`toCanonicalName`
249
+ * above), so a physical-owner lookup must consider both without reading
250
+ * either file. Every other placement type has exactly one inverse spelling.
251
+ */
252
+ export function assetPathCandidatesForName(assetType, typeRoot, name) {
253
+ const primary = assetPathForName(assetType, typeRoot, name);
254
+ if (assetType !== "env")
255
+ return [primary];
256
+ const base = name === "default" ? "" : name.endsWith("/default") ? name.slice(0, -"default".length) : undefined;
257
+ if (base === undefined)
258
+ return [primary];
259
+ const dotForm = path.join(typeRoot, base, ".env");
260
+ const namedForm = path.join(typeRoot, base, "default.env");
261
+ return [...new Set([primary, dotForm, namedForm])];
262
+ }
@@ -5,7 +5,7 @@ import crypto from "node:crypto";
5
5
  import fs from "node:fs";
6
6
  import path from "node:path";
7
7
  import { ConfigError } from "./errors.js";
8
- import { getConfigPath, getDefaultStashDir } from "./paths.js";
8
+ import { getConfigPath, getDefaultStashDir, getRegistryCacheDir, getRegistryIndexCacheDir } from "./paths.js";
9
9
  // ── Constants ───────────────────────────────────────────────────────────────
10
10
  // Moved to the platform leaf so paths.ts can use it without a common↔paths
11
11
  // cycle (chunk-8 WI-8.6, DoD 11); re-exported here for the existing surface.
@@ -382,6 +382,26 @@ export function isContainedRelativePath(value) {
382
382
  export function isWithin(candidate, root) {
383
383
  return isContainedResolvedPath(safeRealpath(candidate), safeRealpath(root));
384
384
  }
385
+ /**
386
+ * True when `filePath` sits inside akm's OWN resolved registry-cache
387
+ * directories (`<cache>/registry`, `<cache>/registry-index` —
388
+ * {@link getRegistryCacheDir}/{@link getRegistryIndexCacheDir}), the
389
+ * read-only installed-source/registry-index copies that lint (and `--fix`)
390
+ * must never touch.
391
+ *
392
+ * This is the single source of truth for that exclusion — it replaces what
393
+ * used to be three independent unanchored substring checks
394
+ * (`posixPath.includes("/.cache/") || posixPath.includes("/registry/")`).
395
+ * That check matched ANY path merely containing the literal text `.cache` or
396
+ * `registry` anywhere in it — a normal XDG `~/.cache/...` user bundle, or a
397
+ * CI workspace checked out under a `.cache`-named directory, tripped it and
398
+ * silently got zero lint findings. Using {@link isWithin} (realpath +
399
+ * containment, not a string search) fixes that while still excluding the
400
+ * real cache content the check was meant to skip.
401
+ */
402
+ export function isAkmRegistryCachePath(filePath) {
403
+ return isWithin(filePath, getRegistryCacheDir()) || isWithin(filePath, getRegistryIndexCacheDir());
404
+ }
385
405
  /**
386
406
  * {@link isWithin} for callers that must not block the event loop (e.g. the
387
407
  * workflow exec dispatch path, which runs once per fan-out unit). Same