akm-cli 0.9.0-rc.13 → 0.9.0-rc.14

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 (100) hide show
  1. package/CHANGELOG.md +117 -23
  2. package/dist/akm-migrate +3 -1
  3. package/dist/assets/hints/cli-hints-full.md +3 -4
  4. package/dist/assets/hints/cli-hints-short.md +5 -5
  5. package/dist/assets/workflows/workflow-template.md +4 -3
  6. package/dist/cli/invocation.js +3 -2
  7. package/dist/cli/retired-commands.js +3 -0
  8. package/dist/cli/unknown-flags.js +226 -0
  9. package/dist/cli.js +19 -35
  10. package/dist/commands/agent/contribute-cli.js +15 -3
  11. package/dist/commands/feedback-cli.js +6 -3
  12. package/dist/commands/improve/collapse-detector.js +2 -3
  13. package/dist/commands/lint/base-linter.js +4 -16
  14. package/dist/commands/lint/index.js +13 -13
  15. package/dist/commands/log.js +6 -1
  16. package/dist/commands/migration-tool.js +4 -5
  17. package/dist/commands/observability-cli.js +1 -1
  18. package/dist/commands/proposal/repository.js +5 -5
  19. package/dist/commands/read/knowledge.js +2 -0
  20. package/dist/commands/registry-cli.js +5 -3
  21. package/dist/commands/sources/add-cli.js +6 -6
  22. package/dist/commands/sources/self-update.js +30 -7
  23. package/dist/commands/sources/source-add.js +17 -2
  24. package/dist/commands/tasks/tasks.js +8 -3
  25. package/dist/commands/workflow-cli.js +142 -119
  26. package/dist/core/adapter/adapters/akm-lint.js +17 -13
  27. package/dist/core/adapter/adapters/akm-task-adapter.js +14 -11
  28. package/dist/core/asset/akm-markdown.js +41 -8
  29. package/dist/core/asset/frontmatter.js +22 -0
  30. package/dist/core/asset/resolve-ref.js +23 -3
  31. package/dist/core/common.js +45 -2
  32. package/dist/core/config/config-schema.js +8 -0
  33. package/dist/core/config/schema/experimental.js +5 -13
  34. package/dist/core/config/schema/sources-bundles.js +11 -0
  35. package/dist/core/config/schema/workflow.js +3 -1
  36. package/dist/core/errors.js +5 -0
  37. package/dist/core/logs-db.js +2 -1
  38. package/dist/core/parse.js +4 -1
  39. package/dist/core/state/migrations.js +11 -14
  40. package/dist/core/state-db.js +4 -6
  41. package/dist/core/subprocess.js +6 -4
  42. package/dist/core/type-presentation.js +1 -1
  43. package/dist/indexer/indexer.js +16 -1
  44. package/dist/indexer/search/search-source.js +1 -1
  45. package/dist/indexer/walk/matchers.js +3 -1
  46. package/dist/integrations/agent/config.js +2 -2
  47. package/dist/integrations/agent/detect.js +49 -19
  48. package/dist/integrations/agent/profiles.js +10 -0
  49. package/dist/integrations/agent/spawn.js +1 -2
  50. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +7 -3
  51. package/dist/integrations/lockfile.js +11 -56
  52. package/dist/output/shapes/passthrough.js +0 -4
  53. package/dist/output/text/command-format.js +3 -3
  54. package/dist/output/text/helpers.js +1 -1
  55. package/dist/output/text/show-directives.js +4 -6
  56. package/dist/output/text/workflow-format.js +7 -62
  57. package/dist/output/text/workflow.js +1 -5
  58. package/dist/scripts/akm-migrate-node.js +58773 -0
  59. package/dist/scripts/akm-migrate.js +33976 -11391
  60. package/dist/setup/detect.js +40 -15
  61. package/dist/setup/setup.js +1 -1
  62. package/dist/sources/providers/git-stash.js +4 -2
  63. package/dist/sources/providers/website.js +5 -0
  64. package/dist/sources/snapshot-fetchers/bluesky.js +146 -0
  65. package/dist/sources/snapshot-fetchers/content-extract.js +370 -0
  66. package/dist/sources/snapshot-fetchers/fetcher-util.js +40 -0
  67. package/dist/sources/snapshot-fetchers/host-guard.js +199 -0
  68. package/dist/sources/snapshot-fetchers/registry.js +10 -1
  69. package/dist/sources/snapshot-fetchers/robots.js +348 -0
  70. package/dist/sources/snapshot-fetchers/rss.js +279 -0
  71. package/dist/sources/snapshot-fetchers/secret-seam.js +42 -0
  72. package/dist/sources/snapshot-fetchers/website-ingest.js +488 -257
  73. package/dist/sources/snapshot-fetchers/x.js +193 -0
  74. package/dist/storage/engines/sqlite-migrations.js +22 -107
  75. package/dist/tasks/runner.js +19 -16
  76. package/dist/tasks/schema.js +24 -1
  77. package/dist/workflows/exec/brief.js +1 -1
  78. package/dist/workflows/exec/frozen-judge.js +28 -2
  79. package/dist/workflows/exec/native-executor.js +18 -1
  80. package/dist/workflows/exec/report.js +11 -4
  81. package/dist/workflows/exec/run-workflow.js +103 -44
  82. package/dist/workflows/exec/step-work.js +19 -18
  83. package/dist/workflows/exec/unit-dispatch.js +4 -0
  84. package/dist/workflows/exec/workflow-engine-gate.js +11 -13
  85. package/dist/workflows/ir/compile.js +2 -2
  86. package/dist/workflows/ir/freeze.js +16 -8
  87. package/dist/workflows/ir/params.js +134 -10
  88. package/dist/workflows/ir/plan-hash.js +1 -1
  89. package/dist/workflows/ir/schema.js +6 -2
  90. package/dist/workflows/renderer.js +2 -2
  91. package/dist/workflows/runtime/checkin.js +3 -3
  92. package/dist/workflows/runtime/runs.js +50 -29
  93. package/dist/workflows/validate-summary.js +30 -14
  94. package/docs/migration/release-notes/0.9.0.md +31 -4
  95. package/docs/migration/v0.8-to-v0.9.md +69 -100
  96. package/docs/reference/data-and-telemetry.md +5 -5
  97. package/package.json +6 -2
  98. package/schemas/akm-config.json +24 -0
  99. package/schemas/akm-workflow.json +1 -1
  100. package/dist/workflows/cli.js +0 -33
@@ -27,20 +27,12 @@ export const ExperimentalConfigSchema = z
27
27
  */
28
28
  improveAutonomy: z.boolean().optional(),
29
29
  /**
30
- * Allow the `akm workflow` native engine to run (Q-05).
30
+ * Allow the harness-neutral workflow external-driver protocol (Q-05).
31
31
  *
32
- * OFF by default. Gates `akm workflow run` (the native step-execution
33
- * engine) and `akm workflow brief`/`report` (the harness-neutral driver
34
- * protocol).
35
- *
36
- * Deliberately NOT gated: authoring/linting the unified markdown format
37
- * and the manual workflow CLI contract (`start`/`next`/`complete`/
38
- * `status`/`list`/`create`/`resume`/`abandon`) remain stable regardless of
39
- * this key.
40
- *
41
- * Unlike `improveAutonomy`, a gated call here REFUSES outright rather than
42
- * degrading: a workflow step either executes or it does not, so there is
43
- * no safe partial-execution fallback to fall back to.
32
+ * OFF by default. Gates only `akm workflow brief`/`report`. Stable native
33
+ * orchestration through `workflow run`, authoring/linting, inspection, and
34
+ * recovery remain available regardless of this key. A gated driver call
35
+ * refuses outright rather than degrading.
44
36
  */
45
37
  workflowEngine: z.boolean().optional(),
46
38
  })
@@ -80,6 +80,17 @@ const BundleWebsiteDescriptorSchema = z
80
80
  refresh: z.string().min(1).optional(),
81
81
  maxPages: positiveInt.optional(),
82
82
  maxDepth: positiveInt.optional(),
83
+ // Default true: crawl-scoped robots.txt compliance (Disallow/Crawl-delay
84
+ // for the akm/akm-cli product tokens or "*"). See
85
+ // src/sources/snapshot-fetchers/robots.ts. Opt out with `false` to
86
+ // restore pre-P1 behavior exactly (no /robots.txt request at all).
87
+ respectRobots: z.boolean().optional(),
88
+ // Hard wall-clock cap on the whole crawl, in milliseconds. Defaults to
89
+ // 10 minutes. Unlike a between-page check, this aborts work already in
90
+ // flight — including a `Retry-After` sleep, which a server can otherwise
91
+ // make arbitrarily long. Set to 0 to disable the cap entirely for a
92
+ // deliberately long-running crawl.
93
+ crawlTimeoutMs: z.number().int().min(0).optional(),
83
94
  })
84
95
  .passthrough();
85
96
  /** One component of a bundle (spec §10.1). Single-entry, transitional. */
@@ -6,7 +6,7 @@
6
6
  * `config-schema.ts` monolith — no behavior change.
7
7
  */
8
8
  import { z } from "zod";
9
- import { positiveInt } from "./primitives.js";
9
+ import { engineName, positiveInt } from "./primitives.js";
10
10
  // ── Workflow engine ─────────────────────────────────────────────────────────
11
11
  /**
12
12
  * Workflow-engine settings (`workflow`).
@@ -25,5 +25,7 @@ import { positiveInt } from "./primitives.js";
25
25
  export const WorkflowConfigSchema = z
26
26
  .object({
27
27
  maxConcurrency: positiveInt.optional(),
28
+ /** Named LLM or agent engine frozen into every criteria-bearing gate. */
29
+ judgeEngine: engineName.optional(),
28
30
  })
29
31
  .passthrough();
@@ -29,12 +29,17 @@ const USAGE_HINTS = {
29
29
  TARGET_NOT_UPDATABLE: "Run `akm bundle list` to view your sources, then retry with one of those values.",
30
30
  MISSING_REQUIRED_ARGUMENT: "Refs use the form [bundle//]conceptId, e.g. `akm show knowledge/guide.md` or `akm show skills/deploy`.",
31
31
  UNKNOWN_COMMAND: "Run `akm --help` to see available commands.",
32
+ UNKNOWN_FLAG: "Run the command with `--help` to see its accepted flags.",
32
33
  };
33
34
  /** Default hint for each NotFoundError code. */
34
35
  const NOT_FOUND_HINTS = {
35
36
  ASSET_NOT_FOUND: "Run `akm search <query>` or `akm index` to refresh the index.",
36
37
  SOURCE_NOT_FOUND: "Run `akm bundle list` to view your sources, then retry with one of those values.",
37
38
  WORKFLOW_NOT_FOUND: "Run `akm workflow list --active` to see runs.",
39
+ // A proposal is addressed by id or ref, never by path — reusing
40
+ // FILE_NOT_FOUND here handed users "check the path exists and is readable"
41
+ // for a mistyped id, which points at the wrong thing entirely.
42
+ PROPOSAL_NOT_FOUND: "Run `akm proposal list` to see pending proposals and their ids.",
38
43
  FILE_NOT_FOUND: "Check the path exists and is readable.",
39
44
  };
40
45
  /**
@@ -38,7 +38,7 @@
38
38
  * @module logs-db
39
39
  */
40
40
  import path from "node:path";
41
- import { runMigrations as runSqliteMigrations } from "../storage/engines/sqlite-migrations.js";
41
+ import { assertMigrationRegistry, runMigrations as runSqliteMigrations, } from "../storage/engines/sqlite-migrations.js";
42
42
  import { openManagedDatabase } from "../storage/managed-db.js";
43
43
  import { getDataDir } from "./paths.js";
44
44
  // ── Path helper ──────────────────────────────────────────────────────────────
@@ -130,6 +130,7 @@ const MIGRATIONS = [
130
130
  `,
131
131
  },
132
132
  ];
133
+ assertMigrationRegistry(MIGRATIONS);
133
134
  /**
134
135
  * Apply every pending migration. Called automatically by
135
136
  * {@link openLogsDatabase}; exported for the same test seams state-db exposes.
@@ -23,7 +23,10 @@
23
23
  * models). Also strips leading/trailing whitespace.
24
24
  */
25
25
  export function stripThinkBlocks(raw) {
26
- return raw.replace(/<think>[\s\S]*?<\/think>/gi, "").trim();
26
+ return raw
27
+ .replace(/<think>[\s\S]*?<\/think>/gi, "")
28
+ .replace(/^[\s\S]*?<\/think>/i, "")
29
+ .trim();
27
30
  }
28
31
  /**
29
32
  * Strips markdown code fences (``` or ~~~, with optional language tag).
@@ -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 { runMigrations as runSqliteMigrations } from "../../storage/engines/sqlite-migrations.js";
4
+ import { assertMigrationRegistry, runMigrations as runSqliteMigrations, } from "../../storage/engines/sqlite-migrations.js";
5
5
  export const STATE_MIGRATIONS = [
6
6
  // ── Migration 001 — initial schema ──────────────────────────────────────────
7
7
  {
@@ -59,7 +59,7 @@ export const STATE_MIGRATIONS = [
59
59
  -- id TEXT PK — UUID (crypto.randomUUID()); stable directory name.
60
60
  -- stash_dir TEXT — absolute stash root; multi-stash installs need
61
61
  -- this to partition proposal lists per stash.
62
- -- ref TEXT — target asset ref (e.g. "lesson:alpha");
62
+ -- ref TEXT — target asset ref (e.g. "lessons/alpha");
63
63
  -- indexed for ref-scoped queue views.
64
64
  -- status TEXT — "pending" | "accepted" | "rejected"; indexed
65
65
  -- so pending-queue queries are fast.
@@ -76,7 +76,7 @@ export const STATE_MIGRATIONS = [
76
76
  -- metadata_json TEXT — JSON object for future proposal fields.
77
77
  -- Current fields stored here: sourceRun,
78
78
  -- review, confidence, gateDecision (#577),
79
- -- backupContent, eligibilitySource.
79
+ -- backupContent.
80
80
  --
81
81
  -- ADD COLUMN extension points (future migrations):
82
82
  -- ALTER TABLE proposals ADD COLUMN source_run TEXT DEFAULT NULL;
@@ -353,9 +353,9 @@ export const STATE_MIGRATIONS = [
353
353
  // (`scripts/akm-migrate/migrate/legacy/proposal-fs-import.ts`, wired through
354
354
  // `akm-migrate apply`),
355
355
  // whose idempotency is INSERT OR IGNORE on the proposal UUID plus migrate-apply's
356
- // own journal — it no longer reads or writes this ledger. The CREATE TABLE
357
- // stays because the migration registry is APPEND-ONLY and checksum-sealed:
358
- // removing a released fragment would make the schema_migrations ledger of an
356
+ // incomplete sentinel — it no longer reads or writes this ledger. The CREATE TABLE
357
+ // stays because migration IDs are append-only: removing a released migration
358
+ // would make the schema_migrations ledger of an
359
359
  // already-migrated rc database stop being an exact ordered prefix, and the
360
360
  // runner would refuse to open it. The empty table is harmless.
361
361
  //
@@ -833,12 +833,11 @@ export const STATE_MIGRATIONS = [
833
833
  //
834
834
  // The state.db half of the three-DB merge (plan §3.2/§8, normative §11.4,
835
835
  // chunk-8 cutover design §1). This migration is PURE,
836
- // SEALABLE, IDEMPOTENT DDL ONLY — `CREATE TABLE IF NOT EXISTS` (+ indexes),
836
+ // IDEMPOTENT DDL ONLY — `CREATE TABLE IF NOT EXISTS` (+ indexes),
837
837
  // never a DROP or a data move. The actual data movement (the workflow.db merge,
838
838
  // the usage_events rescue from index.db, the full old-ref→item_ref re-key, and
839
- // the workflow.db delete / index.db quarantine) is CODE — a journaled step of
840
- // the migrate-apply flow (`scripts/akm-migrate/config-migrate.ts`
841
- // `cutover-applied` phase), driven by
839
+ // the workflow.db delete / index.db quarantine) is idempotent migrate-apply
840
+ // code driven by
842
841
  // `scripts/akm-migrate/migrate/legacy/three-db-cutover.ts`. See the no-DROP
843
842
  // contract carve-out note in `src/core/state-db.ts`.
844
843
  //
@@ -985,6 +984,7 @@ export const STATE_MIGRATIONS = [
985
984
  `,
986
985
  },
987
986
  ];
987
+ assertMigrationRegistry(STATE_MIGRATIONS);
988
988
  /**
989
989
  * Apply every pending migration in a single transaction per migration.
990
990
  *
@@ -994,8 +994,5 @@ export const STATE_MIGRATIONS = [
994
994
  * Called automatically by `openStateDatabase()`.
995
995
  */
996
996
  export function runMigrations(db, options) {
997
- runSqliteMigrations(db, STATE_MIGRATIONS, {
998
- applyPending: options?.applyPending,
999
- generationMarker: options?.generationMarker,
1000
- });
997
+ runSqliteMigrations(db, STATE_MIGRATIONS, { applyPending: options?.applyPending });
1001
998
  }
@@ -45,12 +45,10 @@
45
45
  * merge-target tables at their final shape. The one-time, filesystem-derived,
46
46
  * fail-closed DATA movement (the workflow.db merge, the usage_events rescue, the
47
47
  * full old-ref→item_ref re-key, and the workflow.db unlink / index.db quarantine
48
- * rename) is deliberately NOT a sealed SQL migration body: it is a journaled step
49
- * of the migrate-apply coordinator (`scripts/akm-migrate/config-migrate.ts`
50
- * `cutover-applied` phase →
51
- * `scripts/akm-migrate/migrate/legacy/three-db-cutover.ts`). So the no-DROP
52
- * contract here is intact — the physical workflow.db deletion happens outside
53
- * the ledger DDL, under the backup-verified-restorable fail-closed gate.
48
+ * rename) runs as idempotent code in the migrate-apply coordinator
49
+ * (`scripts/akm-migrate/migrate/legacy/three-db-cutover.ts`). So the no-DROP
50
+ * contract here is intact: physical workflow.db deletion happens outside the
51
+ * ledger DDL, after a verified backup and committed data transaction.
54
52
  *
55
53
  * ## Schema design: indexed columns vs. metadata_json
56
54
  *
@@ -148,6 +148,7 @@ export async function readStream(stream, opts) {
148
148
  }
149
149
  }
150
150
  const EMPTY_READ = { text: "", timedOut: false };
151
+ const UNBOUNDED_STREAM_READ_SAFETY_MS = 60 * 60 * 1000;
151
152
  function toError(err) {
152
153
  return err instanceof Error ? err : new Error(String(err));
153
154
  }
@@ -238,10 +239,11 @@ export async function runManagedSubprocess(cmd, opts) {
238
239
  else
239
240
  abortSignal.addEventListener("abort", onAbort, { once: true });
240
241
  }
241
- // Stream-drain timeout: the wall budget plus a 2 s grace, or 30 s when there
242
- // is no kill timer. Ensures the caller never hangs past the budget even if a
243
- // killed child leaves a pipe write-end open in a background thread.
244
- const streamDrainTimeoutMs = timeoutMs !== null ? timeoutMs + 2_000 : 30_000;
242
+ // Stream-drain timeout: the wall budget plus a 2 s grace, or a one-hour
243
+ // safety bound when execution itself is unbounded. This timer starts with
244
+ // capture, so the null-timeout path must not impose a short hidden deadline
245
+ // on an otherwise healthy long-running process.
246
+ const streamDrainTimeoutMs = timeoutMs !== null ? timeoutMs + 2_000 : UNBOUNDED_STREAM_READ_SAFETY_MS;
245
247
  const stdoutPromise = capture
246
248
  ? readStream(proc.stdout ?? null, {
247
249
  timeoutMs: streamDrainTimeoutMs,
@@ -38,7 +38,7 @@ function shellQuote(value) {
38
38
  }
39
39
  /** Reproduced from `output/renderers.ts#buildWorkflowAction` — the one function-valued `ACTION_BUILDERS.workflow` entry. */
40
40
  function buildWorkflowAction(ref) {
41
- return `Resume the active run or start a new run with \`akm workflow next ${shellQuote(ref)}\`.`;
41
+ return `Start or resume execution with \`akm workflow run ${shellQuote(ref)}\`.`;
42
42
  }
43
43
  /**
44
44
  * Exhaustive over {@link KnownType} — a HAND-WRITTEN literal, not derived
@@ -419,12 +419,27 @@ async function akmIndexReal(options) {
419
419
  // Load config and resolve all stash sources
420
420
  const { loadConfig, mutateConfig } = await import("../core/config/config.js");
421
421
  let config = loadConfig();
422
+ // Durable state must be runtime-compatible before source hydration,
423
+ // adapter persistence, or index.db creation can mutate the installation.
424
+ onProgress({ phase: "preflight", message: "Validating durable state." });
425
+ withStateDb(() => undefined);
422
426
  // Ensure git stash caches are extracted before resolving stash dirs,
423
427
  // so their content directories exist on disk for the walker to discover.
424
428
  const sourceCacheStart = Date.now();
425
429
  onProgress({ phase: "preflight", message: "Hydrating source caches." });
426
430
  const { ensureSourceCaches, resolveSourceEntries } = await import("./search/search-source.js");
427
- await ensureSourceCaches(config, { force: full, materialize: options.hydrateSources !== false });
431
+ // Inject the store-backed secret resolver from here — a composition root
432
+ // ABOVE the provider/fetcher import cycle (this module reaches
433
+ // search-source only via dynamic import). This is what lets a website
434
+ // source's X fetcher resolve `secrets/x-bearer-token` during
435
+ // bundle-update / hydrate, not just from the command-layer URL-ingest
436
+ // path. `secret-seam` is imported here, never from inside the cycle.
437
+ const { storeSecretResolver } = await import("../sources/snapshot-fetchers/secret-seam.js");
438
+ await ensureSourceCaches(config, {
439
+ force: full,
440
+ materialize: options.hydrateSources !== false,
441
+ secrets: storeSecretResolver,
442
+ });
428
443
  const sourceCacheEnd = Date.now();
429
444
  const allSourceEntries = resolveSourceEntries(stashDir, config);
430
445
  const detected = detectAndPersistBundleAdapters(allSourceEntries, config, mutateConfig, {
@@ -304,7 +304,7 @@ export async function ensureSourceCaches(config, options) {
304
304
  continue;
305
305
  }
306
306
  try {
307
- await provider.sync({ force });
307
+ await provider.sync({ force, secrets: options?.secrets });
308
308
  }
309
309
  catch (err) {
310
310
  warn(`Warning: failed to refresh ${provider.kind} source "${provider.name}": ${err instanceof Error ? err.message : String(err)}`);
@@ -188,7 +188,9 @@ function classifyBySmartMd(ctx) {
188
188
  return { type: "workflow", specificity: 19 };
189
189
  }
190
190
  if (fm) {
191
- if ("toolPolicy" in fm || "tools" in fm) {
191
+ // `tools` is the one authoring key for an agent's tool grant. Recognition
192
+ // covers only the key the renderer honors.
193
+ if ("tools" in fm) {
192
194
  return { type: "agent", specificity: 20 };
193
195
  }
194
196
  if ("agent" in fm) {
@@ -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
- /** Default hard timeout for an agent CLI when neither engine nor call overrides it. */
5
- export const DEFAULT_AGENT_TIMEOUT_MS = 60_000;
4
+ /** Default agent CLI timeout; null means agents run until they finish. */
5
+ export const DEFAULT_AGENT_TIMEOUT_MS = null;
6
6
  /** Default hard timeout for direct LLM calls when no engine/use override exists. */
7
7
  export const DEFAULT_LLM_TIMEOUT_MS = 600_000;
@@ -15,26 +15,34 @@
15
15
  */
16
16
  import fs from "node:fs";
17
17
  import path from "node:path";
18
- import { getBuiltinAgentProfile, listBuiltinAgentProfiles } from "./profiles.js";
18
+ import { getBuiltinAgentProfile, listBuiltinAgentProfiles, OPENCODE_SDK_SERVER_BIN, } from "./profiles.js";
19
19
  /**
20
20
  * Default PATH lookup. Walks `process.env.PATH` and returns the first
21
21
  * existing executable file. Returns `undefined` when the bin is not on
22
22
  * PATH or the env is empty.
23
23
  *
24
- * `process.env.PATH` is split on the platform-correct delimiter; on
25
- * Windows the binary may have an executable extension, but for v1 we
26
- * keep this Unix-flavoured (Bun's primary target) and look for an exact
27
- * match.
24
+ * `process.env.PATH` is split on the platform-correct delimiter. On Windows
25
+ * agent CLIs install as extension-bearing shims (`claude.cmd`, `q.exe`), so
26
+ * each PATH entry is probed for the bare name AND for `<bin><ext>` over
27
+ * PATHEXT — an exact-match-only probe reports every agent CLI as missing
28
+ * there, which silently hides the "installed CLI agent" option during setup.
28
29
  */
29
30
  export function defaultWhich(bin, envSource = process.env) {
31
+ // Computed once per call — not per PATH entry — since it never varies.
32
+ const suffixes = executableSuffixes(envSource);
30
33
  if (!bin || bin.includes("/") || bin.includes("\\")) {
31
34
  // Absolute / relative paths: caller already specified location.
32
- try {
33
- return fs.statSync(bin).isFile() ? bin : undefined;
34
- }
35
- catch {
36
- return undefined;
35
+ for (const suffix of suffixes) {
36
+ const candidate = bin + suffix;
37
+ try {
38
+ if (fs.statSync(candidate).isFile())
39
+ return candidate;
40
+ }
41
+ catch {
42
+ /* try the next extension */
43
+ }
37
44
  }
45
+ return undefined;
38
46
  }
39
47
  const pathVar = envSource.PATH ?? envSource.Path ?? envSource.path ?? "";
40
48
  if (!pathVar)
@@ -43,18 +51,40 @@ export function defaultWhich(bin, envSource = process.env) {
43
51
  for (const dir of pathVar.split(sep)) {
44
52
  if (!dir)
45
53
  continue;
46
- const candidate = path.join(dir, bin);
47
- try {
48
- const st = fs.statSync(candidate);
49
- if (st.isFile())
50
- return candidate;
51
- }
52
- catch {
53
- /* keep walking */
54
+ const base = path.join(dir, bin);
55
+ for (const suffix of suffixes) {
56
+ const candidate = base + suffix;
57
+ try {
58
+ const st = fs.statSync(candidate);
59
+ if (st.isFile())
60
+ return candidate;
61
+ }
62
+ catch {
63
+ /* keep walking */
64
+ }
54
65
  }
55
66
  }
56
67
  return undefined;
57
68
  }
69
+ /** Windows PATHEXT default, used when the env carries no explicit list. */
70
+ const DEFAULT_PATHEXT = ".COM;.EXE;.BAT;.CMD";
71
+ /**
72
+ * Executable suffixes to try for each candidate path, bare name (`""`) first
73
+ * so POSIX resolution is byte-for-byte unchanged. Extensions are appended only
74
+ * on win32 or when the env supplies PATHEXT (which is also the seam tests use
75
+ * to cover Windows resolution from a POSIX runner).
76
+ */
77
+ function executableSuffixes(envSource) {
78
+ const pathext = envSource.PATHEXT ?? envSource.Pathext ?? envSource.pathext;
79
+ if (process.platform !== "win32" && !pathext)
80
+ return [""];
81
+ const exts = (pathext ?? DEFAULT_PATHEXT)
82
+ .split(";")
83
+ .map((ext) => ext.trim())
84
+ .filter(Boolean)
85
+ .map((ext) => (ext.startsWith(".") ? ext : `.${ext}`));
86
+ return ["", ...exts];
87
+ }
58
88
  let detectOverrides;
59
89
  /** TEST-ONLY. Swap the detection implementations; pass undefined to restore. */
60
90
  export function _setAgentDetectForTests(fakes) {
@@ -79,7 +109,7 @@ export function detectAgentCliProfiles(agent, whichFn = defaultWhich) {
79
109
  profilesByName.set(name, {
80
110
  name,
81
111
  platform: engine.platform,
82
- bin: engine.bin ?? builtin?.bin ?? (engine.platform === "opencode-sdk" ? "opencode" : engine.platform),
112
+ bin: engine.bin ?? builtin?.bin ?? (engine.platform === "opencode-sdk" ? OPENCODE_SDK_SERVER_BIN : engine.platform),
83
113
  args: engine.args ?? builtin?.args ?? [],
84
114
  stdio: "captured",
85
115
  envPassthrough: builtin?.envPassthrough ?? [],
@@ -91,6 +91,16 @@ const BUILTINS = {
91
91
  parseOutput: "text",
92
92
  },
93
93
  };
94
+ /**
95
+ * Binary the `opencode-sdk` harness needs on PATH.
96
+ *
97
+ * The embedded SDK is not self-contained: its runner spawns `opencode serve`
98
+ * and talks HTTP to it (see `harnesses/opencode-sdk/sdk-runner.ts`), so the
99
+ * `opencode` binary gates the SDK path exactly as it gates the CLI path.
100
+ * `opencode-sdk` deliberately has no {@link BUILTINS} entry — it dispatches
101
+ * without argv construction — so this is the one place that pairing lives.
102
+ */
103
+ export const OPENCODE_SDK_SERVER_BIN = "opencode";
94
104
  /** Names of the canonical built-in harness descriptors. Stable, sorted. */
95
105
  export const BUILTIN_AGENT_PROFILE_NAMES = Object.freeze(Object.keys(BUILTINS).sort());
96
106
  /** Returns the built-in descriptor for a canonical harness id. */
@@ -21,7 +21,6 @@ import { parseEmbeddedJsonResponse } from "../../core/parse.js";
21
21
  import { runManagedSubprocess, } from "../../core/subprocess.js";
22
22
  import { getCommandBuilder } from "./builders.js";
23
23
  import { DEFAULT_AGENT_TIMEOUT_MS } from "./config.js";
24
- const DEFAULT_TIMEOUT_MS = DEFAULT_AGENT_TIMEOUT_MS;
25
24
  /**
26
25
  * Supplement `existingPath` with well-known user binary directories when
27
26
  * running in a scheduler context (cron/launchd) where PATH is stripped.
@@ -144,7 +143,7 @@ function streamFailureMessage(profileName, stdout, stderr) {
144
143
  export async function runAgent(profile, prompt, options = {}) {
145
144
  const stdioMode = options.stdio ?? profile.stdio;
146
145
  // null = explicitly disabled (no kill timer). undefined = runtime default.
147
- const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : DEFAULT_TIMEOUT_MS;
146
+ const timeoutMs = options.timeoutMs !== undefined ? options.timeoutMs : DEFAULT_AGENT_TIMEOUT_MS;
148
147
  const parseOutput = options.parseOutput ?? profile.parseOutput;
149
148
  const setTimeoutImpl = options.setTimeoutFn ?? setTimeout;
150
149
  const clearTimeoutImpl = options.clearTimeoutFn ?? clearTimeout;
@@ -4,9 +4,13 @@
4
4
  /**
5
5
  * OpenCode SDK agent runner (migrated from `agent/sdk-runner.ts`, #564).
6
6
  *
7
- * Uses the embedded `@opencode-ai/sdk` instead of `Bun.spawn`. Requires no
8
- * agent CLI binary to be installed. The user provides an OpenAI-compatible
9
- * endpoint (or inherits from the selected fallback LLM engine) for the SDK.
7
+ * Uses the embedded `@opencode-ai/sdk` instead of `Bun.spawn` for dispatch, so
8
+ * no agent CLI argv is ever constructed — but the SDK is not self-contained:
9
+ * {@link createManagedOpencode} spawns `opencode serve` and talks HTTP to it,
10
+ * so the `opencode` binary must be on PATH (which is why setup's
11
+ * `detectHarness` gates this harness on that probe). The user provides an
12
+ * OpenAI-compatible endpoint (or inherits from the selected fallback LLM
13
+ * engine) for the SDK.
10
14
  *
11
15
  * This is the runtime surface of the {@link OpencodeSdkHarness} (`id =
12
16
  * 'opencode-sdk'`). It is the dispatch path for SDK runner specs; it exposes
@@ -169,9 +169,7 @@ export async function upsertLockEntry(entry) {
169
169
  * migrate-apply's config lock + maintenance barrier) whose synchronous body
170
170
  * cannot await the sentinel's retry loop. No-op for an empty list.
171
171
  */
172
- export function mergeLockEntriesSync(entries) {
173
- if (entries.length === 0)
174
- return;
172
+ function readLockEntriesForMigration() {
175
173
  let existing = [];
176
174
  const lockfilePath = getLockfilePath();
177
175
  if (fs.existsSync(lockfilePath)) {
@@ -187,6 +185,16 @@ export function mergeLockEntriesSync(entries) {
187
185
  }
188
186
  existing = raw;
189
187
  }
188
+ return existing;
189
+ }
190
+ /** Validate the current lockfile before migrate-apply creates its backup or sentinel. */
191
+ export function assertMigrationLockfileReadable() {
192
+ readLockEntriesForMigration();
193
+ }
194
+ export function mergeLockEntriesSync(entries) {
195
+ const existing = readLockEntriesForMigration();
196
+ if (entries.length === 0)
197
+ return;
190
198
  // MERGE per id, never replace: the migrator's entries are sparse (id/source/
191
199
  // ref/localRoot), while an existing row may carry `resolvedVersion`,
192
200
  // `resolvedRevision`, `integrity`, `installedAt`, … from a real install.
@@ -202,59 +210,6 @@ export function mergeLockEntriesSync(entries) {
202
210
  const incomingIds = new Set(entries.map((e) => e.id));
203
211
  writeLockfileUnlocked([...existing.filter((e) => !incomingIds.has(e.id)), ...merged]);
204
212
  }
205
- /** Freeze exact old/desired lockfile bytes while the migration lifecycle lock is held. */
206
- export function planLockMergeSync(entries) {
207
- const lockfilePath = getLockfilePath();
208
- let oldBytes = null;
209
- let existing = [];
210
- try {
211
- oldBytes = fs.readFileSync(lockfilePath, "utf8");
212
- }
213
- catch (error) {
214
- if (error.code !== "ENOENT")
215
- throw error;
216
- }
217
- if (oldBytes !== null) {
218
- let parsed;
219
- try {
220
- parsed = JSON.parse(oldBytes);
221
- }
222
- catch (error) {
223
- throw new ConfigError(`Cannot plan migration lock entries from unreadable lockfile ${lockfilePath}: ${error instanceof Error ? error.message : String(error)}.`, "INVALID_CONFIG_FILE");
224
- }
225
- if (!Array.isArray(parsed) || !parsed.every(isValidLockfileEntry)) {
226
- throw new ConfigError(`Cannot plan migration lock entries from malformed lockfile ${lockfilePath}.`, "INVALID_CONFIG_FILE");
227
- }
228
- existing = parsed;
229
- }
230
- if (entries.length === 0)
231
- return { oldBytes, desiredBytes: oldBytes };
232
- const byId = new Map(existing.map((entry) => [entry.id, entry]));
233
- for (const entry of entries)
234
- byId.set(entry.id, { ...byId.get(entry.id), ...entry });
235
- return { oldBytes, desiredBytes: `${JSON.stringify([...byId.values()], null, 2)}\n` };
236
- }
237
- /** Publish a frozen migration lock generation, accepting only exact old or exact desired bytes. */
238
- export function publishPlannedLockMergeSync(plan) {
239
- const lockfilePath = getLockfilePath();
240
- let current = null;
241
- try {
242
- current = fs.readFileSync(lockfilePath, "utf8");
243
- }
244
- catch (error) {
245
- if (error.code !== "ENOENT")
246
- throw error;
247
- }
248
- if (current === plan.desiredBytes)
249
- return;
250
- if (current !== plan.oldBytes) {
251
- throw new ConfigError(`Refusing migration lockfile publication because ${lockfilePath} is a third generation, neither the journaled old nor desired bytes.`, "INVALID_CONFIG_FILE");
252
- }
253
- if (plan.desiredBytes === null)
254
- return;
255
- fs.mkdirSync(path.dirname(lockfilePath), { recursive: true });
256
- writeFileAtomic(lockfilePath, plan.desiredBytes, 0o600);
257
- }
258
213
  export async function removeLockEntry(id) {
259
214
  if (!fs.existsSync(getDataDir()))
260
215
  return;
@@ -58,15 +58,11 @@ const PASSTHROUGH_COMMANDS = [
58
58
  "upgrade",
59
59
  "workflow-abandon",
60
60
  "workflow-brief",
61
- "workflow-complete",
62
- "workflow-complete-rejected",
63
61
  "workflow-create",
64
62
  "workflow-list",
65
- "workflow-next",
66
63
  "workflow-report",
67
64
  "workflow-resume",
68
65
  "workflow-run",
69
- "workflow-start",
70
66
  "workflow-status",
71
67
  ];
72
68
  export const passthroughShapes = PASSTHROUGH_COMMANDS.map((command) => ({
@@ -289,12 +289,12 @@ export function formatSearchPlain(r, detail) {
289
289
  if (hasWorkflowHit) {
290
290
  const workflowRef = hits.find((h) => h.type === "workflow");
291
291
  const wfRef = workflowRef && typeof workflowRef.ref === "string" ? workflowRef.ref : topRef;
292
- lines.push(`Next: akm show '${topRef}' | To start a workflow: akm workflow next '${wfRef}'`);
293
- lines.push("After running workflow next: follow each step and run `akm workflow complete <run-id> --step <step-id>` when done.");
292
+ lines.push(`Next: akm show '${topRef}' | To execute a workflow: akm workflow run '${wfRef}'`);
293
+ lines.push("Inspect the workflow before running it; `workflow run` executes and verifies its steps automatically.");
294
294
  }
295
295
  else {
296
296
  lines.push(`Next: akm show '${topRef}'`);
297
- lines.push("After reading the asset: check whether a workflow applies before editing — if so, use `akm workflow next` instead.");
297
+ lines.push("After reading the asset: check whether a workflow applies before editing — if so, inspect it and use `akm workflow run`.");
298
298
  }
299
299
  }
300
300
  }
@@ -18,4 +18,4 @@
18
18
  export { formatAddPlain, formatBundleShowPlain, formatClonePlain, formatConfigPlain, formatCuratePlain, formatEnvCreatePlain, formatEnvExportPlain, formatEnvListPlain, formatEnvRemovePlain, formatEventLine, formatEventsPlain, formatFeedbackPlain, formatImportPlain, formatIndexPlain, formatInfoPlain, formatInitPlain, formatListPlain, formatRegistryAddPlain, formatRegistryListPlain, formatRegistryRemovePlain, formatRegistrySearchPlain, formatRememberPlain, formatRemovePlain, formatSearchPlain, formatSyncPlain, formatUpdatePlain, formatUpgradePlain, } from "./command-format.js";
19
19
  export { formatGateDecisionSummary, formatProposalAcceptPlain, formatProposalDiffPlain, formatProposalDrainPlain, formatProposalListPlain, formatProposalProducerPlain, formatProposalRejectPlain, formatProposalShowPlain, } from "./proposal-format.js";
20
20
  export { formatShowPlain } from "./show-format.js";
21
- export { formatWorkflowBriefPlain, formatWorkflowCompleteRejectedPlain, formatWorkflowCreatePlain, formatWorkflowListPlain, formatWorkflowNextPlain, formatWorkflowResumePlain, formatWorkflowRunPlain, formatWorkflowStatusPlain, } from "./workflow-format.js";
21
+ export { formatWorkflowBriefPlain, formatWorkflowCreatePlain, formatWorkflowListPlain, formatWorkflowResumePlain, formatWorkflowRunPlain, formatWorkflowStatusPlain, } from "./workflow-format.js";
@@ -45,9 +45,7 @@ export function appendShowDirectives(lines, r) {
45
45
  if (assetType === "skill" || assetType === "knowledge") {
46
46
  const activeRun = r.activeRun;
47
47
  if (activeRun) {
48
- // Active workflow: redirect agent to workflow commands instead of direct apply
49
- lines.unshift(` akm workflow complete '${activeRun.runId}'${activeRun.stepId ? ` --step '${activeRun.stepId}'` : ""}`);
50
- lines.unshift("Read this schema, then follow your workflow step's instructions to edit the workspace file. When done, mark the step complete:");
48
+ lines.unshift("Read this reference, follow the active unit's instructions, and return the result to the workflow runner. Step completion is automatic.");
51
49
  lines.unshift(`WORKFLOW ACTIVE — schema shown as reference (run: ${activeRun.runId})`);
52
50
  lines.unshift("---");
53
51
  lines.unshift("");
@@ -88,14 +86,14 @@ export function appendShowDirectives(lines, r) {
88
86
  const insertIdx = separatorIdx >= 0 ? separatorIdx + 1 : r.type || r.name ? 1 : 0;
89
87
  const actionDirective = [
90
88
  `ACTION REQUIRED: Do not execute steps manually from this output.`,
91
- `Run \`akm workflow next '${workflowRef}'\` to get your current step with exact instructions.`,
89
+ `Run \`akm workflow run '${workflowRef}'\` to execute the workflow through its declared gates.`,
92
90
  "---",
93
91
  ];
94
92
  lines.splice(insertIdx, 0, "", ...actionDirective);
95
93
  lines.push("");
96
94
  lines.push("---");
97
- lines.push(`NEXT STEP: Run \`akm workflow next '${workflowRef}'\` to see the current workflow step.`);
98
- lines.push("Do not edit workspace files before completing each step with `akm workflow complete`.");
95
+ lines.push(`NEXT STEP: Run \`akm workflow run '${workflowRef}'\` to execute the workflow.`);
96
+ lines.push("Inspect the workflow first; the runner dispatches and completes each step automatically.");
99
97
  }
100
98
  }
101
99
  /**