orchestrator-workflow 0.40.1 → 0.42.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/doctor.js CHANGED
@@ -2,7 +2,7 @@ import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
3
  import { join } from "node:path";
4
4
  import { PACKAGE_VERSION } from "./assets.js";
5
- import { MANIFEST_PATH, readInstalledManifest } from "./init.js";
5
+ import { MANIFEST_PATH, knowledgeEntryProblems, readInstalledManifest, } from "./init.js";
6
6
  import { DEFAULT_MODELS, rolesForProfile } from "./models.js";
7
7
  import { compareRoutingState } from "./routing-state.js";
8
8
  import { OPERATOR_MANIFEST_FILENAME, operatorManifestState, updateOperatorManifest, } from "./operator-manifest.js";
@@ -17,6 +17,9 @@ export function targetReportToJson(report) {
17
17
  ...(report.routingComparisonGaps?.length
18
18
  ? { routingComparisonGaps: report.routingComparisonGaps }
19
19
  : {}),
20
+ ...(report.knowledgeWarnings?.length
21
+ ? { knowledgeWarnings: report.knowledgeWarnings }
22
+ : {}),
20
23
  versionLag: report.versionLag,
21
24
  reason: report.reason,
22
25
  };
@@ -111,6 +114,61 @@ function computeDriftFiles(targetPath, manifest) {
111
114
  }
112
115
  return drifted;
113
116
  }
117
+ /** The kit-wide implicit default knowledge-bundle location every site falls
118
+ * back to when a target configures no `knowledge` list at all. */
119
+ const DEFAULT_KNOWLEDGE_PATH = "docs/okf";
120
+ /** The raw `knowledge` value of a target's manifest file, before
121
+ * `readInstalledManifest` drops invalid entries; `undefined` when the field
122
+ * is absent or the file cannot be re-read. */
123
+ function readRawKnowledge(targetPath) {
124
+ try {
125
+ const parsed = JSON.parse(readFileSync(join(targetPath, MANIFEST_PATH), "utf8"));
126
+ if (typeof parsed !== "object" || parsed === null)
127
+ return undefined;
128
+ return parsed.knowledge;
129
+ }
130
+ catch {
131
+ return undefined;
132
+ }
133
+ }
134
+ function isDirectoryAt(path) {
135
+ const stat = statOrClassify(path);
136
+ return stat.kind === "ok" && stat.stat.isDirectory();
137
+ }
138
+ /**
139
+ * Checks a target's configured `knowledge` list against its own filesystem.
140
+ * Each raw entry `readInstalledManifest` dropped as malformed is reported
141
+ * with its index and reason. For each kept entry, `path` and `repoRoot` are
142
+ * resolved against the target's top level independently (the manifest's
143
+ * model; `path` is not nested under `repoRoot`) and each one that is not a
144
+ * directory is reported by name. A non-empty list whose normalised `path`s
145
+ * omit an existing `docs/okf/` at the target's root (the implicit default
146
+ * every kit site assumes absent a `knowledge` field) is reported once.
147
+ * Absent or empty `knowledge` is today's default behaviour and never warns
148
+ * about `docs/okf/`: a repo that never configured `knowledge` is not warned
149
+ * about lacking an entry for its own default.
150
+ */
151
+ function computeKnowledgeWarnings(targetPath, manifest) {
152
+ const knowledge = manifest.knowledge ?? [];
153
+ const warnings = knowledgeEntryProblems(readRawKnowledge(targetPath));
154
+ for (const bundle of knowledge) {
155
+ if (!isDirectoryAt(join(targetPath, bundle.path))) {
156
+ warnings.push(`missing configured knowledge path: ${bundle.path}`);
157
+ }
158
+ if (bundle.repoRoot !== "." &&
159
+ !isDirectoryAt(join(targetPath, bundle.repoRoot))) {
160
+ warnings.push(`missing configured knowledge repoRoot: ${bundle.repoRoot}`);
161
+ }
162
+ }
163
+ if (knowledge.length > 0) {
164
+ const docsOkfExists = isDirectoryAt(join(targetPath, DEFAULT_KNOWLEDGE_PATH));
165
+ const docsOkfConfigured = knowledge.some((bundle) => bundle.path === DEFAULT_KNOWLEDGE_PATH);
166
+ if (docsOkfExists && !docsOkfConfigured) {
167
+ warnings.push(`${DEFAULT_KNOWLEDGE_PATH}/ exists but is not in the configured knowledge list`);
168
+ }
169
+ }
170
+ return warnings;
171
+ }
114
172
  function baseReport(target, operator, status, reason) {
115
173
  return {
116
174
  path: target.path,
@@ -210,6 +268,7 @@ export function inspectTarget(target, operator, kitVersion) {
210
268
  const versionLag = hasPin
211
269
  ? manifest.pin !== manifest.version
212
270
  : manifest.version !== kitVersion;
271
+ const knowledgeWarnings = computeKnowledgeWarnings(target.path, manifest);
213
272
  let status;
214
273
  if (driftFiles.length > 0) {
215
274
  status = "drift";
@@ -236,6 +295,7 @@ export function inspectTarget(target, operator, kitVersion) {
236
295
  ...(routingComparison.gaps.length > 0
237
296
  ? { routingComparisonGaps: routingComparison.gaps }
238
297
  : {}),
298
+ ...(knowledgeWarnings.length > 0 ? { knowledgeWarnings } : {}),
239
299
  repoProfile: manifest.profile,
240
300
  operatorProfile: operator.defaults.profile,
241
301
  repoTiers: manifest.tiers,
package/dist/init.d.ts CHANGED
@@ -70,6 +70,32 @@ export interface InitOptions {
70
70
  * too, the same as `null`.
71
71
  */
72
72
  pin?: string | null;
73
+ /**
74
+ * Configured knowledge-bundle locations, or `undefined` to carry the
75
+ * previous manifest's `knowledge` forward unchanged (the same
76
+ * omitted-means-sticky convention `routing`/`pin` already use). A present
77
+ * array (including `[]`) replaces the previous value outright and is
78
+ * validated with {@link validateKnowledgeBundles}: an invalid entry throws
79
+ * rather than writing a malformed record. Absent (never set on any
80
+ * install) or `[]` means today's behaviour: `docs/okf/` is the sole,
81
+ * implicit bundle location every kit site assumes. The CLI has no flag for
82
+ * this; operators hand-edit the manifest field instead.
83
+ */
84
+ knowledge?: KnowledgeBundle[];
85
+ }
86
+ /**
87
+ * One configured knowledge-bundle location. `path` is the bundle directory
88
+ * (for example `docs/okf`) and `repoRoot` the root of the repository the
89
+ * bundle's sources live in (`"."` when that is the worktree itself, a
90
+ * sub-repo's path for a workspace-level bundle whose sources live
91
+ * elsewhere). Each is resolved against the worktree top level on its own:
92
+ * `path` is not nested under `repoRoot`. Both are stored normalised
93
+ * (see {@link checkKnowledgeEntry}) and must not escape the worktree top
94
+ * level; no check argv lives here (see verification-set docs).
95
+ */
96
+ export interface KnowledgeBundle {
97
+ path: string;
98
+ repoRoot: string;
73
99
  }
74
100
  export declare const MANIFEST_PATH: string;
75
101
  export interface Manifest extends OpencodeModelMaps {
@@ -114,6 +140,25 @@ export interface Manifest extends OpencodeModelMaps {
114
140
  * recorded" and therefore never sticky.
115
141
  */
116
142
  harnessesRecordedEmpty?: boolean;
143
+ /**
144
+ * Configured knowledge-bundle locations. Absent when never configured on
145
+ * any install of this repo: every kit site that reads this field then
146
+ * falls back to the implicit `docs/okf/` default, today's behaviour; an
147
+ * empty list means the same. When present, an entry that fails
148
+ * validation (a non-relative or top-level-escaping `path`/`repoRoot`) is
149
+ * dropped rather than carried forward, the same per-entry degradation
150
+ * style `files`/`models` above already use for a hand-written or damaged
151
+ * manifest; `doctor` reports each dropped entry, and a re-install that
152
+ * rewrites the manifest notes each one it removes from disk.
153
+ */
154
+ knowledge?: KnowledgeBundle[];
155
+ /**
156
+ * The {@link knowledgeEntryProblems} of the raw on-disk `knowledge` value,
157
+ * set by `readInstalledManifest` only when there is at least one. Never
158
+ * written back: `runInit` turns each into a report note when it rewrites
159
+ * the manifest and so removes the offending value from disk.
160
+ */
161
+ knowledgeProblems?: string[];
117
162
  }
118
163
  /**
119
164
  * A manifest can be hand-written or tampered with, and uninstall deletes by
@@ -122,6 +167,38 @@ export interface Manifest extends OpencodeModelMaps {
122
167
  * can never reach an unlink.
123
168
  */
124
169
  export declare function isContainedRelativePath(relativePath: string): boolean;
170
+ /**
171
+ * Checks one raw `knowledge` entry. `path` (the bundle directory) and
172
+ * `repoRoot` (the root of the repository its sources live in) are each
173
+ * resolved against the worktree top level, independently of one another, so
174
+ * a workspace-level bundle whose sources live in a sub-repo is
175
+ * `{ path: "docs/okf", repoRoot: "sub" }`. `repoRoot` defaults to `"."`
176
+ * when omitted. Returns the normalised entry, or the reason it is invalid
177
+ * (naming the offending field) for the caller to throw or report.
178
+ */
179
+ export declare function checkKnowledgeEntry(entry: unknown): {
180
+ bundle: KnowledgeBundle;
181
+ } | {
182
+ reason: string;
183
+ };
184
+ /**
185
+ * Lists every problem in a raw on-disk `knowledge` value: `[]` when the
186
+ * field is absent or every entry is valid, one item for a non-array value,
187
+ * otherwise one item per invalid entry with its index and reason. `doctor`
188
+ * reports these, since {@link parseKnowledgeBundles} drops such entries on
189
+ * read; `runInit` reports them as notes when a re-install that carries
190
+ * `knowledge` forward rewrites the manifest and so removes them from disk.
191
+ */
192
+ export declare function knowledgeEntryProblems(raw: unknown): string[];
193
+ /**
194
+ * Validates an explicitly-supplied `options.knowledge` array for `runInit`
195
+ * with {@link checkKnowledgeEntry} and returns the normalised entries.
196
+ * Throws on the first invalid entry -- explicit input refuses to write a
197
+ * malformed record rather than dropping it (contrast
198
+ * {@link parseKnowledgeBundles}, which degrades a hand-edited/damaged
199
+ * on-disk manifest by dropping bad entries instead of throwing).
200
+ */
201
+ export declare function validateKnowledgeBundles(knowledge: unknown): KnowledgeBundle[];
125
202
  /**
126
203
  * Reads the manifest of a previous install, if any. Manifests can be written
127
204
  * by hand (manual agent installs) or damaged, so every field is sanitized;
package/dist/init.js CHANGED
@@ -1,6 +1,6 @@
1
1
  import { createHash } from "node:crypto";
2
2
  import { existsSync, readFileSync, statSync } from "node:fs";
3
- import { isAbsolute, join, normalize, sep } from "node:path";
3
+ import { isAbsolute, join, normalize, posix, sep, win32 } from "node:path";
4
4
  import { PACKAGE_VERSION, listSkillReferenceNames, listTemplateNames, readAgentAsset, readAsset, } from "./assets.js";
5
5
  import { HARNESSES } from "./detect.js";
6
6
  import { CLASS_MODELS, DEFAULT_PROFILE, DEFAULT_TIER, READ_ONLY_ROLES, ROLES, ROLE_TIERS, TIER_DEFS, assertValidModelId, claudeModelValue, isProfile, opencodeModelValue, rolesForProfile, } from "./models.js";
@@ -25,6 +25,155 @@ export function isContainedRelativePath(relativePath) {
25
25
  const normalized = normalize(relativePath);
26
26
  return normalized !== ".." && !normalized.startsWith(`..${sep}`);
27
27
  }
28
+ /**
29
+ * The containment problem of one knowledge-bundle path spelling, or
30
+ * `undefined` when it stays inside the worktree top level: an absolute path
31
+ * (native, POSIX, or Windows such as `C:/x`), any other value starting with
32
+ * a Windows drive letter (the drive-relative `C:x`, `C:..` or `C:`, which
33
+ * Windows resolves against that drive's current directory), or a `..`
34
+ * escape. The `..` test is only meaningful on a normalised value.
35
+ */
36
+ function knowledgePathContainmentProblem(value) {
37
+ if (isAbsolute(value) || posix.isAbsolute(value) || win32.isAbsolute(value)) {
38
+ return "is an absolute path";
39
+ }
40
+ if (/^[A-Za-z]:/.test(value))
41
+ return "is a Windows drive path";
42
+ if (value === ".." || value.startsWith("../")) {
43
+ return "escapes the worktree top level";
44
+ }
45
+ return undefined;
46
+ }
47
+ /**
48
+ * Normalises one knowledge-bundle path field (`path` or `repoRoot`) and
49
+ * returns it, or a reason string when it is not acceptable. Both fields are
50
+ * worktree-relative, so spellings of the same location compare equal after
51
+ * this step: POSIX `normalize`, then any trailing `/` stripped (`docs/okf/`,
52
+ * `./docs/okf` and `docs/okf` all become `docs/okf`). An empty string, any
53
+ * backslash, and every value with a
54
+ * {@link knowledgePathContainmentProblem} are rejected. The containment
55
+ * check runs on the normalised value, which is the one that is stored and
56
+ * later resolved, so a prefix that normalisation removes cannot smuggle a
57
+ * drive or absolute form past it (`./C:x` and `a/../C:x` store `C:x`,
58
+ * `docs/../C:/x` stores `C:/x`; all rejected). It also runs on the value as
59
+ * written, so an absolute or drive path is never silently reinterpreted as
60
+ * a relative one (`C:/../docs` would normalise to `docs`). An accepted
61
+ * value's stored form is therefore accepted again unchanged. `.` (the
62
+ * worktree top level itself) is accepted only when `allowTop` is set, which
63
+ * is the case for `repoRoot` and not for `path`. The backslash rule is
64
+ * platform-independent: POSIX normalisation treats `\` as an ordinary
65
+ * character, so `..\outside` would pass the `..` test here and still resolve
66
+ * outside the worktree under Windows path semantics; the stored separator
67
+ * is always `/`.
68
+ */
69
+ function normalizeKnowledgePathField(value, allowTop) {
70
+ if (typeof value !== "string")
71
+ return { reason: "is not a string" };
72
+ if (value === "")
73
+ return { reason: "is empty" };
74
+ if (value.includes("\\"))
75
+ return { reason: "contains a backslash" };
76
+ let normalized = posix.normalize(value);
77
+ while (normalized.length > 1 && normalized.endsWith("/")) {
78
+ normalized = normalized.slice(0, -1);
79
+ }
80
+ const problem = knowledgePathContainmentProblem(value) ??
81
+ knowledgePathContainmentProblem(normalized);
82
+ if (problem !== undefined)
83
+ return { reason: problem };
84
+ if (normalized === "." && !allowTop) {
85
+ return { reason: "names the worktree top level itself" };
86
+ }
87
+ return { value: normalized };
88
+ }
89
+ /**
90
+ * Checks one raw `knowledge` entry. `path` (the bundle directory) and
91
+ * `repoRoot` (the root of the repository its sources live in) are each
92
+ * resolved against the worktree top level, independently of one another, so
93
+ * a workspace-level bundle whose sources live in a sub-repo is
94
+ * `{ path: "docs/okf", repoRoot: "sub" }`. `repoRoot` defaults to `"."`
95
+ * when omitted. Returns the normalised entry, or the reason it is invalid
96
+ * (naming the offending field) for the caller to throw or report.
97
+ */
98
+ export function checkKnowledgeEntry(entry) {
99
+ if (typeof entry !== "object" || entry === null || Array.isArray(entry)) {
100
+ return { reason: "is not an object" };
101
+ }
102
+ const candidate = entry;
103
+ const path = normalizeKnowledgePathField(candidate.path, false);
104
+ if ("reason" in path)
105
+ return { reason: `path ${path.reason}` };
106
+ const repoRoot = normalizeKnowledgePathField(candidate.repoRoot === undefined ? "." : candidate.repoRoot, true);
107
+ if ("reason" in repoRoot)
108
+ return { reason: `repoRoot ${repoRoot.reason}` };
109
+ return { bundle: { path: path.value, repoRoot: repoRoot.value } };
110
+ }
111
+ /**
112
+ * Lists every problem in a raw on-disk `knowledge` value: `[]` when the
113
+ * field is absent or every entry is valid, one item for a non-array value,
114
+ * otherwise one item per invalid entry with its index and reason. `doctor`
115
+ * reports these, since {@link parseKnowledgeBundles} drops such entries on
116
+ * read; `runInit` reports them as notes when a re-install that carries
117
+ * `knowledge` forward rewrites the manifest and so removes them from disk.
118
+ */
119
+ export function knowledgeEntryProblems(raw) {
120
+ if (raw === undefined)
121
+ return [];
122
+ if (!Array.isArray(raw)) {
123
+ return ["knowledge is not an array and is ignored"];
124
+ }
125
+ const problems = [];
126
+ raw.forEach((entry, index) => {
127
+ const checked = checkKnowledgeEntry(entry);
128
+ if ("reason" in checked) {
129
+ problems.push(`knowledge[${index}] ${checked.reason} and is ignored`);
130
+ }
131
+ });
132
+ return problems;
133
+ }
134
+ /**
135
+ * Validates an explicitly-supplied `options.knowledge` array for `runInit`
136
+ * with {@link checkKnowledgeEntry} and returns the normalised entries.
137
+ * Throws on the first invalid entry -- explicit input refuses to write a
138
+ * malformed record rather than dropping it (contrast
139
+ * {@link parseKnowledgeBundles}, which degrades a hand-edited/damaged
140
+ * on-disk manifest by dropping bad entries instead of throwing).
141
+ */
142
+ export function validateKnowledgeBundles(knowledge) {
143
+ if (!Array.isArray(knowledge)) {
144
+ throw new Error("options.knowledge must be an array of { path, repoRoot } entries");
145
+ }
146
+ return knowledge.map((entry, index) => {
147
+ const checked = checkKnowledgeEntry(entry);
148
+ if ("reason" in checked) {
149
+ throw new Error(`options.knowledge[${index}] ${checked.reason}; path and repoRoot must be relative paths inside the worktree top level`);
150
+ }
151
+ return checked.bundle;
152
+ });
153
+ }
154
+ /**
155
+ * Sanitizes a possibly hand-edited or damaged on-disk manifest's raw
156
+ * `knowledge` field for {@link readInstalledManifest}: an entry that fails
157
+ * {@link checkKnowledgeEntry} is dropped rather than throwing (the read path
158
+ * can never crash a re-install on tampered input, the same reasoning
159
+ * `files`/`models` already document above), and a valid entry is kept in
160
+ * its normalised spelling. Returns `undefined` when the raw field is absent
161
+ * or not an array, so the "never configured" and "configured but empty"
162
+ * states stay distinct the same way `routing`'s own `"routing" in
163
+ * candidate` check does.
164
+ */
165
+ function parseKnowledgeBundles(candidate) {
166
+ if (!("knowledge" in candidate) || !Array.isArray(candidate.knowledge)) {
167
+ return undefined;
168
+ }
169
+ const result = [];
170
+ for (const entry of candidate.knowledge) {
171
+ const checked = checkKnowledgeEntry(entry);
172
+ if ("bundle" in checked)
173
+ result.push(checked.bundle);
174
+ }
175
+ return result;
176
+ }
28
177
  /**
29
178
  * Reads the manifest of a previous install, if any. Manifests can be written
30
179
  * by hand (manual agent installs) or damaged, so every field is sanitized;
@@ -102,6 +251,8 @@ export function readInstalledManifest(targetDir) {
102
251
  if ("routing" in candidate) {
103
252
  routing = parseRouting(candidate.routing);
104
253
  }
254
+ const knowledge = parseKnowledgeBundles(candidate);
255
+ const knowledgeProblems = knowledgeEntryProblems(candidate.knowledge);
105
256
  // A hand-written or damaged manifest may carry a non-string `pin`; that
106
257
  // degrades to "no recorded pin" here (the same per-field-degradation
107
258
  // style as `profile`/`tiers` above) rather than throwing. An empty or
@@ -117,6 +268,8 @@ export function readInstalledManifest(targetDir) {
117
268
  profile,
118
269
  tiers,
119
270
  ...(routing !== undefined ? { routing } : {}),
271
+ ...(knowledge !== undefined ? { knowledge } : {}),
272
+ ...(knowledgeProblems.length > 0 ? { knowledgeProblems } : {}),
120
273
  ...opencodeMaps,
121
274
  files,
122
275
  installedAt: typeof candidate.installedAt === "string" ? candidate.installedAt : "",
@@ -348,6 +501,14 @@ export function runInit(options) {
348
501
  updateOpencodeModels: options.opencodeModels !== undefined,
349
502
  updateOpencodeClassModels: options.opencodeClassModels !== undefined,
350
503
  });
504
+ // `undefined` carries the previous manifest's `knowledge` forward
505
+ // unchanged, the same omitted-means-sticky convention `routing`/`pin` use
506
+ // above; a present array (including `[]`) is validated and replaces the
507
+ // previous value outright, throwing before any file is written on an
508
+ // invalid entry rather than persisting a malformed record.
509
+ const knowledge = options.knowledge !== undefined
510
+ ? validateKnowledgeBundles(options.knowledge)
511
+ : previous?.knowledge;
351
512
  // Check only rendered selections before the first mutation.
352
513
  for (const warning of codexCatalogWarnings(routing, { harnesses: options.harnesses, profile, tiers }, options.codexCatalog)) {
353
514
  report.notes.push(`Codex catalog: ${warning}`);
@@ -677,6 +838,7 @@ export function runInit(options) {
677
838
  profile,
678
839
  tiers,
679
840
  routing,
841
+ ...(knowledge !== undefined ? { knowledge } : {}),
680
842
  ...compatibility,
681
843
  files: installedFiles,
682
844
  ...(pin !== undefined ? { pin } : {}),
@@ -691,6 +853,9 @@ export function runInit(options) {
691
853
  profile: previous.profile,
692
854
  tiers: previous.tiers,
693
855
  ...(previous.routing !== undefined ? { routing: previous.routing } : {}),
856
+ ...(previous.knowledge !== undefined
857
+ ? { knowledge: previous.knowledge }
858
+ : {}),
694
859
  ...legacyOpencodeFallbacks(previous),
695
860
  files: previous.files,
696
861
  ...(previous.pin !== undefined ? { pin: previous.pin } : {}),
@@ -698,6 +863,16 @@ export function runInit(options) {
698
863
  report.skipped.push(manifestPath);
699
864
  }
700
865
  else {
866
+ // `previous.knowledge` was sanitized on read, so rewriting the manifest
867
+ // from it removes each invalid hand-edited entry (or a non-array value)
868
+ // from disk, after which `doctor` has nothing left to report. Name each
869
+ // one here instead. An explicit `options.knowledge` replaces the whole
870
+ // field on purpose and gets no note.
871
+ if (options.knowledge === undefined) {
872
+ for (const problem of previous?.knowledgeProblems ?? []) {
873
+ report.notes.push(`manifest: ${problem}; dropped from the rewritten manifest`);
874
+ }
875
+ }
701
876
  const manifest = {
702
877
  ...desired,
703
878
  installedAt: previous?.installedAt || new Date().toISOString(),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orchestrator-workflow",
3
- "version": "0.40.1",
3
+ "version": "0.42.0",
4
4
  "description": "Installer for an orchestrator-led agent workflow: .ai/ run state, an AGENTS.md policy section, and per-harness subagent definitions for Claude Code, OpenAI Codex, and opencode",
5
5
  "main": "dist/index.js",
6
6
  "type": "module",