akm-cli 0.9.0 → 0.9.1-beta.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 (140) hide show
  1. package/CHANGELOG.md +724 -0
  2. package/README.md +28 -63
  3. package/STABILITY.md +4 -2
  4. package/dist/cli/parse-args.js +7 -1
  5. package/dist/commands/agent/contribute-cli.js +1 -1
  6. package/dist/commands/env/child-env.js +14 -0
  7. package/dist/commands/feedback-cli.js +7 -1
  8. package/dist/commands/health/llm-usage.js +2 -1
  9. package/dist/commands/health/surfaces.js +4 -77
  10. package/dist/commands/health.js +65 -11
  11. package/dist/commands/improve/distill/quality-gate.js +6 -1
  12. package/dist/commands/improve/eligibility.js +7 -1
  13. package/dist/commands/improve/eval-cases.js +2 -0
  14. package/dist/commands/improve/improve.js +126 -10
  15. package/dist/commands/improve/locks.js +7 -0
  16. package/dist/commands/improve/memory/memory-improve.js +9 -0
  17. package/dist/commands/improve/run-context.js +5 -0
  18. package/dist/commands/improve/session-asset.js +4 -0
  19. package/dist/commands/lint/base-linter.js +31 -7
  20. package/dist/commands/lint/index.js +205 -51
  21. package/dist/commands/lint/types.js +22 -1
  22. package/dist/commands/proposal/repository.js +17 -1
  23. package/dist/commands/sources/add-cli.js +8 -2
  24. package/dist/commands/sources/info.js +12 -2
  25. package/dist/commands/sources/installed-stashes.js +6 -1
  26. package/dist/commands/sources/migration-help.js +12 -3
  27. package/dist/commands/sources/self-update.js +9 -1
  28. package/dist/commands/tasks/tasks.js +8 -2
  29. package/dist/commands/workflow-cli.js +17 -11
  30. package/dist/core/abort-deadline.js +28 -0
  31. package/dist/core/adapter/adapters/agent-skills-adapter.js +83 -5
  32. package/dist/core/adapter/adapters/akm-adapter.js +13 -10
  33. package/dist/core/adapter/adapters/akm-lint.js +78 -22
  34. package/dist/core/adapter/adapters/akm-task-adapter.js +43 -20
  35. package/dist/core/adapter/adapters/dotenv-adapter.js +21 -0
  36. package/dist/core/adapter/adapters/tool-dir-shared.js +5 -3
  37. package/dist/core/asset/frontmatter.js +10 -1
  38. package/dist/core/common.js +147 -9
  39. package/dist/core/concurrent.js +32 -0
  40. package/dist/core/config/config-io.js +5 -45
  41. package/dist/core/config/schema/engines.js +14 -3
  42. package/dist/core/config/schema/workflow.js +11 -0
  43. package/dist/core/errors.js +25 -0
  44. package/dist/core/events.js +30 -24
  45. package/dist/core/extra-params.js +11 -0
  46. package/dist/core/file-lock.js +7 -1
  47. package/dist/core/fs-txn.js +15 -2
  48. package/dist/core/improve-result.js +5 -0
  49. package/dist/core/json-schema.js +344 -9
  50. package/dist/core/loopback.js +89 -0
  51. package/dist/core/migration-operation.js +17 -2
  52. package/dist/core/path-access.js +107 -0
  53. package/dist/core/paths.js +16 -2
  54. package/dist/core/redaction.js +86 -18
  55. package/dist/core/spawn-env.js +234 -0
  56. package/dist/core/state-db-scope.js +134 -0
  57. package/dist/core/state-db.js +1 -0
  58. package/dist/core/subprocess.js +181 -37
  59. package/dist/core/write-provenance.js +85 -0
  60. package/dist/core/write-source.js +33 -2
  61. package/dist/indexer/db/graph-db.js +17 -6
  62. package/dist/indexer/ensure-index.js +10 -3
  63. package/dist/indexer/index-written-assets.js +17 -2
  64. package/dist/indexer/indexer.js +86 -21
  65. package/dist/indexer/passes/memory-inference.js +4 -0
  66. package/dist/indexer/search/db-search.js +25 -17
  67. package/dist/indexer/walk/walker.js +6 -1
  68. package/dist/integrations/agent/detect.js +13 -1
  69. package/dist/integrations/agent/engine-resolution.js +24 -11
  70. package/dist/integrations/agent/model-aliases.js +1 -1
  71. package/dist/integrations/agent/profiles.js +9 -1
  72. package/dist/integrations/agent/spawn.js +15 -87
  73. package/dist/integrations/harnesses/opencode-sdk/sdk-runner.js +21 -0
  74. package/dist/integrations/lockfile.js +55 -2
  75. package/dist/llm/client.js +14 -19
  76. package/dist/llm/embedder.js +23 -3
  77. package/dist/llm/embedders/remote.js +27 -2
  78. package/dist/output/html-render.js +40 -1
  79. package/dist/output/text/lint-format.js +17 -4
  80. package/dist/runtime.js +23 -1
  81. package/dist/scripts/akm-migrate-node.js +1714 -836
  82. package/dist/scripts/akm-migrate.js +1682 -804
  83. package/dist/setup/setup.js +22 -7
  84. package/dist/sources/providers/git-install.js +25 -2
  85. package/dist/sources/providers/git-stash.js +19 -0
  86. package/dist/sources/providers/git.js +1 -1
  87. package/dist/sources/snapshot-fetchers/content-extract.js +63 -1
  88. package/dist/sources/snapshot-fetchers/website-ingest.js +126 -20
  89. package/dist/storage/database.js +71 -7
  90. package/dist/storage/engines/sqlite-migrations.js +61 -2
  91. package/dist/storage/managed-db.js +19 -0
  92. package/dist/storage/repositories/index-connection.js +39 -4
  93. package/dist/storage/repositories/index-entries-repository.js +6 -1
  94. package/dist/storage/repositories/index-meta-repository.js +11 -0
  95. package/dist/storage/repositories/index-schema.js +17 -2
  96. package/dist/storage/repositories/index-vec-repository.js +43 -5
  97. package/dist/storage/repositories/workflow-runs-repository.js +66 -13
  98. package/dist/storage/sqlite-pragmas.js +12 -1
  99. package/dist/tasks/log-redaction.js +156 -0
  100. package/dist/tasks/parser.js +82 -5
  101. package/dist/tasks/runner.js +222 -17
  102. package/dist/tasks/scheduler-invocation.js +19 -0
  103. package/dist/tasks/schema.js +86 -1
  104. package/dist/text-import-hook.mjs +1 -1
  105. package/dist/workflows/concurrency-policy.js +95 -1
  106. package/dist/workflows/exec/dispatch-redaction.js +114 -0
  107. package/dist/workflows/exec/exec-unit.js +542 -0
  108. package/dist/workflows/exec/frozen-judge.js +114 -42
  109. package/dist/workflows/exec/native-executor.js +465 -238
  110. package/dist/workflows/exec/param-secrets.js +4 -3
  111. package/dist/workflows/exec/run-workflow.js +424 -219
  112. package/dist/workflows/exec/step-work.js +506 -167
  113. package/dist/workflows/exec/unit-dispatch.js +31 -1
  114. package/dist/workflows/exec/unit-writer.js +53 -13
  115. package/dist/workflows/exec/worktree.js +454 -41
  116. package/dist/workflows/ir/compile.js +26 -2
  117. package/dist/workflows/ir/freeze.js +82 -15
  118. package/dist/workflows/ir/schema.js +105 -20
  119. package/dist/workflows/parser.js +242 -19
  120. package/dist/workflows/program/schema.js +24 -0
  121. package/dist/workflows/renderer.js +32 -4
  122. package/dist/workflows/resource-limits.js +182 -0
  123. package/dist/workflows/runtime/runs.js +146 -6
  124. package/dist/workflows/validate-summary.js +17 -2
  125. package/docs/README.md +74 -32
  126. package/docs/migration/release-notes/0.9.0.md +2 -1
  127. package/docs/migration/v0.7-to-v0.8.md +2 -1
  128. package/docs/migration/v0.8-to-v0.9.md +3 -1
  129. package/docs/reference/README.md +11 -4
  130. package/docs/reference/bundle-types.md +19 -0
  131. package/docs/reference/cli.md +105 -16
  132. package/docs/reference/configuration.md +15 -2
  133. package/docs/reference/data-and-telemetry.md +30 -10
  134. package/docs/reference/supported-formats.md +50 -0
  135. package/docs/reference/workflow-schema.md +1014 -0
  136. package/docs/reference/workflows.md +37 -633
  137. package/package.json +13 -6
  138. package/schemas/akm-config.json +18 -5
  139. package/schemas/akm-task.json +27 -5
  140. package/schemas/akm-workflow.json +92 -13
@@ -7,6 +7,7 @@ import { writeFileAtomic } from "../core/common.js";
7
7
  import { ConfigError, rethrowIfTestIsolationError } from "../core/errors.js";
8
8
  import { createLockPayload, probeLock, reclaimStaleLock, releaseLock, tryAcquireLockSync } from "../core/file-lock.js";
9
9
  import { acquireMaintenanceBarrier } from "../core/maintenance-barrier.js";
10
+ import { classifyPathAccess, describeInaccessiblePath } from "../core/path-access.js";
10
11
  import { getDataDir, getLockfileLockPath, getLockfilePath } from "../core/paths.js";
11
12
  // ── Lock sentinel ────────────────────────────────────────────────────────────
12
13
  const LOCK_MAX_RETRIES = 3;
@@ -54,6 +55,29 @@ export function readLockfile() {
54
55
  return [];
55
56
  }
56
57
  }
58
+ /**
59
+ * Refuse to treat an UNREADABLE lockfile (or data dir) as an absent one (#791).
60
+ *
61
+ * Every write path here is read-modify-WRITE: it loads the current entries and
62
+ * writes the whole array back. An unreadable `akm.lock` that reads as `[]`
63
+ * therefore does not merely lose information — the very next
64
+ * `writeFileAtomic` replaces the operator's entire lock record with the single
65
+ * entry this call happened to be adding. That is the same catastrophe R-012
66
+ * guards against for a *corrupt* file, reached instead through a permission
67
+ * fault, and `fs.existsSync`/a swallowed `readFileSync` could not tell the two
68
+ * apart from "the file was never created".
69
+ *
70
+ * No-op when the path is genuinely absent — that case really does have nothing
71
+ * to preserve.
72
+ */
73
+ function assertLockfilePathReadable(target) {
74
+ const { access, code } = classifyPathAccess(target);
75
+ if (access !== "inaccessible")
76
+ return;
77
+ throw new ConfigError(`Refusing to modify the lockfile: ${describeInaccessiblePath(target, code)}. akm cannot read the existing lock ` +
78
+ "records, and writing over them would destroy every bundle they track. Fix the ownership or mode of that " +
79
+ "path (or point AKM_DATA_DIR / XDG_DATA_HOME somewhere this user owns) and retry.", "DATA_DIR_UNREADABLE");
80
+ }
57
81
  /**
58
82
  * Like {@link readLockfile}, but THROWS instead of silently degrading to `[]`
59
83
  * when the on-disk lockfile exists yet is not parseable JSON or not a JSON
@@ -82,7 +106,15 @@ function readLockfileOrThrow() {
82
106
  }
83
107
  catch (err) {
84
108
  rethrowIfTestIsolationError(err);
85
- return []; // File does not exist (or is otherwise unreadable) — nothing to preserve.
109
+ // "Missing file" is the only failure with nothing to preserve. An
110
+ // UNREADABLE lockfile has everything to preserve and we cannot see it —
111
+ // degrading it to `[]` here is precisely the destructive overwrite this
112
+ // function was written to prevent, only triggered by a permission fault
113
+ // instead of a corrupt file (#791). Classify AFTER the failed read so the
114
+ // happy path costs no extra syscall and the answer describes the failure
115
+ // we actually got.
116
+ assertLockfilePathReadable(lockfilePath);
117
+ return [];
86
118
  }
87
119
  let parsed;
88
120
  try {
@@ -94,6 +126,16 @@ function readLockfileOrThrow() {
94
126
  if (!Array.isArray(parsed)) {
95
127
  throw new ConfigError(`Refusing to modify lockfile ${lockfilePath}: existing content is not a JSON array. Fix or remove the file by hand before retrying — every existing lock entry would otherwise be lost.`, "INVALID_CONFIG_FILE");
96
128
  }
129
+ // Refuse rather than filter. This is the WRITE path's read: everything it
130
+ // returns is what gets written back, so silently dropping entries that fail
131
+ // per-entry validation destroyed them on the next write — the same
132
+ // data-losing overwrite the two refusals above exist to prevent, just at
133
+ // entry granularity instead of file granularity.
134
+ const invalid = parsed.filter((entry) => !isValidLockfileEntry(entry));
135
+ if (invalid.length > 0) {
136
+ throw new ConfigError(`Refusing to modify lockfile ${lockfilePath}: ${invalid.length} existing entr${invalid.length === 1 ? "y is" : "ies are"} malformed. ` +
137
+ "Fix or remove the file by hand before retrying — those entries would otherwise be lost.", "INVALID_CONFIG_FILE");
138
+ }
97
139
  return parsed.filter(isValidLockfileEntry);
98
140
  }
99
141
  /**
@@ -172,6 +214,11 @@ export async function upsertLockEntry(entry) {
172
214
  function readLockEntriesForMigration() {
173
215
  let existing = [];
174
216
  const lockfilePath = getLockfilePath();
217
+ // `mergeLockEntriesSync` writes `existing` straight back out, so an
218
+ // unreadable lockfile read as absent would be overwritten with just the
219
+ // migrator's sparse entries (#791). This is also what
220
+ // `assertMigrationLockfileReadable` promises to have checked.
221
+ assertLockfilePathReadable(lockfilePath);
175
222
  if (fs.existsSync(lockfilePath)) {
176
223
  let raw;
177
224
  try {
@@ -211,7 +258,13 @@ export function mergeLockEntriesSync(entries) {
211
258
  writeLockfileUnlocked([...existing.filter((e) => !incomingIds.has(e.id)), ...merged]);
212
259
  }
213
260
  export async function removeLockEntry(id) {
214
- if (!fs.existsSync(getDataDir()))
261
+ // Returning early says "there is no lock record to remove", and the uninstall
262
+ // that called us reports success on that basis. Only an absent data dir earns
263
+ // it — one we cannot read may hold the very entry we were asked to drop, and
264
+ // silently leaving it behind is how a bundle stays "installed" forever (#791).
265
+ const dataDir = getDataDir();
266
+ assertLockfilePathReadable(dataDir);
267
+ if (!fs.existsSync(dataDir))
215
268
  return;
216
269
  const release = await acquireLockSentinel();
217
270
  try {
@@ -9,34 +9,22 @@
9
9
  */
10
10
  import { fetchWithTimeout, readBodyWithByteCap } from "../core/common.js";
11
11
  import { resolveSecret } from "../core/config/config.js";
12
+ import { ENV_REFERENCE_PATTERN } from "../core/config/schema/primitives.js";
12
13
  import { formatExtraParamsIssue, validateExtraParams } from "../core/extra-params.js";
13
14
  import { parseJsonResponse } from "../core/parse.js";
14
- import { redactCredentialPatterns, redactSensitiveText } from "../core/redaction.js";
15
+ import { redactErrorBody, redactSensitiveText } from "../core/redaction.js";
15
16
  import { warnVerbose } from "../core/warn.js";
16
17
  import { DEFAULT_LLM_TIMEOUT_MS } from "../integrations/agent/config.js";
17
18
  import { emitLlmUsage, extractUsageTokens, } from "./usage-telemetry.js";
18
- /** Maximum length of an LLM error response body included in thrown errors. */
19
+ /** Maximum length of an upstream response excerpt included in thrown errors. */
19
20
  const ERROR_BODY_MAX_LEN = 200;
20
21
  /** Stable OpenAI-compatible response-schema name used for every structured call. */
21
22
  const JSON_SCHEMA_RESPONSE_NAME = "akm_response";
22
23
  /**
23
- * Redact credential-shaped substrings from an upstream error body before
24
- * including it in a thrown Error. The body is also trimmed to a fixed length
25
- * so that a verbose provider response cannot leak large amounts of context.
26
- *
27
- * The pattern set itself lives in {@link redactCredentialPatterns}
28
- * (src/core/redaction.ts) so other output paths (e.g. task run logs) can
29
- * reuse it without this function's length cap.
24
+ * Re-exported from src/core/redaction.ts, where it now lives so every HTTP
25
+ * transport can apply the same hardening the embeddings client needs it too.
30
26
  */
31
- export function redactErrorBody(input) {
32
- if (!input)
33
- return "";
34
- let out = redactCredentialPatterns(input);
35
- if (out.length > ERROR_BODY_MAX_LEN) {
36
- out = `${out.slice(0, ERROR_BODY_MAX_LEN)}…`;
37
- }
38
- return out;
39
- }
27
+ export { redactErrorBody } from "../core/redaction.js";
40
28
  /**
41
29
  * Detect a response body that is an HTML document rather than the expected
42
30
  * JSON. LM Studio (and similar local providers) can serve their web UI on
@@ -223,7 +211,14 @@ async function chatCompletionAttempt(config, messages, options, timeoutMs) {
223
211
  throw new Error(formatExtraParamsIssue("LLM extraParams", issue));
224
212
  }
225
213
  const headers = { "Content-Type": "application/json" };
226
- const resolvedKey = resolveSecret(config.apiKey);
214
+ // Resolve ONLY a whole-string env reference. Every live caller already hands
215
+ // us the materialized credential (materializeLlmConnection / materializeFrozenLlm
216
+ // resolve `$VAR` upstream, and engine config REQUIRES the symbolic form), so
217
+ // re-running the substitution over a literal key mangled any credential
218
+ // containing `$` — `sk-live$ecret` lost everything from the `$` onward, and
219
+ // the request failed with an opaque 401. The narrow check keeps the symbolic
220
+ // form working for any direct caller that still passes one.
221
+ const resolvedKey = ENV_REFERENCE_PATTERN.test(config.apiKey ?? "") ? resolveSecret(config.apiKey) : config.apiKey;
227
222
  if (resolvedKey) {
228
223
  headers.Authorization = `Bearer ${resolvedKey}`;
229
224
  }
@@ -65,12 +65,32 @@ export async function embed(text, embeddingConfig, signal) {
65
65
  const cached = getCachedEmbedding(key);
66
66
  if (cached)
67
67
  return cached;
68
- const result = embeddingConfig && hasRemoteEndpoint(embeddingConfig)
69
- ? await new RemoteEmbedder(embeddingConfig).embed(text, signal)
70
- : await getLocalEmbedder().embed(text, signal);
68
+ const result = await embedOnce(text, embeddingConfig, signal);
71
69
  setCachedEmbedding(key, result);
72
70
  return result;
73
71
  }
72
+ /**
73
+ * Resolve a single embedding through the configured provider.
74
+ *
75
+ * The local branch must honour `localModel` exactly as {@link embedBatch}
76
+ * does. The singleton is constructed with no default model, so routing through
77
+ * `getLocalEmbedder().embed()` silently used DEFAULT_LOCAL_MODEL: queries were
78
+ * embedded with a different model than the index was built with. Nothing
79
+ * detected it, because the provider fingerprint keys on `localModel`, so no
80
+ * purge or "pending" status ever fired — a dimension mismatch made semantic
81
+ * ranking contribute nothing, and a same-dimension override silently produced
82
+ * meaningless cross-model scores.
83
+ */
84
+ async function embedOnce(text, embeddingConfig, signal) {
85
+ if (embeddingConfig && hasRemoteEndpoint(embeddingConfig)) {
86
+ return new RemoteEmbedder(embeddingConfig).embed(text, signal);
87
+ }
88
+ const localModel = embeddingConfig?.localModel;
89
+ if (localModel) {
90
+ return getLocalEmbedder().embedWithModel(text, localModel);
91
+ }
92
+ return getLocalEmbedder().embed(text, signal);
93
+ }
74
94
  /**
75
95
  * Generate embeddings for multiple texts in batch.
76
96
  * Uses the OpenAI-compatible batch API for remote endpoints (batches of 100).
@@ -9,6 +9,7 @@
9
9
  */
10
10
  import { fetchWithTimeout, isHttpUrl, readBodyWithByteCap } from "../../core/common.js";
11
11
  import { resolveSecret } from "../../core/config/config.js";
12
+ import { redactErrorBody, redactSensitiveText } from "../../core/redaction.js";
12
13
  const DEFAULT_REMOTE_BATCH_SIZE = 100;
13
14
  /** Cheap token estimator: 4 chars ≈ 1 token. Used in verbose logging and error messages. */
14
15
  export function estimateTokenCount(text) {
@@ -54,7 +55,7 @@ export class RemoteEmbedder {
54
55
  throw err;
55
56
  return "";
56
57
  });
57
- throw new Error(`Embedding request failed (${response.status}): ${errBody}`);
58
+ throw new Error(`Embedding request failed (${response.status}): ${this.safeErrorBody(errBody)}`);
58
59
  }
59
60
  const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
60
61
  if (!json.data?.[0]?.embedding) {
@@ -94,7 +95,7 @@ export class RemoteEmbedder {
94
95
  throw err;
95
96
  return "";
96
97
  });
97
- throw new Error(`Embedding batch request failed (${response.status}): ${respBody}`);
98
+ throw new Error(`Embedding batch request failed (${response.status}): ${this.safeErrorBody(respBody)}`);
98
99
  }
99
100
  const json = JSON.parse(await readBodyWithByteCap(response, undefined, { bodyTimeoutMs: 30_000, signal }));
100
101
  if (!json.data || json.data.length !== batch.length) {
@@ -119,6 +120,21 @@ export class RemoteEmbedder {
119
120
  }
120
121
  return headers;
121
122
  }
123
+ /**
124
+ * Make a provider error body safe to embed in a thrown Error, matching the
125
+ * hardening llm/client.ts applies on the identical path: pattern-redact
126
+ * credential shapes, exact-scrub this connection's own key, and clip.
127
+ *
128
+ * These messages are durable — generateEmbeddingsForDb surfaces them as
129
+ * `embeddingResult.message`, which is written to semantic-status.json and
130
+ * replayed by `akm info` (including `--json`) until the next successful
131
+ * index, and printed on every vector-search attempt. Raw bodies reached that
132
+ * far unredacted and uncapped, at readBodyWithByteCap's 10 MB default.
133
+ */
134
+ safeErrorBody(body) {
135
+ const resolvedKey = resolveSecret(this.config.apiKey);
136
+ return redactSensitiveText(redactErrorBody(body), resolvedKey ? [resolvedKey] : []);
137
+ }
122
138
  }
123
139
  /**
124
140
  * L2-normalize a vector to unit length.
@@ -144,6 +160,15 @@ export function normalizeEmbeddingEndpoint(endpoint) {
144
160
  if (normalizedPath.endsWith("/embeddings")) {
145
161
  return parsed.toString();
146
162
  }
163
+ // Ollama's NATIVE embedding route is `/api/embed` (and the older
164
+ // `/api/embeddings`). Appending "/embeddings" to it produced
165
+ // `/api/embed/embeddings`, a 404 — so pointing akm at the native endpoint,
166
+ // which is what its own options like ollamaOptions and contextLength are
167
+ // for, could never work. An explicit path that is already an embedding route
168
+ // is left alone.
169
+ if (normalizedPath.endsWith("/embed")) {
170
+ return parsed.toString();
171
+ }
147
172
  parsed.pathname = normalizedPath ? `${normalizedPath}/embeddings` : "/embeddings";
148
173
  return parsed.toString();
149
174
  }
@@ -14,8 +14,28 @@
14
14
  */
15
15
  import fs from "node:fs";
16
16
  import path from "node:path";
17
+ import healthTemplate from "../assets/templates/html/health.html" with { type: "text" };
17
18
  import { getDirname } from "../runtime.js";
18
19
  const TEMPLATES_DIR = path.join(getDirname(import.meta.url), "../assets/templates/html");
20
+ /**
21
+ * Templates embedded at build time, keyed by command name.
22
+ *
23
+ * `bun build --compile` embeds only what is imported `with { type: "text" }`;
24
+ * a plain `readFileSync` from a path relative to `import.meta.url` resolves
25
+ * into the virtual `/$bunfs` tree and misses. `akm health --report --format
26
+ * html` therefore crashed with ENOENT (exit 70) on the standalone binary that
27
+ * the CLI's own install error and the CHANGELOG promote as the runtime-free
28
+ * option. The text import works on all three runtimes: natively on Bun, via
29
+ * scripts/node-runtime/text-import-hook.mjs on Node, and embedded in the
30
+ * compiled binary.
31
+ */
32
+ const EMBEDDED_TEMPLATES = {
33
+ // bun-types declares `*.html` as an `HTMLBundle` (its HTML-bundler entrypoint
34
+ // feature), which is not what a `type: "text"` import yields — the value is
35
+ // the file's contents as a string on every runtime. The cast reconciles the
36
+ // ambient declaration with the actual import attribute.
37
+ health: healthTemplate,
38
+ };
19
39
  /**
20
40
  * Resolve the on-disk template path for a command's bespoke `<command>.html`.
21
41
  * The command name is sanitized to a bare basename so a hostile command
@@ -37,9 +57,28 @@ const TOKEN_RE = /%%[A-Z_]+%%/g;
37
57
  * matching the skill renderer's behaviour.
38
58
  */
39
59
  export function renderHtml(templatePath, replacements) {
40
- const html = fs.readFileSync(templatePath, "utf8");
60
+ const html = readTemplate(templatePath);
41
61
  return html.replace(TOKEN_RE, (token) => replacements[token] ?? token);
42
62
  }
63
+ /**
64
+ * Read a template from disk, falling back to the embedded copy.
65
+ *
66
+ * Disk stays primary so an operator (or a test) editing
67
+ * `src/assets/templates/html/<name>.html` sees the change without a rebuild.
68
+ * The fallback covers the standalone binary, where the file does not exist on
69
+ * any real filesystem.
70
+ */
71
+ function readTemplate(templatePath) {
72
+ try {
73
+ return fs.readFileSync(templatePath, "utf8");
74
+ }
75
+ catch (err) {
76
+ const embedded = EMBEDDED_TEMPLATES[path.basename(templatePath, ".html")];
77
+ if (embedded !== undefined)
78
+ return embedded;
79
+ throw err;
80
+ }
81
+ }
43
82
  /**
44
83
  * Minimal HTML entity escaping for text interpolated into templates. Escapes
45
84
  * the single quote as well as the double quote so escaped values are safe in
@@ -16,9 +16,18 @@ function glyphFor(fixed) {
16
16
  return { glyph: "✓", severityRank: 2 };
17
17
  return { glyph: "⚠", severityRank: 1 };
18
18
  }
19
+ /**
20
+ * `file:line` when the finding is line-anchored (workflow parse/compile
21
+ * errors), bare `file` otherwise. `LintIssue.line` is optional precisely
22
+ * because most lint sources are whole-file, so their headline is byte-identical
23
+ * to what it has always been.
24
+ */
25
+ function locationOf(issue) {
26
+ return typeof issue.line === "number" ? `${issue.file}:${issue.line}` : issue.file;
27
+ }
19
28
  function issueEntry(issue) {
20
29
  const { glyph, severityRank } = glyphFor(issue.fixed);
21
- return { severityRank, glyph, headline: `${issue.file} [${issue.issue}] ${issue.detail}` };
30
+ return { severityRank, glyph, headline: `${locationOf(issue)} [${issue.issue}] ${issue.detail}` };
22
31
  }
23
32
  function renderIssueSection(title, issues) {
24
33
  if (issues.length === 0)
@@ -30,14 +39,18 @@ export function formatLintPlain(r) {
30
39
  return null;
31
40
  const fixed = Array.isArray(r.fixed) ? r.fixed : [];
32
41
  const flagged = Array.isArray(r.flagged) ? r.flagged : [];
42
+ const warnings = Array.isArray(r.warnings) ? r.warnings : [];
33
43
  const summary = r.summary;
34
44
  const lines = [];
35
45
  if (typeof r.ok === "boolean")
36
46
  lines.push(`ok: ${r.ok}`);
37
- lines.push(`summary: fixed=${summary?.fixed ?? fixed.length} flagged=${summary?.flagged ?? flagged.length}`);
38
- // Flagged (still needs attention) surfaces before fixed (already handled)
39
- // so a scan of the output hits the actionable items first.
47
+ lines.push(`summary: fixed=${summary?.fixed ?? fixed.length} flagged=${summary?.flagged ?? flagged.length}` +
48
+ ` warnings=${summary?.warnings ?? warnings.length}`);
49
+ // Flagged (still needs attention) surfaces before warnings (advisory,
50
+ // non-fatal) and fixed (already handled), so a scan of the output hits the
51
+ // actionable items first.
40
52
  lines.push("", ...renderIssueSection("flagged", flagged));
53
+ lines.push("", ...renderIssueSection("warnings", warnings));
41
54
  lines.push("", ...renderIssueSection("fixed", fixed));
42
55
  return lines.join("\n").trim();
43
56
  }
package/dist/runtime.js CHANGED
@@ -78,8 +78,17 @@ function nodeSpawnAdapter(cmd, options) {
78
78
  detached: options.detached,
79
79
  stdio: [stdioFor(options.stdin), stdioFor(options.stdout), stdioFor(options.stderr)],
80
80
  });
81
+ // Node's 'exit' fires (null, signal) when the child dies from a signal.
82
+ // Resolving `code ?? 0` reported that as SUCCESS, so an OOM-killed or
83
+ // segfaulted child came back exit 0 with partial stdout. Bun resolves
84
+ // 128 + signum for the same case; match it so both runtimes agree and
85
+ // callers that only check `exitCode !== 0` classify signal deaths correctly.
86
+ let signalCode = null;
81
87
  const exited = new Promise((resolve, reject) => {
82
- child.once("exit", (code) => resolve(code ?? 0));
88
+ child.once("exit", (code, signal) => {
89
+ signalCode = signal;
90
+ resolve(code ?? (signal ? 128 + signalNumber(signal) : 0));
91
+ });
83
92
  child.once("error", reject);
84
93
  });
85
94
  return {
@@ -96,12 +105,25 @@ function nodeSpawnAdapter(cmd, options) {
96
105
  get exitCode() {
97
106
  return child.exitCode;
98
107
  },
108
+ get signalCode() {
109
+ return signalCode ?? child.signalCode;
110
+ },
99
111
  pid: child.pid,
100
112
  kill(signal) {
101
113
  child.kill(signal);
102
114
  },
103
115
  };
104
116
  }
117
+ /**
118
+ * Map a signal name to its number so a signal death can be reported as the
119
+ * conventional 128 + signum exit status. Falls back to SIGKILL's 9 for a name
120
+ * this platform does not define, which still yields a non-zero status — the
121
+ * property that matters for classifying the run as failed.
122
+ */
123
+ function signalNumber(signal) {
124
+ const { constants } = nodeRequire("node:os");
125
+ return constants.signals[signal] ?? 9;
126
+ }
105
127
  // `node:stream`'s Writable.toWeb is available on Node >=17; referenced via the
106
128
  // class to avoid a static import that Bun's typings may not expose.
107
129
  function Writable_toWeb(w) {