akm-cli 0.9.15 → 0.9.16-alpha.2

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 (86) hide show
  1. package/CHANGELOG.md +52 -0
  2. package/dist/assets/hints/cli-hints-full.md +13 -6
  3. package/dist/assets/tasks/improve/akm-improve-catchup.yml +3 -6
  4. package/dist/cli/retired-commands.js +0 -2
  5. package/dist/commands/env/env-binding.js +4 -4
  6. package/dist/commands/env/env-cli.js +3 -3
  7. package/dist/commands/improve/improve-cli.js +19 -14
  8. package/dist/commands/improve/reflect.js +23 -2
  9. package/dist/commands/lint/base-linter.js +9 -0
  10. package/dist/commands/lint/env-key-rules.js +2 -2
  11. package/dist/commands/proposal/propose.js +15 -1
  12. package/dist/commands/proposal/validators/proposal-quality-validators.js +40 -3
  13. package/dist/commands/proposal/validators/proposal-validators.js +5 -4
  14. package/dist/commands/read/search.js +33 -4
  15. package/dist/commands/read/show.js +21 -2
  16. package/dist/commands/registry-cli.js +5 -5
  17. package/dist/commands/sources/add-cli.js +59 -16
  18. package/dist/commands/sources/bundle-cli.js +35 -11
  19. package/dist/commands/sources/bundle-config-ops.js +30 -0
  20. package/dist/commands/sources/dangerous-env-audit.js +4 -4
  21. package/dist/commands/sources/installed-stashes.js +43 -28
  22. package/dist/commands/sources/source-add.js +33 -17
  23. package/dist/commands/sources/source-manage.js +34 -12
  24. package/dist/commands/sources/stash-skeleton.js +6 -3
  25. package/dist/commands/tasks/explain.js +4 -1
  26. package/dist/commands/tasks/tasks-cli.js +31 -9
  27. package/dist/commands/tasks/tasks.js +239 -194
  28. package/dist/commands/tasks/validate.js +20 -32
  29. package/dist/core/activation-policy.js +4 -4
  30. package/dist/core/adapter/adapters/akm-adapter.js +5 -0
  31. package/dist/core/adapter/execution-source.js +10 -29
  32. package/dist/core/config/config-schema.js +64 -8
  33. package/dist/core/config/config-sources.js +96 -2
  34. package/dist/core/config/config.js +190 -24
  35. package/dist/core/config/legacy-source-shape-shim.js +9 -0
  36. package/dist/core/config/schema/execution.js +23 -0
  37. package/dist/core/config/schema/experimental.js +1 -1
  38. package/dist/core/config/schema/scheduler.js +20 -0
  39. package/dist/core/config/schema/search.js +1 -1
  40. package/dist/core/config/schema/sources-bundles.js +32 -1
  41. package/dist/core/content-safety.js +52 -0
  42. package/dist/core/maintenance-barrier.js +6 -6
  43. package/dist/core/type-presentation.js +1 -1
  44. package/dist/core/write-source.js +13 -8
  45. package/dist/indexer/bundle-identity-guard.js +45 -8
  46. package/dist/indexer/indexer.js +1 -1
  47. package/dist/indexer/materialize-embeddings.js +15 -1
  48. package/dist/indexer/search/search-source.js +29 -11
  49. package/dist/integrations/agent/execution-lowering.js +3 -2
  50. package/dist/integrations/agent/execution-preparation.js +32 -1
  51. package/dist/integrations/agent/prompts.js +1 -1
  52. package/dist/integrations/agent/request-lowering.js +3 -2
  53. package/dist/llm/client.js +2 -1
  54. package/dist/output/shapes/passthrough.js +2 -0
  55. package/dist/registry/resolve.js +37 -10
  56. package/dist/scripts/akm-migrate-node.js +13644 -9894
  57. package/dist/scripts/akm-migrate.js +12626 -8876
  58. package/dist/setup/setup.js +3 -3
  59. package/dist/setup/steps/tasks.js +29 -36
  60. package/dist/sources/providers/git-install.js +17 -11
  61. package/dist/sources/providers/git-provider.js +12 -5
  62. package/dist/sources/providers/git-stash.js +38 -16
  63. package/dist/sources/snapshot-fetchers/website-ingest.js +3 -3
  64. package/dist/storage/repositories/index-vec-repository.js +100 -0
  65. package/dist/tasks/activation-config.js +90 -0
  66. package/dist/tasks/backends/cron.js +9 -0
  67. package/dist/tasks/backends/launchd.js +1 -0
  68. package/dist/tasks/backends/schtasks.js +2 -0
  69. package/dist/tasks/embedded.js +4 -5
  70. package/dist/tasks/scheduler-binding.js +2 -2
  71. package/dist/tasks/scheduler-sync-preview.js +8 -1
  72. package/dist/tasks/scheduler-sync.js +19 -10
  73. package/dist/tasks/source/parse-task-source.js +10 -113
  74. package/dist/tasks/source/project-v4.js +2 -2
  75. package/dist/tasks/source/task-source-v4.js +4 -12
  76. package/dist/tasks/source/task-to-v3.js +4 -12
  77. package/dist/tasks/source/task-to-v4.js +40 -7
  78. package/docs/migration/README.md +1 -0
  79. package/docs/migration/release-notes/0.9.16.md +72 -0
  80. package/docs/migration/v0.9.1-to-v0.9.2.md +6 -9
  81. package/docs/reference/cli.md +37 -29
  82. package/docs/reference/configuration.md +48 -5
  83. package/docs/reference/tasks.md +34 -29
  84. package/package.json +1 -1
  85. package/schemas/akm-config.json +112 -4
  86. package/schemas/akm-task.json +1 -2
package/CHANGELOG.md CHANGED
@@ -4,6 +4,58 @@ All notable changes to this project will be documented in this file.
4
4
 
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.9.16-alpha.2] - 2026-09-21
10
+
11
+ ### Added
12
+
13
+ - **Scheduled execution is now granted by host-local config and bound to the
14
+ approved source installation.** `scheduler.enabled` records
15
+ `{kind, ref, sourceId}` entries written by `akm task enable`; authored task
16
+ frontmatter cannot enable itself. `akm migrate apply` performs the explicit,
17
+ backed-up conversion of older grants, and runtime config points unmigrated
18
+ installations to that command instead of silently inventing authority.
19
+ - **Executable assets now run beneath a host-owned tool ceiling.** Local
20
+ `execution.allowedTools` config caps asset tool requests. Workspace,
21
+ environment, and opaque runtime selection are no longer accepted from asset
22
+ frontmatter, and a transport that cannot enforce a non-empty resolved tool
23
+ policy fails before dispatch.
24
+
25
+ ### Changed
26
+
27
+ - **Unscoped `akm task sync` reconciles every enabled bundle.** It refreshes
28
+ the native scheduler from the latest locally enabled task/workflow sources
29
+ and removes attributable bindings for bundles that have been disabled.
30
+ Removing a bundle also revokes its scheduler grants.
31
+ - **Bundle activation and identity are consistent across the CLI.** A disabled
32
+ bundle is inert for reads, writes, indexing, execution, and scheduling;
33
+ explicit lifecycle updates remain available. Physical-root identity rejects
34
+ duplicate or symlink-aliased bundle registrations and prevents an
35
+ `AKM_BUNDLE_DIR` alias from reactivating disabled content.
36
+ - **Shared config inheritance now separates portable policy from host
37
+ authority.** Source/default ownership, registries, scheduler and execution
38
+ grants, credentials, executable arguments/workspace, setup/experimental
39
+ state, reranker connections, and publication hooks stay local. Every
40
+ bundle-relative `extends` hop is checked by real path containment.
41
+ - **Unsafe overrides now name one risk each.** Use
42
+ `--allow-insecure-transport` for reviewed plain HTTP and
43
+ `--allow-dangerous-env-keys` for reviewed process-hijacking environment
44
+ keys; the former combined `--allow-insecure` switch is removed.
45
+ - **Bundle/source resolution carries explicit default and priority state.**
46
+ Search, show, write targeting, registry installation, and configuration
47
+ mutation no longer infer ownership or trust from array position.
48
+
49
+ ### Fixed
50
+
51
+ - Protected generated improve content from credential echoes, redacted bodies,
52
+ and run-only scaffolding, while exercising the real bounded engine probe.
53
+ - Repaired degraded sqlite-vec mirrors, preserved scheduler intent across
54
+ synchronization, kept explicit setup choices and rollback serialization
55
+ stable, and tolerated valid sharded startup contention.
56
+ - Made git, website, npm, and filesystem bundle add/update/remove workflows
57
+ converge on the same lifecycle and dangerous-environment audit behavior.
58
+
7
59
  ## [0.9.15] - 2026-09-10
8
60
 
9
61
  ### Added
@@ -264,6 +264,7 @@ akm bundle add @scope/pkg # From npm (managed)
264
264
  akm bundle add owner/repo # From GitHub (managed)
265
265
  akm bundle add ./path/to/local/bundle # Local directory
266
266
  akm bundle add git@github.com:org/repo.git --provider git --name my-skills --writable
267
+ akm bundle add https://github.com/org/private.git --provider git --credential '$GIT_READ_TOKEN'
267
268
  akm registry add https://skills.sh --name skills.sh --provider skills-sh # Add the skills.sh registry
268
269
  akm registry remove skills.sh # Remove the skills.sh registry
269
270
  akm bundle list # List all sources
@@ -271,9 +272,13 @@ akm bundle list --kind git # Filter by provider (files
271
272
  akm bundle remove <target> # Remove by id, ref, path, or name
272
273
  akm bundle update --all # Update all managed sources
273
274
  akm bundle update <target> --force # Force re-download
274
- akm bundle update <target> --allow-insecure # Approve reviewed dangerous env keys
275
+ akm bundle update <target> --allow-dangerous-env-keys # Approve reviewed dangerous env keys
276
+ akm bundle update --all --skip-if-locked # Scheduled refresh; exit 0 on DB contention
275
277
  ```
276
278
 
279
+ Git bundles refresh only when `akm bundle update` runs. Schedule that command
280
+ when automatic refresh is desired.
281
+
277
282
  ## Registries
278
283
 
279
284
  ```sh
@@ -357,7 +362,9 @@ file plus one `sync` is a complete workflow.
357
362
  ```sh
358
363
  akm task add nightly-improve --schedule "@daily" --command "akm improve --strategy default"
359
364
  akm task add briefing --schedule "0 9 * * *" --prompt "Draft the morning briefing" # Inline command task
360
- akm task sync # Reconcile task files with the OS scheduler
365
+ akm task enable <bundle>//tasks/<id> # Enable locally and sync that bundle
366
+ akm task disable <bundle>//tasks/<id> # Disable locally and unschedule it
367
+ akm task sync # Reconcile activated refs from every enabled configured bundle
361
368
  akm task sync --rebind # Also re-pin the scheduler's akm binary/spelling
362
369
  akm task doctor # Scheduler binding + runtime eligibility diagnosis
363
370
  akm task history # Recent run rows (status, timing)
@@ -370,10 +377,10 @@ Task files use task source v4 (`version: 4`). There is no `akm:` options bag
370
377
  or `on:` block — every control (`schedule`, `timeout`, `engine`, `model`,
371
378
  `redact`, `maxSteps`, `maxRetries`, …) is a top-level key now. Typed
372
379
  `inputs:` declarations and a bounded `output:` schema work like a
373
- workflow's (`output:` replaces v3's `akm.outputSchema`). To disable one
374
- schedule entry, set that entry's `enabled: false` under `schedule:` and run
375
- `akm task sync` (the cron line stays, commented); to remove one, delete the
376
- YAML and run `akm task sync` — the scheduler entry is unbound. Top-level
380
+ workflow's (`output:` replaces v3's `akm.outputSchema`). Task source never
381
+ controls activation: use `akm task enable` / `disable`, which update the
382
+ host-local config allow-list and sync. To remove one, delete the YAML and run
383
+ `akm task sync` — the scheduler entry is unbound. Top-level
377
384
  `timeout:` may be `null` (disable the invocation timer) or a duration/number
378
385
  overriding the selected engine invocation timeout. Preview old task-v2/v3
379
386
  conversion with `akm migrate apply --dry-run`.
@@ -1,9 +1,6 @@
1
1
  version: 4
2
2
  run: akm improve --strategy catchup --skip-if-locked --require-engines
3
3
  description: Manual recovery — consolidation + triage drain (run on demand via `akm task run akm-improve-catchup`)
4
- # Manual-recovery task: ships disabled (the retired registerDefaultTasks
5
- # marked it enableMode: "manual"). `akm task run` works while disabled;
6
- # opting into the schedule is `schedule[].enabled: true` + `akm task sync`.
7
- schedule:
8
- - cron: "0 4 * * *"
9
- enabled: false
4
+ # Manual-recovery task: setup offers it disabled by default. `akm task run`
5
+ # still works manually; `akm task enable` opts this host into the schedule.
6
+ schedule: "0 4 * * *"
@@ -62,8 +62,6 @@ const RETIRED_COMMAND_HINTS = {
62
62
  "workflow report": "`akm workflow report` was removed — the external-driver protocol is gone; `akm workflow run <target>` dispatches and records units itself.",
63
63
  "config show": "`akm config show` was removed in 0.9 — use `akm config list`.",
64
64
  "config validate": "`akm config validate` was removed in 0.9 — the config file is validated on every load.",
65
- "task enable": "`akm task enable` was removed in 0.9 — set `enabled: true` in the task YAML, then `akm task sync`.",
66
- "task disable": "`akm task disable` was removed in 0.9 — set `enabled: false` in the task YAML, then `akm task sync`.",
67
65
  "task init": "`akm task init` was removed in 0.9 — `akm setup` seeds the default schedules.",
68
66
  "task show": "there is no `task show` — task files are indexed assets; use `akm show <ref>`.",
69
67
  "task remove": "there is no `task remove` — delete the task YAML, then run `akm task sync` to unbind it.",
@@ -87,16 +87,16 @@ export function resolveEnvBinding(target, options = {}) {
87
87
  const decision = decideDangerousEnvInjection({
88
88
  dangerousKeys: dangerous,
89
89
  thirdParty: Boolean(source.registryId),
90
- allowInsecure: options.allowInsecure,
90
+ allowDangerousEnvKeys: options.allowDangerousEnvKeys,
91
91
  });
92
92
  if (decision === "block") {
93
93
  throw new UsageError(`Refusing to inject env from a third-party stash. ${detail}\n` +
94
94
  ` Review the file, then copy the values into a first-party env if you trust them, ` +
95
- `or pass --allow-insecure once you have.`, "INVALID_FLAG_VALUE");
95
+ `or pass --allow-dangerous-env-keys once you have.`, "INVALID_FLAG_VALUE");
96
96
  }
97
97
  if (decision === "warn") {
98
- warn(options.allowInsecure && source.registryId
99
- ? `${detail} Injecting anyway (--allow-insecure).`
98
+ warn(options.allowDangerousEnvKeys && source.registryId
99
+ ? `${detail} Injecting anyway (--allow-dangerous-env-keys).`
100
100
  : `${detail} Injecting anyway (first-party stash).`);
101
101
  }
102
102
  }
@@ -214,7 +214,7 @@ async function runEnvInjected(target, opts) {
214
214
  const { values: envValues } = resolveEnvBinding(target, {
215
215
  only: opts.only,
216
216
  except: opts.except,
217
- allowInsecure: opts.allowInsecure,
217
+ allowDangerousEnvKeys: opts.allowDangerousEnvKeys,
218
218
  });
219
219
  const mergedEnv = buildChildEnv(process.env, {
220
220
  clean: opts.clean === true,
@@ -283,7 +283,7 @@ const envRunCommand = defineJsonCommand({
283
283
  type: "string",
284
284
  description: "When used with --clean, also inherit these parent env vars (comma-separated). Ignored without --clean.",
285
285
  },
286
- "allow-insecure": {
286
+ "allow-dangerous-env-keys": {
287
287
  type: "boolean",
288
288
  description: "Allow injecting a process-hijacking variable (e.g. LD_PRELOAD, GIT_SSH_COMMAND) from a third-party stash, which otherwise blocks. Use only after explicitly reviewing the env file.",
289
289
  default: false,
@@ -295,7 +295,7 @@ const envRunCommand = defineJsonCommand({
295
295
  except: parseKeyListFlag(args.except),
296
296
  clean: args.clean === true,
297
297
  inherit: parseKeyListFlag(args.inherit) ?? [],
298
- allowInsecure: args["allow-insecure"] === true,
298
+ allowDangerousEnvKeys: args["allow-dangerous-env-keys"] === true,
299
299
  });
300
300
  },
301
301
  });
@@ -15,7 +15,7 @@ import { redactSensitiveText } from "../../core/redaction.js";
15
15
  import { clearLogFile, setLogFile, warn } from "../../core/warn.js";
16
16
  import { resolveWriteTarget } from "../../core/write-source.js";
17
17
  import { collectEngineCredentialValues } from "../../integrations/agent/engine-resolution.js";
18
- import { probeEndpointOnce, probeLlmEndpoint } from "../../llm/client.js";
18
+ import { probeLlmReachable } from "../../llm/client.js";
19
19
  import { getOutputMode } from "../../output/context.js";
20
20
  import { deliverRendered } from "../../output/html-render.js";
21
21
  import { akmImprove, resolveImproveReadSource } from "./improve.js";
@@ -122,27 +122,32 @@ function collectRequiredEngineTargets(plan) {
122
122
  * `--require-engines` field re-test (#957): the static check above only
123
123
  * proves an engine is configured and credentialed — it cannot see a dead
124
124
  * endpoint. A field run against an unreachable engine sat silent for
125
- * minutes instead of hitting the documented exit-78 path. Reuse the SAME
126
- * bounded reachability probe `akm health`'s `default-llm-engine` /
127
- * `configured-engines` checks already run (`probeLlmEndpoint`, a single
128
- * `/models` GET bounded by its own default timeout) once per distinct
129
- * endpoint (via the shared `probeEndpointOnce` memoization health/checks.ts
130
- * also uses), so a dead engine is caught here instead of during dispatch.
125
+ * minutes instead of hitting the documented exit-78 path. Exercise the real
126
+ * model completion path with a tiny response and a three-second bound. The
127
+ * `/models` endpoint used by the lightweight health check is deliberately
128
+ * insufficient here: a gateway can list a model while its upstream completion
129
+ * route is dead (#980). Deduplicate by endpoint + model, not endpoint alone,
130
+ * because model backends behind one gateway can fail independently.
131
131
  */
132
- async function assertRequiredEnginesReachable(plan, probeReachable = probeLlmEndpoint) {
132
+ async function assertRequiredEnginesReachable(plan, probeReachable = (connection) => probeLlmReachable(connection, 3_000)) {
133
133
  const targets = collectRequiredEngineTargets(plan);
134
134
  if (targets.length === 0)
135
135
  return;
136
- const probesByEndpoint = new Map();
137
- const probed = await Promise.all(targets.map(async (target) => ({
138
- ...target,
139
- reach: await probeEndpointOnce(target.connection, probesByEndpoint, probeReachable),
140
- })));
136
+ const probesByConnection = new Map();
137
+ const probed = await Promise.all(targets.map(async (target) => {
138
+ const key = `${target.connection.endpoint.replace(/\/+$/, "")}|${target.connection.model}`;
139
+ let pending = probesByConnection.get(key);
140
+ if (!pending) {
141
+ pending = probeReachable(target.connection);
142
+ probesByConnection.set(key, pending);
143
+ }
144
+ return { ...target, reach: await pending };
145
+ }));
141
146
  const unreachable = probed.filter((item) => !item.reach.reachable);
142
147
  if (unreachable.length === 0)
143
148
  return;
144
149
  const lines = unreachable.map((item) => ` - ${item.process} (engine "${item.engine}", ${item.connection.endpoint}): ${item.reach.error ?? "did not respond"}`);
145
- throw new ConfigError(`--require-engines: ${unreachable.length} improve process${unreachable.length === 1 ? "" : "es"} cannot run because ${unreachable.length === 1 ? "its" : "their"} engine endpoint is not reachable:\n${lines.join("\n")}`, "LLM_NOT_CONFIGURED");
150
+ throw new ConfigError(`--require-engines: ${unreachable.length} improve process${unreachable.length === 1 ? "" : "es"} cannot run because ${unreachable.length === 1 ? "its" : "their"} engine completion path is not reachable:\n${lines.join("\n")}`, "LLM_NOT_CONFIGURED");
146
151
  }
147
152
  /**
148
153
  * `--show-prompt` (#952): render the composed reflect prompt for one asset ref
@@ -29,6 +29,7 @@ import { parseFrontmatter } from "../../core/asset/frontmatter.js";
29
29
  import { conceptIdFromTypeName, parseRefInput } from "../../core/asset/resolve-ref.js";
30
30
  import { DESCRIPTION_MAX_CHARS, requiresDescription } from "../../core/authoring-rules.js";
31
31
  import { loadConfig } from "../../core/config/config.js";
32
+ import { generatedContentRejection, stripReflectPromptScaffolding } from "../../core/content-safety.js";
32
33
  import { ConfigError, UsageError } from "../../core/errors.js";
33
34
  import { appendEvent, readEvents } from "../../core/events.js";
34
35
  import { lintLessonContent } from "../../core/lesson-lint.js";
@@ -511,7 +512,12 @@ export function sanitizeReflectPayload(payload, sourceContent, targetRef) {
511
512
  mergedFm[field] = sourceFm[field];
512
513
  }
513
514
  }
514
- const cleanedBody = stripAppendedFrontmatter(rawLlmBody.replace(/^\s+/, ""));
515
+ const withoutAppendedFrontmatter = stripAppendedFrontmatter(rawLlmBody.replace(/^\s+/, ""));
516
+ const promptScaffolding = stripReflectPromptScaffolding(withoutAppendedFrontmatter);
517
+ const cleanedBody = promptScaffolding.content;
518
+ if (promptScaffolding.stripped) {
519
+ warnings.push('Removed echoed run-only "Avoid These Patterns" guidance from the proposed asset body (#963).');
520
+ }
515
521
  // #636 — deterministic description fallback (reflect-side belt-and-suspenders).
516
522
  // If the type requires a `description` and the merged frontmatter is still
517
523
  // MISSING one (source had none AND the model didn't author one), derive a
@@ -1881,7 +1887,22 @@ export async function akmReflect(options = {}) {
1881
1887
  // draft paths.
1882
1888
  cleanupReflectDrafts(draftPathsToCleanup);
1883
1889
  }
1884
- payload = { ...payload, content: redactSensitiveText(payload.content, sensitiveValues) };
1890
+ const unsafeContent = generatedContentRejection(payload.content, redactSensitiveText(payload.content, sensitiveValues));
1891
+ if (unsafeContent) {
1892
+ emitReflectFailed("parse_error", "parse_error", options.ref, {
1893
+ ...(result.exitCode !== null ? { exitCode: result.exitCode } : {}),
1894
+ });
1895
+ return {
1896
+ schemaVersion: 2,
1897
+ ok: false,
1898
+ reason: "parse_error",
1899
+ error: unsafeContent,
1900
+ ...(options.ref ? { ref: options.ref } : {}),
1901
+ engine: engineName,
1902
+ exitCode: result.exitCode,
1903
+ ...reflectNoticeFields(executionNotices),
1904
+ };
1905
+ }
1885
1906
  const refFailure = validateReflectPayloadRef({
1886
1907
  payload,
1887
1908
  result,
@@ -41,6 +41,7 @@ import { checkUnquotedDescriptionColon } from "../../core/asset/frontmatter-lint
41
41
  import { isArchivedRelPath } from "../../core/asset/memory-archive.js";
42
42
  import { conceptIdFromTypeName, typeNameFromConceptId } from "../../core/asset/resolve-ref.js";
43
43
  import { localDateStamp } from "../../core/common.js";
44
+ import { containsRedactedContent, REDACTED_CONTENT_MARKER } from "../../core/content-safety.js";
44
45
  import { findFenceRegions } from "./markdown-insertion.js";
45
46
  // ── Helpers ───────────────────────────────────────────────────────────────────
46
47
  /** Fold physically wrapped prose the same way a YAML plain scalar does. */
@@ -603,6 +604,14 @@ export function runBaseChecks(ctx) {
603
604
  .map((v) => String(v).trim())
604
605
  .filter(Boolean);
605
606
  const shouldRun = (issueType) => !lintSkip.includes(issueType);
607
+ if (shouldRun("redacted-content") && containsRedactedContent(currentRaw)) {
608
+ issues.push({
609
+ file: ctx.relPath,
610
+ issue: "redacted-content",
611
+ detail: `asset contains ${REDACTED_CONTENT_MARKER}; restore the original non-secret prose from version history`,
612
+ fixed: false,
613
+ });
614
+ }
606
615
  // ── 1. unquoted-colon ──────────────────────────────────────────────────
607
616
  if (shouldRun("unquoted-colon")) {
608
617
  const unquotedColonDetail = checkUnquotedDescriptionColon(ctx.frontmatter);
@@ -11,7 +11,7 @@
11
11
  *
12
12
  * Enforcement scope:
13
13
  * - `akm lint` reports findings as `dangerous-env-key` (non-blocking warn).
14
- * - `akm bundle add` BLOCKS install unless `--allow-insecure` is set (or, on TTY,
14
+ * - `akm bundle add` BLOCKS install unless `--allow-dangerous-env-keys` is set (or, on TTY,
15
15
  * the user explicitly confirms at the prompt).
16
16
  * - Local env writes do NOT consult this list — by design, the operator may
17
17
  * legitimately store any key locally. The gate exists only for third-party
@@ -22,7 +22,7 @@
22
22
  * invoked by many interactive tools and are a documented RCE vector when
23
23
  * sourced from untrusted environments. They will also flag on benign files
24
24
  * where the operator legitimately wants to set their editor — accept the
25
- * FP and bypass with `--allow-insecure` after review.
25
+ * FP and bypass with `--allow-dangerous-env-keys` after review.
26
26
  */
27
27
  import fs from "node:fs";
28
28
  import { listKeys } from "../env/env.js";
@@ -16,6 +16,7 @@ import fs from "node:fs";
16
16
  import { placementTypes, stashDirFor } from "../../core/asset/asset-placement.js";
17
17
  import { parseRefInput } from "../../core/asset/resolve-ref.js";
18
18
  import { resolveStashDir } from "../../core/common.js";
19
+ import { generatedContentRejection } from "../../core/content-safety.js";
19
20
  import { UsageError } from "../../core/errors.js";
20
21
  import { appendEvent } from "../../core/events.js";
21
22
  import { resolveStandardsContext } from "../../core/standards/resolve-standards-context.js";
@@ -217,7 +218,20 @@ export async function akmPropose(options) {
217
218
  };
218
219
  }
219
220
  }
220
- payload = { ...payload, content: redactWithLoweredExecutionDispatchLease(lease, payload.content) };
221
+ const unsafeContent = generatedContentRejection(payload.content, redactWithLoweredExecutionDispatchLease(lease, payload.content));
222
+ if (unsafeContent) {
223
+ return {
224
+ schemaVersion: 2,
225
+ ok: false,
226
+ reason: "parse_error",
227
+ error: unsafeContent,
228
+ type: options.type,
229
+ name: options.name,
230
+ engine: engineName,
231
+ exitCode: result.exitCode,
232
+ ...noticeFields(notices),
233
+ };
234
+ }
221
235
  // 6. Insert the proposal. Note: we allow the agent's `ref` to normalise the
222
236
  // asset name (e.g. path-cleanup), but only after validating that the ref is
223
237
  // well-formed and the type still matches the requested type.
@@ -61,6 +61,7 @@
61
61
  import { parseFrontmatter } from "../../../core/asset/frontmatter.js";
62
62
  import { parseRefInput } from "../../../core/asset/resolve-ref.js";
63
63
  import { DESCRIPTION_MAX_CHARS, DESCRIPTION_MIN_CHARS, WHEN_TO_USE_MAX_CHARS, WHEN_TO_USE_MIN_CHARS, } from "../../../core/authoring-rules.js";
64
+ import { containsRedactedContent, containsReflectPromptScaffolding, REDACTED_CONTENT_MARKER, REFLECT_AVOID_PATTERNS_HEADING, } from "../../../core/content-safety.js";
64
65
  import { proposalContent } from "../../../core/file-change.js";
65
66
  /**
66
67
  * The canonical asset NAME an inputRef names, lower-cased — the tail the
@@ -399,6 +400,40 @@ const reflectTruncationMarkerValidator = {
399
400
  ];
400
401
  },
401
402
  };
403
+ /** Never promote proposal text that already contains an output-redaction marker (#962). */
404
+ const redactedContentValidator = {
405
+ name: "redacted-content",
406
+ appliesTo(proposal) {
407
+ return typeof proposal.payload?.content === "string";
408
+ },
409
+ validate(proposal) {
410
+ if (!containsRedactedContent(proposalContent(proposal)))
411
+ return [];
412
+ return [
413
+ {
414
+ kind: "redacted-content",
415
+ message: `Proposal ${proposal.id} (${proposal.ref}) contains ${REDACTED_CONTENT_MARKER}. Restore the original non-secret prose and create a clean proposal; redacted output cannot be promoted.`,
416
+ },
417
+ ];
418
+ },
419
+ };
420
+ /** Defense in depth when a reflect proposal bypasses creation-time sanitization (#963). */
421
+ const reflectPromptScaffoldingValidator = {
422
+ name: "reflect-prompt-scaffolding",
423
+ appliesTo(proposal) {
424
+ return proposal.source === "reflect" && typeof proposal.payload?.content === "string";
425
+ },
426
+ validate(proposal) {
427
+ if (!containsReflectPromptScaffolding(proposalContent(proposal)))
428
+ return [];
429
+ return [
430
+ {
431
+ kind: "reflect-prompt-scaffolding",
432
+ message: `Proposal ${proposal.id} (${proposal.ref}) still contains the run-only "${REFLECT_AVOID_PATTERNS_HEADING}" prompt section. Reflect the asset again before promotion.`,
433
+ },
434
+ ];
435
+ },
436
+ };
402
437
  /**
403
438
  * Report a validator's findings as advisory.
404
439
  *
@@ -424,9 +459,9 @@ function advisory(validator) {
424
459
  * Full set of quality validators in registration order. Appended onto
425
460
  * {@link defaultProposalValidators} so they run inside `validateProposal` on
426
461
  * `proposal accept` automatically. All prose-quality checks report without
427
- * blocking (see {@link advisory}); {@link reflectTruncationMarkerValidator} is
428
- * the one exception and blocks, since it guards against data loss rather than
429
- * prose quality.
462
+ * blocking (see {@link advisory}). The truncation-marker, redacted-content,
463
+ * and reflected-prompt-scaffolding validators block because they protect
464
+ * durable content rather than judging prose quality.
430
465
  */
431
466
  export const defaultProposalQualityValidators = [
432
467
  ...[
@@ -436,4 +471,6 @@ export const defaultProposalQualityValidators = [
436
471
  reflectSizeGuardValidator,
437
472
  ].map(advisory),
438
473
  reflectTruncationMarkerValidator,
474
+ redactedContentValidator,
475
+ reflectPromptScaffoldingValidator,
439
476
  ];
@@ -131,14 +131,15 @@ export const defaultProposalValidators = [
131
131
  * and was previously safe to run in full there because every quality
132
132
  * validator was advisory (`advisory()` downgrades findings to `severity:
133
133
  * "warn"`, which {@link runProposalValidators}'s `ok` never treats as
134
- * failing). #952's `reflect-truncation-marker` validator is deliberately
135
- * NOT advisory (it guards against data loss), so running the full
136
- * {@link defaultProposalValidators} list at mint time would throw
134
+ * failing). Blocking durable-content validators (the #952 truncation marker,
135
+ * #962 redaction marker, and #963 reflected prompt scaffolding) deliberately
136
+ * remain outside this subset, so running the full
137
+ * {@link defaultProposalValidators} list at mint time could throw
137
138
  * `invalid_canonical_structure` for any lesson/task/workflow reflect
138
139
  * proposal whose body leaks the truncation marker — instead of letting
139
140
  * `sanitizeReflectPayload` mint the proposal and defer it with
140
141
  * `reflect-truncation-leak`, per the #952 design. Quality validators (prose
141
- * shape, reflect size ratio, the truncation-marker guard) belong at
142
+ * shape, reflect size ratio, and durable-content guards) belong at
142
143
  * `proposal accept` / drain-promotion time, which already calls
143
144
  * {@link validateProposal} (the full list) via `preflightProposalPromotion`
144
145
  * / `promoteProposalWithLease`.
@@ -26,6 +26,24 @@ import { insertUsageEvent } from "../../indexer/usage/usage-events.js";
26
26
  import { TELEMETRY_BUSY_TIMEOUT_MS, withIndexDb } from "../../storage/repositories/index-db.js";
27
27
  import { searchRegistry } from "./registry-search.js";
28
28
  const DEFAULT_LIMIT = 20;
29
+ function duplicateConceptWarnings(hits, defaultBundle) {
30
+ const ownersByConcept = new Map();
31
+ for (const hit of hits) {
32
+ const displayRef = (hit.parentRef ?? hit.ref).split("#", 1)[0] ?? hit.ref;
33
+ const boundary = displayRef.indexOf("//");
34
+ const conceptId = boundary >= 0 ? displayRef.slice(boundary + 2) : displayRef;
35
+ const owner = hit.origin ?? defaultBundle ?? "working-bundle";
36
+ const owners = ownersByConcept.get(conceptId) ?? [];
37
+ if (!owners.includes(owner))
38
+ owners.push(owner);
39
+ ownersByConcept.set(conceptId, owners);
40
+ }
41
+ return [...ownersByConcept]
42
+ .filter(([, owners]) => owners.length > 1)
43
+ .sort(([left], [right]) => left.localeCompare(right))
44
+ .map(([conceptId, owners]) => `Multiple bundles provide "${conceptId}": ${owners.join(", ")}. ` +
45
+ "Unqualified refs resolve by configured bundle priority; use a bundle-qualified ref to select explicitly.");
46
+ }
29
47
  export async function akmSearch(input) {
30
48
  const t0 = Date.now();
31
49
  const query = input.query.trim();
@@ -116,6 +134,7 @@ export async function akmSearch(input) {
116
134
  : await searchRegistry(query, { limit, includeAssets: input.assets === true, registries: config.registries });
117
135
  if (source === "local") {
118
136
  const localHits = localResult?.hits ?? [];
137
+ const warnings = [...(localResult?.warnings ?? []), ...duplicateConceptWarnings(localHits, config.defaultBundle)];
119
138
  const hasResults = localHits.length > 0;
120
139
  const response = {
121
140
  schemaVersion: 1,
@@ -123,7 +142,7 @@ export async function akmSearch(input) {
123
142
  source,
124
143
  hits: localHits,
125
144
  tip: hasResults ? undefined : localResult?.tip,
126
- warnings: localResult?.warnings?.length ? localResult.warnings : undefined,
145
+ warnings: warnings.length ? warnings : undefined,
127
146
  searchMode: localResult?.mode ?? "keyword",
128
147
  timing: { totalMs: Date.now() - t0, rankMs: localResult?.rankMs, embedMs: localResult?.embedMs },
129
148
  };
@@ -162,7 +181,11 @@ export async function akmSearch(input) {
162
181
  }
163
182
  // source === "all"
164
183
  const allStashHits = (localResult?.hits ?? []).slice(0, limit);
165
- const warnings = [...(localResult?.warnings ?? []), ...(registryResult?.warnings ?? [])];
184
+ const warnings = [
185
+ ...(localResult?.warnings ?? []),
186
+ ...duplicateConceptWarnings(allStashHits, config.defaultBundle),
187
+ ...(registryResult?.warnings ?? []),
188
+ ];
166
189
  const hasResults = allStashHits.length > 0 || registryHits.length > 0;
167
190
  const response = {
168
191
  schemaVersion: 1,
@@ -301,9 +324,15 @@ function logSearchEvent(query, response, mode = "keyword", eventSource = "user",
301
324
  */
302
325
  function assertNamedSourceExists(config, namedSourceName) {
303
326
  const configSources = getSources(config);
304
- const foundInConfig = configSources.some((s) => s.name === namedSourceName) || configSources.some((s) => s.path === namedSourceName);
327
+ const foundInConfig = configSources.find((source) => source.name === namedSourceName || source.path === namedSourceName);
328
+ if (foundInConfig?.enabled === false) {
329
+ throw new UsageError(`Source "${namedSourceName}" is disabled.`, "INVALID_SOURCE_VALUE");
330
+ }
305
331
  if (!foundInConfig) {
306
- const validNames = configSources.map((s) => s.name).filter((n) => Boolean(n));
332
+ const validNames = configSources
333
+ .filter((source) => source.enabled !== false)
334
+ .map((source) => source.name)
335
+ .filter((name) => Boolean(name));
307
336
  const hint = validNames.length > 0
308
337
  ? `Known source names: ${validNames.join(", ")}`
309
338
  : "No named sources are configured. Run `akm bundle list` to see installed bundles.";
@@ -75,6 +75,11 @@ export async function akmShowUnified(input) {
75
75
  if (metaRef)
76
76
  return showStashMeta(metaRef);
77
77
  }
78
+ const legacyReplacement = legacyColonRefReplacement(ref);
79
+ if (legacyReplacement) {
80
+ throw new NotFoundError(`The legacy colon ref "${ref}" was removed in 0.9.0. Use the slash form instead: ` +
81
+ `akm show ${legacyReplacement}`);
82
+ }
78
83
  // Env/secret bodies have no safe fragment surface, and a fragment cannot
79
84
  // widen what the env/secret renderers expose: both always omit the body
80
85
  // (env — key names only; secret — never rendered), fragment or not. Warn
@@ -110,6 +115,20 @@ export async function akmShowUnified(input) {
110
115
  }
111
116
  return result;
112
117
  }
118
+ /** Actionable migration guidance for the retired `[bundle//]type:name` spelling. */
119
+ function legacyColonRefReplacement(ref) {
120
+ const match = /^(?:(?<bundle>[^/#]+)\/\/)?(?<type>[a-z][a-z0-9-]*):(?<name>[^#]+)(?<fragment>#.*)?$/i.exec(ref);
121
+ const type = match?.groups?.type?.toLowerCase();
122
+ const name = match?.groups?.name;
123
+ if (!type || !name)
124
+ return undefined;
125
+ const stashDir = stashDirFor(type);
126
+ if (!stashDir)
127
+ return undefined;
128
+ const bundle = match?.groups?.bundle;
129
+ const fragment = match?.groups?.fragment ?? "";
130
+ return `${bundle ? `${bundle}//` : ""}${stashDir}/${name}${fragment}`;
131
+ }
113
132
  /**
114
133
  * Resolve a stash `.meta/` doc and return it as a lightweight ShowResponse.
115
134
  *
@@ -288,7 +307,7 @@ export async function showLocal(input) {
288
307
  throw new UsageError(`Renderer "${match.renderer}" not found for asset: ${makeBundleRef(parsed.bundle, parsed.conceptId)}`);
289
308
  }
290
309
  const renderBundle = indexedEntry.bundleId;
291
- const renderDefaultBundle = config.defaultBundle ?? (source?.path === allSources[0]?.path ? renderBundle : undefined);
310
+ const renderDefaultBundle = config.defaultBundle ?? (source?.isDefault === true ? renderBundle : undefined);
292
311
  const renderCtx = buildRenderContext(fileCtx, match, allSourceDirs, renderBundle, renderDefaultBundle);
293
312
  response = renderer.buildShowResponse(renderCtx);
294
313
  if (parsed.fragment !== undefined) {
@@ -306,7 +325,7 @@ export async function showLocal(input) {
306
325
  }
307
326
  response.type = indexedEntry.type;
308
327
  response.name = indexedEntry.name;
309
- const isPrimaryStash = source !== undefined && source.path === allSources[0]?.path;
328
+ const isPrimaryStash = source?.isDefault === true;
310
329
  const canonicalRef = displayRef({
311
330
  type: indexedEntry.type,
312
331
  name: presentedName,
@@ -25,7 +25,7 @@ export const registryCommand = defineGroupCommand({
25
25
  name: { type: "string", description: "Human-friendly name for the registry" },
26
26
  provider: { type: "string", description: "Provider type (e.g. static-index, skills-sh)" },
27
27
  options: { type: "string", description: "Provider-specific options as JSON." },
28
- "allow-insecure": {
28
+ "allow-insecure-transport": {
29
29
  type: "boolean",
30
30
  description: "Allow a plain HTTP registry URL (otherwise rejected)",
31
31
  default: false,
@@ -41,12 +41,12 @@ export const registryCommand = defineGroupCommand({
41
41
  throw new UsageError("Registry URL must start with http:// or https://");
42
42
  }
43
43
  if (args.url.startsWith("http://")) {
44
- const allowInsecure = args["allow-insecure"];
45
- if (!allowInsecure) {
44
+ const allowInsecureTransport = args["allow-insecure-transport"];
45
+ if (!allowInsecureTransport) {
46
46
  throw new UsageError("Registry URL uses plain HTTP (not HTTPS). An on-path attacker could substitute a malicious index. " +
47
- "Use https:// or pass --allow-insecure if you have explicitly accepted the risk.");
47
+ "Use https:// or pass --allow-insecure-transport if you have explicitly accepted the risk.");
48
48
  }
49
- warn("Warning: registry URL uses plain HTTP (not HTTPS). --allow-insecure was set; an on-path attacker could substitute a malicious index.");
49
+ warn("Warning: registry URL uses plain HTTP (not HTTPS). --allow-insecure-transport was set; an on-path attacker could substitute a malicious index.");
50
50
  }
51
51
  const entry = { url: args.url };
52
52
  if (args.name)