session-orchestrator 4.0.1 → 4.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.
Files changed (145) hide show
  1. package/.agents/skills/session-plan/SKILL.md +1 -1
  2. package/.claude-plugin/marketplace.json +1 -1
  3. package/.claude-plugin/plugin.json +1 -1
  4. package/.codex-plugin/plugin.json +1 -1
  5. package/.codex-plugin/skills/session-plan/SKILL.md +1 -1
  6. package/.cursor/skills/session-plan/SKILL.md +1 -1
  7. package/.cursor-plugin/plugin.json +1 -1
  8. package/CHANGELOG.md +57 -0
  9. package/README.md +55 -51
  10. package/agents/ux-evaluator.md +1 -1
  11. package/commands/close.md +3 -3
  12. package/commands/go.md +2 -0
  13. package/commands/memory-cleanup.md +4 -3
  14. package/commands/persona-panel.md +1 -1
  15. package/commands/release.md +4 -4
  16. package/commands/session.md +3 -2
  17. package/docs/README.md +4 -4
  18. package/docs/USER-GUIDE.md +115 -48
  19. package/docs/agent-authoring.md +2 -2
  20. package/docs/baseline.md +55 -1
  21. package/docs/ci-setup.md +1 -1
  22. package/docs/codex-setup.md +1 -0
  23. package/docs/components.md +2 -2
  24. package/docs/cursor-setup.md +1 -0
  25. package/docs/events-schema.md +4 -1
  26. package/docs/instruction-delivery.md +1 -1
  27. package/docs/memory-proposal-flow.md +3 -3
  28. package/docs/migration-v4.md +2 -2
  29. package/docs/owner-config-schema.md +74 -90
  30. package/docs/persona-panel.md +4 -4
  31. package/docs/pi-setup.md +1 -0
  32. package/docs/rule-authoring.md +13 -6
  33. package/docs/scope-collision-guard.md +2 -0
  34. package/docs/session-config-reference.md +55 -22
  35. package/docs/session-config-template.md +9 -5
  36. package/docs/vault-docs-architecture.md +4 -2
  37. package/hooks/_lib/hook-import-set.json +28 -3
  38. package/hooks/_lib/vcs-create-matcher.mjs +214 -16
  39. package/hooks/hooks-codex.json +1 -1
  40. package/hooks/hooks.json +1 -1
  41. package/hooks/pre-bash-issue-budget.mjs +123 -26
  42. package/hooks/subagent-telemetry.mjs +106 -20
  43. package/package.json +4 -4
  44. package/scripts/baseline-archetypes.mjs +28 -0
  45. package/scripts/ci/assert-coverage-green.mjs +100 -0
  46. package/scripts/lib/auto-dialectic.mjs +0 -68
  47. package/scripts/lib/baseline-archetypes.mjs +439 -0
  48. package/scripts/lib/build-live-signals.mjs +5 -6
  49. package/scripts/lib/config/issue-budget.mjs +68 -8
  50. package/scripts/lib/config/private-config-dir.mjs +3 -2
  51. package/scripts/lib/config/remote-hosts.mjs +2 -2
  52. package/scripts/lib/config-schema.mjs +79 -0
  53. package/scripts/lib/events.mjs +3 -3
  54. package/scripts/lib/file-lock.mjs +47 -5
  55. package/scripts/lib/issue-budget-reconcile.mjs +392 -0
  56. package/scripts/lib/issue-budget.mjs +76 -3
  57. package/scripts/lib/learnings/evolve-telemetry.mjs +1 -2
  58. package/scripts/lib/maintenance-due-banner.mjs +440 -0
  59. package/scripts/lib/owner-config.example.yaml +29 -46
  60. package/scripts/lib/owner-yaml.mjs +14 -13
  61. package/scripts/lib/project-hygiene.mjs +182 -6
  62. package/scripts/lib/quality-gate.mjs +13 -6
  63. package/scripts/lib/resource-probe/evaluate.mjs +19 -21
  64. package/scripts/lib/rules-sync.mjs +34 -4
  65. package/scripts/lib/session-close-backfill.mjs +182 -40
  66. package/scripts/lib/session-end/phase-skip.mjs +85 -86
  67. package/scripts/lib/session-end/tail-runner.mjs +178 -0
  68. package/scripts/lib/session-identity/own-session.mjs +24 -13
  69. package/scripts/lib/session-schema/constants.mjs +6 -0
  70. package/scripts/lib/session-schema/validator.mjs +20 -0
  71. package/scripts/lib/session-shape.mjs +558 -0
  72. package/scripts/lib/session-start-probes.mjs +10 -3
  73. package/scripts/lib/session-token-rollup.mjs +95 -10
  74. package/scripts/lib/state-md/frontmatter-mutators.mjs +22 -34
  75. package/scripts/lib/state-md.mjs +1 -0
  76. package/scripts/lib/subagents-schema.mjs +77 -9
  77. package/scripts/lib/telemetry/pricing.mjs +197 -0
  78. package/scripts/lib/telemetry/sync.mjs +50 -1
  79. package/scripts/lib/validate/check-owner-leakage.mjs +17 -8
  80. package/scripts/lib/validate/check-skill-script-paths.mjs +33 -10
  81. package/scripts/lib/validate/check-unwired-features.mjs +8 -7
  82. package/scripts/lib/vault-mirror/process.mjs +2 -1
  83. package/scripts/lib/vault-mirror/render-sessions.mjs +8 -1
  84. package/scripts/lib/vault-status/narrative-mirror.mjs +4 -4
  85. package/scripts/lib/wave-resource-gate.mjs +23 -27
  86. package/scripts/lib/wave-sizing.mjs +10 -3
  87. package/scripts/materialize-wave-scope.mjs +68 -14
  88. package/scripts/print-applicable-rules.mjs +7 -6
  89. package/scripts/print-learnings-index.mjs +3 -2
  90. package/scripts/release.mjs +32 -11
  91. package/scripts/session-shape.mjs +266 -0
  92. package/skills/_shared/config-reading.md +15 -9
  93. package/skills/_shared/private-capability-context.md +89 -0
  94. package/skills/bootstrap/SKILL.md +61 -13
  95. package/skills/bootstrap/_shared-template.md +99 -14
  96. package/skills/bootstrap/deep-template.md +36 -26
  97. package/skills/bootstrap/fast-template.md +44 -8
  98. package/skills/bootstrap/intensity-heuristic.md +10 -4
  99. package/skills/bootstrap/private-contract.md +119 -0
  100. package/skills/bootstrap/public-fallback.md +30 -18
  101. package/skills/bootstrap/standard-template.md +39 -24
  102. package/skills/discovery/probes-ui.md +1 -1
  103. package/skills/docs-orchestrator/audience-mapping.md +1 -1
  104. package/skills/evolve/SKILL.md +2 -2
  105. package/skills/gitlab-ops/SKILL.md +3 -3
  106. package/skills/grill/SKILL.md +1 -1
  107. package/skills/memory-cleanup/SKILL.md +2 -2
  108. package/skills/plan/mode-new.md +9 -0
  109. package/skills/reconcile/SKILL.md +1 -1
  110. package/skills/session-end/SKILL.md +3 -2
  111. package/skills/session-end/phase-3-2-docs-verification.md +1 -1
  112. package/skills/session-end/phase-3-6-tail.md +23 -65
  113. package/skills/session-end/phase-3-7a-recommendations.md +2 -2
  114. package/skills/session-end/references/phase-3-documentation-updates.md +8 -6
  115. package/skills/session-end/references/phase-5-issue-cleanup.md +26 -0
  116. package/skills/session-end/session-metrics-write.md +31 -12
  117. package/skills/session-plan/SKILL.md +56 -48
  118. package/skills/session-plan/wave-template.md +8 -15
  119. package/skills/session-start/SKILL.md +18 -2
  120. package/skills/session-start/phase-2-5-docs-planning.md +1 -1
  121. package/skills/session-start/phase-8-5-express-path.md +12 -9
  122. package/skills/session-start/references/phase-1-5-session-continuity.md +2 -0
  123. package/skills/session-start/references/phase-4-ssot-environment-check.md +21 -5
  124. package/skills/session-start/references/phase-6-7-memory-banner-telemetry-consent.md +3 -1
  125. package/skills/test-runner/rubric-v1.md +2 -2
  126. package/skills/wave-executor/SKILL.md +42 -12
  127. package/skills/wave-executor/circuit-breaker.md +3 -1
  128. package/skills/wave-executor/references/wave-loop-dispatch.md +4 -2
  129. package/skills/wave-executor/references/wave-loop-review.md +1 -1
  130. package/skills/wave-executor/references/wave-loop-scope-manifest.md +6 -2
  131. package/templates/nextjs-minimal/package.json +1 -1
  132. package/templates/node-minimal/package.json +1 -1
  133. package/scripts/lib/multi-provider-build/providers.mjs +0 -64
  134. package/scripts/lib/multi-provider-build/templating.mjs +0 -130
  135. package/scripts/lib/owner-config/coerce.mjs +0 -29
  136. package/scripts/lib/owner-config/constants.mjs +0 -21
  137. package/scripts/lib/owner-config/defaults.mjs +0 -50
  138. package/scripts/lib/owner-config/error.mjs +0 -19
  139. package/scripts/lib/owner-config/index.mjs +0 -13
  140. package/scripts/lib/owner-config/merge.mjs +0 -52
  141. package/scripts/lib/owner-config/validate.mjs +0 -259
  142. package/scripts/lib/owner-config-loader.mjs +0 -170
  143. package/scripts/lib/owner-config.mjs +0 -28
  144. package/scripts/lib/soul-resolve.mjs +0 -130
  145. package/scripts/lib/vault-mirror/render.mjs +0 -8
@@ -1,130 +0,0 @@
1
- /**
2
- * scripts/lib/multi-provider-build/templating.mjs
3
- *
4
- * Single-source → many-provider templating (PoC). One source document with
5
- * provider-conditional blocks + placeholders renders to a provider-specific
6
- * artifact. This replaces the "maintain N near-identical copies by hand" model
7
- * our codex-install.mjs / cursor-install.mjs currently imply.
8
- *
9
- * Two primitives (clean-room reimplementation of impeccable's mechanic):
10
- * 1. compileProviderBlocks(content, activeTags)
11
- * <codex> … </codex> blocks: keep body iff the tag is active, else drop.
12
- * Unknown tags are left untouched (so real HTML/JSX isn't mangled).
13
- * 2. replacePlaceholders(content, providerKey, opts)
14
- * {{model}} {{config_file}} {{command_prefix}} {{ask_instruction}}
15
- * {{available_commands}} substitution + optional /cmd → $cmd rewrite.
16
- *
17
- * renderForProvider() composes both in the correct order (blocks first, then
18
- * placeholders — matching impeccable's pipeline).
19
- */
20
-
21
- import { PROVIDERS, ALL_BLOCK_TAGS } from './providers.mjs';
22
-
23
- /**
24
- * Keep `<tag>…</tag>` blocks whose tag is in `activeTags`; strip the rest.
25
- * Only tags in `knownTags` are treated as conditional blocks — any other
26
- * `<foo>…</foo>` (e.g. real markup) is left exactly as-is.
27
- *
28
- * Tags must sit on their own line, e.g.:
29
- * <codex>
30
- * Codex-only text.
31
- * </codex>
32
- *
33
- * @param {string} content
34
- * @param {Iterable<string>} activeTags
35
- * @param {Iterable<string>} [knownTags] — defaults to ALL_BLOCK_TAGS
36
- * @returns {string}
37
- */
38
- export function compileProviderBlocks(content, activeTags, knownTags = ALL_BLOCK_TAGS) {
39
- const active = new Set(activeTags);
40
- const known = new Set(knownTags);
41
- let compiledAny = false;
42
-
43
- // Match a block: leading newline (or start), <tag> on its own line, body,
44
- // </tag> on its own line. Non-greedy body; the backreference enforces matching.
45
- const blockRe = /(^|\r?\n)[ \t]*<([a-z][a-z0-9-]*)>[ \t]*\r?\n([\s\S]*?)\r?\n[ \t]*<\/\2>[ \t]*(?=\r?\n|$)/g;
46
-
47
- const out = content.replace(blockRe, (match, prefix, tag, body) => {
48
- if (!known.has(tag)) return match; // not a provider block — leave untouched
49
- compiledAny = true;
50
- return active.has(tag) ? `${prefix}${body}` : prefix;
51
- });
52
-
53
- // Collapse the 3+ blank-line runs that stripping can leave behind.
54
- return compiledAny ? out.replace(/(\r?\n){3,}/g, '\n\n') : out;
55
- }
56
-
57
- /**
58
- * Substitute {{placeholders}} for a provider and (optionally) rewrite command
59
- * invocations from `/name` to the provider's prefix.
60
- *
61
- * @param {string} content
62
- * @param {string} providerKey
63
- * @param {{ commandNames?: string[] }} [opts]
64
- * @returns {string}
65
- */
66
- export function replacePlaceholders(content, providerKey, opts = {}) {
67
- const provider = PROVIDERS[providerKey];
68
- if (!provider) throw new Error(`Unknown provider: ${providerKey}`);
69
- const { model, configFile, commandPrefix, askInstruction } = provider.placeholders;
70
- const commandNames = opts.commandNames ?? [];
71
-
72
- const availableCommands = commandNames.map((n) => `${commandPrefix}${n}`).join(', ');
73
-
74
- let result = content
75
- .replace(/\{\{model\}\}/g, model)
76
- .replace(/\{\{config_file\}\}/g, configFile)
77
- .replace(/\{\{ask_instruction\}\}/g, askInstruction)
78
- .replace(/\{\{available_commands\}\}/g, availableCommands)
79
- .replace(/\{\{command_prefix\}\}/g, commandPrefix);
80
-
81
- // Rewrite `/cmd` invocations to the provider prefix (e.g. Codex uses `$`).
82
- // Longest names first so `/session-end` is rewritten before `/session`.
83
- if (commandPrefix !== '/' && commandNames.length > 0) {
84
- const sorted = [...commandNames].sort((a, b) => b.length - a.length);
85
- for (const name of sorted) {
86
- // `/name` where name is followed by a non-identifier char or end.
87
- const re = new RegExp(`\\/(${escapeRegex(name)})(?=[^a-zA-Z0-9_-]|$)`, 'g');
88
- // Use a replacement FUNCTION, not a string — a `$` commandPrefix would
89
- // otherwise collide with String.replace's `$$`/`$1` escape syntax.
90
- result = result.replace(re, (_match, g1) => `${commandPrefix}${g1}`);
91
- }
92
- }
93
-
94
- return result;
95
- }
96
-
97
- /**
98
- * Render a source document for a single provider: compile blocks, then
99
- * substitute placeholders.
100
- *
101
- * @param {string} source
102
- * @param {string} providerKey
103
- * @param {{ commandNames?: string[] }} [opts]
104
- * @returns {string}
105
- */
106
- export function renderForProvider(source, providerKey, opts = {}) {
107
- const provider = PROVIDERS[providerKey];
108
- if (!provider) throw new Error(`Unknown provider: ${providerKey}`);
109
- const compiled = compileProviderBlocks(source, provider.tags);
110
- return replacePlaceholders(compiled, providerKey, opts);
111
- }
112
-
113
- /**
114
- * Render a source for every configured provider.
115
- * @param {string} source
116
- * @param {{ commandNames?: string[] }} [opts]
117
- * @returns {Record<string,string>} keyed by provider key
118
- */
119
- export function renderAll(source, opts = {}) {
120
- const out = {};
121
- for (const key of Object.keys(PROVIDERS)) {
122
- out[key] = renderForProvider(source, key, opts);
123
- }
124
- return out;
125
- }
126
-
127
- /** @param {string} s */
128
- function escapeRegex(s) {
129
- return s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
130
- }
@@ -1,29 +0,0 @@
1
- /**
2
- * owner-config/coerce.mjs — Strict-mode wrapper around validate().
3
- *
4
- * Imports: validate + OwnerConfigError. Throws on validation failure, returns
5
- * the normalized value on success. Convenient for CLI entrypoints and tests
6
- * that want to assert on success without inspecting `ok`.
7
- */
8
-
9
- import { validate } from './validate.mjs';
10
- import { OwnerConfigError } from './error.mjs';
11
-
12
- /**
13
- * Strict-mode wrapper around validate(). Returns the normalized value when
14
- * the input passes the gate, otherwise throws OwnerConfigError with the
15
- * full error list attached as `.errors`.
16
- *
17
- * @param {unknown} raw
18
- * @returns {object}
19
- */
20
- export function coerce(raw) {
21
- const result = validate(raw);
22
- if (!result.ok) {
23
- throw new OwnerConfigError(
24
- `owner config validation failed (${result.errors.length} error${result.errors.length === 1 ? '' : 's'})`,
25
- result.errors
26
- );
27
- }
28
- return result.value;
29
- }
@@ -1,21 +0,0 @@
1
- /**
2
- * owner-config/constants.mjs — Schema constants for the owner persona config.
3
- *
4
- * Leaf module. No imports. All exported arrays are frozen so consumers cannot
5
- * accidentally mutate the canonical enum sets.
6
- */
7
-
8
- /** Current owner-config schema version. New writes are stamped with this. */
9
- export const CURRENT_OWNER_SCHEMA_VERSION = 1;
10
-
11
- /** Valid values for tone.style. */
12
- export const VALID_TONE_STYLES = Object.freeze(['direct', 'neutral', 'friendly']);
13
-
14
- /** Valid values for efficiency.output-level. */
15
- export const VALID_OUTPUT_LEVELS = Object.freeze(['lite', 'full', 'ultra']);
16
-
17
- /** Valid values for efficiency.preamble. */
18
- export const VALID_PREAMBLE_LEVELS = Object.freeze(['minimal', 'verbose']);
19
-
20
- /** Valid values for efficiency.comments-in-code. */
21
- export const VALID_COMMENTS_LEVELS = Object.freeze(['minimal', 'full']);
@@ -1,50 +0,0 @@
1
- /**
2
- * owner-config/defaults.mjs — Canonical default-filled owner config factory.
3
- *
4
- * Imports constants only. Leaf in the dependency sense (no circular deps).
5
- * Returns the fully-default-filled config with `owner.name` and
6
- * `owner.language` intentionally left blank — those are required from the user;
7
- * the bootstrap interview (D2) fills them in.
8
- */
9
-
10
- import { CURRENT_OWNER_SCHEMA_VERSION } from './constants.mjs';
11
-
12
- /**
13
- * Returns the canonical default-filled owner config. `owner.name` and
14
- * `owner.language` are intentionally empty — those are user-required fields
15
- * the bootstrap interview (D2) fills in. The `defaults()` shape is what
16
- * `merge()` uses as its base when callers pass partial overrides.
17
- *
18
- * @returns {object}
19
- */
20
- export function defaults() {
21
- return {
22
- 'schema-version': CURRENT_OWNER_SCHEMA_VERSION,
23
- owner: {
24
- name: '',
25
- 'email-hash': null,
26
- language: '',
27
- },
28
- tone: {
29
- style: 'neutral',
30
- tonality: null,
31
- },
32
- efficiency: {
33
- 'output-level': 'full',
34
- preamble: 'minimal',
35
- 'comments-in-code': 'minimal',
36
- },
37
- 'hardware-sharing': {
38
- enabled: false,
39
- 'hash-salt': null,
40
- },
41
- defaults: {
42
- 'preferred-test-command': null,
43
- 'preferred-editor': null,
44
- },
45
- metadata: {
46
- created_at: null,
47
- updated_at: null,
48
- },
49
- };
50
- }
@@ -1,19 +0,0 @@
1
- /**
2
- * owner-config/error.mjs — OwnerConfigError class.
3
- *
4
- * Leaf module. No imports. Thrown by coerce() when validation fails. The
5
- * `.errors` array carries the full list of human-readable failure messages
6
- * collected by validate().
7
- */
8
-
9
- export class OwnerConfigError extends Error {
10
- /**
11
- * @param {string} message
12
- * @param {string[]} [errors]
13
- */
14
- constructor(message, errors = []) {
15
- super(message);
16
- this.name = 'OwnerConfigError';
17
- this.errors = errors;
18
- }
19
- }
@@ -1,13 +0,0 @@
1
- /**
2
- * owner-config/index.mjs — Re-export hub for all 10 public symbols.
3
- *
4
- * DAG: constants/error (leaves) → defaults → merge; constants/error → validate → coerce.
5
- * This hub is the single import target for `scripts/lib/owner-config.mjs` (top wrapper).
6
- */
7
-
8
- export { CURRENT_OWNER_SCHEMA_VERSION, VALID_TONE_STYLES, VALID_OUTPUT_LEVELS, VALID_PREAMBLE_LEVELS, VALID_COMMENTS_LEVELS } from './constants.mjs';
9
- export { OwnerConfigError } from './error.mjs';
10
- export { defaults } from './defaults.mjs';
11
- export { validate } from './validate.mjs';
12
- export { coerce } from './coerce.mjs';
13
- export { merge } from './merge.mjs';
@@ -1,52 +0,0 @@
1
- /**
2
- * owner-config/merge.mjs — Deep merge for owner persona configs.
3
- *
4
- * Imports: constants + defaults. One-level-deep section merge because the
5
- * schema has no nested object leaves beyond top-level sections. The output is
6
- * always a full default-filled config — either input may be partial.
7
- */
8
-
9
- import { CURRENT_OWNER_SCHEMA_VERSION } from './constants.mjs';
10
- import { defaults } from './defaults.mjs';
11
-
12
- function isPlainObject(v) {
13
- return v !== null && typeof v === 'object' && !Array.isArray(v);
14
- }
15
-
16
- /**
17
- * Deep merge two owner configs. `override` values win on every leaf key
18
- * where they are defined (i.e. not undefined). The returned value is a
19
- * full default-filled config — either input may be partial.
20
- *
21
- * Used by D3 (`soul.md` runtime-merge) and D4 (baseline-propagation +
22
- * per-session override). The merge is one level deep on each top-level
23
- * section (owner, tone, efficiency, hardware-sharing, defaults, metadata)
24
- * because the schema has no nested object leaves beyond that.
25
- *
26
- * @param {object|null|undefined} base
27
- * @param {object|null|undefined} override
28
- * @returns {object}
29
- */
30
- export function merge(base, override) {
31
- const baseSafe = isPlainObject(base) ? base : {};
32
- const overSafe = isPlainObject(override) ? override : {};
33
- const def = defaults();
34
-
35
- const sections = ['owner', 'tone', 'efficiency', 'hardware-sharing', 'defaults', 'metadata'];
36
- const out = {
37
- 'schema-version': CURRENT_OWNER_SCHEMA_VERSION,
38
- };
39
-
40
- for (const section of sections) {
41
- const baseSection = isPlainObject(baseSafe[section]) ? baseSafe[section] : {};
42
- const overSection = isPlainObject(overSafe[section]) ? overSafe[section] : {};
43
- out[section] = { ...def[section], ...baseSection };
44
- for (const [k, v] of Object.entries(overSection)) {
45
- if (v !== undefined) {
46
- out[section][k] = v;
47
- }
48
- }
49
- }
50
-
51
- return out;
52
- }
@@ -1,259 +0,0 @@
1
- /**
2
- * owner-config/validate.mjs — Pure schema validator for the owner persona config.
3
- * Never throws — returns {ok, value, errors}. Unknown top-level sections dropped.
4
- */
5
-
6
- import {
7
- CURRENT_OWNER_SCHEMA_VERSION,
8
- VALID_TONE_STYLES,
9
- VALID_OUTPUT_LEVELS,
10
- VALID_PREAMBLE_LEVELS,
11
- VALID_COMMENTS_LEVELS,
12
- } from './constants.mjs';
13
-
14
- // Internal constants
15
- const NAME_MAX = 100;
16
- const TONALITY_MAX = 200;
17
- const TEST_COMMAND_MAX = 200;
18
- const EDITOR_MAX = 50;
19
- const HEX64_RE = /^[a-f0-9]{64}$/i;
20
- const ISO_639_1_RE = /^[a-z]{2}(-[A-Z]{2})?$/;
21
-
22
- // Private helpers
23
- const isPlainObject = (v) => v !== null && typeof v === 'object' && !Array.isArray(v);
24
- const isNonEmptyString = (v, max) => typeof v === 'string' && v.length > 0 && (max === undefined || v.length <= max);
25
-
26
- // Section validators (module-private)
27
- function _validateSchemaVersionSection(raw, errors) {
28
- const schemaVersion = raw['schema-version'];
29
- if (schemaVersion === undefined || schemaVersion === null) {
30
- errors.push('schema-version is required');
31
- } else if (schemaVersion !== CURRENT_OWNER_SCHEMA_VERSION) {
32
- errors.push(
33
- `schema-version must be ${CURRENT_OWNER_SCHEMA_VERSION}, got: ${JSON.stringify(schemaVersion)}`
34
- );
35
- }
36
- }
37
-
38
- function _validateOwnerSection(raw, errors, ownerOut) {
39
- const owner = raw.owner;
40
- if (!isPlainObject(owner)) {
41
- errors.push('owner must be an object');
42
- return;
43
- }
44
- if (!isNonEmptyString(owner.name, NAME_MAX)) {
45
- errors.push(`owner.name must be a non-empty string (max ${NAME_MAX} chars)`);
46
- } else {
47
- ownerOut.name = owner.name;
48
- }
49
- if (owner['email-hash'] !== undefined && owner['email-hash'] !== null) {
50
- if (typeof owner['email-hash'] !== 'string' || !HEX64_RE.test(owner['email-hash'])) {
51
- errors.push('owner.email-hash must be a 64-char hex string (sha256) or null');
52
- } else {
53
- ownerOut['email-hash'] = owner['email-hash'].toLowerCase();
54
- }
55
- }
56
- if (!isNonEmptyString(owner.language, 10) || !ISO_639_1_RE.test(owner.language)) {
57
- errors.push('owner.language must be an ISO-639-1 code (e.g. "de", "en", "en-US")');
58
- } else {
59
- ownerOut.language = owner.language;
60
- }
61
- }
62
-
63
- function _validateToneSection(raw, errors, toneOut) {
64
- if (raw.tone === undefined) return;
65
- if (!isPlainObject(raw.tone)) {
66
- errors.push('tone must be an object');
67
- return;
68
- }
69
- if (raw.tone.style !== undefined) {
70
- if (!VALID_TONE_STYLES.includes(raw.tone.style)) {
71
- errors.push(`tone.style must be one of ${VALID_TONE_STYLES.join('|')}, got: ${raw.tone.style}`);
72
- } else {
73
- toneOut.style = raw.tone.style;
74
- }
75
- }
76
- if (raw.tone.tonality !== undefined && raw.tone.tonality !== null) {
77
- if (typeof raw.tone.tonality !== 'string' || raw.tone.tonality.length > TONALITY_MAX) {
78
- errors.push(`tone.tonality must be a string (max ${TONALITY_MAX} chars) or null`);
79
- } else {
80
- toneOut.tonality = raw.tone.tonality;
81
- }
82
- }
83
- }
84
-
85
- function _validateEfficiencySection(raw, errors, efficiencyOut) {
86
- if (raw.efficiency === undefined) return;
87
- if (!isPlainObject(raw.efficiency)) {
88
- errors.push('efficiency must be an object');
89
- return;
90
- }
91
- if (raw.efficiency['output-level'] !== undefined) {
92
- if (!VALID_OUTPUT_LEVELS.includes(raw.efficiency['output-level'])) {
93
- errors.push(
94
- `efficiency.output-level must be one of ${VALID_OUTPUT_LEVELS.join('|')}, got: ${raw.efficiency['output-level']}`
95
- );
96
- } else {
97
- efficiencyOut['output-level'] = raw.efficiency['output-level'];
98
- }
99
- }
100
- if (raw.efficiency.preamble !== undefined) {
101
- if (!VALID_PREAMBLE_LEVELS.includes(raw.efficiency.preamble)) {
102
- errors.push(
103
- `efficiency.preamble must be one of ${VALID_PREAMBLE_LEVELS.join('|')}, got: ${raw.efficiency.preamble}`
104
- );
105
- } else {
106
- efficiencyOut.preamble = raw.efficiency.preamble;
107
- }
108
- }
109
- if (raw.efficiency['comments-in-code'] !== undefined) {
110
- if (!VALID_COMMENTS_LEVELS.includes(raw.efficiency['comments-in-code'])) {
111
- errors.push(
112
- `efficiency.comments-in-code must be one of ${VALID_COMMENTS_LEVELS.join('|')}, got: ${raw.efficiency['comments-in-code']}`
113
- );
114
- } else {
115
- efficiencyOut['comments-in-code'] = raw.efficiency['comments-in-code'];
116
- }
117
- }
118
- }
119
-
120
- function _validateHardwareSharingSection(raw, errors, hwOut) {
121
- if (raw['hardware-sharing'] === undefined) return;
122
- const hw = raw['hardware-sharing'];
123
- if (!isPlainObject(hw)) {
124
- errors.push('hardware-sharing must be an object');
125
- return;
126
- }
127
- if (hw.enabled !== undefined) {
128
- if (typeof hw.enabled !== 'boolean') {
129
- errors.push(`hardware-sharing.enabled must be boolean, got: ${typeof hw.enabled}`);
130
- } else {
131
- hwOut.enabled = hw.enabled;
132
- }
133
- }
134
- if (hw['hash-salt'] !== undefined && hw['hash-salt'] !== null) {
135
- if (typeof hw['hash-salt'] !== 'string' || !HEX64_RE.test(hw['hash-salt'])) {
136
- errors.push('hardware-sharing.hash-salt must be a 64-char hex string (32 bytes) or null');
137
- } else {
138
- hwOut['hash-salt'] = hw['hash-salt'].toLowerCase();
139
- }
140
- }
141
- // Privacy contract: enabled=true requires hash-salt to be set.
142
- if (hwOut.enabled && hwOut['hash-salt'] === null) {
143
- errors.push('hardware-sharing.enabled=true requires hash-salt to be set (D4 consent contract)');
144
- }
145
- }
146
-
147
- function _validateDefaultsSection(raw, errors, defaultsOut) {
148
- if (raw.defaults === undefined) return;
149
- if (!isPlainObject(raw.defaults)) {
150
- errors.push('defaults must be an object');
151
- return;
152
- }
153
- const ptc = raw.defaults['preferred-test-command'];
154
- if (ptc !== undefined && ptc !== null) {
155
- if (typeof ptc !== 'string' || ptc.length > TEST_COMMAND_MAX) {
156
- errors.push(`defaults.preferred-test-command must be a string (max ${TEST_COMMAND_MAX} chars) or null`);
157
- } else {
158
- defaultsOut['preferred-test-command'] = ptc;
159
- }
160
- }
161
- const pe = raw.defaults['preferred-editor'];
162
- if (pe !== undefined && pe !== null) {
163
- if (typeof pe !== 'string' || pe.length > EDITOR_MAX) {
164
- errors.push(`defaults.preferred-editor must be a string (max ${EDITOR_MAX} chars) or null`);
165
- } else {
166
- defaultsOut['preferred-editor'] = pe;
167
- }
168
- }
169
- }
170
-
171
- function _validateMetadataSection(raw, errors, metadataOut) {
172
- if (raw.metadata === undefined) return;
173
- if (!isPlainObject(raw.metadata)) {
174
- errors.push('metadata must be an object');
175
- return;
176
- }
177
- if (raw.metadata.created_at !== undefined && raw.metadata.created_at !== null) {
178
- if (typeof raw.metadata.created_at !== 'string' || Number.isNaN(Date.parse(raw.metadata.created_at))) {
179
- errors.push('metadata.created_at must be an ISO 8601 timestamp string or null');
180
- } else {
181
- metadataOut.created_at = raw.metadata.created_at;
182
- }
183
- }
184
- if (raw.metadata.updated_at !== undefined && raw.metadata.updated_at !== null) {
185
- if (typeof raw.metadata.updated_at !== 'string' || Number.isNaN(Date.parse(raw.metadata.updated_at))) {
186
- errors.push('metadata.updated_at must be an ISO 8601 timestamp string or null');
187
- } else {
188
- metadataOut.updated_at = raw.metadata.updated_at;
189
- }
190
- }
191
- }
192
-
193
- // Public API
194
-
195
- /**
196
- * Validate a raw owner config object. Defensive — never throws. Returns
197
- * `{ok, value, errors}` where `value` is the normalized (default-filled)
198
- * config when ok=true. When ok=false, `value` is null and `errors` is a
199
- * non-empty array of human-readable messages.
200
- *
201
- * @param {unknown} raw
202
- * @returns {{ok: boolean, value: object|null, errors: string[]}}
203
- */
204
- export function validate(raw) {
205
- const errors = [];
206
-
207
- if (!isPlainObject(raw)) {
208
- return { ok: false, value: null, errors: ['owner config must be an object'] };
209
- }
210
-
211
- // schema-version (required, must be 1)
212
- _validateSchemaVersionSection(raw, errors);
213
-
214
- // owner (required)
215
- const ownerOut = { name: '', 'email-hash': null, language: '' };
216
- _validateOwnerSection(raw, errors, ownerOut);
217
-
218
- // tone (optional; defaults applied)
219
- const toneOut = { style: 'neutral', tonality: null };
220
- _validateToneSection(raw, errors, toneOut);
221
-
222
- // efficiency (optional; defaults applied)
223
- const efficiencyOut = {
224
- 'output-level': 'full',
225
- preamble: 'minimal',
226
- 'comments-in-code': 'minimal',
227
- };
228
- _validateEfficiencySection(raw, errors, efficiencyOut);
229
-
230
- // hardware-sharing (optional; defaults applied)
231
- const hwOut = { enabled: false, 'hash-salt': null };
232
- _validateHardwareSharingSection(raw, errors, hwOut);
233
-
234
- // defaults (optional)
235
- const defaultsOut = { 'preferred-test-command': null, 'preferred-editor': null };
236
- _validateDefaultsSection(raw, errors, defaultsOut);
237
-
238
- // metadata (optional, auto-managed by writer)
239
- const metadataOut = { created_at: null, updated_at: null };
240
- _validateMetadataSection(raw, errors, metadataOut);
241
-
242
- if (errors.length > 0) {
243
- return { ok: false, value: null, errors };
244
- }
245
-
246
- return {
247
- ok: true,
248
- errors: [],
249
- value: {
250
- 'schema-version': CURRENT_OWNER_SCHEMA_VERSION,
251
- owner: ownerOut,
252
- tone: toneOut,
253
- efficiency: efficiencyOut,
254
- 'hardware-sharing': hwOut,
255
- defaults: defaultsOut,
256
- metadata: metadataOut,
257
- },
258
- };
259
- }