@mcrescenzo/opencode-workflows 0.1.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/CHANGELOG.md +14 -0
- package/CODE_OF_CONDUCT.md +134 -0
- package/CONTRIBUTING.md +95 -0
- package/LICENSE +21 -0
- package/README.md +746 -0
- package/SECURITY.md +38 -0
- package/commands/repo-bughunt.md +94 -0
- package/commands/repo-review.md +148 -0
- package/commands/workflow-live-gates-release-check.md +143 -0
- package/docs/workflow-plugin.md +400 -0
- package/opencode-workflows.js +5 -0
- package/package.json +86 -0
- package/skills/opencode-workflow-authoring/SKILL.md +180 -0
- package/skills/repo-review-command-protocol/SKILL.md +56 -0
- package/skills/workflow-model-tiering/SKILL.md +57 -0
- package/skills/workflow-plan-review/SKILL.md +91 -0
- package/workflow-kernel/approval-hashing.js +39 -0
- package/workflow-kernel/async-util.js +33 -0
- package/workflow-kernel/audited-shell-policy.js +200 -0
- package/workflow-kernel/authority-policy.js +670 -0
- package/workflow-kernel/budget-accounting.js +142 -0
- package/workflow-kernel/capability-adapter.js +753 -0
- package/workflow-kernel/child-agent-runner.js +1264 -0
- package/workflow-kernel/constants.js +117 -0
- package/workflow-kernel/diagnostics.js +152 -0
- package/workflow-kernel/drain-runtime.js +421 -0
- package/workflow-kernel/errors.js +181 -0
- package/workflow-kernel/event-journal.js +487 -0
- package/workflow-kernel/extension-registry.js +144 -0
- package/workflow-kernel/free-text-redactor.js +91 -0
- package/workflow-kernel/gate-shapes.js +82 -0
- package/workflow-kernel/git-util.js +45 -0
- package/workflow-kernel/index.js +72 -0
- package/workflow-kernel/integration-mode.js +155 -0
- package/workflow-kernel/lane-effort-policy.js +134 -0
- package/workflow-kernel/lifecycle-control.js +608 -0
- package/workflow-kernel/live-gate-probes.js +916 -0
- package/workflow-kernel/notification-toast-cards.js +393 -0
- package/workflow-kernel/notification-toast-policy.js +179 -0
- package/workflow-kernel/notification-toast-scope.js +100 -0
- package/workflow-kernel/notification-toast.js +287 -0
- package/workflow-kernel/path-policy.js +219 -0
- package/workflow-kernel/result-readback.js +106 -0
- package/workflow-kernel/role-template-loading.js +606 -0
- package/workflow-kernel/run-context.js +139 -0
- package/workflow-kernel/run-observability.js +43 -0
- package/workflow-kernel/run-store-fs.js +231 -0
- package/workflow-kernel/run-store-locks.js +180 -0
- package/workflow-kernel/run-store-projections.js +421 -0
- package/workflow-kernel/run-store-rehydrate.js +68 -0
- package/workflow-kernel/run-store-state.js +147 -0
- package/workflow-kernel/run-store-status-format.js +1154 -0
- package/workflow-kernel/run-store-status.js +107 -0
- package/workflow-kernel/sandbox-executor.js +1131 -0
- package/workflow-kernel/session-access.js +56 -0
- package/workflow-kernel/structured-output.js +143 -0
- package/workflow-kernel/test-fix-drain-adapter.js +119 -0
- package/workflow-kernel/text-json.js +136 -0
- package/workflow-kernel/workflow-plugin.js +3017 -0
- package/workflow-kernel/workflow-source.js +444 -0
- package/workflow-kernel/worktree-adapter.js +256 -0
- package/workflow-kernel/worktree-git.js +147 -0
- package/workflows/repo-bughunt.js +362 -0
- package/workflows/repo-cleanup.js +383 -0
- package/workflows/repo-complexity.js +404 -0
- package/workflows/repo-deps.js +457 -0
- package/workflows/repo-modernize.js +395 -0
- package/workflows/repo-perf.js +394 -0
- package/workflows/repo-review.js +831 -0
- package/workflows/repo-security-audit.js +466 -0
- package/workflows/repo-test-gaps.js +377 -0
|
@@ -0,0 +1,606 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import {
|
|
4
|
+
BUNDLED_WORKFLOW_DIR,
|
|
5
|
+
GLOBAL_WORKFLOW_DIR,
|
|
6
|
+
MAX_CHILD_PROMPT_TIMEOUT_MS,
|
|
7
|
+
MAX_SOURCE_BYTES,
|
|
8
|
+
ROLE_DIR,
|
|
9
|
+
TEMPLATE_DIR,
|
|
10
|
+
} from "./constants.js";
|
|
11
|
+
import { extractTextFromError, hash, truncateText, redactValue } from "./text-json.js";
|
|
12
|
+
import { assertWriteWorkflowAllowed, resolveRunAuthority, authoritySummary, AD_HOC_AUTHORITY_PROFILE, VALID_TIERS } from "./authority-policy.js";
|
|
13
|
+
import { normalizeLaneEffort } from "./lane-effort-policy.js";
|
|
14
|
+
import { parseWorkflowSource, projectWorkflowDir, workflowFileName } from "./workflow-source.js";
|
|
15
|
+
import { pathExists, readJsonFile, writeJsonAtomic } from "./run-store-status.js";
|
|
16
|
+
|
|
17
|
+
const ROLE_MANIFEST = path.join(ROLE_DIR, "manifest.json");
|
|
18
|
+
const ROLE_DEFAULTS_MANIFEST = path.join(ROLE_DIR, "roles.json");
|
|
19
|
+
|
|
20
|
+
const DEFAULT_ROLES = {
|
|
21
|
+
explorer: "Explore the assigned surface area. Return concrete files, facts, and uncertainty, not guesses.",
|
|
22
|
+
skeptic: "Challenge assumptions and look for failure modes, regressions, security issues, and missing verification.",
|
|
23
|
+
verifier: "Verify claims with direct evidence. Prefer commands, files, and reproducible checks over opinion.",
|
|
24
|
+
synthesizer: "Combine lane outputs into a concise decision-ready summary with evidence and remaining risks.",
|
|
25
|
+
implementer: "Make the smallest correct implementation change, preserve existing style, and include verification evidence.",
|
|
26
|
+
};
|
|
27
|
+
const DEFAULT_ROLE_DEFAULTS = Object.freeze({
|
|
28
|
+
roles: Object.freeze(Object.fromEntries(Object.keys(DEFAULT_ROLES).map((name) => [name, Object.freeze({})]))),
|
|
29
|
+
});
|
|
30
|
+
const ALLOWED_ROLE_DEFAULT_KEYS = new Set([
|
|
31
|
+
"model",
|
|
32
|
+
"tier",
|
|
33
|
+
"tools",
|
|
34
|
+
"readOnly",
|
|
35
|
+
"retryCount",
|
|
36
|
+
"correctiveRetries",
|
|
37
|
+
"timeoutMs",
|
|
38
|
+
"mcpPolicy",
|
|
39
|
+
"secretGlobs",
|
|
40
|
+
"effort",
|
|
41
|
+
]);
|
|
42
|
+
|
|
43
|
+
// Minimal first-run slice. The smallest safe shape for a fresh agent to validate one
|
|
44
|
+
// read-only slice before building a larger fanout or nested workflow: profile
|
|
45
|
+
// read-only-review, 1-2 scoped parallel lanes, pure-JS synthesis (zero extra agent
|
|
46
|
+
// slots), bounded maxAgents/concurrency, and no filesystem or domain writes. Inner code
|
|
47
|
+
// avoids backticks and ${} so it can live verbatim in this backtick literal; see
|
|
48
|
+
// docs/workflow-recipes.md "Recipe: first-run read-only slice" for the walkthrough.
|
|
49
|
+
const FIRST_RUN_SLICE_TEMPLATE = `export const meta = {
|
|
50
|
+
name: "first-run-slice",
|
|
51
|
+
description: "Minimal read-only first-run slice: 1-2 scoped parallel lanes, pure-JS synthesis, no writes.",
|
|
52
|
+
profile: "read-only-review",
|
|
53
|
+
maxAgents: 2,
|
|
54
|
+
concurrency: 2,
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Edit \`question\` and \`slices\` (1-2) for your surface, then preview -> approve.
|
|
58
|
+
const question = (args && args.question) || "Summarize what this slice does";
|
|
59
|
+
const rawSlices = (args && Array.isArray(args.slices) && args.slices.length > 0) ? args.slices : ["primary"];
|
|
60
|
+
const slices = rawSlices.slice(0, 2);
|
|
61
|
+
|
|
62
|
+
// Per-lane contract: every claim must carry concrete evidence.
|
|
63
|
+
const findingSchema = {
|
|
64
|
+
type: "object",
|
|
65
|
+
required: ["slice", "claim", "evidence"],
|
|
66
|
+
properties: {
|
|
67
|
+
slice: { type: "string" },
|
|
68
|
+
claim: { type: "string" },
|
|
69
|
+
evidence: { type: "string" },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
|
|
73
|
+
// Scoped read-only lanes. read-only-review denies edit/shell/network/mcp, so a lane
|
|
74
|
+
// can read/glob/grep/list but cannot run commands, reach the network, or write.
|
|
75
|
+
const laneResults = await parallel(slices.map((slice) => async ({ agent }) =>
|
|
76
|
+
agent(
|
|
77
|
+
"Read-only slice \\"" + slice + "\\" for: " + question +
|
|
78
|
+
". Use only read/glob/grep/list. Return one claim with concrete evidence " +
|
|
79
|
+
"(file:line or exact text). Say \\"unknown\\" rather than guess.",
|
|
80
|
+
{ role: "explorer", schema: findingSchema, label: "slice:" + slice },
|
|
81
|
+
),
|
|
82
|
+
));
|
|
83
|
+
|
|
84
|
+
// Pure-JS synthesis: no agent() call, zero extra slots. The controller already holds
|
|
85
|
+
// every validated lane result. Drop evidence-free claims into an honesty ledger.
|
|
86
|
+
const grounded = [];
|
|
87
|
+
const dropped = [];
|
|
88
|
+
for (const result of laneResults) {
|
|
89
|
+
const hasEvidence = result && typeof result.evidence === "string" && result.evidence.trim().length > 0;
|
|
90
|
+
if (hasEvidence) grounded.push(result);
|
|
91
|
+
else if (result) dropped.push({ slice: result.slice, claim: result.claim, reason: "no evidence" });
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
return {
|
|
95
|
+
question,
|
|
96
|
+
slices,
|
|
97
|
+
groundedFindings: grounded,
|
|
98
|
+
droppedUnsupportedClaims: dropped,
|
|
99
|
+
note: "First-run read-only slice. No edits, no domain mutation, no files written.",
|
|
100
|
+
};
|
|
101
|
+
`;
|
|
102
|
+
|
|
103
|
+
const DEFAULT_TEMPLATES = {
|
|
104
|
+
"first-run-slice": FIRST_RUN_SLICE_TEMPLATE,
|
|
105
|
+
"scoped-parallel": "export const meta = { name: \"scoped-parallel\", description: \"V2 scoped-helper parallel template\", maxAgents: 4 };\n\nconst items = args?.items ?? [\"one\", \"two\"];\nconst results = await parallel(items.map((item) => async ({ agent }) => {\n return await agent(`Inspect ${item}`, { role: \"explorer\" });\n}));\n\nreturn { results };\n",
|
|
106
|
+
"edit-review": `export const meta = { name: "edit-review", description: "V2 edit/apply template", authority: { edit: true }, maxAgents: 1 };\n\nreturn await agent("Prepare an edit plan", { role: "implementer", edit: true, schema: { type: "object", properties: { patches: { type: "array" } }, required: ["patches"] } });\n`,
|
|
107
|
+
};
|
|
108
|
+
|
|
109
|
+
function roleFileName(name) {
|
|
110
|
+
if (typeof name !== "string" || !/^[a-z][a-z0-9-]{0,62}$/.test(name)) {
|
|
111
|
+
throw new Error(`Invalid workflow role name: ${String(name)}`);
|
|
112
|
+
}
|
|
113
|
+
return `${name}.md`;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
function roleManifestPath(roleDir = ROLE_DIR) {
|
|
117
|
+
return path.join(roleDir, "manifest.json");
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function roleDefaultsManifestPath(roleDir = ROLE_DIR) {
|
|
121
|
+
return path.join(roleDir, "roles.json");
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function isPlainObject(value) {
|
|
125
|
+
return Boolean(value && typeof value === "object" && !Array.isArray(value));
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function cloneJson(value) {
|
|
129
|
+
if (value === undefined) return undefined;
|
|
130
|
+
return JSON.parse(JSON.stringify(value));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
function normalizeStringList(value, label) {
|
|
134
|
+
if (value === undefined) return undefined;
|
|
135
|
+
if (!Array.isArray(value)) throw new Error(`${label} must be an array of non-empty strings`);
|
|
136
|
+
const out = [];
|
|
137
|
+
for (const item of value) {
|
|
138
|
+
if (typeof item !== "string" || item.trim() === "") throw new Error(`${label} entries must be non-empty strings`);
|
|
139
|
+
out.push(item.trim());
|
|
140
|
+
}
|
|
141
|
+
return [...new Set(out)];
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function normalizePolicyDefaults(value, label) {
|
|
145
|
+
if (value === undefined) return undefined;
|
|
146
|
+
if (!isPlainObject(value)) throw new Error(`${label} must be an object`);
|
|
147
|
+
const unknown = Object.keys(value).filter((key) => key !== "allow" && key !== "deny");
|
|
148
|
+
if (unknown.length > 0) throw new Error(`${label} has unsupported key${unknown.length === 1 ? "" : "s"}: ${unknown.join(", ")}`);
|
|
149
|
+
const out = {};
|
|
150
|
+
if (Object.hasOwn(value, "allow")) out.allow = normalizeStringList(value.allow, `${label}.allow`);
|
|
151
|
+
if (Object.hasOwn(value, "deny")) out.deny = normalizeStringList(value.deny, `${label}.deny`);
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function normalizeToolDefaults(value, roleName) {
|
|
156
|
+
if (value === undefined) return undefined;
|
|
157
|
+
if (!isPlainObject(value)) throw new Error(`Role ${roleName} default tools must be an object`);
|
|
158
|
+
const out = {};
|
|
159
|
+
for (const [name, enabled] of Object.entries(value)) {
|
|
160
|
+
if (typeof name !== "string" || name.trim() === "") throw new Error(`Role ${roleName} default tools has an empty tool name`);
|
|
161
|
+
if (typeof enabled !== "boolean") throw new Error(`Role ${roleName} default tools.${name} must be a boolean`);
|
|
162
|
+
out[name] = enabled;
|
|
163
|
+
}
|
|
164
|
+
return out;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
function normalizeRoleDefaults(roleName, rawDefaults) {
|
|
168
|
+
roleFileName(roleName);
|
|
169
|
+
if (!isPlainObject(rawDefaults)) throw new Error(`Role ${roleName} defaults must be an object`);
|
|
170
|
+
const unknown = Object.keys(rawDefaults).filter((key) => !ALLOWED_ROLE_DEFAULT_KEYS.has(key));
|
|
171
|
+
if (unknown.length > 0) {
|
|
172
|
+
throw new Error(
|
|
173
|
+
`Unsupported role default option${unknown.length === 1 ? "" : "s"} for ${roleName}: ${unknown.join(", ")}. ` +
|
|
174
|
+
`Allowed defaults: ${[...ALLOWED_ROLE_DEFAULT_KEYS].sort().join(", ")}.`,
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
const defaults = {};
|
|
178
|
+
if (Object.hasOwn(rawDefaults, "model")) {
|
|
179
|
+
if (typeof rawDefaults.model !== "string" || rawDefaults.model.trim() === "") throw new Error(`Role ${roleName} default model must be a non-empty provider/model string`);
|
|
180
|
+
defaults.model = rawDefaults.model.trim();
|
|
181
|
+
}
|
|
182
|
+
if (Object.hasOwn(rawDefaults, "tier")) {
|
|
183
|
+
if (!VALID_TIERS.includes(rawDefaults.tier)) throw new Error(`Role ${roleName} default tier must be one of ${VALID_TIERS.join(", ")}`);
|
|
184
|
+
defaults.tier = rawDefaults.tier;
|
|
185
|
+
}
|
|
186
|
+
if (Object.hasOwn(rawDefaults, "readOnly")) {
|
|
187
|
+
if (typeof rawDefaults.readOnly !== "boolean") throw new Error(`Role ${roleName} default readOnly must be a boolean`);
|
|
188
|
+
defaults.readOnly = rawDefaults.readOnly;
|
|
189
|
+
}
|
|
190
|
+
if (Object.hasOwn(rawDefaults, "retryCount")) {
|
|
191
|
+
if (!Number.isInteger(rawDefaults.retryCount) || rawDefaults.retryCount < 0) throw new Error(`Role ${roleName} default retryCount must be a non-negative integer`);
|
|
192
|
+
defaults.retryCount = rawDefaults.retryCount;
|
|
193
|
+
}
|
|
194
|
+
if (Object.hasOwn(rawDefaults, "correctiveRetries")) {
|
|
195
|
+
if (!Number.isInteger(rawDefaults.correctiveRetries) || rawDefaults.correctiveRetries < 0) throw new Error(`Role ${roleName} default correctiveRetries must be a non-negative integer`);
|
|
196
|
+
defaults.correctiveRetries = rawDefaults.correctiveRetries;
|
|
197
|
+
}
|
|
198
|
+
if (Object.hasOwn(rawDefaults, "timeoutMs")) {
|
|
199
|
+
if (!Number.isInteger(rawDefaults.timeoutMs) || rawDefaults.timeoutMs <= 0 || rawDefaults.timeoutMs > MAX_CHILD_PROMPT_TIMEOUT_MS) {
|
|
200
|
+
throw new Error(`Role ${roleName} default timeoutMs must be a positive integer no greater than ${MAX_CHILD_PROMPT_TIMEOUT_MS}`);
|
|
201
|
+
}
|
|
202
|
+
defaults.timeoutMs = rawDefaults.timeoutMs;
|
|
203
|
+
}
|
|
204
|
+
if (Object.hasOwn(rawDefaults, "tools")) defaults.tools = normalizeToolDefaults(rawDefaults.tools, roleName);
|
|
205
|
+
if (Object.hasOwn(rawDefaults, "mcpPolicy")) defaults.mcpPolicy = normalizePolicyDefaults(rawDefaults.mcpPolicy, `Role ${roleName} default mcpPolicy`);
|
|
206
|
+
if (Object.hasOwn(rawDefaults, "secretGlobs")) defaults.secretGlobs = normalizeStringList(rawDefaults.secretGlobs, `Role ${roleName} default secretGlobs`);
|
|
207
|
+
if (Object.hasOwn(rawDefaults, "effort")) defaults.effort = normalizeLaneEffort(rawDefaults.effort);
|
|
208
|
+
return defaults;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function roleDefaultsRoot(rawManifest) {
|
|
212
|
+
if (rawManifest === undefined) return DEFAULT_ROLE_DEFAULTS.roles;
|
|
213
|
+
if (!isPlainObject(rawManifest)) throw new Error("roles.json must contain an object");
|
|
214
|
+
if (Object.hasOwn(rawManifest, "roles")) {
|
|
215
|
+
const extra = Object.keys(rawManifest).filter((key) => key !== "roles");
|
|
216
|
+
if (extra.length > 0) throw new Error(`roles.json has unsupported top-level key${extra.length === 1 ? "" : "s"}: ${extra.join(", ")}`);
|
|
217
|
+
if (!isPlainObject(rawManifest.roles)) throw new Error("roles.json `roles` must be an object");
|
|
218
|
+
return rawManifest.roles;
|
|
219
|
+
}
|
|
220
|
+
return rawManifest;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
function normalizeRoleDefaultsManifest(rawManifest) {
|
|
224
|
+
const roles = {};
|
|
225
|
+
for (const [roleName, rawDefaults] of Object.entries(roleDefaultsRoot(rawManifest))) {
|
|
226
|
+
roles[roleName] = normalizeRoleDefaults(roleName, rawDefaults);
|
|
227
|
+
}
|
|
228
|
+
return roles;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
async function ensureRoleDefaultsManifest(roleDir = ROLE_DIR) {
|
|
232
|
+
const filePath = roleDefaultsManifestPath(roleDir);
|
|
233
|
+
try {
|
|
234
|
+
await fs.access(filePath);
|
|
235
|
+
} catch (error) {
|
|
236
|
+
if (error.code !== "ENOENT") throw error;
|
|
237
|
+
await writeJsonAtomic(filePath, DEFAULT_ROLE_DEFAULTS);
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
async function loadRoleDefaultsManifest(roleDir = ROLE_DIR, options = {}) {
|
|
242
|
+
if (options.ensureDefault === true) await ensureRoleDefaultsManifest(roleDir);
|
|
243
|
+
const filePath = roleDefaultsManifestPath(roleDir);
|
|
244
|
+
const rawManifest = await readJsonFile(filePath, DEFAULT_ROLE_DEFAULTS);
|
|
245
|
+
return normalizeRoleDefaultsManifest(rawManifest);
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
function mergeRoleDefaults(defaults = {}, explicitOpts = {}) {
|
|
249
|
+
if (!defaults || Object.keys(defaults).length === 0) return { ...explicitOpts };
|
|
250
|
+
return { ...cloneJson(defaults), ...explicitOpts };
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function ensureRoleFiles(roleDir = ROLE_DIR) {
|
|
254
|
+
await fs.mkdir(roleDir, { recursive: true });
|
|
255
|
+
const manifestPath = roleManifestPath(roleDir);
|
|
256
|
+
const manifest = await readJsonFile(manifestPath, { roles: {} });
|
|
257
|
+
let changed = false;
|
|
258
|
+
for (const [name, content] of Object.entries(DEFAULT_ROLES)) {
|
|
259
|
+
const filePath = path.join(roleDir, roleFileName(name));
|
|
260
|
+
const shippedHash = hash(content);
|
|
261
|
+
let current;
|
|
262
|
+
try {
|
|
263
|
+
current = await fs.readFile(filePath, "utf8");
|
|
264
|
+
} catch (error) {
|
|
265
|
+
if (error.code !== "ENOENT") throw error;
|
|
266
|
+
await fs.writeFile(filePath, content, "utf8");
|
|
267
|
+
manifest.roles[name] = { shippedHash, currentHash: shippedHash, updatedAt: new Date().toISOString() };
|
|
268
|
+
changed = true;
|
|
269
|
+
continue;
|
|
270
|
+
}
|
|
271
|
+
const currentHash = hash(current);
|
|
272
|
+
const previous = manifest.roles[name];
|
|
273
|
+
if (!previous || previous.currentHash === previous.shippedHash) {
|
|
274
|
+
if (currentHash !== shippedHash) {
|
|
275
|
+
manifest.roles[name] = { shippedHash, currentHash, userModified: true, updatedAt: new Date().toISOString() };
|
|
276
|
+
} else {
|
|
277
|
+
manifest.roles[name] = { shippedHash, currentHash, updatedAt: previous?.updatedAt ?? new Date().toISOString() };
|
|
278
|
+
}
|
|
279
|
+
changed = true;
|
|
280
|
+
}
|
|
281
|
+
}
|
|
282
|
+
if (changed) await writeJsonAtomic(manifestPath, manifest);
|
|
283
|
+
await ensureRoleDefaultsManifest(roleDir);
|
|
284
|
+
return manifest;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
async function resolveRole(roleName, roleDir = ROLE_DIR) {
|
|
288
|
+
if (!roleName) return undefined;
|
|
289
|
+
const manifest = await ensureRoleFiles(roleDir);
|
|
290
|
+
const defaults = await loadRoleDefaultsManifest(roleDir);
|
|
291
|
+
const filePath = path.join(roleDir, roleFileName(roleName));
|
|
292
|
+
const content = await fs.readFile(filePath, "utf8");
|
|
293
|
+
const contentHash = hash(content);
|
|
294
|
+
const shippedHash = DEFAULT_ROLES[roleName] ? hash(DEFAULT_ROLES[roleName]) : manifest.roles?.[roleName]?.shippedHash;
|
|
295
|
+
return {
|
|
296
|
+
name: roleName,
|
|
297
|
+
filePath,
|
|
298
|
+
content,
|
|
299
|
+
contentHash,
|
|
300
|
+
shippedHash,
|
|
301
|
+
userModified: shippedHash ? contentHash !== shippedHash : undefined,
|
|
302
|
+
defaults: defaults[roleName] ? cloneJson(defaults[roleName]) : {},
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
async function listRoles(args = {}, options = {}) {
|
|
307
|
+
const roleDir = options.roleDir || args.roleDir || ROLE_DIR;
|
|
308
|
+
const defaults = await loadRoleDefaultsManifest(roleDir);
|
|
309
|
+
const dirNames = [];
|
|
310
|
+
try {
|
|
311
|
+
for (const dirent of await fs.readdir(roleDir, { withFileTypes: true })) {
|
|
312
|
+
if (dirent.isFile() && dirent.name.endsWith(".md")) dirNames.push(dirent.name.replace(/\.md$/, ""));
|
|
313
|
+
}
|
|
314
|
+
} catch (error) {
|
|
315
|
+
if (error.code !== "ENOENT") throw error;
|
|
316
|
+
}
|
|
317
|
+
const names = [
|
|
318
|
+
...Object.keys(DEFAULT_ROLES),
|
|
319
|
+
...Object.keys(defaults).filter((name) => !Object.hasOwn(DEFAULT_ROLES, name)).sort(),
|
|
320
|
+
...dirNames.filter((name) => !Object.hasOwn(DEFAULT_ROLES, name) && !Object.hasOwn(defaults, name)).sort(),
|
|
321
|
+
];
|
|
322
|
+
const entries = [];
|
|
323
|
+
for (const name of names) {
|
|
324
|
+
const filePath = path.join(roleDir, roleFileName(name));
|
|
325
|
+
let content = DEFAULT_ROLES[name];
|
|
326
|
+
let exists = false;
|
|
327
|
+
try {
|
|
328
|
+
content = await fs.readFile(filePath, "utf8");
|
|
329
|
+
exists = true;
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error.code !== "ENOENT") throw error;
|
|
332
|
+
}
|
|
333
|
+
const shippedHash = DEFAULT_ROLES[name] ? hash(DEFAULT_ROLES[name]) : undefined;
|
|
334
|
+
const contentHash = typeof content === "string" ? hash(content) : undefined;
|
|
335
|
+
entries.push({
|
|
336
|
+
name,
|
|
337
|
+
filePath,
|
|
338
|
+
exists,
|
|
339
|
+
contentHash,
|
|
340
|
+
shippedHash,
|
|
341
|
+
userModified: shippedHash ? contentHash !== shippedHash : undefined,
|
|
342
|
+
defaults: defaults[name] ? cloneJson(defaults[name]) : {},
|
|
343
|
+
});
|
|
344
|
+
}
|
|
345
|
+
if (args.format === "json") return JSON.stringify(entries, null, 2);
|
|
346
|
+
return entries.map((entry) => {
|
|
347
|
+
const provenance = entry.shippedHash ? (entry.userModified ? "user-modified" : "shipped") : "custom";
|
|
348
|
+
const defaultsSummary = Object.keys(entry.defaults).length > 0 ? ` defaults=${JSON.stringify(entry.defaults)}` : "";
|
|
349
|
+
const contentHash = entry.contentHash ? entry.contentHash.slice(0, 12) : "no-prompt";
|
|
350
|
+
return `${entry.name} ${contentHash} ${provenance}${entry.exists ? "" : " default-not-written"}${defaultsSummary} ${entry.filePath}`;
|
|
351
|
+
}).join("\n");
|
|
352
|
+
}
|
|
353
|
+
|
|
354
|
+
async function listTemplates(args = {}) {
|
|
355
|
+
const entries = [];
|
|
356
|
+
for (const [name, source] of Object.entries(DEFAULT_TEMPLATES)) {
|
|
357
|
+
if (args.template && args.template !== name) continue;
|
|
358
|
+
const entry = {
|
|
359
|
+
name,
|
|
360
|
+
filePath: path.join(TEMPLATE_DIR, workflowFileName(name)),
|
|
361
|
+
sourceHash: hash(source),
|
|
362
|
+
byteLength: Buffer.byteLength(source, "utf8"),
|
|
363
|
+
lineCount: source.split(/\r\n|\r|\n/).length,
|
|
364
|
+
};
|
|
365
|
+
if (args.includeSource === true) entry.source = source;
|
|
366
|
+
entries.push(entry);
|
|
367
|
+
}
|
|
368
|
+
if (args.template && entries.length === 0) throw new Error(`Unknown workflow template: ${args.template}`);
|
|
369
|
+
if (args.format === "json") return JSON.stringify(entries, null, 2);
|
|
370
|
+
return entries.map((entry) => {
|
|
371
|
+
const lines = [`${entry.name} ${entry.sourceHash.slice(0, 12)} ${entry.lineCount} lines ${entry.byteLength} bytes ${entry.filePath}`];
|
|
372
|
+
if (args.includeSource === true) lines.push(entry.source);
|
|
373
|
+
return lines.join("\n");
|
|
374
|
+
}).join("\n");
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
async function saveTemplate(context, args) {
|
|
378
|
+
assertWriteWorkflowAllowed(context, "workflow_template_save");
|
|
379
|
+
const source = DEFAULT_TEMPLATES[args.template];
|
|
380
|
+
if (!source) throw new Error(`Unknown workflow template: ${args.template}`);
|
|
381
|
+
return await saveWorkflow(context, {
|
|
382
|
+
name: args.name || args.template,
|
|
383
|
+
source,
|
|
384
|
+
scope: args.scope || "project",
|
|
385
|
+
overwrite: args.overwrite,
|
|
386
|
+
globalScopeIntent: args.globalScopeIntent,
|
|
387
|
+
});
|
|
388
|
+
}
|
|
389
|
+
|
|
390
|
+
async function saveWorkflow(context, args) {
|
|
391
|
+
assertWriteWorkflowAllowed(context, "workflow_save");
|
|
392
|
+
if (!args.source || typeof args.source !== "string") throw new Error("workflow_save requires `source`");
|
|
393
|
+
const scope = args.scope || "project";
|
|
394
|
+
if (scope === "global" && args.globalScopeIntent !== "save-global-workflow") {
|
|
395
|
+
throw new Error("workflow_save global scope requires globalScopeIntent: \"save-global-workflow\"");
|
|
396
|
+
}
|
|
397
|
+
if (Buffer.byteLength(args.source, "utf8") > MAX_SOURCE_BYTES) {
|
|
398
|
+
throw new Error(`Workflow source exceeds ${MAX_SOURCE_BYTES} bytes`);
|
|
399
|
+
}
|
|
400
|
+
parseWorkflowSource(args.source);
|
|
401
|
+
|
|
402
|
+
const directory = scope === "project" ? projectWorkflowDir(context) : GLOBAL_WORKFLOW_DIR;
|
|
403
|
+
const filePath = path.join(directory, workflowFileName(args.name));
|
|
404
|
+
await fs.mkdir(directory, { recursive: true });
|
|
405
|
+
|
|
406
|
+
try {
|
|
407
|
+
await fs.access(filePath);
|
|
408
|
+
if (args.overwrite !== true) {
|
|
409
|
+
throw new Error(`Workflow already exists: ${filePath}. Pass overwrite: true to replace it.`);
|
|
410
|
+
}
|
|
411
|
+
} catch (error) {
|
|
412
|
+
if (error.code !== "ENOENT") throw error;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
await fs.writeFile(filePath, args.source, "utf8");
|
|
416
|
+
return [`Saved workflow ${args.name}.`, `Path: ${filePath}`, `sourceHash: ${hash(args.source)}`].join("\n");
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
// Curated, built-in invocation hints for BUNDLED workflows only. This is the single place
|
|
420
|
+
// where example args / category / notes that are not read verbatim from a workflow's own
|
|
421
|
+
// `meta` are attached. Keyed by workflow name and applied only to the bundled scope so a
|
|
422
|
+
// same-named saved/global workflow never inherits curated examples it did not declare.
|
|
423
|
+
// Curated per-workflow invocation hints (keyed by workflow name), merged over a workflow's own
|
|
424
|
+
// meta in workflow_list. Domain-specific workflows supply their examples via their own meta.examples;
|
|
425
|
+
// this stays empty in the core kernel (no bundled domain workflows).
|
|
426
|
+
const CURATED_INVOCATION_HINTS = {};
|
|
427
|
+
|
|
428
|
+
const MAX_INVOCATION_EXAMPLES = 4;
|
|
429
|
+
|
|
430
|
+
// Only accept example args that are explicit, plain JSON objects. redactValue bounds depth,
|
|
431
|
+
// string length, and array width and masks sensitive keys, so a malformed or oversized
|
|
432
|
+
// `meta.examples` entry can never leak secrets or blow up the listing.
|
|
433
|
+
function sanitizeArgsExamples(rawExamples) {
|
|
434
|
+
if (!Array.isArray(rawExamples)) return [];
|
|
435
|
+
const out = [];
|
|
436
|
+
for (const item of rawExamples) {
|
|
437
|
+
if (!item || typeof item !== "object" || Array.isArray(item)) continue;
|
|
438
|
+
const candidateArgs = item.args;
|
|
439
|
+
if (!candidateArgs || typeof candidateArgs !== "object" || Array.isArray(candidateArgs)) continue;
|
|
440
|
+
const example = { args: redactValue(candidateArgs, { maxDepth: 4 }) };
|
|
441
|
+
if (typeof item.label === "string") example.label = truncateText(item.label, 120);
|
|
442
|
+
out.push(example);
|
|
443
|
+
if (out.length >= MAX_INVOCATION_EXAMPLES) break;
|
|
444
|
+
}
|
|
445
|
+
return out;
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
// jbs3.10: render a one-line, human-readable summary of a workflow's declared meta.argsSchema so
|
|
449
|
+
// `workflow_list` advertises the args contract (which keys are accepted, which are required) without
|
|
450
|
+
// dumping the whole schema. Returns undefined when no usable object schema is declared.
|
|
451
|
+
function summarizeArgsSchema(schema) {
|
|
452
|
+
if (!schema || typeof schema !== "object" || Array.isArray(schema)) return undefined;
|
|
453
|
+
const properties = schema.properties && typeof schema.properties === "object" && !Array.isArray(schema.properties)
|
|
454
|
+
? schema.properties
|
|
455
|
+
: undefined;
|
|
456
|
+
const required = new Set(Array.isArray(schema.required) ? schema.required.filter((k) => typeof k === "string") : []);
|
|
457
|
+
if (!properties) {
|
|
458
|
+
// No property map (e.g. a bare type constraint); still surface the declared top-level type.
|
|
459
|
+
return typeof schema.type === "string" ? `type=${schema.type}` : "declared";
|
|
460
|
+
}
|
|
461
|
+
const fields = Object.keys(properties).slice(0, 16).map((key) => {
|
|
462
|
+
const propType = properties[key] && typeof properties[key].type === "string" ? properties[key].type : undefined;
|
|
463
|
+
const label = propType ? `${key}:${propType}` : key;
|
|
464
|
+
return required.has(key) ? `${label}*` : label;
|
|
465
|
+
});
|
|
466
|
+
if (fields.length === 0) return schema.additionalProperties === false ? "{} (no args)" : "declared";
|
|
467
|
+
return `{ ${fields.join(", ")} }${Object.keys(properties).length > fields.length ? " …" : ""} (*=required)`;
|
|
468
|
+
}
|
|
469
|
+
|
|
470
|
+
function renderRunExample(name, argsExample) {
|
|
471
|
+
const parts = [`workflow_run name=${JSON.stringify(name)}`];
|
|
472
|
+
if (argsExample && argsExample.args && Object.keys(argsExample.args).length > 0) {
|
|
473
|
+
parts.push(`args=${truncateText(JSON.stringify(argsExample.args), 200)}`);
|
|
474
|
+
}
|
|
475
|
+
return parts.join(" ");
|
|
476
|
+
}
|
|
477
|
+
|
|
478
|
+
// Build runnable invocation metadata for one workflow entry. Only explicit `meta` fields
|
|
479
|
+
// (examples/category/notes/modelTiers) and the curated bundled defaults above are surfaced;
|
|
480
|
+
// nothing is inferred from the workflow body.
|
|
481
|
+
function buildInvocationMetadata(entry, meta) {
|
|
482
|
+
const curated = entry.scope === "bundled" ? CURATED_INVOCATION_HINTS[entry.name] : undefined;
|
|
483
|
+
// Explicit meta.examples win over curated defaults; never infer args from the source body.
|
|
484
|
+
let argsExamples = sanitizeArgsExamples(meta.examples);
|
|
485
|
+
if (argsExamples.length === 0 && curated) argsExamples = sanitizeArgsExamples(curated.argsExamples);
|
|
486
|
+
const category = typeof meta.category === "string" ? truncateText(meta.category, 80) : curated?.category;
|
|
487
|
+
const notes = typeof meta.notes === "string" ? truncateText(meta.notes, 240) : curated?.notes;
|
|
488
|
+
const runExamples = argsExamples.length > 0
|
|
489
|
+
? argsExamples.map((example) => renderRunExample(entry.name, example))
|
|
490
|
+
: [renderRunExample(entry.name)];
|
|
491
|
+
const defaultModel = entry.childModel;
|
|
492
|
+
const modelTier = {
|
|
493
|
+
default: defaultModel,
|
|
494
|
+
fast: (meta.modelTiers && typeof meta.modelTiers.fast === "string") ? meta.modelTiers.fast : defaultModel,
|
|
495
|
+
deep: (meta.modelTiers && typeof meta.modelTiers.deep === "string") ? meta.modelTiers.deep : defaultModel,
|
|
496
|
+
};
|
|
497
|
+
const nextSteps = [
|
|
498
|
+
"workflow_status detail=compact # poll run progress",
|
|
499
|
+
"workflow_status detail=result # read redacted lane results after completion",
|
|
500
|
+
];
|
|
501
|
+
if (entry.authority?.editGate && entry.authority.editGate !== "not-requested") {
|
|
502
|
+
nextSteps.push("workflow_apply # approve staged writes before they land");
|
|
503
|
+
}
|
|
504
|
+
const invocation = {
|
|
505
|
+
category,
|
|
506
|
+
profile: entry.authority?.profile || AD_HOC_AUTHORITY_PROFILE,
|
|
507
|
+
authorityMode: entry.authority?.mode,
|
|
508
|
+
maxAgents: entry.maxAgents ?? null,
|
|
509
|
+
concurrency: entry.concurrency ?? null,
|
|
510
|
+
modelTier,
|
|
511
|
+
runExamples,
|
|
512
|
+
argsExamples,
|
|
513
|
+
nextSteps,
|
|
514
|
+
};
|
|
515
|
+
if (notes) invocation.notes = notes;
|
|
516
|
+
return invocation;
|
|
517
|
+
}
|
|
518
|
+
|
|
519
|
+
// `sessionModel` is the invoking session's resolved model (provider/model) or null. It is the
|
|
520
|
+
// display fallback for a workflow that declares no childModel/defaultChildModel: the child default
|
|
521
|
+
// is "whatever the session is on at run time". When the session model is unreadable this stays
|
|
522
|
+
// null (the listing surfaces no model rather than substituting a hard-coded one).
|
|
523
|
+
async function listWorkflows(context, args = {}, sessionModel = null, extensionWorkflowDirs = []) {
|
|
524
|
+
const dirs = [
|
|
525
|
+
{ scope: "project", directory: projectWorkflowDir(context) },
|
|
526
|
+
{ scope: "global", directory: GLOBAL_WORKFLOW_DIR },
|
|
527
|
+
...extensionWorkflowDirs.map((directory) => ({ scope: "extension", directory })),
|
|
528
|
+
{ scope: "bundled", directory: BUNDLED_WORKFLOW_DIR },
|
|
529
|
+
];
|
|
530
|
+
const entries = [];
|
|
531
|
+
for (const { scope, directory } of dirs) {
|
|
532
|
+
if (!(await pathExists(directory))) continue;
|
|
533
|
+
const dirents = await fs.readdir(directory, { withFileTypes: true });
|
|
534
|
+
for (const dirent of dirents) {
|
|
535
|
+
if (!dirent.isFile() || !dirent.name.endsWith(".js")) continue;
|
|
536
|
+
const sourcePath = path.join(directory, dirent.name);
|
|
537
|
+
try {
|
|
538
|
+
const source = await fs.readFile(sourcePath, "utf8");
|
|
539
|
+
const { meta } = parseWorkflowSource(source);
|
|
540
|
+
const authority = resolveRunAuthority(meta, {});
|
|
541
|
+
const entry = {
|
|
542
|
+
name: dirent.name.replace(/\.js$/, ""),
|
|
543
|
+
scope,
|
|
544
|
+
sourcePath,
|
|
545
|
+
sourceHash: hash(source),
|
|
546
|
+
description: meta.description,
|
|
547
|
+
phases: meta.phases,
|
|
548
|
+
maxAgents: meta.maxAgents,
|
|
549
|
+
concurrency: meta.concurrency,
|
|
550
|
+
childModel: meta.childModel || meta.defaultChildModel || sessionModel || null,
|
|
551
|
+
authority,
|
|
552
|
+
};
|
|
553
|
+
entry.invocation = buildInvocationMetadata(entry, meta);
|
|
554
|
+
const argsShape = summarizeArgsSchema(meta.argsSchema);
|
|
555
|
+
if (argsShape) {
|
|
556
|
+
entry.argsSchema = redactValue(meta.argsSchema, { maxDepth: 4 });
|
|
557
|
+
entry.invocation.argsShape = argsShape;
|
|
558
|
+
}
|
|
559
|
+
entries.push(entry);
|
|
560
|
+
} catch (error) {
|
|
561
|
+
entries.push({ name: dirent.name.replace(/\.js$/, ""), scope, sourcePath, status: "malformed", error: extractTextFromError(error) });
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
entries.sort((a, b) => `${a.scope}:${a.name}`.localeCompare(`${b.scope}:${b.name}`));
|
|
566
|
+
if (args.format === "json") return JSON.stringify(entries, null, 2);
|
|
567
|
+
if (entries.length === 0) return "No saved workflows found.";
|
|
568
|
+
return entries.map((entry) => {
|
|
569
|
+
if (entry.status === "malformed") return `${entry.scope}/${entry.name} malformed: ${truncateText(entry.error, 160)}`;
|
|
570
|
+
const lines = [`${entry.scope}/${entry.name} ${entry.sourceHash.slice(0, 12)} ${entry.description || "no description"} authority=${authoritySummary(entry.authority)}`];
|
|
571
|
+
const inv = entry.invocation;
|
|
572
|
+
if (inv) {
|
|
573
|
+
if (inv.category) lines.push(` category: ${inv.category}`);
|
|
574
|
+
if (inv.argsShape) lines.push(` args: ${truncateText(inv.argsShape, 200)}`);
|
|
575
|
+
lines.push(` run: ${inv.runExamples[0]}`);
|
|
576
|
+
for (const extra of inv.runExamples.slice(1)) lines.push(` ${extra}`);
|
|
577
|
+
lines.push(` profile=${inv.profile} maxAgents=${inv.maxAgents ?? "default"} concurrency=${inv.concurrency ?? "default"} model=${inv.modelTier.default} fast=${inv.modelTier.fast} deep=${inv.modelTier.deep}`);
|
|
578
|
+
lines.push(` next: ${inv.nextSteps.join(" | ")}`);
|
|
579
|
+
if (inv.notes) lines.push(` notes: ${truncateText(inv.notes, 160)}`);
|
|
580
|
+
}
|
|
581
|
+
return lines.join("\n");
|
|
582
|
+
}).join("\n");
|
|
583
|
+
}
|
|
584
|
+
|
|
585
|
+
export {
|
|
586
|
+
ROLE_MANIFEST,
|
|
587
|
+
ROLE_DEFAULTS_MANIFEST,
|
|
588
|
+
DEFAULT_ROLES,
|
|
589
|
+
DEFAULT_ROLE_DEFAULTS,
|
|
590
|
+
ALLOWED_ROLE_DEFAULT_KEYS,
|
|
591
|
+
DEFAULT_TEMPLATES,
|
|
592
|
+
roleFileName,
|
|
593
|
+
roleManifestPath,
|
|
594
|
+
roleDefaultsManifestPath,
|
|
595
|
+
ensureRoleFiles,
|
|
596
|
+
loadRoleDefaultsManifest,
|
|
597
|
+
normalizeRoleDefaults,
|
|
598
|
+
normalizeRoleDefaultsManifest,
|
|
599
|
+
mergeRoleDefaults,
|
|
600
|
+
resolveRole,
|
|
601
|
+
listRoles,
|
|
602
|
+
listTemplates,
|
|
603
|
+
saveTemplate,
|
|
604
|
+
saveWorkflow,
|
|
605
|
+
listWorkflows,
|
|
606
|
+
};
|