codeharness 0.21.0 → 0.22.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1941,7 +1941,7 @@ async function scaffoldDocs(opts) {
1941
1941
  }
1942
1942
 
1943
1943
  // src/modules/infra/init-project.ts
1944
- var HARNESS_VERSION = true ? "0.21.0" : "0.0.0-dev";
1944
+ var HARNESS_VERSION = true ? "0.22.0" : "0.0.0-dev";
1945
1945
  function failResult(opts, error) {
1946
1946
  return {
1947
1947
  status: "fail",
@@ -5178,11 +5178,134 @@ import { join as join16 } from "path";
5178
5178
  // src/modules/observability/coverage.ts
5179
5179
  import { readFileSync as readFileSync18, writeFileSync as writeFileSync12, renameSync as renameSync2, existsSync as existsSync21 } from "fs";
5180
5180
  import { join as join17 } from "path";
5181
+ var STATE_FILE2 = "sprint-state.json";
5182
+ var DEFAULT_STATIC_TARGET = 80;
5183
+ function defaultCoverageState() {
5184
+ return {
5185
+ static: {
5186
+ coveragePercent: 0,
5187
+ lastScanTimestamp: "",
5188
+ history: []
5189
+ },
5190
+ targets: {
5191
+ staticTarget: DEFAULT_STATIC_TARGET
5192
+ }
5193
+ };
5194
+ }
5195
+ function readStateFile(projectDir) {
5196
+ const fp = join17(projectDir, STATE_FILE2);
5197
+ if (!existsSync21(fp)) {
5198
+ return ok2({});
5199
+ }
5200
+ try {
5201
+ const raw = readFileSync18(fp, "utf-8");
5202
+ const parsed = JSON.parse(raw);
5203
+ return ok2(parsed);
5204
+ } catch (err) {
5205
+ const msg = err instanceof Error ? err.message : String(err);
5206
+ return fail2(`Failed to read ${STATE_FILE2}: ${msg}`);
5207
+ }
5208
+ }
5209
+ function readCoverageState(projectDir) {
5210
+ if (!projectDir || typeof projectDir !== "string") {
5211
+ return fail2("projectDir is required and must be a non-empty string");
5212
+ }
5213
+ const stateResult = readStateFile(projectDir);
5214
+ if (!stateResult.success) {
5215
+ return fail2(stateResult.error);
5216
+ }
5217
+ return ok2(extractCoverageState(stateResult.data));
5218
+ }
5219
+ function extractCoverageState(state) {
5220
+ const obs = state.observability;
5221
+ if (!obs) {
5222
+ return defaultCoverageState();
5223
+ }
5224
+ const staticSection = obs.static;
5225
+ const targets = obs.targets;
5226
+ const runtimeSection = obs.runtime;
5227
+ const runtime = runtimeSection && typeof runtimeSection.coveragePercent === "number" ? {
5228
+ coveragePercent: runtimeSection.coveragePercent,
5229
+ lastValidationTimestamp: typeof runtimeSection.lastValidationTimestamp === "string" ? runtimeSection.lastValidationTimestamp : "",
5230
+ modulesWithTelemetry: typeof runtimeSection.modulesWithTelemetry === "number" ? runtimeSection.modulesWithTelemetry : 0,
5231
+ totalModules: typeof runtimeSection.totalModules === "number" ? runtimeSection.totalModules : 0,
5232
+ telemetryDetected: typeof runtimeSection.telemetryDetected === "boolean" ? runtimeSection.telemetryDetected : false
5233
+ } : void 0;
5234
+ const parsedGaps = parseGapArray(staticSection?.gaps);
5235
+ const result = {
5236
+ static: {
5237
+ coveragePercent: typeof staticSection?.coveragePercent === "number" ? staticSection.coveragePercent : 0,
5238
+ lastScanTimestamp: typeof staticSection?.lastScanTimestamp === "string" ? staticSection.lastScanTimestamp : "",
5239
+ history: Array.isArray(staticSection?.history) ? staticSection.history.filter(
5240
+ (entry) => typeof entry === "object" && entry !== null && typeof entry.coveragePercent === "number" && typeof entry.timestamp === "string"
5241
+ ) : [],
5242
+ ...parsedGaps.length > 0 ? { gaps: parsedGaps } : {}
5243
+ },
5244
+ targets: {
5245
+ staticTarget: typeof targets?.staticTarget === "number" ? targets.staticTarget : DEFAULT_STATIC_TARGET,
5246
+ ...typeof targets?.runtimeTarget === "number" ? { runtimeTarget: targets.runtimeTarget } : {}
5247
+ },
5248
+ ...runtime ? { runtime } : {}
5249
+ };
5250
+ return result;
5251
+ }
5252
+ function parseGapArray(raw) {
5253
+ if (!Array.isArray(raw)) return [];
5254
+ return raw.filter((g) => {
5255
+ if (typeof g !== "object" || g === null) return false;
5256
+ const r = g;
5257
+ return typeof r.file === "string" && typeof r.line === "number" && typeof r.type === "string" && typeof r.description === "string";
5258
+ }).map((g) => ({
5259
+ file: g.file,
5260
+ line: g.line,
5261
+ type: g.type,
5262
+ description: g.description,
5263
+ severity: g.severity === "error" || g.severity === "warning" ? g.severity : "info"
5264
+ }));
5265
+ }
5181
5266
 
5182
5267
  // src/modules/observability/runtime-coverage.ts
5183
5268
  import { readFileSync as readFileSync19, writeFileSync as writeFileSync13, renameSync as renameSync3, existsSync as existsSync22 } from "fs";
5184
5269
  import { join as join18 } from "path";
5185
5270
 
5271
+ // src/modules/observability/coverage-gate.ts
5272
+ var DEFAULT_STATIC_TARGET2 = 80;
5273
+ var DEFAULT_RUNTIME_TARGET = 60;
5274
+ function checkObservabilityCoverageGate(projectDir, overrides) {
5275
+ if (!projectDir || typeof projectDir !== "string") {
5276
+ return fail2("projectDir is required and must be a non-empty string");
5277
+ }
5278
+ const stateResult = readCoverageState(projectDir);
5279
+ if (!stateResult.success) {
5280
+ return fail2(stateResult.error);
5281
+ }
5282
+ const state = stateResult.data;
5283
+ const staticTarget = overrides?.staticTarget ?? state.targets.staticTarget ?? DEFAULT_STATIC_TARGET2;
5284
+ const runtimeTarget = overrides?.runtimeTarget ?? state.targets.runtimeTarget ?? DEFAULT_RUNTIME_TARGET;
5285
+ const staticCurrent = state.static.coveragePercent;
5286
+ const staticMet = staticCurrent >= staticTarget;
5287
+ const staticResult = {
5288
+ met: staticMet,
5289
+ current: staticCurrent,
5290
+ target: staticTarget,
5291
+ gap: staticMet ? 0 : staticTarget - staticCurrent
5292
+ };
5293
+ let runtimeResult = null;
5294
+ if (state.runtime) {
5295
+ const runtimeCurrent = state.runtime.coveragePercent;
5296
+ const runtimeMet = runtimeCurrent >= runtimeTarget;
5297
+ runtimeResult = {
5298
+ met: runtimeMet,
5299
+ current: runtimeCurrent,
5300
+ target: runtimeTarget,
5301
+ gap: runtimeMet ? 0 : runtimeTarget - runtimeCurrent
5302
+ };
5303
+ }
5304
+ const passed = staticResult.met && (runtimeResult === null || runtimeResult.met);
5305
+ const gapSummary = state.static.gaps ? [...state.static.gaps] : [];
5306
+ return ok2({ passed, staticResult, runtimeResult, gapSummary });
5307
+ }
5308
+
5186
5309
  // src/modules/verify/browser.ts
5187
5310
  import { execFileSync as execFileSync9 } from "child_process";
5188
5311
  import { existsSync as existsSync23, readFileSync as readFileSync20 } from "fs";
@@ -10693,8 +10816,106 @@ function registerProgressCommand(program) {
10693
10816
  });
10694
10817
  }
10695
10818
 
10819
+ // src/commands/observability-gate.ts
10820
+ function registerObservabilityGateCommand(program) {
10821
+ program.command("observability-gate").description("Check observability coverage against targets (commit gate)").option("--json", "Machine-readable JSON output").option("--min-static <percent>", "Override static coverage target").option("--min-runtime <percent>", "Override runtime coverage target").action((opts, cmd) => {
10822
+ const globalOpts = cmd.optsWithGlobals();
10823
+ const isJson = opts.json === true || globalOpts.json === true;
10824
+ const root = process.cwd();
10825
+ const overrides = {};
10826
+ if (opts.minStatic !== void 0) {
10827
+ const parsed = parseInt(opts.minStatic, 10);
10828
+ if (isNaN(parsed) || parsed < 0 || parsed > 100) {
10829
+ if (isJson) {
10830
+ jsonOutput({ status: "error", message: "--min-static must be a number between 0 and 100" });
10831
+ } else {
10832
+ fail("--min-static must be a number between 0 and 100");
10833
+ }
10834
+ process.exitCode = 1;
10835
+ return;
10836
+ }
10837
+ overrides.staticTarget = parsed;
10838
+ }
10839
+ if (opts.minRuntime !== void 0) {
10840
+ const parsed = parseInt(opts.minRuntime, 10);
10841
+ if (isNaN(parsed) || parsed < 0 || parsed > 100) {
10842
+ if (isJson) {
10843
+ jsonOutput({ status: "error", message: "--min-runtime must be a number between 0 and 100" });
10844
+ } else {
10845
+ fail("--min-runtime must be a number between 0 and 100");
10846
+ }
10847
+ process.exitCode = 1;
10848
+ return;
10849
+ }
10850
+ overrides.runtimeTarget = parsed;
10851
+ }
10852
+ const result = checkObservabilityCoverageGate(root, overrides);
10853
+ if (!result.success) {
10854
+ if (isJson) {
10855
+ jsonOutput({ status: "error", message: result.error });
10856
+ } else {
10857
+ fail(`Observability gate error: ${result.error}`);
10858
+ }
10859
+ process.exitCode = 1;
10860
+ return;
10861
+ }
10862
+ const gate = result.data;
10863
+ if (isJson) {
10864
+ jsonOutput({
10865
+ status: gate.passed ? "pass" : "fail",
10866
+ passed: gate.passed,
10867
+ static: {
10868
+ current: gate.staticResult.current,
10869
+ target: gate.staticResult.target,
10870
+ met: gate.staticResult.met,
10871
+ gap: gate.staticResult.gap
10872
+ },
10873
+ runtime: gate.runtimeResult ? {
10874
+ current: gate.runtimeResult.current,
10875
+ target: gate.runtimeResult.target,
10876
+ met: gate.runtimeResult.met,
10877
+ gap: gate.runtimeResult.gap
10878
+ } : null,
10879
+ gaps: gate.gapSummary.map((g) => ({
10880
+ file: g.file,
10881
+ line: g.line,
10882
+ type: g.type,
10883
+ description: g.description
10884
+ }))
10885
+ });
10886
+ } else {
10887
+ const staticLine = `Static: ${gate.staticResult.current}% / ${gate.staticResult.target}% target`;
10888
+ if (gate.passed) {
10889
+ ok(`Observability gate passed. ${staticLine}`);
10890
+ if (gate.runtimeResult) {
10891
+ ok(`Runtime: ${gate.runtimeResult.current}% / ${gate.runtimeResult.target}% target`);
10892
+ }
10893
+ } else {
10894
+ fail(`Observability gate failed. ${staticLine}`);
10895
+ if (gate.runtimeResult && !gate.runtimeResult.met) {
10896
+ fail(`Runtime: ${gate.runtimeResult.current}% / ${gate.runtimeResult.target}% target`);
10897
+ }
10898
+ if (gate.gapSummary.length > 0) {
10899
+ fail("Gaps:");
10900
+ const shown = gate.gapSummary.slice(0, 5);
10901
+ for (const g of shown) {
10902
+ fail(` ${g.file}:${g.line} \u2014 ${g.description}`);
10903
+ }
10904
+ if (gate.gapSummary.length > 5) {
10905
+ fail(` ... and ${gate.gapSummary.length - 5} more.`);
10906
+ }
10907
+ }
10908
+ fail("Add logging to flagged functions. Run: codeharness observability-gate for details.");
10909
+ }
10910
+ }
10911
+ if (!gate.passed) {
10912
+ process.exitCode = 1;
10913
+ }
10914
+ });
10915
+ }
10916
+
10696
10917
  // src/index.ts
10697
- var VERSION = true ? "0.21.0" : "0.0.0-dev";
10918
+ var VERSION = true ? "0.22.0" : "0.0.0-dev";
10698
10919
  function createProgram() {
10699
10920
  const program = new Command();
10700
10921
  program.name("codeharness").description("Makes autonomous coding agents produce software that actually works").version(VERSION).option("--json", "Output in machine-readable JSON format");
@@ -10719,6 +10940,7 @@ function createProgram() {
10719
10940
  registerValidateStateCommand(program);
10720
10941
  registerValidateCommand(program);
10721
10942
  registerProgressCommand(program);
10943
+ registerObservabilityGateCommand(program);
10722
10944
  return program;
10723
10945
  }
10724
10946
  if (!process.env["VITEST"]) {
@@ -96,6 +96,8 @@ interface StaticCoverageState {
96
96
  readonly lastScanTimestamp: string;
97
97
  /** Historical coverage entries */
98
98
  readonly history: readonly CoverageHistoryEntry[];
99
+ /** Cached observability gaps from last analysis run (persisted by saveCoverageResult) */
100
+ readonly gaps?: readonly ObservabilityGap[];
99
101
  }
100
102
  /** Coverage targets configuration */
101
103
  interface CoverageTargets {
@@ -153,6 +155,17 @@ interface RuntimeCoverageState {
153
155
  /** Whether telemetry was detected at all */
154
156
  readonly telemetryDetected: boolean;
155
157
  }
158
+ /** Result of the observability coverage gate check */
159
+ interface ObservabilityCoverageGateResult {
160
+ /** Whether the gate passed (all required checks met their targets) */
161
+ readonly passed: boolean;
162
+ /** Static coverage check result */
163
+ readonly staticResult: CoverageTargetResult;
164
+ /** Runtime coverage check result, or null if no runtime data exists */
165
+ readonly runtimeResult: CoverageTargetResult | null;
166
+ /** List of observability gaps for actionable feedback */
167
+ readonly gapSummary: ObservabilityGap[];
168
+ }
156
169
  /** Trend comparison between latest and previous coverage entries */
157
170
  interface CoverageTrend {
158
171
  /** Latest coverage percentage */
@@ -214,38 +227,15 @@ type Result<T> = Ok<T> | Fail;
214
227
  */
215
228
  declare function analyze(projectDir: string, config?: AnalyzerConfig): Result<AnalyzerResult>;
216
229
 
217
- /**
218
- * Coverage state persistence — tracks observability coverage in sprint-state.json.
219
- *
220
- * Implements Story 1.3: read/write/trend/target comparison for static analysis coverage.
221
- * Only the `static` section is managed here; `runtime` is deferred to Epic 2.
222
- */
230
+ /** Coverage state persistence — tracks observability coverage in sprint-state.json. */
223
231
 
224
- /**
225
- * Save coverage result from an analysis run into sprint-state.json.
226
- *
227
- * Updates `observability.static.coveragePercent`, `observability.static.lastScanTimestamp`,
228
- * and appends to `observability.static.history`. Preserves all other state fields.
229
- */
232
+ /** Save coverage result from an analysis run into sprint-state.json. */
230
233
  declare function saveCoverageResult(projectDir: string, result: AnalyzerResult): Result<void>;
231
- /**
232
- * Read the observability coverage state from sprint-state.json.
233
- *
234
- * Returns the typed `ObservabilityCoverageState` from the `observability` key.
235
- * If no observability section exists, returns default state.
236
- */
234
+ /** Read the observability coverage state from sprint-state.json. */
237
235
  declare function readCoverageState(projectDir: string): Result<ObservabilityCoverageState>;
238
- /**
239
- * Get coverage trend by comparing latest vs previous history entries.
240
- */
236
+ /** Get coverage trend by comparing latest vs previous history entries. */
241
237
  declare function getCoverageTrend(projectDir: string): Result<CoverageTrend>;
242
- /**
243
- * Check current coverage against a target threshold.
244
- *
245
- * @param projectDir - Project root directory
246
- * @param target - Target percentage (default 80%)
247
- * @returns Structured result indicating whether coverage meets the target
248
- */
238
+ /** Check current coverage against a target threshold. */
249
239
  declare function checkCoverageTarget(projectDir: string, target?: number): Result<CoverageTargetResult>;
250
240
 
251
241
  /** Per-AC observability gap presence */
@@ -292,4 +282,28 @@ declare function computeRuntimeCoverage(gapResults: ObservabilityGapResult): Run
292
282
  */
293
283
  declare function saveRuntimeCoverage(projectDir: string, result: RuntimeCoverageResult): Result<void>;
294
284
 
295
- export { type AnalyzerConfig, type AnalyzerResult, type AnalyzerSummary, type CoverageHistoryEntry, type CoverageTargetResult, type CoverageTargets, type CoverageTrend, type GapSeverity, type ObservabilityCoverageState, type ObservabilityGap, type RuntimeCoverageEntry, type RuntimeCoverageResult, type RuntimeCoverageState, type StaticCoverageState, analyze, checkCoverageTarget, computeRuntimeCoverage, getCoverageTrend, readCoverageState, saveCoverageResult, saveRuntimeCoverage };
285
+ /**
286
+ * Observability coverage gate — checks if coverage meets targets for commit gating.
287
+ *
288
+ * Implements Story 2.2: reads cached coverage from sprint-state.json and compares
289
+ * against targets. Does NOT re-run Semgrep — uses cached results for performance.
290
+ * Static coverage is always required; runtime is only checked when data exists.
291
+ */
292
+
293
+ /**
294
+ * Check observability coverage against targets for commit gating.
295
+ *
296
+ * Reads static and runtime coverage from sprint-state.json. Static coverage
297
+ * is always checked. Runtime coverage is only checked when runtime data exists
298
+ * in the state file (i.e., after at least one verification run).
299
+ *
300
+ * @param projectDir - Project root directory
301
+ * @param overrides - Optional target overrides
302
+ * @returns Gate result with pass/fail, per-dimension results, and gap summary
303
+ */
304
+ declare function checkObservabilityCoverageGate(projectDir: string, overrides?: {
305
+ staticTarget?: number;
306
+ runtimeTarget?: number;
307
+ }): Result<ObservabilityCoverageGateResult>;
308
+
309
+ export { type AnalyzerConfig, type AnalyzerResult, type AnalyzerSummary, type CoverageHistoryEntry, type CoverageTargetResult, type CoverageTargets, type CoverageTrend, type GapSeverity, type ObservabilityCoverageGateResult, type ObservabilityCoverageState, type ObservabilityGap, type RuntimeCoverageEntry, type RuntimeCoverageResult, type RuntimeCoverageState, type StaticCoverageState, analyze, checkCoverageTarget, checkObservabilityCoverageGate, computeRuntimeCoverage, getCoverageTrend, readCoverageState, saveCoverageResult, saveRuntimeCoverage };
@@ -195,11 +195,13 @@ function saveCoverageResult(projectDir, result) {
195
195
  const updatedStatic = {
196
196
  coveragePercent: result.summary.coveragePercent,
197
197
  lastScanTimestamp: timestamp,
198
- history: trimmedHistory
198
+ history: trimmedHistory,
199
+ gaps: result.gaps.map(({ file, line, type, description, severity }) => ({ file, line, type, description, severity }))
199
200
  };
200
201
  const updatedObservability = {
201
202
  static: updatedStatic,
202
- targets: existing.targets
203
+ targets: existing.targets,
204
+ ...existing.runtime ? { runtime: existing.runtime } : {}
203
205
  };
204
206
  const updatedState = {
205
207
  ...state,
@@ -275,18 +277,45 @@ function extractCoverageState(state) {
275
277
  }
276
278
  const staticSection = obs.static;
277
279
  const targets = obs.targets;
278
- return {
280
+ const runtimeSection = obs.runtime;
281
+ const runtime = runtimeSection && typeof runtimeSection.coveragePercent === "number" ? {
282
+ coveragePercent: runtimeSection.coveragePercent,
283
+ lastValidationTimestamp: typeof runtimeSection.lastValidationTimestamp === "string" ? runtimeSection.lastValidationTimestamp : "",
284
+ modulesWithTelemetry: typeof runtimeSection.modulesWithTelemetry === "number" ? runtimeSection.modulesWithTelemetry : 0,
285
+ totalModules: typeof runtimeSection.totalModules === "number" ? runtimeSection.totalModules : 0,
286
+ telemetryDetected: typeof runtimeSection.telemetryDetected === "boolean" ? runtimeSection.telemetryDetected : false
287
+ } : void 0;
288
+ const parsedGaps = parseGapArray(staticSection?.gaps);
289
+ const result = {
279
290
  static: {
280
291
  coveragePercent: typeof staticSection?.coveragePercent === "number" ? staticSection.coveragePercent : 0,
281
292
  lastScanTimestamp: typeof staticSection?.lastScanTimestamp === "string" ? staticSection.lastScanTimestamp : "",
282
293
  history: Array.isArray(staticSection?.history) ? staticSection.history.filter(
283
294
  (entry) => typeof entry === "object" && entry !== null && typeof entry.coveragePercent === "number" && typeof entry.timestamp === "string"
284
- ) : []
295
+ ) : [],
296
+ ...parsedGaps.length > 0 ? { gaps: parsedGaps } : {}
285
297
  },
286
298
  targets: {
287
- staticTarget: typeof targets?.staticTarget === "number" ? targets.staticTarget : DEFAULT_STATIC_TARGET
288
- }
299
+ staticTarget: typeof targets?.staticTarget === "number" ? targets.staticTarget : DEFAULT_STATIC_TARGET,
300
+ ...typeof targets?.runtimeTarget === "number" ? { runtimeTarget: targets.runtimeTarget } : {}
301
+ },
302
+ ...runtime ? { runtime } : {}
289
303
  };
304
+ return result;
305
+ }
306
+ function parseGapArray(raw) {
307
+ if (!Array.isArray(raw)) return [];
308
+ return raw.filter((g) => {
309
+ if (typeof g !== "object" || g === null) return false;
310
+ const r = g;
311
+ return typeof r.file === "string" && typeof r.line === "number" && typeof r.type === "string" && typeof r.description === "string";
312
+ }).map((g) => ({
313
+ file: g.file,
314
+ line: g.line,
315
+ type: g.type,
316
+ description: g.description,
317
+ severity: g.severity === "error" || g.severity === "warning" ? g.severity : "info"
318
+ }));
290
319
  }
291
320
 
292
321
  // src/modules/observability/runtime-coverage.ts
@@ -355,9 +384,48 @@ function saveRuntimeCoverage(projectDir, result) {
355
384
  return fail(`Failed to write ${STATE_FILE2}: ${msg}`);
356
385
  }
357
386
  }
387
+
388
+ // src/modules/observability/coverage-gate.ts
389
+ var DEFAULT_STATIC_TARGET2 = 80;
390
+ var DEFAULT_RUNTIME_TARGET = 60;
391
+ function checkObservabilityCoverageGate(projectDir, overrides) {
392
+ if (!projectDir || typeof projectDir !== "string") {
393
+ return fail("projectDir is required and must be a non-empty string");
394
+ }
395
+ const stateResult = readCoverageState(projectDir);
396
+ if (!stateResult.success) {
397
+ return fail(stateResult.error);
398
+ }
399
+ const state = stateResult.data;
400
+ const staticTarget = overrides?.staticTarget ?? state.targets.staticTarget ?? DEFAULT_STATIC_TARGET2;
401
+ const runtimeTarget = overrides?.runtimeTarget ?? state.targets.runtimeTarget ?? DEFAULT_RUNTIME_TARGET;
402
+ const staticCurrent = state.static.coveragePercent;
403
+ const staticMet = staticCurrent >= staticTarget;
404
+ const staticResult = {
405
+ met: staticMet,
406
+ current: staticCurrent,
407
+ target: staticTarget,
408
+ gap: staticMet ? 0 : staticTarget - staticCurrent
409
+ };
410
+ let runtimeResult = null;
411
+ if (state.runtime) {
412
+ const runtimeCurrent = state.runtime.coveragePercent;
413
+ const runtimeMet = runtimeCurrent >= runtimeTarget;
414
+ runtimeResult = {
415
+ met: runtimeMet,
416
+ current: runtimeCurrent,
417
+ target: runtimeTarget,
418
+ gap: runtimeMet ? 0 : runtimeTarget - runtimeCurrent
419
+ };
420
+ }
421
+ const passed = staticResult.met && (runtimeResult === null || runtimeResult.met);
422
+ const gapSummary = state.static.gaps ? [...state.static.gaps] : [];
423
+ return ok({ passed, staticResult, runtimeResult, gapSummary });
424
+ }
358
425
  export {
359
426
  analyze,
360
427
  checkCoverageTarget,
428
+ checkObservabilityCoverageGate,
361
429
  computeRuntimeCoverage,
362
430
  getCoverageTrend,
363
431
  readCoverageState,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "codeharness",
3
- "version": "0.21.0",
3
+ "version": "0.22.0",
4
4
  "type": "module",
5
5
  "description": "CLI for codeharness — makes autonomous coding agents produce software that actually works",
6
6
  "bin": {
package/ralph/bridge.sh CHANGED
@@ -99,9 +99,9 @@ fi
99
99
 
100
100
  # ─── Sprint Status Parsing ────────────────────────────────────────────────
101
101
 
102
- # Parse sprint status YAML into an associative array
102
+ # Parse sprint status YAML into a newline-delimited key=value store
103
103
  # Maps story slug (e.g., "1-1-login-page") to status
104
- declare -A SPRINT_STATUSES
104
+ SPRINT_STATUSES=""
105
105
 
106
106
  parse_sprint_status() {
107
107
  local file="$1"
@@ -133,7 +133,8 @@ parse_sprint_status() {
133
133
  # Extract story number from slug: "1-1-login-page" -> "1.1"
134
134
  if [[ "$key" =~ ^([0-9]+)-([0-9]+) ]]; then
135
135
  local story_id="${BASH_REMATCH[1]}.${BASH_REMATCH[2]}"
136
- SPRINT_STATUSES["$story_id"]="$value"
136
+ SPRINT_STATUSES="${SPRINT_STATUSES}${story_id}=${value}
137
+ "
137
138
  fi
138
139
  fi
139
140
  fi
@@ -181,8 +182,10 @@ parse_epics() {
181
182
 
182
183
  # Determine status from sprint status or default to pending
183
184
  local status="pending"
184
- if [[ -n "${SPRINT_STATUSES[$current_story_id]:-}" ]]; then
185
- status=$(map_status "${SPRINT_STATUSES[$current_story_id]}")
185
+ local _sprint_val
186
+ _sprint_val=$(echo "$SPRINT_STATUSES" | grep "^${current_story_id}=" | head -1 | cut -d= -f2-)
187
+ if [[ -n "$_sprint_val" ]]; then
188
+ status=$(map_status "$_sprint_val")
186
189
  fi
187
190
 
188
191
  # Clean up description
@@ -27,7 +27,7 @@ NC='\033[0m'
27
27
 
28
28
  init_circuit_breaker() {
29
29
  if [[ -f "$CB_STATE_FILE" ]]; then
30
- if ! jq '.' "$CB_STATE_FILE" > /dev/null 2>&1; then
30
+ if ! jq -e type "$CB_STATE_FILE" > /dev/null 2>&1; then
31
31
  rm -f "$CB_STATE_FILE"
32
32
  fi
33
33
  fi