@okfit/engine 0.9.4 → 0.11.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/index.js CHANGED
@@ -1,5 +1,5 @@
1
1
  import { resolveBundleRoot, resolveProjectRoot } from "./config/anchor.js";
2
- import { ConfigMalformedError, ConfigPathNotFoundError, DocumentPathError, InitOverwriteError, SyncStagedLogError, VerifyConceptNotFoundError, VerifySelectionError, VerifyUnsupportedFrontmatterError } from "./errors.js";
2
+ import { ConfigMalformedError, ConfigPathNotFoundError, DocumentPathError, InitOverwriteError, QueryConceptNotFoundError, QuerySelectionError, QueryUnknownVocabularyError, SyncStagedLogError, VerifyConceptNotFoundError, VerifySelectionError, VerifyUnsupportedFrontmatterError } from "./errors.js";
3
3
  import { buildConfigLayer, provideConfig } from "./config/layer.js";
4
4
  import { DEFAULT_PROFILE_NAME, resolveProjectConfig } from "./config/resolve.js";
5
5
  import { runContext } from "./context/run.js";
@@ -10,6 +10,7 @@ import { CONFIG_RELATIVE_PATH, configValue, files, targetPaths } from "./init/sc
10
10
  import { OverlayDocuments, layerOverlayFileSystem, makeOverlayFileSystem } from "./overlay/layer.js";
11
11
  import { provideDocuments, resolveDocumentPath } from "./overlay/documents.js";
12
12
  import { OkfitPlatform } from "./platform.js";
13
+ import { ConceptQuery } from "./query/ConceptQuery.js";
13
14
  import { ContextEnvelope, ContextField, ContextFieldValue, ContextTag, ContextType, contextEnvelope } from "./render/context.js";
14
15
  import { DistributionField } from "./render/distribution.js";
15
16
  import { forDiagnostics, tally } from "./render/exit.js";
@@ -17,6 +18,7 @@ import { ENGINE_VERSION } from "./version.js";
17
18
  import { GraphEdgeEnvelope, GraphEnvelope, GraphNodeEnvelope, GraphSummary, graphEnvelope } from "./render/graph.js";
18
19
  import { collect, sort } from "./render/sort.js";
19
20
  import { JsonDiagnostic, JsonEnvelope, JsonErrorEnvelope, JsonSummary, json, jsonError } from "./render/json.js";
21
+ import { ConceptSummary, QueryGetEnvelope, QueryLink, QueryListEnvelope, QueryNeighbor, QueryNeighborsEnvelope, queryGetEnvelope, queryListEnvelope, queryNeighborsEnvelope, toConceptSummary } from "./render/query.js";
20
22
  import { StaleEnvelope, StaleItem, StaleSummary, staleEnvelope } from "./render/stale.js";
21
23
  import { SkipReason, runSync } from "./sync/run.js";
22
24
  import { SyncEnvelope, SyncModeEnvelope, syncEnvelope } from "./render/sync.js";
@@ -26,6 +28,7 @@ import { conceptFor } from "./session/concept.js";
26
28
  import { withFallbackRange } from "./session/range.js";
27
29
  import { BundleSession } from "./session/BundleSession.js";
28
30
  import { runStale } from "./stale/run.js";
29
- import { runVerify, runVerifyBatch } from "./verify/run.js";
31
+ import { resolveBatchTypes, selectAttestable, selectPickerCandidates } from "./verify/select.js";
32
+ import { loadPickerCandidates, runVerify, runVerifyBatch, runVerifyIds } from "./verify/run.js";
30
33
 
31
- export { BundleSession, CONFIG_RELATIVE_PATH, ConfigMalformedError, ConfigPathNotFoundError, ContextEnvelope, ContextField, ContextFieldValue, ContextTag, ContextType, DEFAULT_PROFILE_NAME, DistributionField, DocumentPathError, ENGINE_VERSION, ExternalReferences, FrontmatterEdits, GraphEdgeEnvelope, GraphEnvelope, GraphNodeEnvelope, GraphSummary, InitOverwriteError, JsonDiagnostic, JsonEnvelope, JsonErrorEnvelope, JsonSummary, Now, OkfitPlatform, OverlayDocuments, ReferenceCheck, ReferenceState, SkipReason, StaleEnvelope, StaleItem, StaleSummary, SyncEnvelope, SyncModeEnvelope, SyncStagedLogError, UnsupportedFrontmatterError, VerifyBatchEnvelope, VerifyConceptNotFoundError, VerifyEnvelope, VerifySelectionError, VerifyUnsupportedFrontmatterError, buildConfigLayer, collect, conceptFor, configValue, contextEnvelope, files, forDiagnostics, graphEnvelope, json, jsonError, layerOverlayFileSystem, makeOverlayFileSystem, provideConfig, provideDocuments, resolveBundleRoot, resolveDocumentPath, resolveProjectConfig, resolveProjectRoot, run, runContext, runGraph, runStale, runSync, runVerify, runVerifyBatch, sort, staleEnvelope, syncEnvelope, tally, targetPaths, verifyBatchEnvelope, verifyEnvelope, withFallbackRange };
34
+ export { BundleSession, CONFIG_RELATIVE_PATH, ConceptQuery, ConceptSummary, ConfigMalformedError, ConfigPathNotFoundError, ContextEnvelope, ContextField, ContextFieldValue, ContextTag, ContextType, DEFAULT_PROFILE_NAME, DistributionField, DocumentPathError, ENGINE_VERSION, ExternalReferences, FrontmatterEdits, GraphEdgeEnvelope, GraphEnvelope, GraphNodeEnvelope, GraphSummary, InitOverwriteError, JsonDiagnostic, JsonEnvelope, JsonErrorEnvelope, JsonSummary, Now, OkfitPlatform, OverlayDocuments, QueryConceptNotFoundError, QueryGetEnvelope, QueryLink, QueryListEnvelope, QueryNeighbor, QueryNeighborsEnvelope, QuerySelectionError, QueryUnknownVocabularyError, ReferenceCheck, ReferenceState, SkipReason, StaleEnvelope, StaleItem, StaleSummary, SyncEnvelope, SyncModeEnvelope, SyncStagedLogError, UnsupportedFrontmatterError, VerifyBatchEnvelope, VerifyConceptNotFoundError, VerifyEnvelope, VerifySelectionError, VerifyUnsupportedFrontmatterError, buildConfigLayer, collect, conceptFor, configValue, contextEnvelope, files, forDiagnostics, graphEnvelope, json, jsonError, layerOverlayFileSystem, loadPickerCandidates, makeOverlayFileSystem, provideConfig, provideDocuments, queryGetEnvelope, queryListEnvelope, queryNeighborsEnvelope, resolveBatchTypes, resolveBundleRoot, resolveDocumentPath, resolveProjectConfig, resolveProjectRoot, run, runContext, runGraph, runStale, runSync, runVerify, runVerifyBatch, runVerifyIds, selectAttestable, selectPickerCandidates, sort, staleEnvelope, syncEnvelope, tally, targetPaths, toConceptSummary, verifyBatchEnvelope, verifyEnvelope, withFallbackRange };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@okfit/engine",
3
- "version": "0.9.4",
3
+ "version": "0.11.0",
4
4
  "private": false,
5
5
  "description": "The okfit engine: platform layer, config discovery, and the validate, verify, sync, init and context programs shared by the okfit CLI and MCP server.",
6
6
  "keywords": [
@@ -34,23 +34,23 @@
34
34
  "./package.json": "./package.json"
35
35
  },
36
36
  "dependencies": {
37
- "@effect/platform-node": "4.0.0-rc.118",
38
- "@effected/app": "^0.19.0",
39
- "@effected/config-file": "^0.13.1",
40
- "@effected/engine": "^0.2.0",
41
- "@effected/git": "^0.19.0",
42
- "@effected/glob": "^0.9.0",
43
- "@effected/jsonc": "^0.14.0",
44
- "@effected/markdown": "^0.14.0",
45
- "@effected/schemastore": "^0.17.0",
46
- "@effected/store": "^0.11.0",
47
- "@effected/toml": "^0.10.0",
48
- "@effected/walker": "^0.14.0",
49
- "@effected/xdg": "^0.8.2",
50
- "@effected/yaml": "^0.18.0",
51
- "@okfit/core": "0.8.3",
52
- "@okfit/profiles": "0.8.2",
53
- "effect": "4.0.0-rc.118"
37
+ "@effect/platform-node": "^4.0.0",
38
+ "@effected/app": "^0.20.0",
39
+ "@effected/config-file": "^0.14.0",
40
+ "@effected/engine": "^0.3.0",
41
+ "@effected/git": "^0.20.0",
42
+ "@effected/glob": "^0.10.0",
43
+ "@effected/jsonc": "^0.15.0",
44
+ "@effected/markdown": "^0.15.0",
45
+ "@effected/schemastore": "^0.18.0",
46
+ "@effected/store": "^0.12.0",
47
+ "@effected/toml": "^0.11.0",
48
+ "@effected/walker": "^0.15.0",
49
+ "@effected/xdg": "^0.9.0",
50
+ "@effected/yaml": "^0.19.0",
51
+ "@okfit/core": "0.8.4",
52
+ "@okfit/profiles": "0.8.3",
53
+ "effect": "^4.0.0"
54
54
  },
55
55
  "engines": {
56
56
  "node": ">=24.11.0"
@@ -0,0 +1,102 @@
1
+ import { QueryConceptNotFoundError, QueryUnknownVocabularyError } from "../errors.js";
2
+ import { ConceptId, Derive, Graph } from "@okfit/core";
3
+ import { Effect, Option } from "effect";
4
+
5
+ //#region src/query/ConceptQuery.ts
6
+ const byId = (a, b) => a.id < b.id ? -1 : a.id > b.id ? 1 : 0;
7
+ const resolve = (bundle, rawId) => Effect.gen(function* () {
8
+ const normalized = ConceptId.normalize(rawId);
9
+ if (Option.isNone(normalized)) return yield* Effect.fail(new QueryConceptNotFoundError({
10
+ id: rawId,
11
+ reason: "empty-id"
12
+ }));
13
+ const id = normalized.value;
14
+ const concept = bundle.concepts.get(id);
15
+ if (concept === void 0) return yield* Effect.fail(new QueryConceptNotFoundError({
16
+ id: rawId,
17
+ reason: "not-a-concept"
18
+ }));
19
+ return {
20
+ id,
21
+ concept
22
+ };
23
+ });
24
+ /**
25
+ * Read-only queries over a loaded bundle, shared by `okfit query` and the MCP
26
+ * tools.
27
+ *
28
+ * @public
29
+ */
30
+ const ConceptQuery = {
31
+ /** Validates types then tags against config (first unknown fails), then filters and sorts by id. */
32
+ list: (bundle, config, filter) => Effect.gen(function* () {
33
+ const declaredTypes = Object.keys(config.types ?? {}).toSorted();
34
+ const declaredTags = Object.keys(config.tags ?? {}).toSorted();
35
+ const types = filter.types ?? [];
36
+ for (const type of types) if (!declaredTypes.includes(type)) return yield* Effect.fail(new QueryUnknownVocabularyError({
37
+ kind: "type",
38
+ requested: type,
39
+ valid: declaredTypes
40
+ }));
41
+ const requestedTags = filter.tags ?? [];
42
+ for (const tag of requestedTags) if (!declaredTags.includes(tag)) return yield* Effect.fail(new QueryUnknownVocabularyError({
43
+ kind: "tag",
44
+ requested: tag,
45
+ valid: declaredTags
46
+ }));
47
+ const statuses = filter.statuses ?? [];
48
+ return [...bundle.concepts.values()].filter((concept) => {
49
+ if (types.length > 0 && !types.includes(concept.frontmatter.type)) return false;
50
+ const tags = concept.frontmatter.tags ?? [];
51
+ if (!requestedTags.every((tag) => tags.includes(tag))) return false;
52
+ if (statuses.length > 0 && !statuses.includes(Derive.status(concept.frontmatter))) return false;
53
+ if (filter.verified !== void 0) {
54
+ if ((concept.frontmatter.verified ?? []).length > 0 !== filter.verified) return false;
55
+ }
56
+ return true;
57
+ }).toSorted(byId);
58
+ }),
59
+ /** One concept by tolerant id, with its outgoing links. */
60
+ get: (bundle, id) => Effect.gen(function* () {
61
+ const resolved = yield* resolve(bundle, id);
62
+ const graph = Graph.fromBundle(bundle);
63
+ const links = graph.edges.filter((edge) => edge.from === resolved.id).map((edge) => ({
64
+ to: edge.to,
65
+ kind: Option.match(graph.node(edge.to), {
66
+ onNone: () => "missing",
67
+ onSome: (node) => node.kind
68
+ }),
69
+ source: edge.data.source,
70
+ ...edge.data.field === void 0 ? {} : { field: edge.data.field }
71
+ }));
72
+ return {
73
+ concept: resolved.concept,
74
+ links
75
+ };
76
+ }),
77
+ /** Outgoing and incoming graph neighbours of one concept. */
78
+ neighbors: (bundle, id) => Effect.gen(function* () {
79
+ const resolved = yield* resolve(bundle, id);
80
+ const graph = Graph.fromBundle(bundle);
81
+ const project = (node) => {
82
+ if (node.kind !== "concept") return {
83
+ id: node.id,
84
+ kind: node.kind,
85
+ concept: null
86
+ };
87
+ return {
88
+ id: node.id,
89
+ kind: node.kind,
90
+ concept: bundle.concepts.get(node.id) ?? null
91
+ };
92
+ };
93
+ return {
94
+ id: resolved.id,
95
+ outgoing: graph.successors(resolved.id).map(project),
96
+ incoming: graph.predecessors(resolved.id).map(project)
97
+ };
98
+ })
99
+ };
100
+
101
+ //#endregion
102
+ export { ConceptQuery };
@@ -0,0 +1,104 @@
1
+ import { DistributionField } from "./distribution.js";
2
+ import { ENGINE_VERSION } from "../version.js";
3
+ import { Derive, GraphNodeKind, Status } from "@okfit/core";
4
+ import { Schema } from "effect";
5
+
6
+ //#region src/render/query.ts
7
+ /** The seven-field shape every list-like query result carries (N-16). @public */
8
+ const ConceptSummary = Schema.Struct({
9
+ id: Schema.String,
10
+ type: Schema.String,
11
+ title: Schema.String,
12
+ description: Schema.NullOr(Schema.String),
13
+ status: Status,
14
+ tags: Schema.Array(Schema.String),
15
+ path: Schema.String
16
+ });
17
+ /** Project one loaded concept into its summary. @public */
18
+ const toConceptSummary = (concept) => ({
19
+ id: concept.id,
20
+ type: concept.frontmatter.type,
21
+ title: Derive.title(concept),
22
+ description: concept.frontmatter.description ?? null,
23
+ status: Derive.status(concept.frontmatter),
24
+ tags: [...concept.frontmatter.tags ?? []],
25
+ path: concept.path
26
+ });
27
+ /** `okfit query list --format json`'s envelope, schema 1, snake_case. @public */
28
+ const QueryListEnvelope = Schema.Struct({
29
+ schema: Schema.Literal(1),
30
+ okfit_version: Schema.String,
31
+ engine_version: Schema.String,
32
+ distribution: DistributionField,
33
+ total: Schema.Int,
34
+ items: Schema.Array(ConceptSummary)
35
+ });
36
+ /** One outgoing link of a concept, as `okfit query get` reports it. @public */
37
+ const QueryLink = Schema.Struct({
38
+ to: Schema.String,
39
+ kind: GraphNodeKind,
40
+ source: Schema.Literals(["body", "frontmatter"]),
41
+ field: Schema.optionalKey(Schema.String)
42
+ });
43
+ /** `okfit query get --format json`'s envelope, schema 1, snake_case. @public */
44
+ const QueryGetEnvelope = Schema.Struct({
45
+ schema: Schema.Literal(1),
46
+ okfit_version: Schema.String,
47
+ engine_version: Schema.String,
48
+ distribution: DistributionField,
49
+ concept: Schema.Struct({
50
+ ...ConceptSummary.fields,
51
+ frontmatter: Schema.Record(Schema.String, Schema.Unknown),
52
+ links: Schema.Array(QueryLink)
53
+ })
54
+ });
55
+ /** One neighbour of a concept; `summary` is `null` for a non-concept node. @public */
56
+ const QueryNeighbor = Schema.Struct({
57
+ id: Schema.String,
58
+ kind: GraphNodeKind,
59
+ summary: Schema.NullOr(ConceptSummary)
60
+ });
61
+ /** `okfit query neighbors --format json`'s envelope, schema 1, snake_case. @public */
62
+ const QueryNeighborsEnvelope = Schema.Struct({
63
+ schema: Schema.Literal(1),
64
+ okfit_version: Schema.String,
65
+ engine_version: Schema.String,
66
+ distribution: DistributionField,
67
+ id: Schema.String,
68
+ outgoing: Schema.Array(QueryNeighbor),
69
+ incoming: Schema.Array(QueryNeighbor)
70
+ });
71
+ /** Build the list envelope; `items` is the already-paged, already-sorted summaries. @public */
72
+ const queryListEnvelope = (input) => ({
73
+ schema: 1,
74
+ okfit_version: input.okfitVersion,
75
+ engine_version: ENGINE_VERSION,
76
+ distribution: input.distribution ?? null,
77
+ total: input.total,
78
+ items: input.items
79
+ });
80
+ /** Build the get envelope from a summary, the raw frontmatter and the outgoing links. @public */
81
+ const queryGetEnvelope = (input) => ({
82
+ schema: 1,
83
+ okfit_version: input.okfitVersion,
84
+ engine_version: ENGINE_VERSION,
85
+ distribution: input.distribution ?? null,
86
+ concept: {
87
+ ...input.concept,
88
+ frontmatter: input.frontmatter,
89
+ links: input.links
90
+ }
91
+ });
92
+ /** Build the neighbors envelope. @public */
93
+ const queryNeighborsEnvelope = (input) => ({
94
+ schema: 1,
95
+ okfit_version: input.okfitVersion,
96
+ engine_version: ENGINE_VERSION,
97
+ distribution: input.distribution ?? null,
98
+ id: input.id,
99
+ outgoing: input.outgoing,
100
+ incoming: input.incoming
101
+ });
102
+
103
+ //#endregion
104
+ export { ConceptSummary, QueryGetEnvelope, QueryLink, QueryListEnvelope, QueryNeighbor, QueryNeighborsEnvelope, queryGetEnvelope, queryListEnvelope, queryNeighborsEnvelope, toConceptSummary };
package/render/verify.js CHANGED
@@ -1,5 +1,6 @@
1
1
  import { DistributionField } from "./distribution.js";
2
2
  import { ENGINE_VERSION } from "../version.js";
3
+ import { Status } from "@okfit/core";
3
4
  import { Schema } from "effect";
4
5
 
5
6
  //#region src/render/verify.ts
@@ -23,6 +24,11 @@ const VerifyEnvelope = Schema.Struct({
23
24
  by: Schema.String,
24
25
  at: Schema.String
25
26
  }),
27
+ /** Issue #185: the status change `--stable`/`--draft` made; `null` when neither flag was given. */
28
+ status: Schema.NullOr(Schema.Struct({
29
+ from: Schema.NullOr(Status),
30
+ to: Schema.Literals(["stable", "draft"])
31
+ })),
26
32
  dry_run: Schema.Boolean,
27
33
  exit_code: Schema.Literal(0)
28
34
  });
@@ -45,6 +51,7 @@ const verifyEnvelope = (input) => ({
45
51
  by: input.by,
46
52
  at: input.at
47
53
  },
54
+ status: input.status,
48
55
  dry_run: input.dryRun,
49
56
  exit_code: 0
50
57
  });
@@ -69,7 +76,11 @@ const VerifyBatchEnvelope = Schema.Struct({
69
76
  })),
70
77
  skipped: Schema.Array(Schema.Struct({
71
78
  id: Schema.String,
72
- reason: Schema.Literals(["draft", "already-verified"])
79
+ reason: Schema.Literals([
80
+ "draft",
81
+ "deprecated",
82
+ "already-verified"
83
+ ])
73
84
  })),
74
85
  dry_run: Schema.Boolean,
75
86
  exit_code: Schema.Literal(0)
package/verify/run.js CHANGED
@@ -1,6 +1,7 @@
1
- import { VerifyConceptNotFoundError, VerifySelectionError, VerifyUnsupportedFrontmatterError } from "../errors.js";
2
- import { documentNewline, locate, stripBom } from "./locate.js";
3
- import { splice } from "./splice.js";
1
+ import { VerifyConceptNotFoundError, VerifyUnsupportedFrontmatterError } from "../errors.js";
2
+ import { documentNewline, locate, locateTopLevelScalar, stripBom } from "./locate.js";
3
+ import { mergeSameOffset, splice, spliceTopLevelScalar } from "./splice.js";
4
+ import { resolveBatchTypes, selectAttestable, selectPickerCandidates } from "./select.js";
4
5
  import { Bundle, ConceptId, Timestamp } from "@okfit/core";
5
6
  import { Effect, FileSystem, Option, Path, Schema } from "effect";
6
7
  import { Derivation } from "@okfit/profiles";
@@ -24,25 +25,39 @@ const UNDECODABLE_CODES = /* @__PURE__ */ new Set([
24
25
  "type-missing"
25
26
  ]);
26
27
  /** Steps 9-14 of runVerify: read, locate (fail closed), splice; no write. */
27
- const prepareVerify = Effect.fn("okfit/verify/prepareVerify")(function* (bundle, concept, actor, at) {
28
+ const prepareVerify = Effect.fn("okfit/verify/prepareVerify")(function* (bundle, concept, actor, at, status) {
28
29
  const fs = yield* FileSystem.FileSystem;
29
30
  const absolutePath = (yield* Path.Path).join(bundle.root, concept.path);
30
- const source = yield* fs.readFileString(absolutePath);
31
+ const source = new TextDecoder("utf-8", { ignoreBOM: true }).decode(yield* fs.readFile(absolutePath));
31
32
  const { text, bom } = stripBom(source);
32
33
  const located = yield* locate(text);
33
34
  if (located._tag === "unsupported") return yield* new VerifyUnsupportedFrontmatterError({
34
35
  id: concept.id,
35
- shape: located.shape
36
+ shape: located.shape,
37
+ key: "verified"
36
38
  });
37
39
  const priorAt = (concept.frontmatter.verified ?? []).filter((entry) => entry.by === actor).map((entry) => Schema.encodeSync(Timestamp)(entry.at));
38
- const edit = splice(located, {
40
+ const newline = documentNewline(text);
41
+ const verifiedEdit = splice(located, {
39
42
  by: actor,
40
43
  at
41
- }, documentNewline(text));
44
+ }, newline);
45
+ let statusEdit;
46
+ if (status !== void 0 && concept.frontmatter.status !== status) {
47
+ const target = yield* locateTopLevelScalar(text, "status");
48
+ if (target._tag === "unsupported") return yield* new VerifyUnsupportedFrontmatterError({
49
+ id: concept.id,
50
+ shape: target.shape,
51
+ key: "status"
52
+ });
53
+ statusEdit = spliceTopLevelScalar(target, "status", status, newline);
54
+ }
55
+ const edits = mergeSameOffset([verifiedEdit, ...statusEdit === void 0 ? [] : [statusEdit]].toSorted((a, b) => a.offset - b.offset || (a === statusEdit ? -1 : b === statusEdit ? 1 : 0)));
42
56
  return {
43
57
  absolutePath,
44
- finalText: bom + MarkdownEdit.applyAll(text, [edit]),
45
- fragment: edit.content,
58
+ finalText: bom + MarkdownEdit.applyAll(text, edits),
59
+ fragment: verifiedEdit.content,
60
+ statusFragment: statusEdit?.content ?? null,
46
61
  priorAt
47
62
  };
48
63
  });
@@ -56,21 +71,11 @@ const writeVerified = Effect.fn("okfit/verify/writeVerified")(function* (absolut
56
71
  yield* fs.chmod(tempPath, original.mode);
57
72
  yield* fs.rename(tempPath, target).pipe(Effect.onError(() => fs.remove(tempPath, { force: true }).pipe(Effect.ignore)));
58
73
  });
59
- /**
60
- * Contract §2.3 steps 6–15. Loads the bundle, resolves the id, resolves the
61
- * human actor from git, splices exactly one entry into the concept's
62
- * frontmatter and writes it back atomically.
63
- *
64
- * Nothing here reads `status`, `stale_after`, or a type's
65
- * `require_verified`: `verify` is mechanical, not config-aware (V-4, V-5).
66
- *
67
- * @internal
68
- */
69
- const runVerify = Effect.fn("okfit/verify/runVerify")(function* (options) {
70
- const bundle = yield* Bundle.load({ root: options.bundleRoot });
71
- const normalized = ConceptId.normalize(options.id);
74
+ /** Steps 7a-7d of runVerify, shared with `runVerifyIds`: tolerant id, reserved file, located concept. */
75
+ const resolveConcept = (bundle, rawId) => Effect.gen(function* () {
76
+ const normalized = ConceptId.normalize(rawId);
72
77
  if (Option.isNone(normalized)) return yield* new VerifyConceptNotFoundError({
73
- id: options.id,
78
+ id: rawId,
74
79
  root: bundle.root,
75
80
  reason: "not-a-concept"
76
81
  });
@@ -93,13 +98,32 @@ const runVerify = Effect.fn("okfit/verify/runVerify")(function* (options) {
93
98
  }
94
99
  });
95
100
  }
101
+ return {
102
+ id,
103
+ concept
104
+ };
105
+ });
106
+ /**
107
+ * Contract §2.3 steps 6–15. Loads the bundle, resolves the id, resolves the
108
+ * human actor from git, splices exactly one entry into the concept's
109
+ * frontmatter and writes it back atomically.
110
+ *
111
+ * Nothing here reads `stale_after` or a type's `require_verified`; `status`
112
+ * is read only to skip a no-op edit under `options.status` (#185). `verify`
113
+ * is mechanical, not config-aware (V-4, V-5).
114
+ *
115
+ * @internal
116
+ */
117
+ const runVerify = Effect.fn("okfit/verify/runVerify")(function* (options) {
118
+ const bundle = yield* Bundle.load({ root: options.bundleRoot });
119
+ const { id, concept } = yield* resolveConcept(bundle, options.id);
96
120
  const actor = yield* Derivation.generatedBy({
97
121
  writer: "human",
98
122
  cwd: options.projectRoot,
99
123
  config: options.config
100
124
  });
101
125
  const at = Schema.encodeSync(Timestamp)(options.at);
102
- const prepared = yield* prepareVerify(bundle, concept, actor, at);
126
+ const prepared = yield* prepareVerify(bundle, concept, actor, at, options.status);
103
127
  if (!options.dryRun) yield* writeVerified(prepared.absolutePath, prepared.finalText);
104
128
  return {
105
129
  id,
@@ -109,13 +133,18 @@ const runVerify = Effect.fn("okfit/verify/runVerify")(function* (options) {
109
133
  at,
110
134
  priorAt: prepared.priorAt,
111
135
  dryRun: options.dryRun,
112
- fragment: prepared.fragment
136
+ fragment: prepared.fragment,
137
+ status: options.status === void 0 ? null : {
138
+ from: concept.frontmatter.status ?? null,
139
+ to: options.status
140
+ },
141
+ statusFragment: prepared.statusFragment
113
142
  };
114
143
  });
115
144
  /**
116
145
  * Issue #138. Attests every concept selected by `options.types` (or, when
117
146
  * empty, every type whose declaration sets `require_verified = true`) that
118
- * is not a draft and does not already carry a `verified` entry by the
147
+ * is not a draft, is not deprecated (#143) and does not already carry a `verified` entry by the
119
148
  * resolved actor. Every candidate is located FIRST -- an unsupported
120
149
  * `verified` shape on any one of them fails the whole batch with nothing
121
150
  * written, mirroring `runVerify`'s own fail-closed rule at batch scale.
@@ -124,43 +153,66 @@ const runVerify = Effect.fn("okfit/verify/runVerify")(function* (options) {
124
153
  */
125
154
  const runVerifyBatch = Effect.fn("okfit/verify/runVerifyBatch")(function* (options) {
126
155
  const bundle = yield* Bundle.load({ root: options.bundleRoot });
127
- const declared = options.config.types ?? {};
128
- let types;
129
- if (options.types.length > 0) {
130
- for (const type of options.types) if (!Object.hasOwn(declared, type)) return yield* new VerifySelectionError({
131
- reason: "unknown-type",
132
- detail: type
133
- });
134
- types = new Set(options.types);
135
- } else types = new Set(Object.entries(declared).flatMap(([name, spec]) => spec.require_verified === true ? [name] : []));
156
+ const types = yield* resolveBatchTypes(options.config, options.types);
157
+ const actor = yield* Derivation.generatedBy({
158
+ writer: "human",
159
+ cwd: options.projectRoot,
160
+ config: options.config
161
+ });
162
+ const at = Schema.encodeSync(Timestamp)(options.at);
163
+ const selection = selectAttestable(bundle, types, actor);
164
+ const prepared = [];
165
+ for (const concept of selection.candidates) prepared.push({
166
+ id: concept.id,
167
+ conceptPath: concept.path,
168
+ ...yield* prepareVerify(bundle, concept, actor, at, void 0)
169
+ });
170
+ if (!options.dryRun) for (const entry of prepared) yield* writeVerified(entry.absolutePath, entry.finalText);
171
+ return {
172
+ bundleRoot: bundle.root,
173
+ by: actor,
174
+ at,
175
+ dryRun: options.dryRun,
176
+ verified: prepared.map(({ id, conceptPath, fragment }) => ({
177
+ id,
178
+ conceptPath,
179
+ fragment
180
+ })),
181
+ skipped: selection.skipped
182
+ };
183
+ });
184
+ /**
185
+ * Issue #214. Attests an explicit list of concept ids in one all-or-nothing
186
+ * batch. Every id is resolved exactly as {@link runVerify} resolves it, and
187
+ * every concept is located and spliced BEFORE any file is written, so one
188
+ * unknown id, reserved file or unsupported `verified`/`status` shape fails the
189
+ * whole call with the tree untouched. An explicit pick is never second-guessed:
190
+ * drafts, deprecated and already-attested concepts are all attested, and
191
+ * `skipped` is always empty. Duplicate ids (after normalisation) are written
192
+ * once. With `promote`, a draft's `status` becomes `stable` in the same write.
193
+ *
194
+ * @public
195
+ */
196
+ const runVerifyIds = Effect.fn("okfit/verify/runVerifyIds")(function* (options) {
197
+ const bundle = yield* Bundle.load({ root: options.bundleRoot });
136
198
  const actor = yield* Derivation.generatedBy({
137
199
  writer: "human",
138
200
  cwd: options.projectRoot,
139
201
  config: options.config
140
202
  });
141
203
  const at = Schema.encodeSync(Timestamp)(options.at);
142
- const skipped = [];
204
+ const concepts = /* @__PURE__ */ new Map();
205
+ for (const rawId of options.ids) {
206
+ const { id, concept } = yield* resolveConcept(bundle, rawId);
207
+ concepts.set(id, concept);
208
+ }
143
209
  const prepared = [];
144
- for (const [id, concept] of bundle.concepts) {
145
- if (!types.has(concept.frontmatter.type)) continue;
146
- if (concept.frontmatter.status === "draft") {
147
- skipped.push({
148
- id,
149
- reason: "draft"
150
- });
151
- continue;
152
- }
153
- if ((concept.frontmatter.verified ?? []).some((entry) => entry.by === actor)) {
154
- skipped.push({
155
- id,
156
- reason: "already-verified"
157
- });
158
- continue;
159
- }
210
+ for (const [id, concept] of concepts) {
211
+ const status = options.promote === true && concept.frontmatter.status === "draft" ? "stable" : void 0;
160
212
  prepared.push({
161
213
  id,
162
214
  conceptPath: concept.path,
163
- ...yield* prepareVerify(bundle, concept, actor, at)
215
+ ...yield* prepareVerify(bundle, concept, actor, at, status)
164
216
  });
165
217
  }
166
218
  if (!options.dryRun) for (const entry of prepared) yield* writeVerified(entry.absolutePath, entry.finalText);
@@ -174,9 +226,28 @@ const runVerifyBatch = Effect.fn("okfit/verify/runVerifyBatch")(function* (optio
174
226
  conceptPath,
175
227
  fragment
176
228
  })),
177
- skipped
229
+ skipped: []
230
+ };
231
+ });
232
+ /**
233
+ * The interactive verify picker's starting point: loads the bundle, resolves
234
+ * the human actor from git exactly as {@link runVerify} does, and selects the
235
+ * rows that actor may still attest. Reads only; nothing is written.
236
+ *
237
+ * @public
238
+ */
239
+ const loadPickerCandidates = Effect.fn("okfit/verify/loadPickerCandidates")(function* (options) {
240
+ const bundle = yield* Bundle.load({ root: options.bundleRoot });
241
+ const by = yield* Derivation.generatedBy({
242
+ writer: "human",
243
+ cwd: options.projectRoot,
244
+ config: options.config
245
+ });
246
+ return {
247
+ by,
248
+ candidates: selectPickerCandidates(bundle, options.config, by)
178
249
  };
179
250
  });
180
251
 
181
252
  //#endregion
182
- export { runVerify, runVerifyBatch };
253
+ export { loadPickerCandidates, runVerify, runVerifyBatch, runVerifyIds };