@open-agent-toolkit/cli 0.2.5 → 0.2.7
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/assets/docs/cli-utilities/configuration.md +40 -0
- package/assets/docs/cli-utilities/tool-packs.md +31 -0
- package/assets/docs/workflows/projects/artifacts.md +27 -0
- package/assets/docs/workflows/skills/explainer-kit.md +125 -0
- package/assets/docs/workflows/skills/index.md +4 -0
- package/assets/migration/pjm-restructure.md +12 -0
- package/assets/public-package-versions.json +4 -4
- package/assets/skills/explainer-kit/SKILL.md +95 -0
- package/assets/skills/explainer-kit/examples/project-explainer/content.md +31 -0
- package/assets/skills/explainer-kit/examples/project-explainer/fact-base.md +22 -0
- package/assets/skills/explainer-kit/examples/project-recap/content.md +34 -0
- package/assets/skills/explainer-kit/examples/project-recap/fact-base.md +22 -0
- package/assets/skills/explainer-kit/examples/theme-bundle.json +87 -0
- package/assets/skills/explainer-kit/palettes/ember.json +37 -0
- package/assets/skills/explainer-kit/palettes/forest.json +37 -0
- package/assets/skills/explainer-kit/palettes/neutral.json +37 -0
- package/assets/skills/explainer-kit/palettes/ocean.json +37 -0
- package/assets/skills/explainer-kit/palettes/violet.json +37 -0
- package/assets/skills/explainer-kit/profiles/clean.json +42 -0
- package/assets/skills/explainer-kit/profiles/editorial.json +42 -0
- package/assets/skills/explainer-kit/profiles/technical.json +39 -0
- package/assets/skills/explainer-kit/recipes/engineer-tour.json +33 -0
- package/assets/skills/explainer-kit/recipes/project-explainer.json +33 -0
- package/assets/skills/explainer-kit/recipes/project-recap.json +34 -0
- package/assets/skills/explainer-kit/references/contracts.md +101 -0
- package/assets/skills/explainer-kit/references/destination-contract.md +94 -0
- package/assets/skills/explainer-kit/references/extension-contract.md +71 -0
- package/assets/skills/explainer-kit/references/fact-base-contract.md +119 -0
- package/assets/skills/explainer-kit/schemas/build-record.schema.json +92 -0
- package/assets/skills/explainer-kit/schemas/durability-evidence.schema.json +53 -0
- package/assets/skills/explainer-kit/schemas/fact-base.schema.json +129 -0
- package/assets/skills/explainer-kit/schemas/manifest.schema.json +193 -0
- package/assets/skills/explainer-kit/schemas/publish-receipt.schema.json +97 -0
- package/assets/skills/explainer-kit/schemas/publish-request.schema.json +32 -0
- package/assets/skills/explainer-kit/schemas/run-request.schema.json +132 -0
- package/assets/skills/explainer-kit/schemas/theme.schema.json +217 -0
- package/assets/skills/explainer-kit/scripts/lib/content-approval.mjs +180 -0
- package/assets/skills/explainer-kit/scripts/lib/contracts.mjs +680 -0
- package/assets/skills/explainer-kit/scripts/lib/durability.mjs +538 -0
- package/assets/skills/explainer-kit/scripts/lib/fact-base.mjs +494 -0
- package/assets/skills/explainer-kit/scripts/lib/fs-safe.mjs +218 -0
- package/assets/skills/explainer-kit/scripts/lib/qa.mjs +512 -0
- package/assets/skills/explainer-kit/scripts/lib/recipes.mjs +289 -0
- package/assets/skills/explainer-kit/scripts/lib/records.mjs +231 -0
- package/assets/skills/explainer-kit/scripts/lib/render.mjs +485 -0
- package/assets/skills/explainer-kit/scripts/lib/s3-static.mjs +587 -0
- package/assets/skills/explainer-kit/scripts/lib/safe-paths.mjs +96 -0
- package/assets/skills/explainer-kit/scripts/lib/theme.mjs +332 -0
- package/assets/skills/explainer-kit/scripts/publish.mjs +76 -0
- package/assets/skills/explainer-kit/scripts/record-durability.mjs +64 -0
- package/assets/skills/explainer-kit/scripts/render-qa.mjs +273 -0
- package/assets/skills/explainer-kit/scripts/run.mjs +778 -0
- package/assets/skills/explainer-kit/scripts/validate.mjs +59 -0
- package/assets/skills/explainer-kit/templates/deck-shell.html +233 -0
- package/assets/skills/explainer-kit/templates/diagram-shell.html +219 -0
- package/assets/skills/explainer-kit/templates/engineer-tour.html +273 -0
- package/assets/skills/explainer-kit/templates/house-style.html +218 -0
- package/assets/skills/oat-explainer-kit/SKILL.md +92 -0
- package/assets/skills/oat-explainer-kit/references/config-contract.md +67 -0
- package/assets/skills/oat-explainer-kit/references/lifecycle-contract.md +143 -0
- package/assets/skills/oat-explainer-kit/references/migration.md +143 -0
- package/assets/skills/oat-explainer-kit/scripts/bind-project-sources.mjs +103 -0
- package/assets/skills/oat-explainer-kit/scripts/check-core.mjs +127 -0
- package/assets/skills/oat-explainer-kit/scripts/finalize-tracked-run.mjs +392 -0
- package/assets/skills/oat-explainer-kit/scripts/persist-intent.mjs +130 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-config.mjs +293 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-intent.mjs +209 -0
- package/assets/skills/oat-explainer-kit/scripts/resolve-paths.mjs +166 -0
- package/assets/skills/oat-explainer-kit/scripts/run.mjs +284 -0
- package/assets/skills/oat-project-autonomous/SKILL.md +22 -1
- package/assets/skills/oat-project-autonomous/references/gate-inventory.md +5 -1
- package/assets/skills/oat-project-complete/SKILL.md +166 -28
- package/assets/skills/oat-project-document/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-implement/SKILL.md +1 -1
- package/assets/skills/oat-project-implement/references/completion-and-closeout.md +17 -0
- package/assets/skills/oat-project-implement/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-plan/SKILL.md +30 -1
- package/assets/skills/oat-project-pr-final/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-quick-start/references/docs/autonomy-contract.md +5 -1
- package/assets/skills/oat-project-summary/SKILL.md +20 -1
- package/assets/skills/oat-wave-execute/SKILL.md +1 -1
- package/dist/commands/config/index.d.ts.map +1 -1
- package/dist/commands/config/index.js +232 -1
- package/dist/commands/init/tools/shared/skill-manifest.d.ts +2 -2
- package/dist/commands/init/tools/shared/skill-manifest.d.ts.map +1 -1
- package/dist/commands/init/tools/shared/skill-manifest.js +2 -0
- package/dist/commands/project/archive/archive-utils.d.ts +12 -0
- package/dist/commands/project/archive/archive-utils.d.ts.map +1 -1
- package/dist/commands/project/archive/archive-utils.js +320 -8
- package/dist/commands/project/archive/index.d.ts.map +1 -1
- package/dist/commands/project/archive/index.js +1 -0
- package/dist/commands/project/archive/push-runner.d.ts +1 -0
- package/dist/commands/project/archive/push-runner.d.ts.map +1 -1
- package/dist/commands/project/archive/push-runner.js +9 -2
- package/dist/config/oat-config.d.ts +26 -0
- package/dist/config/oat-config.d.ts.map +1 -1
- package/dist/config/oat-config.js +88 -0
- package/dist/config/resolve.d.ts.map +1 -1
- package/dist/config/resolve.js +18 -0
- package/dist/validation/project-state.d.ts +3 -0
- package/dist/validation/project-state.d.ts.map +1 -1
- package/dist/validation/project-state.js +91 -0
- package/package.json +2 -2
- /package/assets/skills/{oat-wave-execute/assets/program-recap.recipe.json → explainer-kit/recipes/program-recap.json} +0 -0
|
@@ -0,0 +1,293 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { join } from 'node:path';
|
|
3
|
+
import { promisify } from 'node:util';
|
|
4
|
+
|
|
5
|
+
import { resolveSourceAwarePath } from './resolve-paths.mjs';
|
|
6
|
+
|
|
7
|
+
const execFileAsync = promisify(execFile);
|
|
8
|
+
|
|
9
|
+
export const EXPLAINER_CONFIG_KEYS = Object.freeze([
|
|
10
|
+
'explainers.defaults.palette',
|
|
11
|
+
'explainers.defaults.visualProfile',
|
|
12
|
+
'explainers.defaults.themeBundlePath',
|
|
13
|
+
'explainers.publish.provider',
|
|
14
|
+
'explainers.publish.s3Uri',
|
|
15
|
+
'explainers.publish.publicBaseUrl',
|
|
16
|
+
'explainers.publish.awsRegion',
|
|
17
|
+
'explainers.publish.awsProfile',
|
|
18
|
+
'workflow.explainers.projectExplainer',
|
|
19
|
+
'workflow.explainers.projectRecap',
|
|
20
|
+
]);
|
|
21
|
+
|
|
22
|
+
const CONFIG_KEY_SET = new Set(EXPLAINER_CONFIG_KEYS);
|
|
23
|
+
const SOURCES = new Set(['local', 'shared', 'user', 'env', 'default']);
|
|
24
|
+
const PREFERENCES = new Set(['always', 'ask', 'never']);
|
|
25
|
+
|
|
26
|
+
export async function resolveExplainerConfig({
|
|
27
|
+
repoRoot,
|
|
28
|
+
runtimeOverrides = {},
|
|
29
|
+
getConfig = (key) => getConfigFromCli(repoRoot, key),
|
|
30
|
+
}) {
|
|
31
|
+
validateRuntimeOverrides(runtimeOverrides);
|
|
32
|
+
|
|
33
|
+
const entries = await Promise.all(
|
|
34
|
+
EXPLAINER_CONFIG_KEYS.map(async (key) => {
|
|
35
|
+
const response = await getConfig(key);
|
|
36
|
+
validateConfigResponse(key, response);
|
|
37
|
+
return [key, response];
|
|
38
|
+
}),
|
|
39
|
+
);
|
|
40
|
+
|
|
41
|
+
const values = {};
|
|
42
|
+
const sources = {};
|
|
43
|
+
for (const [key, response] of entries) {
|
|
44
|
+
values[key] = response.value;
|
|
45
|
+
sources[key] = response.source;
|
|
46
|
+
}
|
|
47
|
+
for (const [key, value] of Object.entries(runtimeOverrides)) {
|
|
48
|
+
values[key] = normalizeRuntimeValue(key, value);
|
|
49
|
+
sources[key] = 'runtime';
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
const warnings = [];
|
|
53
|
+
const theme = {};
|
|
54
|
+
const bundlePath = nullableString(
|
|
55
|
+
values['explainers.defaults.themeBundlePath'],
|
|
56
|
+
);
|
|
57
|
+
if (bundlePath) {
|
|
58
|
+
theme.suppliedBundlePath = await resolveSourceAwarePath({
|
|
59
|
+
repoRoot,
|
|
60
|
+
candidate: bundlePath,
|
|
61
|
+
source: sources['explainers.defaults.themeBundlePath'],
|
|
62
|
+
field: 'explainers.defaults.themeBundlePath',
|
|
63
|
+
});
|
|
64
|
+
if (
|
|
65
|
+
nullableString(values['explainers.defaults.palette']) ||
|
|
66
|
+
nullableString(values['explainers.defaults.visualProfile'])
|
|
67
|
+
) {
|
|
68
|
+
warnings.push(
|
|
69
|
+
'explainers.defaults.themeBundlePath overrides configured palette and visual profile.',
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
} else {
|
|
73
|
+
theme.palette =
|
|
74
|
+
nullableString(values['explainers.defaults.palette']) ?? 'neutral';
|
|
75
|
+
theme.visualProfile =
|
|
76
|
+
nullableString(values['explainers.defaults.visualProfile']) ?? 'clean';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
const publish = resolvePublish(values);
|
|
80
|
+
const preferences = {
|
|
81
|
+
projectExplainer: resolvePreference(
|
|
82
|
+
'workflow.explainers.projectExplainer',
|
|
83
|
+
values['workflow.explainers.projectExplainer'],
|
|
84
|
+
),
|
|
85
|
+
projectRecap: resolvePreference(
|
|
86
|
+
'workflow.explainers.projectRecap',
|
|
87
|
+
values['workflow.explainers.projectRecap'],
|
|
88
|
+
),
|
|
89
|
+
};
|
|
90
|
+
|
|
91
|
+
return { values, sources, theme, publish, preferences, warnings };
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
export function toExplainerRunRequest({
|
|
95
|
+
resolvedConfig,
|
|
96
|
+
recipe,
|
|
97
|
+
slug,
|
|
98
|
+
outputRoot,
|
|
99
|
+
factBase,
|
|
100
|
+
mode,
|
|
101
|
+
durabilityStrategy = 'none',
|
|
102
|
+
artDirection,
|
|
103
|
+
defaultMode,
|
|
104
|
+
renderStrategy,
|
|
105
|
+
retainRawArtDirection = false,
|
|
106
|
+
}) {
|
|
107
|
+
if (!resolvedConfig || !outputRoot || !factBase) {
|
|
108
|
+
throw new TypeError(
|
|
109
|
+
'resolvedConfig, outputRoot, and factBase are required to build a run request.',
|
|
110
|
+
);
|
|
111
|
+
}
|
|
112
|
+
if (retainRawArtDirection && !nullableString(artDirection)) {
|
|
113
|
+
throw new Error(
|
|
114
|
+
'retainRawArtDirection requires a non-empty artDirection runtime input.',
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const theme = {
|
|
119
|
+
...resolvedConfig.theme,
|
|
120
|
+
...(nullableString(artDirection)
|
|
121
|
+
? { artDirection: artDirection.trim() }
|
|
122
|
+
: {}),
|
|
123
|
+
...(defaultMode ? { defaultMode } : {}),
|
|
124
|
+
...(renderStrategy ? { renderStrategy } : {}),
|
|
125
|
+
};
|
|
126
|
+
const durability = { strategy: durabilityStrategy };
|
|
127
|
+
if (durabilityStrategy === 'publish') {
|
|
128
|
+
if (!resolvedConfig.publish) {
|
|
129
|
+
throw new Error(
|
|
130
|
+
'Publish durability requires a complete explainer publish configuration.',
|
|
131
|
+
);
|
|
132
|
+
}
|
|
133
|
+
durability.publish = {
|
|
134
|
+
schemaVersion: 'explainer-kit.publish-request/v1',
|
|
135
|
+
...resolvedConfig.publish,
|
|
136
|
+
siteRoot: join(outputRoot, slug, 'site'),
|
|
137
|
+
manifestPath: join(outputRoot, slug, 'manifest.json'),
|
|
138
|
+
};
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return {
|
|
142
|
+
schemaVersion: 'explainer-kit.run-request/v1',
|
|
143
|
+
recipe: { id: recipe, version: '1' },
|
|
144
|
+
slug,
|
|
145
|
+
outputRoot,
|
|
146
|
+
factBase,
|
|
147
|
+
...(Object.keys(theme).length > 0 ? { theme } : {}),
|
|
148
|
+
durability,
|
|
149
|
+
privacy: { retainRawArtDirection },
|
|
150
|
+
mode,
|
|
151
|
+
};
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
async function getConfigFromCli(repoRoot, key) {
|
|
155
|
+
let stdout;
|
|
156
|
+
try {
|
|
157
|
+
({ stdout } = await execFileAsync('oat', ['config', 'get', key, '--json'], {
|
|
158
|
+
cwd: repoRoot,
|
|
159
|
+
encoding: 'utf8',
|
|
160
|
+
maxBuffer: 1024 * 1024,
|
|
161
|
+
}));
|
|
162
|
+
} catch (error) {
|
|
163
|
+
const detail =
|
|
164
|
+
error && typeof error === 'object' && typeof error.stderr === 'string'
|
|
165
|
+
? error.stderr.trim()
|
|
166
|
+
: '';
|
|
167
|
+
throw new Error(
|
|
168
|
+
`Failed to resolve ${key} with \`oat config get ${key} --json\`${detail ? `: ${detail}` : '.'}`,
|
|
169
|
+
{ cause: error },
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
try {
|
|
174
|
+
return JSON.parse(stdout);
|
|
175
|
+
} catch {
|
|
176
|
+
throw new Error(`oat config get returned invalid JSON for ${key}.`);
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
function validateConfigResponse(key, response) {
|
|
181
|
+
if (
|
|
182
|
+
!response ||
|
|
183
|
+
typeof response !== 'object' ||
|
|
184
|
+
response.status !== 'ok' ||
|
|
185
|
+
response.key !== key ||
|
|
186
|
+
!SOURCES.has(response.source)
|
|
187
|
+
) {
|
|
188
|
+
throw new Error(
|
|
189
|
+
`Invalid oat config get --json response for ${key}; expected value and source metadata.`,
|
|
190
|
+
);
|
|
191
|
+
}
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
function validateRuntimeOverrides(overrides) {
|
|
195
|
+
if (!overrides || typeof overrides !== 'object' || Array.isArray(overrides)) {
|
|
196
|
+
throw new TypeError('runtimeOverrides must be an object.');
|
|
197
|
+
}
|
|
198
|
+
for (const key of Object.keys(overrides)) {
|
|
199
|
+
if (!CONFIG_KEY_SET.has(key)) {
|
|
200
|
+
throw new Error(`Unsupported runtime override: ${key}`);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
function normalizeRuntimeValue(key, value) {
|
|
206
|
+
if (value === null || value === undefined) {
|
|
207
|
+
return null;
|
|
208
|
+
}
|
|
209
|
+
if (typeof value !== 'string' || !value.trim()) {
|
|
210
|
+
throw new Error(
|
|
211
|
+
`Runtime override ${key} must be a non-empty string or null.`,
|
|
212
|
+
);
|
|
213
|
+
}
|
|
214
|
+
const normalized = value.trim();
|
|
215
|
+
if (key === 'explainers.publish.provider' && normalized !== 's3-static') {
|
|
216
|
+
throw new Error(`${key} runtime override must be s3-static.`);
|
|
217
|
+
}
|
|
218
|
+
if (key.startsWith('workflow.explainers.') && !PREFERENCES.has(normalized)) {
|
|
219
|
+
throw new Error(`${key} runtime override must be always, ask, or never.`);
|
|
220
|
+
}
|
|
221
|
+
if (
|
|
222
|
+
key === 'explainers.publish.s3Uri' &&
|
|
223
|
+
!/^s3:\/\/[^/\s]+(?:\/.*)?$/.test(normalized)
|
|
224
|
+
) {
|
|
225
|
+
throw new Error(`${key} runtime override must be an s3:// URI.`);
|
|
226
|
+
}
|
|
227
|
+
if (
|
|
228
|
+
key === 'explainers.publish.publicBaseUrl' &&
|
|
229
|
+
!/^https:\/\/[^\s]+$/.test(normalized)
|
|
230
|
+
) {
|
|
231
|
+
throw new Error(`${key} runtime override must be an https:// URL.`);
|
|
232
|
+
}
|
|
233
|
+
if (
|
|
234
|
+
key === 'explainers.publish.s3Uri' ||
|
|
235
|
+
key === 'explainers.publish.publicBaseUrl'
|
|
236
|
+
) {
|
|
237
|
+
return normalized.replace(/\/+$/, '');
|
|
238
|
+
}
|
|
239
|
+
return normalized;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function resolvePublish(values) {
|
|
243
|
+
const provider = nullableString(values['explainers.publish.provider']);
|
|
244
|
+
if (!provider) {
|
|
245
|
+
return null;
|
|
246
|
+
}
|
|
247
|
+
if (provider !== 's3-static') {
|
|
248
|
+
throw new Error(`Unsupported explainer publish provider: ${provider}`);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
const fields = {
|
|
252
|
+
s3Uri: nullableString(values['explainers.publish.s3Uri']),
|
|
253
|
+
publicBaseUrl: nullableString(values['explainers.publish.publicBaseUrl']),
|
|
254
|
+
awsRegion: nullableString(values['explainers.publish.awsRegion']),
|
|
255
|
+
};
|
|
256
|
+
const missing = Object.entries(fields)
|
|
257
|
+
.filter(([, value]) => !value)
|
|
258
|
+
.map(([key]) => key);
|
|
259
|
+
if (missing.length > 0) {
|
|
260
|
+
throw new Error(
|
|
261
|
+
`Incomplete explainer publish configuration; missing ${missing.join(', ')}.`,
|
|
262
|
+
);
|
|
263
|
+
}
|
|
264
|
+
if (!/^s3:\/\/[^/\s]+(?:\/.*)?$/.test(fields.s3Uri)) {
|
|
265
|
+
throw new Error('explainers.publish.s3Uri must be a valid s3:// URI.');
|
|
266
|
+
}
|
|
267
|
+
if (!/^https:\/\/[^\s]+$/.test(fields.publicBaseUrl)) {
|
|
268
|
+
throw new Error(
|
|
269
|
+
'explainers.publish.publicBaseUrl must be a valid https:// URL.',
|
|
270
|
+
);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
const awsProfile = nullableString(values['explainers.publish.awsProfile']);
|
|
274
|
+
return {
|
|
275
|
+
provider,
|
|
276
|
+
s3Uri: fields.s3Uri.replace(/\/+$/, ''),
|
|
277
|
+
publicBaseUrl: fields.publicBaseUrl.replace(/\/+$/, ''),
|
|
278
|
+
awsRegion: fields.awsRegion,
|
|
279
|
+
...(awsProfile ? { awsProfile } : {}),
|
|
280
|
+
};
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function resolvePreference(key, value) {
|
|
284
|
+
const preference = nullableString(value) ?? 'ask';
|
|
285
|
+
if (!PREFERENCES.has(preference)) {
|
|
286
|
+
throw new Error(`${key} must be always, ask, or never.`);
|
|
287
|
+
}
|
|
288
|
+
return preference;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
function nullableString(value) {
|
|
292
|
+
return typeof value === 'string' && value.trim() ? value.trim() : null;
|
|
293
|
+
}
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
const PRODUCTS = new Set(['projectExplainer', 'projectRecap']);
|
|
2
|
+
const MODES = new Set(['interactive', 'autonomous']);
|
|
3
|
+
const PREFERENCES = new Set(['always', 'ask', 'never']);
|
|
4
|
+
const DECISIONS = new Set(['generate', 'skip']);
|
|
5
|
+
const SOURCES = new Set(['interactive', 'kickoff_prompt', 'autonomous_policy']);
|
|
6
|
+
const ALLOWED_PAIRS = Object.freeze({
|
|
7
|
+
projectExplainer: new Set([
|
|
8
|
+
'generate:interactive',
|
|
9
|
+
'skip:interactive',
|
|
10
|
+
'generate:kickoff_prompt',
|
|
11
|
+
]),
|
|
12
|
+
projectRecap: new Set([
|
|
13
|
+
'generate:interactive',
|
|
14
|
+
'skip:interactive',
|
|
15
|
+
'generate:autonomous_policy',
|
|
16
|
+
]),
|
|
17
|
+
});
|
|
18
|
+
const ISO_TIMESTAMP_PATTERN =
|
|
19
|
+
/^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(?:\.\d+)?(?:Z|[+-]\d{2}:\d{2})$/;
|
|
20
|
+
|
|
21
|
+
export function resolveIntent({
|
|
22
|
+
product,
|
|
23
|
+
mode,
|
|
24
|
+
state = null,
|
|
25
|
+
preference,
|
|
26
|
+
kickoffRequest = false,
|
|
27
|
+
answer,
|
|
28
|
+
now = new Date().toISOString(),
|
|
29
|
+
}) {
|
|
30
|
+
assertProduct(product);
|
|
31
|
+
if (!MODES.has(mode)) {
|
|
32
|
+
throw new Error(`Unsupported lifecycle mode: ${String(mode)}`);
|
|
33
|
+
}
|
|
34
|
+
if (preference !== undefined && !PREFERENCES.has(preference)) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`${product} workflow preference must be always, ask, or never.`,
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
if (typeof kickoffRequest !== 'boolean') {
|
|
40
|
+
throw new TypeError('kickoffRequest must be a boolean.');
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (mode === 'autonomous') {
|
|
44
|
+
if (answer !== undefined) {
|
|
45
|
+
throw new Error('Autonomous lifecycle intent cannot use an answer.');
|
|
46
|
+
}
|
|
47
|
+
return resolveAutonomous({
|
|
48
|
+
product,
|
|
49
|
+
state,
|
|
50
|
+
preference,
|
|
51
|
+
kickoffRequest,
|
|
52
|
+
now,
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
if (state !== null) {
|
|
57
|
+
validateIntentRecord(product, state);
|
|
58
|
+
}
|
|
59
|
+
if (state) {
|
|
60
|
+
if (answer !== undefined) {
|
|
61
|
+
throw new Error(
|
|
62
|
+
`${product} already has a recorded decision; it must not prompt again.`,
|
|
63
|
+
);
|
|
64
|
+
}
|
|
65
|
+
return result(product, state.decision, 'project_state', state);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const effectivePreference = preference ?? 'ask';
|
|
69
|
+
if (effectivePreference === 'always') {
|
|
70
|
+
if (answer !== undefined) {
|
|
71
|
+
throw new Error(
|
|
72
|
+
`${product} preference always does not require an answer.`,
|
|
73
|
+
);
|
|
74
|
+
}
|
|
75
|
+
return result(product, 'generate', 'workflow_preference');
|
|
76
|
+
}
|
|
77
|
+
if (effectivePreference === 'never') {
|
|
78
|
+
if (answer !== undefined) {
|
|
79
|
+
throw new Error(
|
|
80
|
+
`${product} preference never does not require an answer.`,
|
|
81
|
+
);
|
|
82
|
+
}
|
|
83
|
+
return result(product, 'skip', 'workflow_preference');
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
if (answer === undefined) {
|
|
87
|
+
return {
|
|
88
|
+
product,
|
|
89
|
+
decision: 'ask',
|
|
90
|
+
resolutionSource:
|
|
91
|
+
preference === undefined ? 'default' : 'workflow_preference',
|
|
92
|
+
needsPrompt: true,
|
|
93
|
+
record: null,
|
|
94
|
+
warnings: [],
|
|
95
|
+
};
|
|
96
|
+
}
|
|
97
|
+
if (!DECISIONS.has(answer)) {
|
|
98
|
+
throw new Error(`${product} answer must be generate or skip.`);
|
|
99
|
+
}
|
|
100
|
+
const record = createRecord(product, answer, 'interactive', now);
|
|
101
|
+
return result(product, answer, 'interactive_answer', record);
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
export function validateIntentRecord(product, record) {
|
|
105
|
+
assertProduct(product);
|
|
106
|
+
if (!record || typeof record !== 'object' || Array.isArray(record)) {
|
|
107
|
+
throw new TypeError(`${product} intent must be a decision record.`);
|
|
108
|
+
}
|
|
109
|
+
const keys = Object.keys(record);
|
|
110
|
+
if (
|
|
111
|
+
keys.length !== 3 ||
|
|
112
|
+
keys.some((key) => !['decision', 'source', 'decided_at'].includes(key))
|
|
113
|
+
) {
|
|
114
|
+
throw new Error(
|
|
115
|
+
`${product} intent must contain only decision, source, and decided_at.`,
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (!DECISIONS.has(record.decision)) {
|
|
119
|
+
throw new Error(`${product} decision must be generate or skip.`);
|
|
120
|
+
}
|
|
121
|
+
if (!SOURCES.has(record.source)) {
|
|
122
|
+
throw new Error(`${product} intent has an invalid source.`);
|
|
123
|
+
}
|
|
124
|
+
if (!ALLOWED_PAIRS[product].has(`${record.decision}:${record.source}`)) {
|
|
125
|
+
throw new Error(
|
|
126
|
+
`Invalid ${product} decision/source pair: ${record.decision}/${record.source}.`,
|
|
127
|
+
);
|
|
128
|
+
}
|
|
129
|
+
assertTimestamp(record.decided_at);
|
|
130
|
+
return record;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function resolveAutonomous({
|
|
134
|
+
product,
|
|
135
|
+
state,
|
|
136
|
+
preference,
|
|
137
|
+
kickoffRequest,
|
|
138
|
+
now,
|
|
139
|
+
}) {
|
|
140
|
+
if (product === 'projectRecap') {
|
|
141
|
+
const warnings = [];
|
|
142
|
+
if (state?.decision === 'skip') {
|
|
143
|
+
warnings.push(
|
|
144
|
+
'Autonomous project recap policy overrode a lower-precedence skip decision.',
|
|
145
|
+
);
|
|
146
|
+
}
|
|
147
|
+
if (preference === 'never') {
|
|
148
|
+
warnings.push(
|
|
149
|
+
'Autonomous project recap policy overrode workflow preference never.',
|
|
150
|
+
);
|
|
151
|
+
}
|
|
152
|
+
return result(
|
|
153
|
+
product,
|
|
154
|
+
'generate',
|
|
155
|
+
'mode',
|
|
156
|
+
createRecord(product, 'generate', 'autonomous_policy', now),
|
|
157
|
+
warnings,
|
|
158
|
+
);
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
if (kickoffRequest) {
|
|
162
|
+
return result(
|
|
163
|
+
product,
|
|
164
|
+
'generate',
|
|
165
|
+
'mode',
|
|
166
|
+
createRecord(product, 'generate', 'kickoff_prompt', now),
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
return result(product, 'skip', 'mode');
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
function createRecord(product, decision, source, now) {
|
|
173
|
+
const record = { decision, source, decided_at: now };
|
|
174
|
+
validateIntentRecord(product, record);
|
|
175
|
+
return record;
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function result(
|
|
179
|
+
product,
|
|
180
|
+
decision,
|
|
181
|
+
resolutionSource,
|
|
182
|
+
record = null,
|
|
183
|
+
warnings = [],
|
|
184
|
+
) {
|
|
185
|
+
return {
|
|
186
|
+
product,
|
|
187
|
+
decision,
|
|
188
|
+
resolutionSource,
|
|
189
|
+
needsPrompt: false,
|
|
190
|
+
record,
|
|
191
|
+
warnings,
|
|
192
|
+
};
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function assertProduct(product) {
|
|
196
|
+
if (!PRODUCTS.has(product)) {
|
|
197
|
+
throw new Error(`Unsupported lifecycle product: ${String(product)}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
function assertTimestamp(value) {
|
|
202
|
+
if (
|
|
203
|
+
typeof value !== 'string' ||
|
|
204
|
+
!ISO_TIMESTAMP_PATTERN.test(value) ||
|
|
205
|
+
Number.isNaN(Date.parse(value))
|
|
206
|
+
) {
|
|
207
|
+
throw new Error('Intent decided_at must be a valid ISO 8601 timestamp.');
|
|
208
|
+
}
|
|
209
|
+
}
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
import { lstat, realpath } from 'node:fs/promises';
|
|
2
|
+
import { isAbsolute, join, relative, resolve, sep } from 'node:path';
|
|
3
|
+
|
|
4
|
+
export async function resolveExplainerOutputRoot({
|
|
5
|
+
repoRoot,
|
|
6
|
+
invocation,
|
|
7
|
+
activeProject,
|
|
8
|
+
outputRoot,
|
|
9
|
+
}) {
|
|
10
|
+
if (!repoRoot) {
|
|
11
|
+
throw new TypeError('repoRoot is required.');
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
if (invocation === 'direct') {
|
|
15
|
+
if (!outputRoot) {
|
|
16
|
+
throw new Error(
|
|
17
|
+
'Direct callers must provide an explicit outputRoot to the core.',
|
|
18
|
+
);
|
|
19
|
+
}
|
|
20
|
+
return resolveSafePath(outputRoot, {
|
|
21
|
+
baseRoot: repoRoot,
|
|
22
|
+
field: 'outputRoot',
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const canonicalRepoRoot = await realpath(resolve(repoRoot));
|
|
27
|
+
if (invocation === 'repo') {
|
|
28
|
+
return resolveSafePath('.oat/repo/reference/explainers', {
|
|
29
|
+
baseRoot: canonicalRepoRoot,
|
|
30
|
+
confinedRoot: canonicalRepoRoot,
|
|
31
|
+
field: 'repo explainer output root',
|
|
32
|
+
});
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
if (invocation !== 'project') {
|
|
36
|
+
throw new Error(`Unsupported OAT explainer invocation: ${invocation}`);
|
|
37
|
+
}
|
|
38
|
+
if (!activeProject) {
|
|
39
|
+
throw new Error('Project explainer invocation requires an active project.');
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const projectRoot = await resolveSafePath(activeProject, {
|
|
43
|
+
baseRoot: canonicalRepoRoot,
|
|
44
|
+
confinedRoot: canonicalRepoRoot,
|
|
45
|
+
field: 'active project',
|
|
46
|
+
mustExist: true,
|
|
47
|
+
});
|
|
48
|
+
if (projectRoot === canonicalRepoRoot) {
|
|
49
|
+
throw new Error('The active project cannot be the repository root.');
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return resolveSafePath(join(projectRoot, 'explainers'), {
|
|
53
|
+
baseRoot: canonicalRepoRoot,
|
|
54
|
+
confinedRoot: projectRoot,
|
|
55
|
+
field: 'project explainer output root',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
export async function resolveSourceAwarePath({
|
|
60
|
+
repoRoot,
|
|
61
|
+
candidate,
|
|
62
|
+
source,
|
|
63
|
+
field,
|
|
64
|
+
}) {
|
|
65
|
+
if (typeof candidate !== 'string' || !candidate.trim()) {
|
|
66
|
+
throw new Error(`${field} must be a non-empty path.`);
|
|
67
|
+
}
|
|
68
|
+
if (source === 'user') {
|
|
69
|
+
throw new Error(`${field} cannot be resolved from user config.`);
|
|
70
|
+
}
|
|
71
|
+
if (source === 'shared' && isAbsolute(candidate)) {
|
|
72
|
+
throw new Error(`${field} from shared config must be repository-relative.`);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const canonicalRepoRoot = await realpath(resolve(repoRoot));
|
|
76
|
+
const absolute = isAbsolute(candidate);
|
|
77
|
+
const confined =
|
|
78
|
+
source === 'shared' ||
|
|
79
|
+
(!absolute && (source === 'local' || source === 'runtime'));
|
|
80
|
+
|
|
81
|
+
return resolveSafePath(candidate, {
|
|
82
|
+
baseRoot: canonicalRepoRoot,
|
|
83
|
+
confinedRoot: confined ? canonicalRepoRoot : undefined,
|
|
84
|
+
field,
|
|
85
|
+
mustExist: true,
|
|
86
|
+
});
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function resolveSafePath(
|
|
90
|
+
candidate,
|
|
91
|
+
{ baseRoot, confinedRoot, field, mustExist = false },
|
|
92
|
+
) {
|
|
93
|
+
validateLexicalPath(candidate, field);
|
|
94
|
+
const lexicalPath = resolve(baseRoot, candidate);
|
|
95
|
+
if (confinedRoot && !isWithin(resolve(confinedRoot), lexicalPath)) {
|
|
96
|
+
throw new Error(`${field} resolves outside its allowed root.`);
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let canonicalPath;
|
|
100
|
+
try {
|
|
101
|
+
canonicalPath = await realpath(lexicalPath);
|
|
102
|
+
} catch (error) {
|
|
103
|
+
if (!isMissing(error)) {
|
|
104
|
+
throw error;
|
|
105
|
+
}
|
|
106
|
+
if (mustExist) {
|
|
107
|
+
throw new Error(`${field} does not exist: ${candidate}`, {
|
|
108
|
+
cause: error,
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
canonicalPath = await canonicalizeMissingPath(lexicalPath);
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
if (confinedRoot) {
|
|
115
|
+
const canonicalConfinedRoot = await realpath(resolve(confinedRoot));
|
|
116
|
+
if (!isWithin(canonicalConfinedRoot, canonicalPath)) {
|
|
117
|
+
throw new Error(
|
|
118
|
+
`${field} resolves outside its allowed root through a symlink ancestor.`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
return canonicalPath;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function canonicalizeMissingPath(target) {
|
|
126
|
+
const suffix = [];
|
|
127
|
+
let ancestor = target;
|
|
128
|
+
while (true) {
|
|
129
|
+
try {
|
|
130
|
+
await lstat(ancestor);
|
|
131
|
+
return join(await realpath(ancestor), ...suffix.reverse());
|
|
132
|
+
} catch (error) {
|
|
133
|
+
if (!isMissing(error)) {
|
|
134
|
+
throw error;
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
const parent = resolve(ancestor, '..');
|
|
138
|
+
if (parent === ancestor) {
|
|
139
|
+
throw new Error(`No existing ancestor found for path: ${target}`);
|
|
140
|
+
}
|
|
141
|
+
suffix.push(relative(parent, ancestor));
|
|
142
|
+
ancestor = parent;
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
function validateLexicalPath(candidate, field) {
|
|
147
|
+
if (typeof candidate !== 'string' || !candidate.trim()) {
|
|
148
|
+
throw new Error(`${field} must be a non-empty path.`);
|
|
149
|
+
}
|
|
150
|
+
if (
|
|
151
|
+
candidate.includes('\0') ||
|
|
152
|
+
candidate.includes('\\') ||
|
|
153
|
+
candidate.split('/').includes('..')
|
|
154
|
+
) {
|
|
155
|
+
throw new Error(`${field} contains unsafe traversal or path separators.`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
function isWithin(root, target) {
|
|
160
|
+
const path = relative(root, target);
|
|
161
|
+
return path === '' || (path !== '..' && !path.startsWith(`..${sep}`));
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function isMissing(error) {
|
|
165
|
+
return error && typeof error === 'object' && error.code === 'ENOENT';
|
|
166
|
+
}
|