@fro.bot/systematic 3.14.4 → 3.15.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.
@@ -0,0 +1,8 @@
1
+ interface ValidatorOptions {
2
+ readonly argv: readonly string[];
3
+ readonly cwd?: string;
4
+ readonly outputSink?: (message: string) => void;
5
+ readonly errorSink?: (message: string) => void;
6
+ }
7
+ export declare function runClaudeCodeValidator(options: ValidatorOptions): number;
8
+ export {};
package/dist/cli.js CHANGED
@@ -17,9 +17,9 @@ import {
17
17
  } from "./index-0stf3ag0.js";
18
18
 
19
19
  // src/cli.ts
20
- import fs5 from "fs";
20
+ import fs6 from "fs";
21
21
  import os2 from "os";
22
- import path4 from "path";
22
+ import path5 from "path";
23
23
 
24
24
  // src/lib/agent-resolver.ts
25
25
  import fs from "fs";
@@ -1978,6 +1978,108 @@ function cleanup(agentsRoot, configOptions) {
1978
1978
  return { status: "ok" };
1979
1979
  }
1980
1980
 
1981
+ // src/lib/review-artifact-path.ts
1982
+ import fs4 from "fs";
1983
+ import path3 from "path";
1984
+ function formatReviewArtifactIssuePath(issuePath) {
1985
+ if (issuePath.length === 0)
1986
+ return "$";
1987
+ return issuePath.map((segment) => typeof segment === "number" ? String(segment) : segment).join(".");
1988
+ }
1989
+ function readReviewArtifact(filePath) {
1990
+ let content;
1991
+ try {
1992
+ content = fs4.readFileSync(filePath, "utf8");
1993
+ } catch {
1994
+ return { message: "Review artifact file could not be read", ok: false };
1995
+ }
1996
+ try {
1997
+ return { ok: true, value: JSON.parse(content) };
1998
+ } catch (error) {
1999
+ return {
2000
+ message: error instanceof SyntaxError ? "Review artifact contains malformed JSON" : "Review artifact file could not be read",
2001
+ ok: false
2002
+ };
2003
+ }
2004
+ }
2005
+ function isLegacyReviewArtifact(value) {
2006
+ return value !== null && typeof value === "object" && !Array.isArray(value) && !Object.hasOwn(value, "schema_version");
2007
+ }
2008
+ function hasParentDirectoryTraversal(input) {
2009
+ return input.split(/[\\/]+/).some((segment) => segment === "..");
2010
+ }
2011
+ function pathContainsSymlink(candidate) {
2012
+ let current = path3.parse(candidate).root;
2013
+ const relative2 = path3.relative(current, candidate);
2014
+ for (const segment of relative2.split(path3.sep)) {
2015
+ if (!segment)
2016
+ continue;
2017
+ current = path3.join(current, segment);
2018
+ try {
2019
+ if (fs4.lstatSync(current).isSymbolicLink())
2020
+ return true;
2021
+ } catch {
2022
+ return false;
2023
+ }
2024
+ }
2025
+ return false;
2026
+ }
2027
+ function isWithinDirectory(candidate, directory) {
2028
+ const relative2 = path3.relative(directory, candidate);
2029
+ return relative2 !== "" && relative2 !== ".." && !relative2.startsWith(`..${path3.sep}`) && !path3.isAbsolute(relative2);
2030
+ }
2031
+ function resolveReviewArtifactPath(input, cwd) {
2032
+ if (hasParentDirectoryTraversal(input)) {
2033
+ return {
2034
+ message: "Review artifact path must not contain parent-directory traversal",
2035
+ ok: false
2036
+ };
2037
+ }
2038
+ const artifactRoot = path3.resolve(cwd, ".context", "systematic", "ce-review");
2039
+ let canonicalRoot;
2040
+ try {
2041
+ canonicalRoot = fs4.realpathSync(artifactRoot);
2042
+ if (!fs4.statSync(canonicalRoot).isDirectory()) {
2043
+ return {
2044
+ message: "Review artifact directory is not a directory",
2045
+ ok: false
2046
+ };
2047
+ }
2048
+ } catch {
2049
+ return { message: "Review artifact directory is unavailable", ok: false };
2050
+ }
2051
+ const candidate = path3.resolve(cwd, input);
2052
+ if (pathContainsSymlink(candidate)) {
2053
+ return {
2054
+ message: "Review artifact path must not contain symlinks",
2055
+ ok: false
2056
+ };
2057
+ }
2058
+ let canonicalTarget;
2059
+ try {
2060
+ canonicalTarget = fs4.realpathSync(candidate);
2061
+ } catch {
2062
+ return { message: "Review artifact file was not found", ok: false };
2063
+ }
2064
+ if (!isWithinDirectory(canonicalTarget, canonicalRoot)) {
2065
+ return {
2066
+ message: "Review artifact path must remain inside .context/systematic/ce-review",
2067
+ ok: false
2068
+ };
2069
+ }
2070
+ try {
2071
+ if (!fs4.lstatSync(canonicalTarget).isFile()) {
2072
+ return {
2073
+ message: "Review artifact target is not a regular file",
2074
+ ok: false
2075
+ };
2076
+ }
2077
+ } catch {
2078
+ return { message: "Review artifact file was not found", ok: false };
2079
+ }
2080
+ return { ok: true, path: canonicalTarget };
2081
+ }
2082
+
1981
2083
  // src/lib/review-artifact-schema.ts
1982
2084
  var MAX_REVIEWER_LENGTH = 64;
1983
2085
  var MAX_RUN_ID_LENGTH = 64;
@@ -2216,8 +2318,8 @@ var ReviewArtifactSchema = exports_external.object({
2216
2318
  }).strict();
2217
2319
 
2218
2320
  // src/lib/setup.ts
2219
- import fs4 from "fs";
2220
- import path3 from "path";
2321
+ import fs5 from "fs";
2322
+ import path4 from "path";
2221
2323
  var SYSTEMATIC_PACKAGE_NAME = "@fro.bot/systematic";
2222
2324
  var PI_PACKAGE_IDENTIFIER = `npm:${SYSTEMATIC_PACKAGE_NAME}`;
2223
2325
  var SETUP_ERROR_NAME = "SetupError";
@@ -2227,10 +2329,10 @@ function createSetupError(message) {
2227
2329
  return error;
2228
2330
  }
2229
2331
  var DEFAULT_OPS = {
2230
- writeFileSync: fs4.writeFileSync,
2231
- renameSync: fs4.renameSync,
2232
- unlinkSync: fs4.unlinkSync,
2233
- chmodSync: fs4.chmodSync
2332
+ writeFileSync: fs5.writeFileSync,
2333
+ renameSync: fs5.renameSync,
2334
+ unlinkSync: fs5.unlinkSync,
2335
+ chmodSync: fs5.chmodSync
2234
2336
  };
2235
2337
  function isRecord2(value) {
2236
2338
  return typeof value === "object" && value !== null && !Array.isArray(value);
@@ -2252,7 +2354,7 @@ function isPiSystematicIdentifier(identifier) {
2252
2354
  }
2253
2355
  function lstatOrNull(targetPath) {
2254
2356
  try {
2255
- return fs4.lstatSync(targetPath);
2357
+ return fs5.lstatSync(targetPath);
2256
2358
  } catch (error) {
2257
2359
  if (error instanceof Error && "code" in error && error.code === "ENOENT") {
2258
2360
  return null;
@@ -2261,10 +2363,10 @@ function lstatOrNull(targetPath) {
2261
2363
  }
2262
2364
  }
2263
2365
  function assertRealpathUnderCwd(dir, cwd) {
2264
- const realDir = fs4.realpathSync(dir);
2265
- const realCwd = fs4.realpathSync(cwd);
2266
- const relative2 = path3.relative(realCwd, realDir);
2267
- if (relative2.startsWith("..") || path3.isAbsolute(relative2)) {
2366
+ const realDir = fs5.realpathSync(dir);
2367
+ const realCwd = fs5.realpathSync(cwd);
2368
+ const relative2 = path4.relative(realCwd, realDir);
2369
+ if (relative2.startsWith("..") || path4.isAbsolute(relative2)) {
2268
2370
  throw createSetupError(`Refusing to write under ${dir}: resolved directory escapes the project root`);
2269
2371
  }
2270
2372
  }
@@ -2277,7 +2379,7 @@ function assertParentTrusted(parentDir, cwd) {
2277
2379
  assertRealpathUnderCwd(parentDir, cwd);
2278
2380
  return;
2279
2381
  }
2280
- fs4.mkdirSync(parentDir, { recursive: true });
2382
+ fs5.mkdirSync(parentDir, { recursive: true });
2281
2383
  assertRealpathUnderCwd(parentDir, cwd);
2282
2384
  }
2283
2385
  var OPENCODE_TARGET_CANDIDATES = [
@@ -2287,19 +2389,19 @@ var OPENCODE_TARGET_CANDIDATES = [
2287
2389
  "opencode.json"
2288
2390
  ];
2289
2391
  function resolveOpenCodeTargetPath(cwd) {
2290
- const opencodeDirStat = lstatOrNull(path3.join(cwd, ".opencode"));
2392
+ const opencodeDirStat = lstatOrNull(path4.join(cwd, ".opencode"));
2291
2393
  if (opencodeDirStat?.isSymbolicLink()) {
2292
- return path3.join(cwd, OPENCODE_TARGET_CANDIDATES[0]);
2394
+ return path4.join(cwd, OPENCODE_TARGET_CANDIDATES[0]);
2293
2395
  }
2294
2396
  for (const candidate of OPENCODE_TARGET_CANDIDATES) {
2295
- const candidatePath = path3.join(cwd, candidate);
2397
+ const candidatePath = path4.join(cwd, candidate);
2296
2398
  if (lstatOrNull(candidatePath) !== null)
2297
2399
  return candidatePath;
2298
2400
  }
2299
- return path3.join(cwd, "opencode.jsonc");
2401
+ return path4.join(cwd, "opencode.jsonc");
2300
2402
  }
2301
2403
  var IS_WINDOWS = process.platform === "win32";
2302
- var OPEN_FLAGS = IS_WINDOWS ? fs4.constants.O_RDONLY : fs4.constants.O_RDONLY | fs4.constants.O_NOFOLLOW | fs4.constants.O_NONBLOCK;
2404
+ var OPEN_FLAGS = IS_WINDOWS ? fs5.constants.O_RDONLY : fs5.constants.O_RDONLY | fs5.constants.O_NOFOLLOW | fs5.constants.O_NONBLOCK;
2303
2405
  function assertWindowsPreOpenTrust(targetPath) {
2304
2406
  const preStat = lstatOrNull(targetPath);
2305
2407
  if (preStat && (preStat.isSymbolicLink() || !preStat.isFile())) {
@@ -2311,7 +2413,7 @@ function openTrustedExisting(targetPath) {
2311
2413
  assertWindowsPreOpenTrust(targetPath);
2312
2414
  let fd;
2313
2415
  try {
2314
- fd = fs4.openSync(targetPath, OPEN_FLAGS);
2416
+ fd = fs5.openSync(targetPath, OPEN_FLAGS);
2315
2417
  } catch (error) {
2316
2418
  if (error instanceof Error && "code" in error) {
2317
2419
  if (error.code === "ENOENT")
@@ -2323,13 +2425,13 @@ function openTrustedExisting(targetPath) {
2323
2425
  throw error;
2324
2426
  }
2325
2427
  try {
2326
- const stat = fs4.fstatSync(fd);
2428
+ const stat = fs5.fstatSync(fd);
2327
2429
  if (!stat.isFile()) {
2328
2430
  throw createSetupError(`Refusing to read ${targetPath}: not a regular file`);
2329
2431
  }
2330
- return { bytes: fs4.readFileSync(fd), mode: stat.mode & 511 };
2432
+ return { bytes: fs5.readFileSync(fd), mode: stat.mode & 511 };
2331
2433
  } finally {
2332
- fs4.closeSync(fd);
2434
+ fs5.closeSync(fd);
2333
2435
  }
2334
2436
  }
2335
2437
  function atomicWrite(targetPath, content, originalBytes, mode, ops) {
@@ -2344,8 +2446,8 @@ function atomicWrite(targetPath, content, originalBytes, mode, ops) {
2344
2446
  writeTempAndRename(targetPath, content, mode, preserveMode, ops);
2345
2447
  }
2346
2448
  function writeTempAndRename(destPath, content, mode, preserveExactMode, ops) {
2347
- const parentDir = path3.dirname(destPath);
2348
- const tempPath = makeTempPath(parentDir, path3.basename(destPath));
2449
+ const parentDir = path4.dirname(destPath);
2450
+ const tempPath = makeTempPath(parentDir, path4.basename(destPath));
2349
2451
  try {
2350
2452
  ops.writeFileSync(tempPath, content, { flag: "wx", mode });
2351
2453
  if (preserveExactMode) {
@@ -2358,10 +2460,10 @@ function writeTempAndRename(destPath, content, mode, preserveExactMode, ops) {
2358
2460
  }
2359
2461
  }
2360
2462
  function makeTempPath(parentDir, basename2) {
2361
- return path3.join(parentDir, `.${basename2}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
2463
+ return path4.join(parentDir, `.${basename2}.tmp-${process.pid}-${Date.now()}-${Math.random().toString(36).slice(2)}`);
2362
2464
  }
2363
2465
  function cleanupTemp(tempPath, ops) {
2364
- if (!fs4.existsSync(tempPath))
2466
+ if (!fs5.existsSync(tempPath))
2365
2467
  return;
2366
2468
  try {
2367
2469
  ops.unlinkSync(tempPath);
@@ -2395,7 +2497,7 @@ function parseOpenCodeJsonc(rawText, targetPath) {
2395
2497
  }
2396
2498
  function setupOpenCode(cwd, ops) {
2397
2499
  const targetPath = resolveOpenCodeTargetPath(cwd);
2398
- assertParentTrusted(path3.dirname(targetPath), cwd);
2500
+ assertParentTrusted(path4.dirname(targetPath), cwd);
2399
2501
  const existing = openTrustedExisting(targetPath);
2400
2502
  if (existing === null) {
2401
2503
  const content = `${JSON.stringify({ plugin: [SYSTEMATIC_PACKAGE_NAME] }, null, 2)}
@@ -2453,8 +2555,8 @@ function assertPiTaggedEntryUsable(entry, targetPath) {
2453
2555
  }
2454
2556
  }
2455
2557
  function setupPi(cwd, ops) {
2456
- const targetPath = path3.join(cwd, ".pi", "settings.json");
2457
- assertParentTrusted(path3.dirname(targetPath), cwd);
2558
+ const targetPath = path4.join(cwd, ".pi", "settings.json");
2559
+ assertParentTrusted(path4.dirname(targetPath), cwd);
2458
2560
  const existing = openTrustedExisting(targetPath);
2459
2561
  if (existing === null) {
2460
2562
  const content = `${JSON.stringify({ packages: [PI_PACKAGE_IDENTIFIER] }, null, 2)}
@@ -2514,10 +2616,10 @@ function setupHarness(harness, cwd, opsOverride) {
2514
2616
  // src/cli.ts
2515
2617
  function readPackageMetadata(packageRoot) {
2516
2618
  try {
2517
- const packageJsonPath = path4.join(packageRoot, "package.json");
2518
- if (!fs5.existsSync(packageJsonPath))
2619
+ const packageJsonPath = path5.join(packageRoot, "package.json");
2620
+ if (!fs6.existsSync(packageJsonPath))
2519
2621
  return {};
2520
- const content = fs5.readFileSync(packageJsonPath, "utf8");
2622
+ const content = fs6.readFileSync(packageJsonPath, "utf8");
2521
2623
  const parsed = JSON.parse(content);
2522
2624
  if (parsed === null || typeof parsed !== "object" || Array.isArray(parsed)) {
2523
2625
  return {};
@@ -2531,7 +2633,7 @@ function readPackageMetadata(packageRoot) {
2531
2633
  return {};
2532
2634
  }
2533
2635
  }
2534
- var PACKAGE_ROOT = path4.resolve(import.meta.dirname, "..");
2636
+ var PACKAGE_ROOT = path5.resolve(import.meta.dirname, "..");
2535
2637
  var VERSION = readPackageMetadata(PACKAGE_ROOT).version ?? "unknown";
2536
2638
  var HELP = `
2537
2639
  systematic - OpenCode plugin for systematic engineering workflows
@@ -2590,9 +2692,9 @@ Scope:
2590
2692
  var VALIDATE_REVIEW_ARTIFACT_USAGE = "Usage: systematic validate-review-artifact <path>";
2591
2693
  var REVIEW_ARTIFACT_SCHEMA_RELATIVE_PATH = "skills/ce-review/references/review-summary-schema.json";
2592
2694
  function defaultCapabilityRoots() {
2593
- const configDir = process.env.XDG_CONFIG_HOME ? path4.join(process.env.XDG_CONFIG_HOME, "opencode") : path4.join(os2.homedir(), ".config/opencode");
2695
+ const configDir = process.env.XDG_CONFIG_HOME ? path5.join(process.env.XDG_CONFIG_HOME, "opencode") : path5.join(os2.homedir(), ".config/opencode");
2594
2696
  return {
2595
- agentsRoot: path4.join(PACKAGE_ROOT, "agents"),
2697
+ agentsRoot: path5.join(PACKAGE_ROOT, "agents"),
2596
2698
  configDir,
2597
2699
  cwd: process.cwd(),
2598
2700
  homeDir: os2.homedir(),
@@ -2691,9 +2793,9 @@ function collectConfigMetadata(roots, injected) {
2691
2793
  }
2692
2794
  function resolveCapabilityRootPath(root) {
2693
2795
  try {
2694
- return fs5.realpathSync(root);
2796
+ return fs6.realpathSync(root);
2695
2797
  } catch {
2696
- return path4.resolve(root);
2798
+ return path5.resolve(root);
2697
2799
  }
2698
2800
  }
2699
2801
  function capabilityRoots(roots) {
@@ -2703,7 +2805,7 @@ function capabilityRoots(roots) {
2703
2805
  { id: "package", path: resolveCapabilityRootPath(roots.packageRoot) },
2704
2806
  {
2705
2807
  id: "skills",
2706
- path: resolveCapabilityRootPath(path4.join(roots.packageRoot, "skills"))
2808
+ path: resolveCapabilityRootPath(path5.join(roots.packageRoot, "skills"))
2707
2809
  },
2708
2810
  { id: "user", path: resolveCapabilityRootPath(roots.homeDir) }
2709
2811
  ];
@@ -2744,85 +2846,6 @@ function runCapabilities(options) {
2744
2846
  function runCapabilitiesCli(options) {
2745
2847
  return runCapabilities(options);
2746
2848
  }
2747
- function hasParentDirectoryTraversal(input) {
2748
- return input.split(/[\\/]+/).some((segment) => segment === "..");
2749
- }
2750
- function pathContainsSymlink(candidate) {
2751
- let current = path4.parse(candidate).root;
2752
- const relative2 = path4.relative(current, candidate);
2753
- for (const segment of relative2.split(path4.sep)) {
2754
- if (!segment)
2755
- continue;
2756
- current = path4.join(current, segment);
2757
- try {
2758
- if (fs5.lstatSync(current).isSymbolicLink())
2759
- return true;
2760
- } catch {
2761
- return false;
2762
- }
2763
- }
2764
- return false;
2765
- }
2766
- function isWithinDirectory(candidate, directory) {
2767
- const relative2 = path4.relative(directory, candidate);
2768
- return relative2 !== "" && relative2 !== ".." && !relative2.startsWith(`..${path4.sep}`) && !path4.isAbsolute(relative2);
2769
- }
2770
- function resolveReviewArtifactPath(input, cwd) {
2771
- if (hasParentDirectoryTraversal(input)) {
2772
- return {
2773
- message: "Review artifact path must not contain parent-directory traversal",
2774
- ok: false
2775
- };
2776
- }
2777
- const artifactRoot = path4.resolve(cwd, ".context", "systematic", "ce-review");
2778
- let canonicalRoot;
2779
- try {
2780
- canonicalRoot = fs5.realpathSync(artifactRoot);
2781
- if (!fs5.statSync(canonicalRoot).isDirectory()) {
2782
- return {
2783
- message: "Review artifact directory is not a directory",
2784
- ok: false
2785
- };
2786
- }
2787
- } catch {
2788
- return { message: "Review artifact directory is unavailable", ok: false };
2789
- }
2790
- const candidate = path4.resolve(cwd, input);
2791
- if (pathContainsSymlink(candidate)) {
2792
- return {
2793
- message: "Review artifact path must not contain symlinks",
2794
- ok: false
2795
- };
2796
- }
2797
- let canonicalTarget;
2798
- try {
2799
- canonicalTarget = fs5.realpathSync(candidate);
2800
- } catch {
2801
- return { message: "Review artifact file was not found", ok: false };
2802
- }
2803
- if (!isWithinDirectory(canonicalTarget, canonicalRoot)) {
2804
- return {
2805
- message: "Review artifact path must remain inside .context/systematic/ce-review",
2806
- ok: false
2807
- };
2808
- }
2809
- try {
2810
- if (!fs5.lstatSync(canonicalTarget).isFile()) {
2811
- return {
2812
- message: "Review artifact target is not a regular file",
2813
- ok: false
2814
- };
2815
- }
2816
- } catch {
2817
- return { message: "Review artifact file was not found", ok: false };
2818
- }
2819
- return { ok: true, path: canonicalTarget };
2820
- }
2821
- function formatReviewArtifactIssuePath(issuePath) {
2822
- if (issuePath.length === 0)
2823
- return "$";
2824
- return issuePath.map((segment) => typeof segment === "number" ? String(segment) : segment).join(".");
2825
- }
2826
2849
  function validateReviewArtifactArgument(argv) {
2827
2850
  const commandIndex = argv[0] === "systematic" ? 1 : 0;
2828
2851
  if (argv[commandIndex] !== "validate-review-artifact")
@@ -2831,25 +2854,6 @@ function validateReviewArtifactArgument(argv) {
2831
2854
  return;
2832
2855
  return argv[commandIndex + 1];
2833
2856
  }
2834
- function readReviewArtifact(filePath) {
2835
- let content;
2836
- try {
2837
- content = fs5.readFileSync(filePath, "utf8");
2838
- } catch {
2839
- return { message: "Review artifact file could not be read", ok: false };
2840
- }
2841
- try {
2842
- return { ok: true, value: JSON.parse(content) };
2843
- } catch (error) {
2844
- return {
2845
- message: error instanceof SyntaxError ? "Review artifact contains malformed JSON" : "Review artifact file could not be read",
2846
- ok: false
2847
- };
2848
- }
2849
- }
2850
- function isLegacyReviewArtifact(value) {
2851
- return value !== null && typeof value === "object" && !Array.isArray(value) && !Object.hasOwn(value, "schema_version");
2852
- }
2853
2857
  function runValidateReviewArtifact(options) {
2854
2858
  const outputSink = options.outputSink ?? ((message) => console.log(message));
2855
2859
  const errorSink = options.errorSink ?? ((message) => console.error(message));
@@ -2916,7 +2920,7 @@ function setupCommand(rest) {
2916
2920
  }
2917
2921
  }
2918
2922
  function listItems(type) {
2919
- const packageRoot = path4.resolve(import.meta.dirname, "..");
2923
+ const packageRoot = path5.resolve(import.meta.dirname, "..");
2920
2924
  const bundledDir = packageRoot;
2921
2925
  let finder;
2922
2926
  let subdir;
@@ -2937,7 +2941,7 @@ function listItems(type) {
2937
2941
  console.error(`Unknown type: ${type}. Use: skills, agents, commands`);
2938
2942
  process.exit(1);
2939
2943
  }
2940
- const items = finder(path4.join(bundledDir, subdir));
2944
+ const items = finder(path5.join(bundledDir, subdir));
2941
2945
  if (items.length === 0) {
2942
2946
  console.log(`No ${type} found.`);
2943
2947
  return;
@@ -2954,15 +2958,15 @@ function configShow() {
2954
2958
  `);
2955
2959
  console.log(` User config: ${paths.userConfig}`);
2956
2960
  console.log(` Project config: ${paths.projectConfig}`);
2957
- if (fs5.existsSync(paths.projectConfig)) {
2961
+ if (fs6.existsSync(paths.projectConfig)) {
2958
2962
  console.log(`
2959
2963
  Project configuration:`);
2960
- console.log(fs5.readFileSync(paths.projectConfig, "utf-8"));
2964
+ console.log(fs6.readFileSync(paths.projectConfig, "utf-8"));
2961
2965
  }
2962
- if (fs5.existsSync(paths.userConfig)) {
2966
+ if (fs6.existsSync(paths.userConfig)) {
2963
2967
  console.log(`
2964
2968
  User configuration:`);
2965
- console.log(fs5.readFileSync(paths.userConfig, "utf-8"));
2969
+ console.log(fs6.readFileSync(paths.userConfig, "utf-8"));
2966
2970
  }
2967
2971
  }
2968
2972
  function configPath() {
@@ -0,0 +1,20 @@
1
+ export type ArtifactPathResult = {
2
+ readonly ok: true;
3
+ readonly path: string;
4
+ } | {
5
+ readonly ok: false;
6
+ readonly message: string;
7
+ };
8
+ export type ReadArtifactResult = {
9
+ readonly ok: true;
10
+ readonly value: unknown;
11
+ } | {
12
+ readonly ok: false;
13
+ readonly message: string;
14
+ };
15
+ export declare function formatReviewArtifactIssuePath(issuePath: readonly PropertyKey[]): string;
16
+ export declare function readReviewArtifact(filePath: string): ReadArtifactResult;
17
+ export declare function isLegacyReviewArtifact(value: unknown): boolean;
18
+ export declare function hasParentDirectoryTraversal(input: string): boolean;
19
+ export declare function pathContainsSymlink(candidate: string): boolean;
20
+ export declare function resolveReviewArtifactPath(input: string, cwd: string): ArtifactPathResult;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@fro.bot/systematic",
3
- "version": "3.14.4",
3
+ "version": "3.15.0",
4
4
  "description": "Compound-engineering loops for OpenCode, Pi, and Claude Code",
5
5
  "type": "module",
6
6
  "homepage": "https://fro.bot/systematic",
@@ -127,20 +127,22 @@ Launch research subagents. Each returns text data to the orchestrator.
127
127
  - Identifies cross-references and links
128
128
  - Finds related GitHub issues
129
129
  - Flags any related learning or pattern docs that may now be stale, contradicted, or overly broad
130
- - **Assesses overlap** with the new doc being created across five dimensions: problem statement, root cause, solution approach, referenced files, and prevention rules. Score as:
130
+ - **Assesses overlap** with the new doc being created across five dimensions: problem statement, root cause, solution approach, referenced files, and prevention rules. Judge each dimension as match or no-match first, then count the matches, then read the score off the count:
131
131
  - **High**: 4-5 dimensions match — essentially the same problem solved again
132
132
  - **Moderate**: 2-3 dimensions match — same area but different angle or solution
133
133
  - **Low**: 0-1 dimensions match — related but distinct
134
- - Returns: Links, relationships, refresh candidates, and overlap assessment (score + which dimensions matched)
134
+
135
+ The score is a function of the count, not a separate judgment about how related the docs feel. Two docs on the same topic that match on one dimension score Low. When the score you are inclined to write disagrees with the count, the count wins.
136
+ - Returns: Links, relationships, refresh candidates, and overlap assessment. State the per-dimension verdicts, then the resulting count, then the score, in that order. Two consistency checks apply in sequence and both must hold: the score follows the count, and the recommendation follows the score. High overlap means update the existing doc, so an assessment that scores High while recommending a new doc has broken the second check even if the first one held. Either break is a defect to re-derive, not a nuance to note.
135
137
 
136
138
  **Search strategy (grep-first filtering for efficiency):**
137
139
 
138
- 1. Extract keywords from the problem context: module names, technical terms, error messages, component types
140
+ 1. Extract keywords from the problem context: technical terms, error messages, component types. Separately, enumerate the `module:` values already in use across `docs/solutions/` and pick the one or two closest to this problem, rather than inferring a module name from the problem text. Module values are curated identifiers that often do not appear in a problem description at all, so keyword search alone will not find docs clustered under one — and docs sharing a module are among the highest-value candidates.
139
141
  2. If the problem category is clear, narrow search to the matching `docs/solutions/<category>/` directory
140
142
  3. Use the native content-search tool (e.g., Grep in OpenCode) to pre-filter candidate files BEFORE reading any content. Run multiple searches in parallel, case-insensitive, targeting frontmatter fields. These are template patterns -- substitute actual keywords:
141
143
  - `title:.*<keyword>`
142
144
  - `tags:.*(<keyword1>|<keyword2>)`
143
- - `module:.*<module name>`
145
+ - `module:.*<module name>` — fill from the values enumerated in step 1, not from free-text keywords
144
146
  - `component:.*<component>`
145
147
  4. If search returns >25 candidates, re-run with more specific patterns. If <3, broaden to full content search
146
148
  5. Read only frontmatter (first 30 lines) of candidate files to score relevance
@@ -422,6 +424,7 @@ What's next?
422
424
 
423
425
  Overlap detected: docs/solutions/performance-issues/n-plus-one-queries.md
424
426
  Matched dimensions: problem statement, root cause, solution, referenced files
427
+ Count: 4 of 5 -> High
425
428
  Action: Updated existing doc with fresher code examples and prevention tips
426
429
 
427
430
  File updated:
@@ -210,15 +210,27 @@ validating it. This ordering makes the artifact validatable at all: without
210
210
  `schema_version`, the validator reports the legacy status (exit 3) rather than
211
211
  a real validation result.
212
212
 
213
- After writing `review-summary.json`, the parent checks whether the
214
- `systematic` executable is available on the invoking environment's `PATH`.
215
- When it is available, the parent runs
216
- `systematic validate-review-artifact <path>` against it. The executable ships
217
- through the npm package's `bin` entry; a harness that installs bundled
218
- markdown without that package will not have it. When it is unavailable, the
219
- parent records `validation.status: "unavailable"` and a `validation.reason` in
220
- the run record. When the executable is available and the parent does not run
221
- it, that is `validation.status: "not_attempted"`, also with a reason. The
213
+ After writing `review-summary.json`, the parent resolves and runs a validator
214
+ in this order: the bundled `systematic-validate-review-artifact <path>` command
215
+ first, then the npm-installed `systematic validate-review-artifact <path>`
216
+ command. Both can be present at once. The bundled command ships beside the
217
+ prose being executed, so it is the one whose behavior matches the contract.
218
+ The parent runs the first command it resolves and reads its result; it does not
219
+ merely test whether a name is on `PATH`, because a version-manager shim can be
220
+ present there and fail on every invocation.
221
+
222
+ The command runs from the repository root. Containment resolves
223
+ `.context/systematic/ce-review` relative to the working directory. If it runs
224
+ elsewhere, the CLI reports that the directory is unavailable and the parent
225
+ records that reason. This is visible degradation rather than silent success,
226
+ so it is acceptable. If neither command is available, the parent records
227
+ `validation.status: "unavailable"` with a reason that the validator is absent.
228
+ If a resolved command is present but cannot be started or otherwise fails
229
+ before returning a validation result, the parent records the same status with
230
+ a distinct invocation-failure reason. Neither case is `failed`: that value is
231
+ reserved for a validator that ran and found that the artifact did not conform.
232
+ When a validator is available and the parent does not run it, that is
233
+ `validation.status: "not_attempted"`, also with a reason. The
222
234
  `validation.status` values are `passed`, `failed`, `unavailable`, and
223
235
  `not_attempted`; `validation.reason` is required for every status except
224
236
  `passed`, where it is forbidden.