@okfit/engine 0.1.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/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 C. Spencer Beggs
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,9 @@
1
+ # @okfit/engine
2
+
3
+ The shared engine behind [okfit](https://github.com/spencerbeggs/okfit): the platform layer, config discovery, and the `validate`, `verify`, `sync`, `init` and `context` programs, plus the JSON envelope contracts that `@okfit/cli` and `@okfit/mcp` both emit.
4
+
5
+ You probably want `@okfit/plugin` (both bins), `@okfit/cli` (the `okfit` bin) or `@okfit/mcp` (the `okfit-mcp` bin) instead. This package is what they are built from.
6
+
7
+ ## License
8
+
9
+ [MIT](LICENSE)
@@ -0,0 +1,57 @@
1
+ //#region src/config/anchor.ts
2
+ /**
3
+ * The explicit `--config` anchor rule (C-8): the parent of `.config` "if the
4
+ * file sits in a `.config` directory, else the file's directory", with no
5
+ * exception for a custom name. Upstream's `ConfigResolver.explicitPath`
6
+ * reports no `dir` in its `match` by design, so this stays a hand-rolled,
7
+ * basename-based rule rather than reading `ConfigMatch.dir` — the one
8
+ * remaining path-tail string match in the CLI outside `init/scaffold.ts`
9
+ * (C1-4).
10
+ */
11
+ const anchorForExplicit = (configPath, path) => {
12
+ const parent = path.dirname(configPath);
13
+ return path.basename(parent) === ".config" ? path.dirname(parent) : parent;
14
+ };
15
+ /**
16
+ * K-12's project root, as amended by K-58 and C1-2, in order:
17
+ *
18
+ * 1. `pathArg`, if given. `Argument.path` has already resolved it absolute.
19
+ * 2. otherwise, if `--config` was given: `anchorForExplicit` applied to that
20
+ * path.
21
+ * 3. otherwise, if a config was discovered by the `"project"` resolver
22
+ * (`ConfigResolver.upwardWalk`, named `"project"` in `layer.ts`) AND it
23
+ * reported a `dir`: that `dir` verbatim — it is already the ancestor
24
+ * `upwardWalk` anchored the match against (the parent of `.config` for a
25
+ * `.config/okfit.toml` candidate, the candidate's own directory
26
+ * otherwise), so no path-tail test is needed here any more.
27
+ * 4. otherwise `cwd` — this covers every other resolver name (`"xdg"`,
28
+ * `"native"`, `"system"`) as defence in depth, a `"project"` match with no
29
+ * `dir` (should not happen, since `upwardWalk` always reports one), and
30
+ * no discovery at all.
31
+ *
32
+ * The CLI never probes for `.git` (K-12), even though
33
+ * `ConfigResolver.gitRoot` exists.
34
+ *
35
+ * @public
36
+ */
37
+ const resolveProjectRoot = (input) => {
38
+ if (input.pathArg._tag === "Some") return input.pathArg.value;
39
+ if (input.explicitConfigPath._tag === "Some") return anchorForExplicit(input.explicitConfigPath.value, input.path);
40
+ if (input.discovered._tag === "Some") {
41
+ const discovered = input.discovered.value;
42
+ if (discovered.resolver === "project" && discovered.dir !== void 0) return discovered.dir;
43
+ }
44
+ return input.cwd;
45
+ };
46
+ /**
47
+ * `<project root>/<bundle.path>`, absolute (K-2). `config` is the MERGED
48
+ * config, so `bundle.path` is `OkfitConfig.DEFAULTS.bundle.path` (`"okf"`)
49
+ * unless a file or profile overrode it. The bundle root is never what
50
+ * `[path]` names.
51
+ *
52
+ * @public
53
+ */
54
+ const resolveBundleRoot = (projectRoot, config, path) => path.resolve(projectRoot, config.bundle?.path ?? "okf");
55
+
56
+ //#endregion
57
+ export { resolveBundleRoot, resolveProjectRoot };
@@ -0,0 +1,129 @@
1
+ import { ConfigMalformedError, ConfigPathNotFoundError } from "../errors.js";
2
+ import { AppConfig } from "@effected/app";
3
+ import { ConfigCodecError, ConfigResolver, ConfigValidationError, MergeStrategy, TomlCodec } from "@effected/config-file";
4
+ import { OkfitConfig, OkfitConfigFile } from "@okfit/core";
5
+ import { Cause, Effect, FileSystem, Option, Result } from "effect";
6
+
7
+ //#region src/config/layer.ts
8
+ /**
9
+ * The `OkfitConfigFile` layer for one invocation (K-9 to K-11, K-57, C-6),
10
+ * now one `AppConfig.layer(OkfitConfigFile, ...)` call in both branches; only
11
+ * the chain options vary:
12
+ *
13
+ * - `explicitConfigPath` is `Some`: `resolvers: [ConfigResolver.explicitPath(path)]`
14
+ * and `xdg: false` — K-10's "only that path" rule, so the chain is exactly
15
+ * one resolver and no `systemEtc` tier is added either.
16
+ * - `explicitConfigPath` is `None`: `resolvers: [ConfigResolver.upwardWalk({ filenames, ... })]`
17
+ * carrying C-1's three per-directory candidates (`.okfit.toml`,
18
+ * `okfit.toml`, `.config/okfit.toml`, directory-major so a child's later
19
+ * candidate beats a parent's earlier one — C-2's ascent to the filesystem
20
+ * root is `upwardWalk`'s own default with no `stopAt`), named `"project"`
21
+ * so `resolve.ts`/`anchor.ts` can identify it without string-matching a
22
+ * path tail. `systemEtc` is C-5's `/etc` tier, present unless
23
+ * `systemConfigDir` overrides its root (tests only — no production call
24
+ * site sets it). `xdg` and `native` stay on their defaults (C-4), which is
25
+ * what puts personal defaults at `$XDG_CONFIG_HOME/okfit/config.toml` and
26
+ * the native probe behind it.
27
+ *
28
+ * `filename: "config.toml"` is the same in both branches — it is the XDG/
29
+ * native/system tiers' filename, unrelated to the C-1 project names above.
30
+ * `defaultPath` is `AppConfig.layer`'s own `XdgConfig.savePath(filename)`, so
31
+ * `save`/`update` do not fail with `ConfigDefaultPathMissingError`; nothing
32
+ * here sets it explicitly any more.
33
+ *
34
+ * `discoveryCwd` is `[path]` when given, else `process.cwd()` (K-2), passed
35
+ * straight through as `upwardWalk`'s own `cwd` so nothing in this module
36
+ * reads the process.
37
+ *
38
+ * `App.layer`, `AppStore` and `AppCache` appear nowhere, so no
39
+ * `store.db`/`cache.db` is ever created (K-9).
40
+ *
41
+ * @public
42
+ */
43
+ const buildConfigLayer = (options) => Option.isSome(options.explicitConfigPath) ? AppConfig.layer(OkfitConfigFile, {
44
+ filename: "config.toml",
45
+ schema: OkfitConfig,
46
+ codec: TomlCodec,
47
+ strategy: MergeStrategy.firstMatch(),
48
+ resolvers: [ConfigResolver.explicitPath(options.explicitConfigPath.value)],
49
+ xdg: false
50
+ }) : AppConfig.layer(OkfitConfigFile, {
51
+ filename: "config.toml",
52
+ schema: OkfitConfig,
53
+ codec: TomlCodec,
54
+ strategy: MergeStrategy.firstMatch(),
55
+ resolvers: [ConfigResolver.upwardWalk({
56
+ filenames: [
57
+ ".okfit.toml",
58
+ "okfit.toml",
59
+ ".config/okfit.toml"
60
+ ],
61
+ cwd: options.discoveryCwd,
62
+ name: "project"
63
+ })],
64
+ systemEtc: options.systemConfigDir === void 0 ? true : { dir: options.systemConfigDir }
65
+ });
66
+ /**
67
+ * The K-1 pre-flight and the provide, in that order and in one place: stat
68
+ * `explicitConfigPath` with `FileSystem.exists` and fail with
69
+ * `ConfigPathNotFoundError` BEFORE `buildConfigLayer` is called at all. This
70
+ * is why the CLI does not use `Command.provide(cmd, (input) => layer)`,
71
+ * which would construct the layer first (Judge notes 9) — here the stat
72
+ * runs as the first step of one `Effect.gen`, and `buildConfigLayer` is only
73
+ * reached, and only then actually run, on the step after it.
74
+ *
75
+ * `FileSystem.FileSystem.exists` is `(path: string) => Effect.Effect<boolean, PlatformError>`
76
+ * (`EF/FileSystem.ts:143-145`), not infallible, so
77
+ * `PlatformError.PlatformError` joins the error channel here (decision 7):
78
+ * an unusual stat failure (e.g. a permission error on a parent directory)
79
+ * renders through `renderFailure`'s catch-all rule rather than being
80
+ * uncatchable by the type checker.
81
+ *
82
+ * K-46/K-63 fix: a `ConfigCodecError`/`ConfigValidationError` from
83
+ * `buildConfigLayer`'s provided layer is wrapped into `ConfigMalformedError`
84
+ * whenever the failing path is known, so `renderFailure` can name it:
85
+ *
86
+ * - `ConfigCodecError` now carries its own `path: string | undefined`
87
+ * (config-file 0.7.0, `ConfigFile.discover` re-raises with `path` attached
88
+ * at every site that fed the codec a path it resolved) — used when
89
+ * present, so a malformed file found during DISCOVERY (no `--config`) now
90
+ * also wraps into `ConfigMalformedError` naming the candidate that failed,
91
+ * closing the K-63 gap. It falls back to `explicitConfigPath` only when
92
+ * the library's own `path` is `undefined` and `--config` was given; only
93
+ * when neither is known does the cause pass through unwrapped.
94
+ * - `ConfigValidationError` carries its own `path: Option<string>` — used
95
+ * when present (either branch), falling back to `explicitConfigPath` when
96
+ * the library's own `path` is `None` and `--config` was given.
97
+ *
98
+ * @public
99
+ */
100
+ const provideConfig = (options) => (effect) => Effect.gen(function* () {
101
+ if (Option.isSome(options.explicitConfigPath)) {
102
+ const path = options.explicitConfigPath.value;
103
+ if (!(yield* (yield* FileSystem.FileSystem).exists(path))) return yield* Effect.fail(new ConfigPathNotFoundError({ path }));
104
+ }
105
+ return yield* effect.pipe(Effect.provide(buildConfigLayer(options)), Effect.catchCause((cause) => {
106
+ const found = Cause.findFail(cause);
107
+ if (Result.isSuccess(found)) {
108
+ const error = found.success.error;
109
+ if (error instanceof ConfigCodecError) {
110
+ const path = error.path !== void 0 ? error.path : Option.isSome(options.explicitConfigPath) ? options.explicitConfigPath.value : void 0;
111
+ if (path !== void 0) return Effect.fail(new ConfigMalformedError({
112
+ path,
113
+ cause: error
114
+ }));
115
+ }
116
+ if (error instanceof ConfigValidationError) {
117
+ const path = Option.isSome(options.explicitConfigPath) ? options.explicitConfigPath : error.path;
118
+ if (Option.isSome(path)) return Effect.fail(new ConfigMalformedError({
119
+ path: path.value,
120
+ cause: error
121
+ }));
122
+ }
123
+ }
124
+ return Effect.failCause(cause);
125
+ }));
126
+ });
127
+
128
+ //#endregion
129
+ export { buildConfigLayer, provideConfig };
@@ -0,0 +1,67 @@
1
+ import { resolveBundleRoot, resolveProjectRoot } from "./anchor.js";
2
+ import { OKF_SPEC_VERSION, OkfitConfig, OkfitConfigFile } from "@okfit/core";
3
+ import { Console, Effect, Option, Path } from "effect";
4
+ import { Profiles } from "@okfit/profiles";
5
+
6
+ //#region src/config/resolve.ts
7
+ /**
8
+ * `OkfitConfig.DEFAULTS.bundle.profile` is `"software-project"` at runtime
9
+ * (the frozen literal always sets it), but `bundle` and `profile` are both
10
+ * `optionalKey` in the schema, so the type checker sees `string | undefined`
11
+ * two levels deep. The `?? "software-project"` fallback here is unreachable
12
+ * in practice; it exists only to satisfy `noUncheckedIndexedAccess`-style
13
+ * strictness without a non-null assertion. Formerly duplicated identically
14
+ * in `commands/validate.ts` and `commands/context.ts`; this is its one home
15
+ * now that both commands delegate to {@link resolveProjectConfig}.
16
+ *
17
+ * @public
18
+ */
19
+ const DEFAULT_PROFILE_NAME = OkfitConfig.DEFAULTS.bundle?.profile ?? "software-project";
20
+ /**
21
+ * The config-resolution step byte-identical between `validate` and
22
+ * `context`'s handlers (A2 review finding): discover, pick `sources[0]`,
23
+ * default `{ extensions: {} }`, resolve the profile name and the K-4
24
+ * unknown-profile warning, merge `DEFAULTS < profile < file` (D-28), warn on
25
+ * an `okf_version` mismatch (K-15), then resolve the project and bundle
26
+ * roots (K-12). Every message string and the merge order are unchanged from
27
+ * the two commands' former inline copies.
28
+ *
29
+ * @public
30
+ */
31
+ const resolveProjectConfig = (input) => Effect.gen(function* () {
32
+ const path = yield* Path.Path;
33
+ const discoveredSource = (yield* (yield* OkfitConfigFile).discover)[0];
34
+ const fileConfig = discoveredSource === void 0 ? { extensions: {} } : discoveredSource.value;
35
+ const profileName = fileConfig.bundle?.profile ?? DEFAULT_PROFILE_NAME;
36
+ const profile = profileName === "none" ? Option.none() : Profiles.get(profileName);
37
+ if (profileName !== "none" && Option.isNone(profile)) yield* Console.error(`warning: unknown profile "${profileName}"; continuing with defaults`);
38
+ const base = Option.match(profile, {
39
+ onNone: () => OkfitConfig.DEFAULTS,
40
+ onSome: (p) => OkfitConfig.merge(OkfitConfig.DEFAULTS, p.config)
41
+ });
42
+ const merged = OkfitConfig.merge(base, fileConfig);
43
+ if (merged.okf_version !== void 0 && merged.okf_version !== OKF_SPEC_VERSION) yield* Console.error(`warning: okf_version "${merged.okf_version}" does not match this okfit's spec version "${OKF_SPEC_VERSION}"; continuing`);
44
+ const discovered = discoveredSource === void 0 ? Option.none() : Option.some({
45
+ path: discoveredSource.path,
46
+ resolver: discoveredSource.resolver,
47
+ ...discoveredSource.match?.dir !== void 0 ? { dir: discoveredSource.match.dir } : {}
48
+ });
49
+ const projectRoot = resolveProjectRoot({
50
+ pathArg: input.pathArg,
51
+ explicitConfigPath: input.explicitConfigPath,
52
+ discovered,
53
+ cwd: input.cwd,
54
+ path
55
+ });
56
+ return {
57
+ projectRoot,
58
+ bundleRoot: resolveBundleRoot(projectRoot, merged, path),
59
+ config: merged,
60
+ profile,
61
+ profileName,
62
+ discovered
63
+ };
64
+ });
65
+
66
+ //#endregion
67
+ export { DEFAULT_PROFILE_NAME, resolveProjectConfig };
package/context/run.js ADDED
@@ -0,0 +1,35 @@
1
+ import { Effect, FileSystem, Path } from "effect";
2
+
3
+ //#region src/context/run.ts
4
+ /**
5
+ * The whole of `context`'s filesystem work: join `index.md` onto the bundle
6
+ * root and stat it. Loading a bundle and running conformance/lint checks
7
+ * over it never happen here — that is what makes `context` cheap enough
8
+ * for a hook to run on every session start and every in-bundle write
9
+ * (M-15).
10
+ *
11
+ * `"index.md"` is a literal here, not read from the profile's layout: the
12
+ * spec fixes the reserved filename regardless of profile, and a
13
+ * profile-less merge (`profile = "none"`) has no layout to read from at
14
+ * all — `context` behaves identically with and without a profile.
15
+ *
16
+ * `exists` is `(path) => Effect.Effect<boolean, PlatformError>`, so an
17
+ * unreadable parent directory would otherwise fail the command; here it is
18
+ * absorbed to `false`, because "the hook could not stat index.md" and
19
+ * "index.md is not there" are the same fact from a consumer's point of
20
+ * view.
21
+ *
22
+ * @public
23
+ */
24
+ const runContext = (options) => Effect.gen(function* () {
25
+ const path = yield* Path.Path;
26
+ const fs = yield* FileSystem.FileSystem;
27
+ const indexPath = path.join(options.bundleRoot, "index.md");
28
+ return {
29
+ indexPath,
30
+ indexExists: yield* fs.exists(indexPath).pipe(Effect.orElseSucceed(() => false))
31
+ };
32
+ });
33
+
34
+ //#endregion
35
+ export { runContext };
package/errors.js ADDED
@@ -0,0 +1,109 @@
1
+ import { Runtime, Schema } from "effect";
2
+
3
+ //#region src/errors.ts
4
+ /**
5
+ * `--config <path>` named a path that does not exist. Every `ConfigResolver`
6
+ * has a `never` error channel and `explicitPath` absorbs a missing target
7
+ * into `Option.none()`, so a bad `--config` would otherwise fall through to
8
+ * the XDG file silently. The CLI checks it itself before building any layer
9
+ * (K-1, spec 4.1).
10
+ *
11
+ * @public
12
+ */
13
+ var ConfigPathNotFoundError = class extends Schema.TaggedError()("ConfigPathNotFoundError", { path: Schema.String }) {
14
+ [Runtime.errorExitCode] = 3;
15
+ get message() {
16
+ return `config path not found: ${this.path}`;
17
+ }
18
+ };
19
+ /**
20
+ * `okfit init` found one or more of its target paths already present (K-28).
21
+ * Carries the whole conflicting-path list so the renderer prints every one.
22
+ * `paths` are absolute; `renderFailure` relativises them to `cwd` (K-51).
23
+ *
24
+ * @public
25
+ */
26
+ var InitOverwriteError = class extends Schema.TaggedError()("InitOverwriteError", {
27
+ paths: Schema.Array(Schema.String),
28
+ cwd: Schema.String
29
+ }) {
30
+ [Runtime.errorExitCode] = 3;
31
+ get message() {
32
+ return "refusing to overwrite existing files";
33
+ }
34
+ };
35
+ /**
36
+ * V-9: `<id>` did not resolve to a verifiable concept. `reason` is
37
+ * `"not-a-concept"` (no such file, or an id that normalises to nothing),
38
+ * `"reserved"` (the id names `index.md` or `log.md`), or `"undecodable"`
39
+ * (a file exists but `bundle.diagnostics` names it — `diagnosticCode`
40
+ * carries which). `root` is the absolute bundle root, kept OFF `message`
41
+ * so K-51's relativisation rule has nothing to apply to and the human
42
+ * line and the JSON envelope's `message` are identical.
43
+ *
44
+ * @public
45
+ */
46
+ var VerifyConceptNotFoundError = class extends Schema.TaggedError()("VerifyConceptNotFoundError", {
47
+ id: Schema.String,
48
+ root: Schema.String,
49
+ reason: Schema.Literals([
50
+ "not-a-concept",
51
+ "reserved",
52
+ "undecodable"
53
+ ]),
54
+ diagnosticCode: Schema.optionalKey(Schema.String)
55
+ }) {
56
+ [Runtime.errorExitCode] = 3;
57
+ get message() {
58
+ const detail = this.diagnosticCode === void 0 ? this.reason : `${this.reason}: ${this.diagnosticCode}`;
59
+ return `no concept "${this.id}" in this bundle (${detail})`;
60
+ }
61
+ };
62
+ /**
63
+ * V-14: fail closed on a `verified` shape the classifier does not name.
64
+ * `shape` is one of `"alias"`, `"merge-key"`, `"scalar"`, `"empty"` (and,
65
+ * defensively, `"no-frontmatter"` or `"not-a-mapping"`, neither reachable
66
+ * for a concept that reached `bundle.concepts`). The file is never opened
67
+ * for writing when this is raised.
68
+ *
69
+ * @public
70
+ */
71
+ var VerifyUnsupportedFrontmatterError = class extends Schema.TaggedError()("VerifyUnsupportedFrontmatterError", {
72
+ id: Schema.String,
73
+ shape: Schema.String
74
+ }) {
75
+ [Runtime.errorExitCode] = 3;
76
+ get message() {
77
+ return `"${this.id}"'s verified value is a shape okfit verify cannot edit (${this.shape}); edit it by hand`;
78
+ }
79
+ };
80
+ /** The error's `message` when it has one as a string, else `String(error)`. */
81
+ const messageOf = (error) => {
82
+ if (typeof error === "object" && error !== null && "message" in error) {
83
+ const message = error.message;
84
+ if (typeof message === "string") return message;
85
+ }
86
+ return String(error);
87
+ };
88
+ /**
89
+ * A config file at a KNOWN path failed to parse or validate (K-46's own
90
+ * intent — "each class' message already names the offending path" does not
91
+ * hold for `@effected/config-file`'s `ConfigCodecError`, which carries no
92
+ * `path` field at all; this wraps it, and `ConfigValidationError`, with the
93
+ * path the caller already knows). `cause` is the original config-file error,
94
+ * preserved structurally, never stringified early.
95
+ *
96
+ * @public
97
+ */
98
+ var ConfigMalformedError = class extends Schema.TaggedError()("ConfigMalformedError", {
99
+ path: Schema.String,
100
+ cause: Schema.Defect()
101
+ }) {
102
+ [Runtime.errorExitCode] = 3;
103
+ get message() {
104
+ return `malformed config ${this.path}: ${messageOf(this.cause)}`;
105
+ }
106
+ };
107
+
108
+ //#endregion
109
+ export { ConfigMalformedError, ConfigPathNotFoundError, InitOverwriteError, VerifyConceptNotFoundError, VerifyUnsupportedFrontmatterError };