@okfit/profiles 0.1.0 → 0.2.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,44 @@
1
+ import { Timestamp } from "@okfit/core";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/BodyProvenance.ts
5
+ /**
6
+ * The body is at HEAD and last changed in the commit described here (P-9).
7
+ * `at` is that commit's author date (P-4); `committedAt` and the author
8
+ * identity are carried for callers that want a history-based policy later (P-40).
9
+ * `creating` is true exactly when the winning entry is also the oldest entry
10
+ * of the path's history — the commit that created the path (S-9).
11
+ *
12
+ * @public
13
+ */
14
+ const BodyCommitted = Schema.TaggedStruct("committed", {
15
+ at: Timestamp,
16
+ sha: Schema.String,
17
+ committedAt: Timestamp,
18
+ authorName: Schema.String,
19
+ authorEmail: Schema.String,
20
+ creating: Schema.Boolean
21
+ });
22
+ /**
23
+ * The body has no usable commit (P-9): `untracked` means HEAD does not contain
24
+ * the path, `dirty` means the on-disk body differs from HEAD's blob, `unborn`
25
+ * means HEAD has no commits (P-11).
26
+ *
27
+ * @public
28
+ */
29
+ const BodyUncommitted = Schema.TaggedStruct("uncommitted", { reason: Schema.Literals([
30
+ "untracked",
31
+ "dirty",
32
+ "unborn"
33
+ ]) });
34
+ /**
35
+ * What {@link Derivation.generatedAt} reports. A schema so the value also
36
+ * serialises for MCP later without adaptation. Derivation never substitutes
37
+ * `now`; the uncommitted policy is the caller's (P-10).
38
+ *
39
+ * @public
40
+ */
41
+ const BodyProvenance = Schema.Union([BodyCommitted, BodyUncommitted]);
42
+
43
+ //#endregion
44
+ export { BodyCommitted, BodyProvenance, BodyUncommitted };
package/Derivation.js ADDED
@@ -0,0 +1,190 @@
1
+ import { GitHistory } from "./GitHistory.js";
2
+ import { Actor, OkfitConfig } from "@okfit/core";
3
+ import { DateTime, Duration, Effect, FileSystem, Option, Path, Schema } from "effect";
4
+ import { Git } from "@effected/git";
5
+ import { FrontmatterSource } from "@effected/markdown";
6
+
7
+ //#region src/Derivation.ts
8
+ /**
9
+ * git has no identity to derive a `human:` actor from (P-13 step 4).
10
+ * `userName` and `userEmail` are the trimmed `configGet` answers when git had
11
+ * them and absent keys otherwise (decision 53: not `name`/`email`, since every
12
+ * Effect error class carries the class name on its own prototype and a field
13
+ * literally named `name` would shadow `error.name` when present); test for
14
+ * git's answer with `Object.hasOwn(error, "userName")`.
15
+ *
16
+ * @public
17
+ */
18
+ var HumanActorUnresolvedError = class extends Schema.TaggedError()("HumanActorUnresolvedError", {
19
+ userName: Schema.optionalKey(Schema.String),
20
+ userEmail: Schema.optionalKey(Schema.String)
21
+ }) {
22
+ get message() {
23
+ return "git has no user.name or user.email to derive a human actor from; set one or add actors.humans";
24
+ }
25
+ };
26
+ /**
27
+ * The writer is an agent but `actors.agent` is not configured (P-17); the
28
+ * profile never sets it.
29
+ *
30
+ * @public
31
+ */
32
+ var AgentActorUnconfiguredError = class extends Schema.TaggedError()("AgentActorUnconfiguredError", {}) {
33
+ get message() {
34
+ return "writer is agent but actors.agent is not configured";
35
+ }
36
+ };
37
+ const HUMAN_PREFIX = "human:";
38
+ const FALLBACK_STALE_AFTER = Duration.days(90);
39
+ const decodeActor = Schema.decodeSync(Actor);
40
+ const normalise = (text) => text.replace(/\r\n?/g, "\n").trimEnd();
41
+ const body = (text) => normalise(FrontmatterSource.split(text).body);
42
+ /** The text before the first `@` (the whole value when there is none), or undefined when empty or containing whitespace. */
43
+ const localPartOf = (email) => {
44
+ if (email === void 0) return void 0;
45
+ const at = email.indexOf("@");
46
+ const local = at === -1 ? email : email.slice(0, at);
47
+ return local.length > 0 && !/\s/.test(local) ? local : void 0;
48
+ };
49
+ /** P-13 step 3: lower-case, runs outside `[a-z0-9._-]` to `-`, leading and trailing `-` trimmed. */
50
+ const slugOf = (name) => {
51
+ if (name === void 0) return void 0;
52
+ const slug = name.toLowerCase().replace(/[^a-z0-9._-]+/g, "-").replace(/^-+|-+$/g, "");
53
+ return slug.length > 0 ? slug : void 0;
54
+ };
55
+ const humanActorId = (identity, humans) => {
56
+ const local = localPartOf(identity.email);
57
+ const slug = slugOf(identity.name);
58
+ const candidates = [local, slug].filter((candidate) => candidate !== void 0).map((candidate) => candidate.toLowerCase());
59
+ for (const entry of humans) {
60
+ if (!entry.startsWith(HUMAN_PREFIX)) continue;
61
+ if (candidates.includes(entry.slice(6).toLowerCase())) return Option.some(entry);
62
+ }
63
+ if (local !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${local}`));
64
+ if (slug !== void 0) return Option.some(decodeActor(`${HUMAN_PREFIX}${slug}`));
65
+ return Option.none();
66
+ };
67
+ const staleAfter = (from, config) => DateTime.addDuration(from, config.lifecycle?.default_stale_after ?? OkfitConfig.DEFAULTS.lifecycle?.default_stale_after ?? FALLBACK_STALE_AFTER);
68
+ const committed = (entry, entries) => ({
69
+ _tag: "committed",
70
+ at: entry.authoredAt,
71
+ sha: entry.sha,
72
+ committedAt: entry.committedAt,
73
+ authorName: entry.authorName,
74
+ authorEmail: entry.authorEmail,
75
+ creating: entry === entries[entries.length - 1]
76
+ });
77
+ const uncommitted = (reason) => ({
78
+ _tag: "uncommitted",
79
+ reason
80
+ });
81
+ /**
82
+ * Spec 5.4 derivation rules for the `software-project` profile, package-global
83
+ * (P-38). Pure members take every input as an argument; effectful members
84
+ * require `Git`, `GitHistory` and, for the worktree read, `FileSystem` and
85
+ * `Path` (P-30). Never writes, never reads `generated.by` (P-40).
86
+ *
87
+ * @public
88
+ */
89
+ var Derivation = class {
90
+ constructor() {}
91
+ /**
92
+ * `FrontmatterSource.split(text).body`, then CRLF and lone CR to LF, then trailing whitespace at end of
93
+ * text removed (P-6). Pure. Applied to both sides of every comparison so `core.autocrlf` worktrees compare clean.
94
+ */
95
+ static body = body;
96
+ /**
97
+ * Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
98
+ * 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
99
+ * submodules and worktrees work because the question is asked from the file's directory (P-39).
100
+ * 2. `realPath` both (rev-parse is symlink-resolved), then the repo-relative posix path via `Path` (P-39).
101
+ * 3. `worktree = body(readFileString(file))`.
102
+ * 4. `Git.show(realRoot, "HEAD", rel)`: `UnknownRefError` is `uncommitted { unborn }` (P-11), `Option.none`
103
+ * is `uncommitted { untracked }` (P-47), a body differing from `worktree` is `uncommitted { dirty }` (P-9).
104
+ * The comparison is against HEAD's blob, never the newest log entry (P-46).
105
+ * 5. `entries = GitHistory.pathLog(realRoot, rel)` newest first (P-5, P-46); `[]` is `uncommitted { unborn }`.
106
+ * 6. One `Git.show` per older blob (P-8), newest first: the first entry whose body differs from the next
107
+ * older entry's body wins, else the oldest entry (the creating commit). "Previous" is the previous entry
108
+ * of the simplified `--follow` history, not the true parent (P-7). The newest entry's body is never
109
+ * re-fetched: `head.blob`, already confirmed equal to `worktree` by step 4, is reused (decision 56).
110
+ * `Option.none` for an older entry counts as "differs", never a defect (decision 56).
111
+ * `config` is reserved and ignored (P-48). Never writes, never reads `by` (P-40).
112
+ */
113
+ static generatedAt = Effect.fn("Derivation.generatedAt")(function* (options) {
114
+ const git = yield* Git;
115
+ const history = yield* GitHistory;
116
+ const fs = yield* FileSystem.FileSystem;
117
+ const path = yield* Path.Path;
118
+ const root = yield* git.repoRoot(path.dirname(options.file));
119
+ const realRoot = yield* fs.realPath(root);
120
+ const realFile = yield* fs.realPath(options.file);
121
+ const rel = path.relative(realRoot, realFile).split(path.sep).join("/");
122
+ const worktree = body(yield* fs.readFileString(options.file));
123
+ const head = yield* git.show(realRoot, "HEAD", rel).pipe(Effect.map((blob) => ({
124
+ unborn: false,
125
+ blob
126
+ })), Effect.catchTag("UnknownRefError", () => Effect.succeed({
127
+ unborn: true,
128
+ blob: Option.none()
129
+ })));
130
+ if (head.unborn) return uncommitted("unborn");
131
+ if (Option.isNone(head.blob)) return uncommitted("untracked");
132
+ if (body(head.blob.value) !== worktree) return uncommitted("dirty");
133
+ const entries = yield* history.pathLog(realRoot, rel);
134
+ const newest = entries[0];
135
+ if (newest === void 0) return uncommitted("unborn");
136
+ let current = worktree;
137
+ for (let index = 0; index < entries.length - 1; index += 1) {
138
+ const entry = entries[index];
139
+ const older = entries[index + 1];
140
+ if (entry === void 0 || older === void 0) break;
141
+ const olderBlob = yield* git.show(realRoot, older.sha, older.path);
142
+ if (Option.isNone(olderBlob)) return committed(entry, entries);
143
+ const olderBody = body(olderBlob.value);
144
+ if (olderBody !== current) return committed(entry, entries);
145
+ current = olderBody;
146
+ }
147
+ return committed(entries[entries.length - 1] ?? newest, entries);
148
+ });
149
+ /**
150
+ * P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
151
+ * and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
152
+ * candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
153
+ * The result is branded through core's `Actor` codec and always passes it.
154
+ */
155
+ static humanActorId = humanActorId;
156
+ /**
157
+ * Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
158
+ * (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
159
+ * scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
160
+ * Answers only "who is writing now" (P-40).
161
+ */
162
+ static generatedBy = Effect.fn("Derivation.generatedBy")(function* (options) {
163
+ if (options.writer === "agent") {
164
+ const agent = options.config.actors?.agent;
165
+ if (agent === void 0) return yield* new AgentActorUnconfiguredError({});
166
+ return agent;
167
+ }
168
+ const git = yield* Git;
169
+ const name = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.name"));
170
+ const email = Option.getOrUndefined(yield* git.configGet(options.cwd, "user.email"));
171
+ const identity = {
172
+ ...name === void 0 ? {} : { name },
173
+ ...email === void 0 ? {} : { email }
174
+ };
175
+ const resolved = humanActorId(identity, options.config.actors?.humans ?? []);
176
+ if (Option.isNone(resolved)) return yield* new HumanActorUnresolvedError({
177
+ ...identity.name === void 0 ? {} : { userName: identity.name },
178
+ ...identity.email === void 0 ? {} : { userEmail: identity.email }
179
+ });
180
+ return resolved.value;
181
+ });
182
+ /**
183
+ * P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
184
+ * 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
185
+ */
186
+ static staleAfter = staleAfter;
187
+ };
188
+
189
+ //#endregion
190
+ export { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError };
package/GitHistory.js ADDED
@@ -0,0 +1,107 @@
1
+ import { Timestamp } from "@okfit/core";
2
+ import { Context, Effect, Layer, Schema } from "effect";
3
+ import { Git } from "@effected/git";
4
+
5
+ //#region src/GitHistory.ts
6
+ /**
7
+ * One record of the path log, newest first (P-3, P-5): the commit, both dates decoded through core's
8
+ * `Timestamp` (`%aI` / `%cI`, strict ISO with an offset; the `Type` is `DateTime.Utc`, P-4), the author
9
+ * identity, and the repository-relative posix path the blob had at that commit (the `--name-only` line).
10
+ * @public
11
+ */
12
+ var PathHistoryEntry = class extends Schema.Class("PathHistoryEntry")({
13
+ sha: Schema.String,
14
+ authoredAt: Timestamp,
15
+ committedAt: Timestamp,
16
+ authorName: Schema.String,
17
+ authorEmail: Schema.String,
18
+ path: Schema.String
19
+ }) {};
20
+ /**
21
+ * Profiles' own spawn failure, shaped like `@effected/git`'s `GitCommandError` (P-12). Raised by
22
+ * `GitHistory.layer` for any `GitCommandError` `Git.log` produces, including the one `Git.log`
23
+ * itself synthesizes when its own spawn timeout expires.
24
+ * @public
25
+ */
26
+ var GitHistoryError = class extends Schema.TaggedError()("GitHistoryError", {
27
+ /** The argv without the leading `git`. */
28
+ args: Schema.Array(Schema.String),
29
+ /** The working directory the command ran in. */
30
+ cwd: Schema.String,
31
+ /** git's exit code; absent when git never produced one. */
32
+ exitCode: Schema.optionalKey(Schema.Number),
33
+ /** git's stderr, captured under `LC_ALL=C`. */
34
+ stderr: Schema.String,
35
+ /** Spawn failure text, `timed out after 30s`, or `Git.log`'s own absorbed-failure detail. */
36
+ detail: Schema.optionalKey(Schema.String)
37
+ }) {
38
+ get message() {
39
+ const exit = this.exitCode === void 0 ? "" : ` (exit ${this.exitCode})`;
40
+ return `git ${this.args.join(" ")}${exit} in ${this.cwd}: ${this.detail ?? this.stderr}`;
41
+ }
42
+ };
43
+ const toGitHistoryError = (cwd, error) => new GitHistoryError({
44
+ args: error.args,
45
+ cwd,
46
+ ...error.exitCode === void 0 ? {} : { exitCode: error.exitCode },
47
+ stderr: error.stderr,
48
+ ...error.detail === void 0 ? {} : { detail: error.detail }
49
+ });
50
+ const make = (git) => ({ pathLog: (cwd, path, options) => Effect.gen(function* () {
51
+ return (yield* git.log(cwd, {
52
+ paths: [path],
53
+ follow: true,
54
+ firstParentDiffMerges: true,
55
+ ...options?.limit === void 0 ? {} : { limit: options.limit }
56
+ }).pipe(Effect.catchTag("GitCommandError", (error) => Effect.fail(toGitHistoryError(cwd, error))))).filter((entry) => entry.paths.length > 0).map((entry) => {
57
+ const [path] = entry.paths;
58
+ return PathHistoryEntry.make({
59
+ sha: entry.sha,
60
+ authoredAt: entry.authoredAt,
61
+ committedAt: entry.committedAt,
62
+ authorName: entry.authorName,
63
+ authorEmail: entry.authorEmail,
64
+ path: path ?? ""
65
+ });
66
+ });
67
+ }) });
68
+ const notScripted = (path) => Effect.die(/* @__PURE__ */ new Error(`GitHistory.makeTest: pathLog(${path}) was called but not scripted`));
69
+ /**
70
+ * A path-scoped, date-bearing log: `Git.log` reads the whole repository history, and this adapter scopes
71
+ * it to one path (`--follow`, `--diff-merges=first-parent`) and decodes it into `PathHistoryEntry`.
72
+ * Delegates to `Git.log` as of `@effected/git` 0.12.0 (P-1, spec deviation retired).
73
+ * @public
74
+ */
75
+ var GitHistory = class GitHistory extends Context.Service()("@okfit/profiles/GitHistory") {
76
+ /**
77
+ * Live layer (P-1, P-5, P-11): one `Git.log` call per `pathLog`, resolved once at construction like
78
+ * `Git.layer` itself, so every member's `R` is `never`. `Git.log` already degrades an unborn `HEAD` and
79
+ * an unmatched pathspec to the empty array and classifies `not a git repository` as `NotARepositoryError`
80
+ * (both pass through unchanged); every other failure becomes `GitHistoryError`, including the one
81
+ * `Git.log` itself produces when its own 30 s spawn timeout expires (`@effected/git`'s `GIT_TIMEOUT`
82
+ * ceiling, not this adapter's) — `Git.log` never lets a bare `Cause.TimeoutError` escape. The CLI
83
+ * provides `ChildProcessSpawner` through `NodeServices.layer` (P-30).
84
+ */
85
+ static layer = Layer.effect(GitHistory, Effect.gen(function* () {
86
+ const git = yield* Git;
87
+ return make(git);
88
+ })).pipe(Layer.provide(Git.layer));
89
+ /**
90
+ * Scripted double (P-32): responses keyed by the `path` argument exactly as passed to `pathLog`. A call
91
+ * whose path has no entry DIES with `GitHistory.makeTest: pathLog(<path>) was called but not scripted`,
92
+ * mirroring `Git.makeTest`; `limit` is honoured by slicing. Models none of the live semantics.
93
+ */
94
+ static makeTest = (script) => ({ pathLog: (_cwd, path, options) => {
95
+ if (!Object.hasOwn(script, path)) return notScripted(path);
96
+ const entries = script[path] ?? [];
97
+ return Effect.succeed(options?.limit === void 0 ? entries : entries.slice(0, options.limit));
98
+ } });
99
+ /**
100
+ * `Layer.succeed(GitHistory, GitHistory.makeTest(script))`. Mints a fresh layer per call: bind it to a
101
+ * `const`, since layers memoize by reference.
102
+ */
103
+ static layerTest = (script) => Layer.succeed(GitHistory, GitHistory.makeTest(script));
104
+ };
105
+
106
+ //#endregion
107
+ export { GitHistory, GitHistoryError, PathHistoryEntry, make };
package/Profile.js ADDED
@@ -0,0 +1,38 @@
1
+ import { DiagnosticRange, DiagnosticSeverity } from "@okfit/core";
2
+ import { Schema } from "effect";
3
+
4
+ //#region src/Profile.ts
5
+ /**
6
+ * Names of the profiles this package ships (P-38).
7
+ *
8
+ * @public
9
+ */
10
+ const PROFILE_NAMES = ["software-project"];
11
+ /**
12
+ * Profile-owned diagnostic codes (P-21). Core's `LintCode` is untouched; all
13
+ * three are severity `error` and are not configurable.
14
+ *
15
+ * @public
16
+ */
17
+ const ProfileDiagnosticCode = Schema.Literals([
18
+ "project-missing",
19
+ "project-multiple",
20
+ "project-not-at-root"
21
+ ]);
22
+ /**
23
+ * Same fields as core's `Diagnostic` with a profile code; a `Schema.Struct`
24
+ * per P-21/P-51. `file` is the bundle-relative posix path, `""` for the
25
+ * bundle-level `project-missing`. `range` is never set in phase 1.
26
+ *
27
+ * @public
28
+ */
29
+ const ProfileDiagnostic = Schema.Struct({
30
+ file: Schema.String,
31
+ range: Schema.optionalKey(DiagnosticRange),
32
+ code: ProfileDiagnosticCode,
33
+ severity: DiagnosticSeverity,
34
+ message: Schema.String
35
+ });
36
+
37
+ //#endregion
38
+ export { PROFILE_NAMES, ProfileDiagnostic, ProfileDiagnosticCode };
package/Profiles.js ADDED
@@ -0,0 +1,27 @@
1
+ import { softwareProject } from "./SoftwareProject.js";
2
+ import { Option } from "effect";
3
+
4
+ //#region src/Profiles.ts
5
+ const REGISTRY = /* @__PURE__ */ new Map([[softwareProject.name, softwareProject]]);
6
+ /**
7
+ * The profile registry (P-38). No third-party registration in phase 1; the
8
+ * `DEFAULTS < profile < file` merge is the CLI's (D-28).
9
+ *
10
+ * @public
11
+ */
12
+ var Profiles = class {
13
+ constructor() {}
14
+ /** The `software-project` profile (spec 5). */
15
+ static softwareProject = softwareProject;
16
+ /**
17
+ * `Option.none` for `"none"`, unknown names and anything not in
18
+ * `PROFILE_NAMES`; the CLI decides what that means (P-38). Exact match only.
19
+ */
20
+ static get = (name) => {
21
+ const found = REGISTRY.get(name);
22
+ return found === void 0 ? Option.none() : Option.some(found);
23
+ };
24
+ };
25
+
26
+ //#endregion
27
+ export { Profiles };
package/Provenance.js ADDED
@@ -0,0 +1,59 @@
1
+ import { Derivation } from "./Derivation.js";
2
+ import { Diagnostic, OkfitConfig, Timestamp } from "@okfit/core";
3
+ import { DateTime, Effect, Path, Schema } from "effect";
4
+
5
+ //#region src/Provenance.ts
6
+ const encodeAt = Schema.encodeSync(Timestamp);
7
+ /**
8
+ * A profiles-owned lint over committed provenance (S-8). NOT a `Profile`
9
+ * member and NOT a `ProfileDiagnostic` -- it returns core `Diagnostic`s with
10
+ * code `"generated-at-drift"`, a real `LintCode` member, so `severityFor`,
11
+ * the `[lint]` table, the renderers, the hook, and the MCP tool treat it
12
+ * like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
13
+ * are unchanged.
14
+ *
15
+ * Runs `Derivation.generatedAt` per concept, skips uncommitted bodies and
16
+ * concepts without a `generated` block, and returns `[]` the moment a
17
+ * `NotARepositoryError` surfaces: a bundle outside git has nothing to
18
+ * derive for any concept, so the first occurrence short-circuits the whole
19
+ * walk, not just the concept that triggered it (S-8). Emits no `range`
20
+ * (S-12, Judge note 7): `Diagnostic.range` is `Schema.optionalKey` and is
21
+ * simply omitted.
22
+ *
23
+ * `Provenance.lint` is total over severity (S-28, amending Judge note 3):
24
+ * when `OkfitConfig.severityFor(config, "generated-at-drift")` resolves to
25
+ * `"off"`, it returns `[]` before any per-concept work and before any git
26
+ * call, so it is safe to call at any severity and never needs to cast
27
+ * `"off"` away. `validate/run.ts` (the CLI) still gates on `"off"` before
28
+ * calling at all; the two agree by construction (both skip the walk).
29
+ *
30
+ * @public
31
+ */
32
+ var Provenance = class {
33
+ constructor() {}
34
+ static lint = Effect.fn("Provenance.lint")(function* (bundle, config) {
35
+ const severity = OkfitConfig.severityFor(config, "generated-at-drift");
36
+ if (severity === "off") return [];
37
+ const path = yield* Path.Path;
38
+ const diagnostics = [];
39
+ for (const [, concept] of bundle.concepts) {
40
+ if (concept.frontmatter.generated === void 0) continue;
41
+ const file = path.join(bundle.root, concept.path);
42
+ const derived = yield* Derivation.generatedAt({ file }).pipe(Effect.catchTag("NotARepositoryError", () => Effect.succeed(void 0)));
43
+ if (derived === void 0) return [];
44
+ if (derived._tag === "uncommitted") continue;
45
+ const recorded = concept.frontmatter.generated.at;
46
+ if (recorded !== void 0 && DateTime.Equivalence(recorded, derived.at)) continue;
47
+ diagnostics.push(Diagnostic.make({
48
+ file: concept.path,
49
+ code: "generated-at-drift",
50
+ severity,
51
+ message: `generated.at is ${recorded === void 0 ? "missing" : encodeAt(recorded)}; the last body change was ${encodeAt(derived.at)} (${derived.sha.slice(0, 7)})`
52
+ }));
53
+ }
54
+ return diagnostics;
55
+ });
56
+ };
57
+
58
+ //#endregion
59
+ export { Provenance };
package/README.md CHANGED
@@ -1,12 +1,157 @@
1
1
  # @okfit/profiles
2
2
 
3
- Named configuration profiles for [okfit](https://github.com/spencerbeggs/okfit). A profile is a complete okfit config plus derivation rules. The first profile, `software-project`, describes a software repository's knowledge bundle.
3
+ Named configuration profiles for [okfit](https://github.com/spencerbeggs/okfit). A profile is a complete `OkfitConfig` value plus derivation rules for `generated.at` and `generated.by`. The first profile, `software-project`, describes a software repository's knowledge bundle.
4
4
 
5
5
  > **Pre-release.** Part of the `@okfit/*` kit, in early development.
6
6
 
7
- ## Status
7
+ ## The `software-project` profile
8
8
 
9
- Skeleton. The public surface is `PROFILE_NAMES` only.
9
+ `Profiles.softwareProject.config` is a hand-authored `OkfitConfig` (P-25) that sets only `concepts`, `types`, `tags`, and `extensions: {}`; `okf_version`, `bundle`, `lifecycle`, `actors`, and `lint` are inherited from `OkfitConfig.DEFAULTS` by whoever merges it (the CLI applies `DEFAULTS < profile < file` with `OkfitConfig.merge`). `Profiles.get(name)` resolves a profile by its exact name and answers `Option.none` for `"none"` and unknown names. The TOML below decodes to the exact same value and is checked against the literal by `__test__/SoftwareProject.test.ts`:
10
+
11
+ ```toml
12
+ [concepts]
13
+ required = ["title", "description"]
14
+ tags.required = []
15
+
16
+ [types.Project]
17
+ description = "The repository's root concept: its purpose, boundaries, and non-goals."
18
+ guidance = "Exactly one Project exists and it lives at the bundle root as the project file. State the purpose in one paragraph and list what is deliberately out of scope so a reader never infers boundaries from silence."
19
+
20
+ [types.Module]
21
+ description = "A unit of code with an owner and a boundary."
22
+ guidance = "One per workspace package, plugin, website, or action. Link to the Decisions that shaped it and the Conventions it is bound by."
23
+ required = ["resource", "kind"]
24
+
25
+ [types.Module.fields.kind]
26
+ description = "What sort of unit this is; drives which conventions apply."
27
+ values.workspace = "The monorepo root: tooling, CI, release, shared config."
28
+ values.package = "A publishable npm package under packages/."
29
+ values.website = "A docs or marketing site, usually RSPress."
30
+ values.plugin = "A Claude Code or editor plugin distributed outside npm."
31
+ values.action = "A GitHub Action."
32
+
33
+ [types.Module.fields.resource]
34
+ description = "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core."
35
+ kind = "path"
36
+
37
+ [types.Decision]
38
+ description = "A choice made, the alternatives rejected, and why."
39
+ guidance = "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it."
40
+ require_verified = true
41
+
42
+ [types.Decision.fields.supersedes]
43
+ description = "The Decision this one replaces."
44
+ kind = "path"
45
+
46
+ [types.Convention]
47
+ description = "A rule contributors and agents must follow."
48
+ guidance = "State the rule as an instruction rather than a description of current behaviour. Give it a staleness window so it is re-examined on a cadence instead of rotting silently."
49
+
50
+ [types.Interface]
51
+ description = "A contract others depend on."
52
+ guidance = "Document the contract from the consumer's side: what stays stable, not how it is built. Point resource at the file, endpoint, or schema the promise lives in."
53
+ required = ["kind"]
54
+
55
+ [types.Interface.fields.kind]
56
+ description = "What kind of contract this is."
57
+ values.api = "A programmatic library surface: exported functions, classes, and types."
58
+ values.cli = "A command-line interface: commands, flags, and exit codes."
59
+ values.config = "A configuration file schema other tools read or write."
60
+ values.wire = "A network or IPC wire format."
61
+ values.mcp = "An MCP tool or resource surface."
62
+
63
+ [types.Interface.fields.resource]
64
+ description = "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core/src/index.ts."
65
+ kind = "path"
66
+
67
+ [types.Reference]
68
+ description = "Mirrored external material kept under the references directory."
69
+ guidance = "Only for material this repository must cite reliably even if the original moves. Every Reference declares where it came from in sources."
70
+ required = ["sources"]
71
+
72
+ [tags.architecture]
73
+ description = "Concerns the shape of the system rather than one module."
74
+
75
+ [tags.testing]
76
+ description = "Concerns how the system is verified: strategy, fixtures, and coverage policy."
77
+
78
+ [tags.release]
79
+ description = "Concerns how changes ship: versioning, changelogs, publishing, and tagging."
80
+
81
+ [tags.security]
82
+ description = "Concerns trust boundaries, secrets, permissions, or attack surface."
83
+
84
+ [tags.performance]
85
+ description = "Concerns speed, memory, or resource cost and the trade-offs made for them."
86
+ ```
87
+
88
+ Only `concepts`, `types`, and `tags` carry real vocabulary; the literal's `extensions: {}` is `OkfitConfig`'s one required key and never appears on disk, since the codec folds unknown top-level keys into it (P-24: `fields.<k>.kind = "path"` and the `tags` table are vocabulary only — core enforces none of it yet). `Reference.required = ["sources"]` checks presence only: `sources = []` passes, and each entry's shape is core's `family-invalid` (P-23).
89
+
90
+ `Profiles.softwareProject.check(bundle)` additionally enforces, outside `OkfitConfig` entirely, that exactly one `Project` concept exists and lives at the bundle root (P-21): zero `Project`s is `project-missing`, more than one is `project-multiple` (one diagnostic per `Project` concept), and a `Project` whose path contains `/` is `project-not-at-root`. All three are severity `error` and are not configurable.
91
+
92
+ ## Layout
93
+
94
+ `Profiles.softwareProject.layout` is scaffolding data for `okfit init`, never part of `OkfitConfig` (P-26):
95
+
96
+ ```text
97
+ root:
98
+ index.md -- bundle index
99
+ log.md -- bundle log
100
+ project.md -- the one Project concept, at the bundle root
101
+
102
+ directories:
103
+ modules/ -> Module
104
+ decisions/ -> Decision
105
+ conventions/ -> Convention
106
+ interfaces/ -> Interface
107
+ references/ -> Reference
108
+ ```
109
+
110
+ Each directory carries its own `index.md`. What `okfit init` writes into these files is the CLI plan's concern, not this package's.
111
+
112
+ ## Derivation
113
+
114
+ `Derivation` is package-global, not per profile:
115
+
116
+ - **`generated.at` is the author date, not the stamp date (P-4).** Walking a path's git history newest-first, the commit that changed the body is the one whose blob first differs from its predecessor's; that commit's *author* date (`%aI`) is `generated.at`. Its committer date, author name, and author email are carried on the result for callers that want a history-based policy later, but `generated.at` itself is never the date of a later frontmatter-only stamp.
117
+ - **The path log is `@effected/git`'s `Git.log(cwd, { paths: [path], follow: true, firstParentDiffMerges: true })` (P-5, amended P-46, decision 56).** `firstParentDiffMerges` surfaces a conflict-resolving merge whose blob differs from both parents (probed on git 2.54.0), which plain `--follow` can omit and which would otherwise attribute that body change to the wrong commit. `Git.log`'s unconditional `-z` makes `--name-only` emit paths raw — no C-style quoting — so no `core.quotePath` override is needed and a path containing a space, a quote, or a newline round-trips verbatim. The dirty check compares the worktree body against `Git.show(root, "HEAD", path)`, never against the newest log entry. `--follow`'s rename detection can itself stop at a commit that renames a file and rewrites its content enough that git no longer treats it as a continuation of the same history; when that happens, `generated.at` answers that rename commit rather than an older one.
118
+ - **Uncommitted is the caller's to interpret (P-10).** `Derivation.generatedAt` reports `{ _tag: "uncommitted", reason: "untracked" | "dirty" | "unborn" }` and never substitutes `now`. The recommended policy, followed by the snippet below: omit `generated.at` entirely until the body is committed.
119
+ - **Human actor resolution (P-13).** Given git identity `{ name?, email? }` and `config.actors.humans`, the first hit wins: (1) a `human:<id>` entry whose id matches the email's local part or the name's slug, case-insensitively (config spelling returned); (2) `human:<local part>`; (3) `human:<slug of name>`; (4) unresolved. The slug lowercases `name`, replaces runs of whitespace and characters outside `[A-Za-z0-9._-]` with `-`, and trims leading and trailing `-`.
120
+ - **Identity is read at merged config scope (P-15).** `Derivation.generatedBy` calls `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` with no `scope` option. Environment overrides (`GIT_AUTHOR_NAME`, `GIT_AUTHOR_EMAIL`, `EMAIL`, `user.useConfigOnly`) are out of scope for phase 1.
121
+ - **`stale_after` is pure addition (P-19).** `Derivation.staleAfter(from, config)` is `from + (config.lifecycle?.default_stale_after ?? OkfitConfig.DEFAULTS.lifecycle.default_stale_after)`, falling back to 90 days. `from` should be `generated.at` when the provenance is `committed`, otherwise the caller's own `now`; re-stamp whenever `generated.at` changes.
122
+
123
+ Recommended stamping policy, followed end to end:
124
+
125
+ ```ts
126
+ const by = yield* Derivation.generatedBy({ writer, cwd: path.dirname(file), config }); // P-16; agent needs actors.agent (P-17)
127
+ const provenance = yield* Derivation.generatedAt({ file, config }); // P-2, P-9, P-39
128
+ const at = provenance._tag === "committed" ? Option.some(provenance.at) : Option.none(); // omit `at` until committed (P-10)
129
+ const staleAfter = Derivation.staleAfter(Option.getOrElse(at, () => now), config); // P-19; `now` is the caller's
130
+ // serialisation is the CLI's: Schema.encodeSync(Timestamp) writes "...T08:00:00Z" after the P-18 core patch (P-41)
131
+ ```
132
+
133
+ Frontmatter serialisation, the uncommitted policy's enforcement, `now`, layer composition (`Layer.mergeAll(Git.layer, GitHistory.layer).pipe(Layer.provideMerge(NodeServices.layer))`), exit codes, and everything `okfit init` writes belong to the CLI plan, not this package (P-30, P-41, P-44).
134
+
135
+ ## Provenance
136
+
137
+ `Provenance.lint(bundle, config)` is the `generated-at-drift` lint
138
+ (`lint.generated_at_drift`, config default `"info"`): it walks
139
+ `Derivation.generatedAt` once per concept with a `generated` block, and
140
+ reports a core `Diagnostic` when a committed body's `generated.at` is
141
+ missing or does not match the derived instant (compared as decoded
142
+ `DateTime.Utc` values, never encoded strings). It is silent for an
143
+ uncommitted body, for a concept with no `generated` block, and returns `[]`
144
+ the moment it discovers the bundle is outside a git repository at all. It
145
+ emits no `range` — the message carries both instants and a 7-character
146
+ sha, which is what a reader acts on. It is a plain facade over
147
+ `Derivation`, not a `Profile` member: `severityFor`, the `[lint]` table,
148
+ the renderers, the PostToolUse hook, and the MCP `validate_bundle` tool all
149
+ treat its `Diagnostic`s like every other lint. It is total over severity
150
+ (S-28): when the resolved `generated-at-drift` severity is `"off"` it
151
+ returns `[]` before any per-concept work and before any git call, so it is
152
+ safe to call at any severity. The CLI (`okfit validate`, `okfit sync`'s own
153
+ reporting) also gates on `"off"` before calling at all; the two agree by
154
+ construction.
10
155
 
11
156
  ## License
12
157
 
@@ -0,0 +1,170 @@
1
+ //#region src/SoftwareProject.ts
2
+ /**
3
+ * Hand-authored, typed `OkfitConfig` (P-25). Sets ONLY `concepts`, `types`,
4
+ * `tags` and `extensions: {}`; `okf_version`, `bundle`, `lifecycle`, `actors`
5
+ * and `lint` are inherited from `OkfitConfig.DEFAULTS` by whoever merges.
6
+ * `actors.agent` is deliberately unset (P-17): which agent writes is a fact
7
+ * about the repository, not about the software-project shape. Every string
8
+ * obeys P-42 (one-sentence descriptions, at most two-sentence guidance, plain
9
+ * text). No explicit `required = []` anywhere (P-20). The literal type-checks
10
+ * as `OkfitConfig` without a decode step because no branded `Actor` is set
11
+ * (`CORE/OkfitConfig.ts:172`; `OkfitConfig.test.ts:102-107` precedent).
12
+ */
13
+ const config = {
14
+ concepts: {
15
+ required: ["title", "description"],
16
+ tags: { required: [] }
17
+ },
18
+ types: {
19
+ Project: {
20
+ description: "The repository's root concept: its purpose, boundaries, and non-goals.",
21
+ guidance: "Exactly one Project exists and it lives at the bundle root as the project file. State the purpose in one paragraph and list what is deliberately out of scope so a reader never infers boundaries from silence."
22
+ },
23
+ Module: {
24
+ description: "A unit of code with an owner and a boundary.",
25
+ guidance: "One per workspace package, plugin, website, or action. Link to the Decisions that shaped it and the Conventions it is bound by.",
26
+ required: ["resource", "kind"],
27
+ fields: {
28
+ kind: {
29
+ description: "What sort of unit this is; drives which conventions apply.",
30
+ values: {
31
+ workspace: "The monorepo root: tooling, CI, release, shared config.",
32
+ package: "A publishable npm package under packages/.",
33
+ website: "A docs or marketing site, usually RSPress.",
34
+ plugin: "A Claude Code or editor plugin distributed outside npm.",
35
+ action: "A GitHub Action."
36
+ }
37
+ },
38
+ resource: {
39
+ description: "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core.",
40
+ kind: "path"
41
+ }
42
+ }
43
+ },
44
+ Decision: {
45
+ description: "A choice made, the alternatives rejected, and why.",
46
+ guidance: "Never edit a stable Decision; deprecate it and write a new one that names it in supersedes. A Decision counts as settled only once a human has verified it.",
47
+ require_verified: true,
48
+ fields: { supersedes: {
49
+ description: "The Decision this one replaces.",
50
+ kind: "path"
51
+ } }
52
+ },
53
+ Convention: {
54
+ description: "A rule contributors and agents must follow.",
55
+ guidance: "State the rule as an instruction rather than a description of current behaviour. Give it a staleness window so it is re-examined on a cadence instead of rotting silently."
56
+ },
57
+ Interface: {
58
+ description: "A contract others depend on.",
59
+ guidance: "Document the contract from the consumer's side: what stays stable, not how it is built. Point resource at the file, endpoint, or schema the promise lives in.",
60
+ required: ["kind"],
61
+ fields: {
62
+ kind: {
63
+ description: "What kind of contract this is.",
64
+ values: {
65
+ api: "A programmatic library surface: exported functions, classes, and types.",
66
+ cli: "A command-line interface: commands, flags, and exit codes.",
67
+ config: "A configuration file schema other tools read or write.",
68
+ wire: "A network or IPC wire format.",
69
+ mcp: "An MCP tool or resource surface."
70
+ }
71
+ },
72
+ resource: {
73
+ description: "A path relative to this concept file, normally escaping the bundle, for example ../../packages/core/src/index.ts.",
74
+ kind: "path"
75
+ }
76
+ }
77
+ },
78
+ Reference: {
79
+ description: "Mirrored external material kept under the references directory.",
80
+ guidance: "Only for material this repository must cite reliably even if the original moves. Every Reference declares where it came from in sources.",
81
+ required: ["sources"]
82
+ }
83
+ },
84
+ tags: {
85
+ architecture: { description: "Concerns the shape of the system rather than one module." },
86
+ testing: { description: "Concerns how the system is verified: strategy, fixtures, and coverage policy." },
87
+ release: { description: "Concerns how changes ship: versioning, changelogs, publishing, and tagging." },
88
+ security: { description: "Concerns trust boundaries, secrets, permissions, or attack surface." },
89
+ performance: { description: "Concerns speed, memory, or resource cost and the trade-offs made for them." }
90
+ },
91
+ extensions: {}
92
+ };
93
+ /** Spec 5.2 as data (P-26); consumed by `okfit init`, never by validation. */
94
+ const layout = {
95
+ root: {
96
+ index: "index.md",
97
+ log: "log.md",
98
+ project: "project.md"
99
+ },
100
+ directories: [
101
+ {
102
+ directory: "modules",
103
+ type: "Module"
104
+ },
105
+ {
106
+ directory: "decisions",
107
+ type: "Decision"
108
+ },
109
+ {
110
+ directory: "conventions",
111
+ type: "Convention"
112
+ },
113
+ {
114
+ directory: "interfaces",
115
+ type: "Interface"
116
+ },
117
+ {
118
+ directory: "references",
119
+ type: "Reference"
120
+ }
121
+ ]
122
+ };
123
+ const PROJECT_TYPE = "Project";
124
+ const diagnostic = (file, code, message) => ({
125
+ file,
126
+ code,
127
+ severity: "error",
128
+ message
129
+ });
130
+ /** Plain code-unit order on `file` then `code` (decision 6); never locale-dependent. */
131
+ const compare = (a, b) => {
132
+ if (a.file < b.file) return -1;
133
+ if (a.file > b.file) return 1;
134
+ if (a.code < b.code) return -1;
135
+ if (a.code > b.code) return 1;
136
+ return 0;
137
+ };
138
+ /**
139
+ * Exactly one `Project`, at the bundle root (P-21). Pure over `bundle.concepts`
140
+ * (`CORE/Bundle.ts:98`; `Concept.type` `CORE/Concept.ts:27`). The rules are
141
+ * independent: two Projects with one nested yield three diagnostics. The file
142
+ * name `project.md` is layout guidance, not checked. Severity is always
143
+ * `error`; no `range`. Output is sorted by `file` then `code`.
144
+ */
145
+ const check = (bundle) => {
146
+ const projects = [...bundle.concepts.values()].filter((concept) => concept.frontmatter.type === PROJECT_TYPE);
147
+ const out = [];
148
+ if (projects.length === 0) out.push(diagnostic("", "project-missing", `No Project concept in the bundle; software-project expects exactly one at the bundle root (${layout.root.project})`));
149
+ if (projects.length > 1) for (const project of projects) {
150
+ const others = projects.filter((other) => other !== project).map((other) => other.path).sort();
151
+ out.push(diagnostic(project.path, "project-multiple", `Project concept "${project.path}" is one of ${projects.length}; software-project expects exactly one (others: ${others.join(", ")})`));
152
+ }
153
+ for (const project of projects) if (project.path.includes("/")) out.push(diagnostic(project.path, "project-not-at-root", `Project concept "${project.path}" is below the bundle root; software-project expects it beside ${layout.root.index}`));
154
+ return out.sort(compare);
155
+ };
156
+ /**
157
+ * The `software-project` profile (spec 5; P-20 to P-26, P-42). Reachable
158
+ * publicly only as `Profiles.softwareProject` (P-38).
159
+ *
160
+ * @public
161
+ */
162
+ const softwareProject = {
163
+ name: "software-project",
164
+ config,
165
+ layout,
166
+ check
167
+ };
168
+
169
+ //#endregion
170
+ export { softwareProject };
package/index.d.ts CHANGED
@@ -1,21 +1,415 @@
1
- //#region src/index.d.ts
1
+ import { Context, DateTime, Effect, FileSystem, Layer, Option, Path, PlatformError, Schema } from "effect";
2
+ import { Git, GitCommandError, NotARepositoryError, UnknownRefError } from "@effected/git";
3
+ import { Actor, Diagnostic, DiagnosticRange, LoadedBundle, OkfitConfig } from "@okfit/core";
4
+ import { ChildProcessSpawner } from "effect/unstable/process";
5
+ //#region src/BodyProvenance.d.ts
2
6
  /**
3
- * Named okfit configuration profiles.
7
+ * The body is at HEAD and last changed in the commit described here (P-9).
8
+ * `at` is that commit's author date (P-4); `committedAt` and the author
9
+ * identity are carried for callers that want a history-based policy later (P-40).
10
+ * `creating` is true exactly when the winning entry is also the oldest entry
11
+ * of the path's history — the commit that created the path (S-9).
4
12
  *
5
- * @packageDocumentation
13
+ * @public
14
+ */
15
+ export declare const BodyCommitted: Schema.TaggedStruct<"committed", {
16
+ readonly at: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
17
+ readonly sha: Schema.String;
18
+ readonly committedAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
19
+ readonly authorName: Schema.String;
20
+ readonly authorEmail: Schema.String;
21
+ readonly creating: Schema.Boolean;
22
+ }>;
23
+ /**
24
+ * The body has no usable commit (P-9): `untracked` means HEAD does not contain
25
+ * the path, `dirty` means the on-disk body differs from HEAD's blob, `unborn`
26
+ * means HEAD has no commits (P-11).
27
+ *
28
+ * @public
29
+ */
30
+ export declare const BodyUncommitted: Schema.TaggedStruct<"uncommitted", {
31
+ readonly reason: Schema.Literals<readonly ["untracked", "dirty", "unborn"]>;
32
+ }>;
33
+ /**
34
+ * What {@link Derivation.generatedAt} reports. A schema so the value also
35
+ * serialises for MCP later without adaptation. Derivation never substitutes
36
+ * `now`; the uncommitted policy is the caller's (P-10).
37
+ *
38
+ * @public
39
+ */
40
+ export declare const BodyProvenance: Schema.Union<readonly [Schema.TaggedStruct<"committed", {
41
+ readonly at: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
42
+ readonly sha: Schema.String;
43
+ readonly committedAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
44
+ readonly authorName: Schema.String;
45
+ readonly authorEmail: Schema.String;
46
+ readonly creating: Schema.Boolean;
47
+ }>, Schema.TaggedStruct<"uncommitted", {
48
+ readonly reason: Schema.Literals<readonly ["untracked", "dirty", "unborn"]>;
49
+ }>]>;
50
+ /**
51
+ * The type of {@link (BodyProvenance:variable)}.
52
+ *
53
+ * @public
54
+ */
55
+ export type BodyProvenance = typeof BodyProvenance.Type;
56
+ /**
57
+ * Why a body is uncommitted (P-9).
58
+ *
59
+ * @public
60
+ */
61
+ type UncommittedReason = typeof BodyUncommitted.fields.reason.Type;
62
+ //#endregion
63
+ //#region src/GitHistory.d.ts
64
+ declare const PathHistoryEntry_base: Schema.Class<PathHistoryEntry, Schema.Struct<{
65
+ readonly sha: Schema.String;
66
+ readonly authoredAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
67
+ readonly committedAt: Schema.Codec<import("effect/DateTime").Utc, string, never, never>;
68
+ readonly authorName: Schema.String;
69
+ readonly authorEmail: Schema.String;
70
+ readonly path: Schema.String;
71
+ }>, {}>;
72
+ /**
73
+ * One record of the path log, newest first (P-3, P-5): the commit, both dates decoded through core's
74
+ * `Timestamp` (`%aI` / `%cI`, strict ISO with an offset; the `Type` is `DateTime.Utc`, P-4), the author
75
+ * identity, and the repository-relative posix path the blob had at that commit (the `--name-only` line).
76
+ * @public
77
+ */
78
+ export declare class PathHistoryEntry extends PathHistoryEntry_base {}
79
+ declare const GitHistoryError_base: Schema.Class<GitHistoryError, Schema.TaggedStruct<"GitHistoryError", {
80
+ /** The argv without the leading `git`. */
81
+ readonly args: Schema.$Array<Schema.String>;
82
+ /** The working directory the command ran in. */
83
+ readonly cwd: Schema.String;
84
+ /** git's exit code; absent when git never produced one. */
85
+ readonly exitCode: Schema.optionalKey<Schema.Number>;
86
+ /** git's stderr, captured under `LC_ALL=C`. */
87
+ readonly stderr: Schema.String;
88
+ /** Spawn failure text, `timed out after 30s`, or `Git.log`'s own absorbed-failure detail. */
89
+ readonly detail: Schema.optionalKey<Schema.String>;
90
+ }>, import("effect/Cause").YieldableError>;
91
+ /**
92
+ * Profiles' own spawn failure, shaped like `@effected/git`'s `GitCommandError` (P-12). Raised by
93
+ * `GitHistory.layer` for any `GitCommandError` `Git.log` produces, including the one `Git.log`
94
+ * itself synthesizes when its own spawn timeout expires.
95
+ * @public
96
+ */
97
+ export declare class GitHistoryError extends GitHistoryError_base {
98
+ get message(): string;
99
+ }
100
+ /**
101
+ * Options for `pathLog`; `limit` becomes `Git.log`'s `--max-count=<n>` (P-3, P-8).
102
+ * @public
103
+ */
104
+ interface PathLogOptions {
105
+ readonly limit?: number;
106
+ }
107
+ /**
108
+ * The service shape. `cwd` is any directory inside the repository; `path` is relative to `cwd` (git
109
+ * pathspec semantics). The result is newest first, and empty when no commit contains the path or HEAD is
110
+ * unborn (P-11).
111
+ * @public
112
+ */
113
+ interface GitHistoryShape {
114
+ readonly pathLog: (cwd: string, path: string, options?: PathLogOptions) => Effect.Effect<ReadonlyArray<PathHistoryEntry>, GitHistoryError | NotARepositoryError>;
115
+ }
116
+ declare const GitHistory_base: Context.ServiceClass<GitHistory, "@okfit/profiles/GitHistory", GitHistoryShape>;
117
+ /**
118
+ * A path-scoped, date-bearing log: `Git.log` reads the whole repository history, and this adapter scopes
119
+ * it to one path (`--follow`, `--diff-merges=first-parent`) and decodes it into `PathHistoryEntry`.
120
+ * Delegates to `Git.log` as of `@effected/git` 0.12.0 (P-1, spec deviation retired).
121
+ * @public
122
+ */
123
+ export declare class GitHistory extends GitHistory_base {
124
+ /**
125
+ * Live layer (P-1, P-5, P-11): one `Git.log` call per `pathLog`, resolved once at construction like
126
+ * `Git.layer` itself, so every member's `R` is `never`. `Git.log` already degrades an unborn `HEAD` and
127
+ * an unmatched pathspec to the empty array and classifies `not a git repository` as `NotARepositoryError`
128
+ * (both pass through unchanged); every other failure becomes `GitHistoryError`, including the one
129
+ * `Git.log` itself produces when its own 30 s spawn timeout expires (`@effected/git`'s `GIT_TIMEOUT`
130
+ * ceiling, not this adapter's) — `Git.log` never lets a bare `Cause.TimeoutError` escape. The CLI
131
+ * provides `ChildProcessSpawner` through `NodeServices.layer` (P-30).
132
+ */
133
+ static readonly layer: Layer.Layer<GitHistory, never, ChildProcessSpawner.ChildProcessSpawner>;
134
+ /**
135
+ * Scripted double (P-32): responses keyed by the `path` argument exactly as passed to `pathLog`. A call
136
+ * whose path has no entry DIES with `GitHistory.makeTest: pathLog(<path>) was called but not scripted`,
137
+ * mirroring `Git.makeTest`; `limit` is honoured by slicing. Models none of the live semantics.
138
+ */
139
+ static readonly makeTest: (script: Readonly<Record<string, ReadonlyArray<PathHistoryEntry>>>) => GitHistoryShape;
140
+ /**
141
+ * `Layer.succeed(GitHistory, GitHistory.makeTest(script))`. Mints a fresh layer per call: bind it to a
142
+ * `const`, since layers memoize by reference.
143
+ */
144
+ static readonly layerTest: (script: Readonly<Record<string, ReadonlyArray<PathHistoryEntry>>>) => Layer.Layer<GitHistory>;
145
+ }
146
+ //#endregion
147
+ //#region src/Derivation.d.ts
148
+ /**
149
+ * Who is writing now. Explicit, never read from the environment (P-16).
150
+ *
151
+ * @public
152
+ */
153
+ type Writer = "agent" | "human";
154
+ declare const HumanActorUnresolvedError_base: Schema.Class<HumanActorUnresolvedError, Schema.TaggedStruct<"HumanActorUnresolvedError", {
155
+ readonly userName: Schema.optionalKey<Schema.String>;
156
+ readonly userEmail: Schema.optionalKey<Schema.String>;
157
+ }>, import("effect/Cause").YieldableError>;
158
+ /**
159
+ * git has no identity to derive a `human:` actor from (P-13 step 4).
160
+ * `userName` and `userEmail` are the trimmed `configGet` answers when git had
161
+ * them and absent keys otherwise (decision 53: not `name`/`email`, since every
162
+ * Effect error class carries the class name on its own prototype and a field
163
+ * literally named `name` would shadow `error.name` when present); test for
164
+ * git's answer with `Object.hasOwn(error, "userName")`.
165
+ *
166
+ * @public
6
167
  */
168
+ export declare class HumanActorUnresolvedError extends HumanActorUnresolvedError_base {
169
+ get message(): string;
170
+ }
171
+ declare const AgentActorUnconfiguredError_base: Schema.Class<AgentActorUnconfiguredError, Schema.TaggedStruct<"AgentActorUnconfiguredError", {}>, import("effect/Cause").YieldableError>;
7
172
  /**
8
- * Names of the profiles this package ships.
173
+ * The writer is an agent but `actors.agent` is not configured (P-17); the
174
+ * profile never sets it.
9
175
  *
10
176
  * @public
11
177
  */
12
- declare const PROFILE_NAMES: readonly ["software-project"];
178
+ export declare class AgentActorUnconfiguredError extends AgentActorUnconfiguredError_base {
179
+ get message(): string;
180
+ }
181
+ /**
182
+ * Options for {@link Derivation.generatedAt} (P-39).
183
+ *
184
+ * @public
185
+ */
186
+ interface GeneratedAtOptions {
187
+ /** ABSOLUTE path to the concept file on disk. */
188
+ readonly file: string;
189
+ /** Reserved (P-48): accepted and ignored in phase 1; no key affects the walk. */
190
+ readonly config?: OkfitConfig;
191
+ }
192
+ /**
193
+ * Everything {@link Derivation.generatedAt} can fail with: profiles' own
194
+ * `GitHistoryError`, the `@effected/git` peer's errors (P-12), and the
195
+ * `PlatformError` of `realPath`/`readFileString` on `file`.
196
+ *
197
+ * @public
198
+ */
199
+ type GeneratedAtError = GitHistoryError | GitCommandError | NotARepositoryError | UnknownRefError | PlatformError.PlatformError;
200
+ /**
201
+ * Options for {@link Derivation.generatedBy} (P-16, P-45).
202
+ *
203
+ * @public
204
+ */
205
+ interface GeneratedByOptions {
206
+ readonly writer: Writer;
207
+ /** Any directory inside the repository; every `Git` member takes a cwd and profiles never reads `process.cwd()`. */
208
+ readonly cwd: string;
209
+ /** Merged or raw; only `actors` is read. */
210
+ readonly config: OkfitConfig;
211
+ }
212
+ /**
213
+ * Everything {@link Derivation.generatedBy} can fail with.
214
+ *
215
+ * @public
216
+ */
217
+ type GeneratedByError = AgentActorUnconfiguredError | HumanActorUnresolvedError | GitCommandError | NotARepositoryError | UnknownRefError;
218
+ /**
219
+ * Trimmed `user.name` / `user.email` as `Git.configGet` answers them; a key is absent when git has no value.
220
+ *
221
+ * @public
222
+ */
223
+ interface GitIdentity {
224
+ readonly name?: string;
225
+ readonly email?: string;
226
+ }
227
+ /**
228
+ * Spec 5.4 derivation rules for the `software-project` profile, package-global
229
+ * (P-38). Pure members take every input as an argument; effectful members
230
+ * require `Git`, `GitHistory` and, for the worktree read, `FileSystem` and
231
+ * `Path` (P-30). Never writes, never reads `generated.by` (P-40).
232
+ *
233
+ * @public
234
+ */
235
+ export declare class Derivation {
236
+ private constructor();
237
+ /**
238
+ * `FrontmatterSource.split(text).body`, then CRLF and lone CR to LF, then trailing whitespace at end of
239
+ * text removed (P-6). Pure. Applied to both sides of every comparison so `core.autocrlf` worktrees compare clean.
240
+ */
241
+ static readonly body: (text: string) => string;
242
+ /**
243
+ * Spec 5.4 rule 2 as P-2 (DERIVE option C). Steps:
244
+ * 1. `root = Git.repoRoot(dirname(file))`: a file outside any repository surfaces `NotARepositoryError`;
245
+ * submodules and worktrees work because the question is asked from the file's directory (P-39).
246
+ * 2. `realPath` both (rev-parse is symlink-resolved), then the repo-relative posix path via `Path` (P-39).
247
+ * 3. `worktree = body(readFileString(file))`.
248
+ * 4. `Git.show(realRoot, "HEAD", rel)`: `UnknownRefError` is `uncommitted { unborn }` (P-11), `Option.none`
249
+ * is `uncommitted { untracked }` (P-47), a body differing from `worktree` is `uncommitted { dirty }` (P-9).
250
+ * The comparison is against HEAD's blob, never the newest log entry (P-46).
251
+ * 5. `entries = GitHistory.pathLog(realRoot, rel)` newest first (P-5, P-46); `[]` is `uncommitted { unborn }`.
252
+ * 6. One `Git.show` per older blob (P-8), newest first: the first entry whose body differs from the next
253
+ * older entry's body wins, else the oldest entry (the creating commit). "Previous" is the previous entry
254
+ * of the simplified `--follow` history, not the true parent (P-7). The newest entry's body is never
255
+ * re-fetched: `head.blob`, already confirmed equal to `worktree` by step 4, is reused (decision 56).
256
+ * `Option.none` for an older entry counts as "differs", never a defect (decision 56).
257
+ * `config` is reserved and ignored (P-48). Never writes, never reads `by` (P-40).
258
+ */
259
+ static readonly generatedAt: (options: GeneratedAtOptions) => Effect.Effect<BodyProvenance, GeneratedAtError, Git | GitHistory | FileSystem.FileSystem | Path.Path>;
260
+ /**
261
+ * P-13 / P-14, pure. Candidates: the email local part (returned as written, lower-cased only for matching)
262
+ * and the `user.name` slug. Order, first hit wins: (1) a `human:` entry of `humans` whose id equals a
263
+ * candidate case-insensitively, returned verbatim; (2) `human:<local>`; (3) `human:<slug>`; (4) none.
264
+ * The result is branded through core's `Actor` codec and always passes it.
265
+ */
266
+ static readonly humanActorId: (identity: GitIdentity, humans: ReadonlyArray<Actor>) => Option.Option<Actor>;
267
+ /**
268
+ * Spec 5.4 rule 1 (P-16, P-45). `"agent"`: `config.actors.agent`, else {@link AgentActorUnconfiguredError}
269
+ * (P-17). `"human"`: `Git.configGet(cwd, "user.name")` and `Git.configGet(cwd, "user.email")` in merged
270
+ * scope (P-15) through {@link Derivation.humanActorId}; none is {@link HumanActorUnresolvedError}.
271
+ * Answers only "who is writing now" (P-40).
272
+ */
273
+ static readonly generatedBy: (options: GeneratedByOptions) => Effect.Effect<Actor, GeneratedByError, Git>;
274
+ /**
275
+ * P-19, pure: `from` plus `config.lifecycle.default_stale_after`, falling back to `OkfitConfig.DEFAULTS`'
276
+ * 90 days. `from` is the caller's: `generated.at` when committed, else its own `now`.
277
+ */
278
+ static readonly staleAfter: (from: DateTime.Utc, config: OkfitConfig) => DateTime.Utc;
279
+ }
280
+ //#endregion
281
+ //#region src/Profile.d.ts
282
+ /**
283
+ * Names of the profiles this package ships (P-38).
284
+ *
285
+ * @public
286
+ */
287
+ export declare const PROFILE_NAMES: readonly ["software-project"];
13
288
  /**
14
289
  * A profile name shipped by this package.
15
290
  *
16
291
  * @public
17
292
  */
18
293
  type ProfileName = (typeof PROFILE_NAMES)[number];
294
+ /**
295
+ * One concept directory of a profile's bundle layout (P-26). Each directory
296
+ * carries its own `index.md`; `type` is the `types.<Name>` key held there.
297
+ *
298
+ * @public
299
+ */
300
+ interface LayoutDirectory {
301
+ readonly directory: string;
302
+ readonly type: string;
303
+ }
304
+ /**
305
+ * Scaffolding data for `okfit init` (spec 5.2); never part of `OkfitConfig`
306
+ * (P-26). What the CLI writes into these files is the CLI plan's concern.
307
+ *
308
+ * @public
309
+ */
310
+ interface Layout {
311
+ readonly root: {
312
+ readonly index: "index.md";
313
+ readonly log: "log.md";
314
+ readonly project: "project.md";
315
+ };
316
+ readonly directories: ReadonlyArray<LayoutDirectory>;
317
+ }
318
+ /**
319
+ * Profile-owned diagnostic codes (P-21). Core's `LintCode` is untouched; all
320
+ * three are severity `error` and are not configurable.
321
+ *
322
+ * @public
323
+ */
324
+ export declare const ProfileDiagnosticCode: Schema.Literals<readonly ["project-missing", "project-multiple", "project-not-at-root"]>;
325
+ /**
326
+ * The type of `ProfileDiagnosticCode`.
327
+ *
328
+ * @public
329
+ */
330
+ export type ProfileDiagnosticCode = typeof ProfileDiagnosticCode.Type;
331
+ /**
332
+ * Same fields as core's `Diagnostic` with a profile code; a `Schema.Struct`
333
+ * per P-21/P-51. `file` is the bundle-relative posix path, `""` for the
334
+ * bundle-level `project-missing`. `range` is never set in phase 1.
335
+ *
336
+ * @public
337
+ */
338
+ export declare const ProfileDiagnostic: Schema.Struct<{
339
+ readonly file: Schema.String;
340
+ readonly range: Schema.optionalKey<typeof DiagnosticRange>;
341
+ readonly code: Schema.Literals<readonly ["project-missing", "project-multiple", "project-not-at-root"]>;
342
+ readonly severity: Schema.Literals<readonly ["error", "warning", "info"]>;
343
+ readonly message: Schema.String;
344
+ }>;
345
+ /**
346
+ * The type of `ProfileDiagnostic`.
347
+ *
348
+ * @public
349
+ */
350
+ export type ProfileDiagnostic = typeof ProfileDiagnostic.Type;
351
+ /**
352
+ * A named profile (P-38): a partial `OkfitConfig` the CLI merges as
353
+ * `DEFAULTS < profile < file`, a scaffolding `Layout`, and a pure `check`
354
+ * over a loaded bundle run after `Validate.all`.
355
+ *
356
+ * @public
357
+ */
358
+ interface Profile {
359
+ readonly name: ProfileName;
360
+ readonly config: OkfitConfig;
361
+ readonly layout: Layout;
362
+ readonly check: (bundle: LoadedBundle) => ReadonlyArray<ProfileDiagnostic>;
363
+ }
364
+ //#endregion
365
+ //#region src/Profiles.d.ts
366
+ /**
367
+ * The profile registry (P-38). No third-party registration in phase 1; the
368
+ * `DEFAULTS < profile < file` merge is the CLI's (D-28).
369
+ *
370
+ * @public
371
+ */
372
+ export declare class Profiles {
373
+ private constructor();
374
+ /** The `software-project` profile (spec 5). */
375
+ static readonly softwareProject: Profile;
376
+ /**
377
+ * `Option.none` for `"none"`, unknown names and anything not in
378
+ * `PROFILE_NAMES`; the CLI decides what that means (P-38). Exact match only.
379
+ */
380
+ static readonly get: (name: string) => Option.Option<Profile>;
381
+ }
382
+ //#endregion
383
+ //#region src/Provenance.d.ts
384
+ /**
385
+ * A profiles-owned lint over committed provenance (S-8). NOT a `Profile`
386
+ * member and NOT a `ProfileDiagnostic` -- it returns core `Diagnostic`s with
387
+ * code `"generated-at-drift"`, a real `LintCode` member, so `severityFor`,
388
+ * the `[lint]` table, the renderers, the hook, and the MCP tool treat it
389
+ * like every other lint (S-7). `Profile.check` and `ProfileDiagnosticCode`
390
+ * are unchanged.
391
+ *
392
+ * Runs `Derivation.generatedAt` per concept, skips uncommitted bodies and
393
+ * concepts without a `generated` block, and returns `[]` the moment a
394
+ * `NotARepositoryError` surfaces: a bundle outside git has nothing to
395
+ * derive for any concept, so the first occurrence short-circuits the whole
396
+ * walk, not just the concept that triggered it (S-8). Emits no `range`
397
+ * (S-12, Judge note 7): `Diagnostic.range` is `Schema.optionalKey` and is
398
+ * simply omitted.
399
+ *
400
+ * `Provenance.lint` is total over severity (S-28, amending Judge note 3):
401
+ * when `OkfitConfig.severityFor(config, "generated-at-drift")` resolves to
402
+ * `"off"`, it returns `[]` before any per-concept work and before any git
403
+ * call, so it is safe to call at any severity and never needs to cast
404
+ * `"off"` away. `validate/run.ts` (the CLI) still gates on `"off"` before
405
+ * calling at all; the two agree by construction (both skip the walk).
406
+ *
407
+ * @public
408
+ */
409
+ export declare class Provenance {
410
+ private constructor();
411
+ static readonly lint: (bundle: LoadedBundle, config: OkfitConfig) => Effect.Effect<ReadonlyArray<Diagnostic>, GitHistoryError | GitCommandError | UnknownRefError | PlatformError.PlatformError, Git | GitHistory | FileSystem.FileSystem | Path.Path>;
412
+ }
19
413
  //#endregion
20
- export { PROFILE_NAMES, ProfileName };
414
+ export type { GeneratedAtError, GeneratedAtOptions, GeneratedByError, GeneratedByOptions, GitHistoryShape, GitIdentity, Layout, LayoutDirectory, PathLogOptions, Profile, ProfileName, UncommittedReason, Writer };
21
415
  //# sourceMappingURL=index.d.ts.map
package/index.js CHANGED
@@ -1,15 +1,8 @@
1
- //#region src/index.ts
2
- /**
3
- * Named okfit configuration profiles.
4
- *
5
- * @packageDocumentation
6
- */
7
- /**
8
- * Names of the profiles this package ships.
9
- *
10
- * @public
11
- */
12
- const PROFILE_NAMES = ["software-project"];
1
+ import { BodyCommitted, BodyProvenance, BodyUncommitted } from "./BodyProvenance.js";
2
+ import { GitHistory, GitHistoryError, PathHistoryEntry } from "./GitHistory.js";
3
+ import { AgentActorUnconfiguredError, Derivation, HumanActorUnresolvedError } from "./Derivation.js";
4
+ import { PROFILE_NAMES, ProfileDiagnostic, ProfileDiagnosticCode } from "./Profile.js";
5
+ import { Profiles } from "./Profiles.js";
6
+ import { Provenance } from "./Provenance.js";
13
7
 
14
- //#endregion
15
- export { PROFILE_NAMES };
8
+ export { AgentActorUnconfiguredError, BodyCommitted, BodyProvenance, BodyUncommitted, Derivation, GitHistory, GitHistoryError, HumanActorUnresolvedError, PROFILE_NAMES, PathHistoryEntry, ProfileDiagnostic, ProfileDiagnosticCode, Profiles, Provenance };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okfit/profiles",
3
- "version": "0.1.0",
3
+ "version": "0.2.0",
4
4
  "private": false,
5
5
  "description": "Named OKF configuration profiles for okfit, starting with the software-project profile.",
6
6
  "keywords": [
@@ -34,9 +34,11 @@
34
34
  },
35
35
  "./package.json": "./package.json"
36
36
  },
37
- "dependencies": {
38
- "@okfit/core": "0.1.0",
39
- "effect": "4.0.0-rc.109"
37
+ "peerDependencies": {
38
+ "@effected/git": "^0.12.0",
39
+ "@effected/markdown": "^0.9.0",
40
+ "@okfit/core": "^0.2.0",
41
+ "effect": "4.0.0-rc.112"
40
42
  },
41
43
  "engines": {
42
44
  "node": ">=24.11.0"