@namewta/speculo 0.7.0 → 0.7.2
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/README.md +9 -6
- package/dist/src/cli.js +12 -0
- package/dist/src/cli.js.map +1 -1
- package/dist/src/index.d.ts +2 -0
- package/dist/src/index.js +56 -58
- package/dist/src/index.js.map +1 -1
- package/dist/src/migrations.d.ts +23 -0
- package/dist/src/migrations.js +470 -0
- package/dist/src/migrations.js.map +1 -0
- package/package.json +2 -2
- package/template/.speculo/README.md +13 -6
- package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +178 -0
- package/template/canonical/canonical-specdev-goal-plan.md +386 -74
- package/template/canonical/canonical-specdev-grill-with-docs.md +178 -0
- package/template/canonical/canonical-specdev-spec.md +178 -0
- package/template/canonical/canonical-specdev-tickets.md +247 -21
- package/template/canonical/canonical-specdev-wayfinder.md +178 -0
- package/template/commands/migrate-runtime-state.md +43 -0
- package/template/skills/github-npm-ops/references/preflight-checklist.md +1 -1
- package/template/skills/migrate-runtime-state/SKILL.md +93 -0
- package/template/skills/migrate-runtime-state/references/migration-contract.md +58 -0
- package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +545 -0
- package/template/skills/optimize-codex-config/SKILL.md +81 -0
- package/template/skills/optimize-codex-config/references/configuration-contract.md +103 -0
- package/template/skills/optimize-codex-config/references/troubleshooting.md +79 -0
- package/template/skills/optimize-codex-config/scripts/audit-codex-config.mjs +747 -0
- package/template/workflows/specdev/I-implement/I-implement.md +11 -10
- package/template/workflows/specdev/I-implement/delegated-evidence-template.md +2 -1
- package/template/workflows/specdev/I-implement/execution-preflight.md +5 -3
- package/template/workflows/specdev/I-implement/merge-conflict-protocol.md +6 -5
- package/template/workflows/specdev/I-init-setup/I-init-setup.md +1 -1
- package/template/workflows/specdev/INDEX.md +13 -9
- package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +27 -18
- package/template/workflows/specdev/P-goal-plan/completion-control.md +3 -3
- package/template/workflows/specdev/P-goal-plan/delegated-execution-template.md +6 -4
- package/template/workflows/specdev/P-goal-plan/delegated-execution.md +13 -7
- package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +11 -2
- package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +7 -3
- package/template/workflows/specdev/P-goal-plan/planning-modes.md +23 -8
- package/template/workflows/specdev/P-goal-plan/workspace-execution-template.md +24 -0
- package/template/workflows/specdev/common/README.md +2 -2
- package/template/workflows/specdev/common/rules/change-completion.md +3 -2
- package/template/workflows/specdev/common/rules/path-ownership.md +2 -2
- package/template/workflows/specdev/common/schemas/change-status.schema.json +178 -0
- package/template/workflows/specdev/common/schemas/goal-plan.schema.json +10 -0
- package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +11 -11
- package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +19 -4
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +12 -5
- package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +4 -3
- package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +1 -1
- package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +2 -3
- package/template/workflows/specdev/common/tools/validate-specdev.mjs +218 -5
|
@@ -0,0 +1,545 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import {
|
|
5
|
+
cp,
|
|
6
|
+
lstat,
|
|
7
|
+
mkdir,
|
|
8
|
+
mkdtemp,
|
|
9
|
+
readFile,
|
|
10
|
+
readdir,
|
|
11
|
+
rename,
|
|
12
|
+
rm,
|
|
13
|
+
writeFile,
|
|
14
|
+
} from "node:fs/promises";
|
|
15
|
+
import { dirname, join, relative, resolve, sep } from "node:path";
|
|
16
|
+
|
|
17
|
+
const STAGE_PREFIX = ".speculo-runtime-migrate-stage-";
|
|
18
|
+
const ROLLBACK_NAME = ".speculo-runtime-migrate-rollback";
|
|
19
|
+
const VALID_ACTIONS = new Set(["copy", "replace-json", "keep-current", "remove-current"]);
|
|
20
|
+
const VALID_DECISIONS = new Set(["restore", "merge-json", "replace-json", "keep-current", "remove-current"]);
|
|
21
|
+
|
|
22
|
+
function usage() {
|
|
23
|
+
process.stderr.write([
|
|
24
|
+
"Usage:",
|
|
25
|
+
" node migrate-runtime-state.mjs inspect --project-root <path>",
|
|
26
|
+
" node migrate-runtime-state.mjs fingerprint --project-root <path> --target <relative-path>",
|
|
27
|
+
" node migrate-runtime-state.mjs apply --project-root <path> --plan <plan.json> --confirmed",
|
|
28
|
+
"",
|
|
29
|
+
"inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v1 plan.",
|
|
30
|
+
"",
|
|
31
|
+
].join("\n"));
|
|
32
|
+
return 2;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function parseArgs(argv) {
|
|
36
|
+
const [operation, ...rest] = argv;
|
|
37
|
+
const options = { operation, confirmed: false };
|
|
38
|
+
for (let index = 0; index < rest.length; index += 1) {
|
|
39
|
+
const item = rest[index];
|
|
40
|
+
if (item === "--confirmed") {
|
|
41
|
+
options.confirmed = true;
|
|
42
|
+
} else if (item === "--project-root" || item === "--plan" || item === "--target") {
|
|
43
|
+
options[item.slice(2).replaceAll("-", "_")] = rest[index + 1];
|
|
44
|
+
index += 1;
|
|
45
|
+
} else if (item === "--help" || item === "-h") {
|
|
46
|
+
options.help = true;
|
|
47
|
+
} else {
|
|
48
|
+
throw new Error("Unknown argument: " + item);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return options;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
async function exists(path) {
|
|
55
|
+
try {
|
|
56
|
+
await lstat(path);
|
|
57
|
+
return true;
|
|
58
|
+
} catch {
|
|
59
|
+
return false;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function toPosix(path) {
|
|
64
|
+
return path.split(sep).join("/");
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function safeRelative(value, label) {
|
|
68
|
+
if (typeof value !== "string" || !value || value.includes("\\")) {
|
|
69
|
+
throw new Error(label + " must be a non-empty POSIX relative path");
|
|
70
|
+
}
|
|
71
|
+
const parts = value.split("/");
|
|
72
|
+
if (value.startsWith("/") || /^[A-Za-z]:/.test(value) || parts.some((part) => !part || part === "." || part === "..")) {
|
|
73
|
+
throw new Error(label + " escapes its allowed root: " + value);
|
|
74
|
+
}
|
|
75
|
+
return value;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function inside(root, relativePath) {
|
|
79
|
+
const target = resolve(root, safeRelative(relativePath, "path"));
|
|
80
|
+
const prefix = root.endsWith(sep) ? root : root + sep;
|
|
81
|
+
if (target !== root && !target.startsWith(prefix)) throw new Error("Path escapes root: " + relativePath);
|
|
82
|
+
return target;
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function readJson(path) {
|
|
86
|
+
return JSON.parse(await readFile(path, "utf8"));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
async function sha256(path) {
|
|
90
|
+
return createHash("sha256").update(await readFile(path)).digest("hex");
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async function walk(root, current = root, options = {}) {
|
|
94
|
+
if (!(await exists(current))) return [];
|
|
95
|
+
const values = [];
|
|
96
|
+
for (const entry of await readdir(current, { withFileTypes: true })) {
|
|
97
|
+
const path = join(current, entry.name);
|
|
98
|
+
const item = toPosix(relative(root, path));
|
|
99
|
+
if (options.exclude?.(item)) continue;
|
|
100
|
+
if (entry.isDirectory()) {
|
|
101
|
+
values.push({ path: item, type: "directory" });
|
|
102
|
+
values.push(...await walk(root, path, options));
|
|
103
|
+
} else if (entry.isSymbolicLink()) {
|
|
104
|
+
values.push({ path: item, type: "symlink" });
|
|
105
|
+
} else if (entry.isFile()) {
|
|
106
|
+
const stat = await lstat(path);
|
|
107
|
+
values.push({ path: item, type: "file", bytes: stat.size, sha256: await sha256(path) });
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
return values.sort((left, right) => left.path.localeCompare(right.path));
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async function fingerprint(path) {
|
|
114
|
+
if (!(await exists(path))) return "absent";
|
|
115
|
+
const stat = await lstat(path);
|
|
116
|
+
if (stat.isSymbolicLink()) throw new Error("Target is a symbolic link: " + path);
|
|
117
|
+
if (stat.isFile()) return "file:" + await sha256(path);
|
|
118
|
+
if (!stat.isDirectory()) throw new Error("Unsupported target type: " + path);
|
|
119
|
+
const entries = await walk(path);
|
|
120
|
+
const digest = createHash("sha256");
|
|
121
|
+
for (const entry of entries) digest.update(JSON.stringify(entry) + "\n");
|
|
122
|
+
return "directory:" + digest.digest("hex");
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
async function assertNoSymlinkPath(root, relativePath) {
|
|
126
|
+
let current = root;
|
|
127
|
+
for (const part of safeRelative(relativePath, "target").split("/")) {
|
|
128
|
+
current = join(current, part);
|
|
129
|
+
if (!(await exists(current))) continue;
|
|
130
|
+
if ((await lstat(current)).isSymbolicLink()) throw new Error("Target path traverses a symbolic link: " + relativePath);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
async function context(projectRootArg) {
|
|
135
|
+
if (!projectRootArg) throw new Error("--project-root is required");
|
|
136
|
+
const projectRoot = resolve(projectRootArg);
|
|
137
|
+
const speculoRoot = join(projectRoot, "speculo");
|
|
138
|
+
const stateRoot = join(speculoRoot, ".speculo");
|
|
139
|
+
const backupRoot = join(stateRoot, "back");
|
|
140
|
+
const markerPath = join(stateRoot, "migration.json");
|
|
141
|
+
const manifestPath = join(backupRoot, "manifest.json");
|
|
142
|
+
for (const [label, path] of [["Speculo installation", speculoRoot], ["pending marker", markerPath], ["backup manifest", manifestPath]]) {
|
|
143
|
+
if (!(await exists(path))) throw new Error(label + " does not exist: " + path);
|
|
144
|
+
}
|
|
145
|
+
for (const [label, path] of [["Speculo installation", speculoRoot], ["runtime state", stateRoot], ["backup root", backupRoot]]) {
|
|
146
|
+
if ((await lstat(path)).isSymbolicLink()) throw new Error(label + " must not be a symbolic link: " + path);
|
|
147
|
+
}
|
|
148
|
+
const marker = await readJson(markerPath);
|
|
149
|
+
if (marker.schema_version !== 1 || marker.status !== "pending") throw new Error("migration.json is not a pending schema-v1 marker");
|
|
150
|
+
const manifest = await readJson(manifestPath);
|
|
151
|
+
if (manifest.schema_version !== 1 || !Array.isArray(manifest.files)) throw new Error("back/manifest.json is not a schema-v1 manifest");
|
|
152
|
+
return { projectRoot, speculoRoot, stateRoot, backupRoot, markerPath, manifestPath, marker, manifest };
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function validateBackup(ctx, checkMigrationWorkspace = true) {
|
|
156
|
+
const issues = [];
|
|
157
|
+
const expected = new Map();
|
|
158
|
+
for (const entry of ctx.manifest.files) {
|
|
159
|
+
try {
|
|
160
|
+
const item = safeRelative(entry.path, "manifest path");
|
|
161
|
+
if (item === "manifest.json") throw new Error("manifest cannot include itself");
|
|
162
|
+
if (expected.has(item)) throw new Error("duplicate manifest entry: " + item);
|
|
163
|
+
if (entry.type !== "file" && entry.type !== "symlink") throw new Error("invalid manifest entry type: " + item);
|
|
164
|
+
if (entry.type === "file" && (typeof entry.sha256 !== "string" || typeof entry.bytes !== "number")) {
|
|
165
|
+
throw new Error("file manifest entry has no hash or size: " + item);
|
|
166
|
+
}
|
|
167
|
+
expected.set(item, entry);
|
|
168
|
+
} catch (error) {
|
|
169
|
+
issues.push(String(error));
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
const actual = await walk(ctx.backupRoot, ctx.backupRoot, { exclude: (item) => item === "manifest.json" });
|
|
173
|
+
const actualFiles = actual.filter((entry) => entry.type !== "directory");
|
|
174
|
+
for (const entry of actualFiles) {
|
|
175
|
+
const declared = expected.get(entry.path);
|
|
176
|
+
if (!declared) {
|
|
177
|
+
issues.push("undeclared backup entry: " + entry.path);
|
|
178
|
+
continue;
|
|
179
|
+
}
|
|
180
|
+
if (entry.type === "symlink" || declared.type === "symlink") {
|
|
181
|
+
issues.push("backup symlink requires manual recovery outside this command: " + entry.path);
|
|
182
|
+
} else if (entry.sha256 !== declared.sha256 || entry.bytes !== declared.bytes) {
|
|
183
|
+
issues.push("backup hash or size mismatch: " + entry.path);
|
|
184
|
+
}
|
|
185
|
+
expected.delete(entry.path);
|
|
186
|
+
}
|
|
187
|
+
for (const path of expected.keys()) issues.push("missing backup entry: " + path);
|
|
188
|
+
if (checkMigrationWorkspace) {
|
|
189
|
+
const projectEntries = await readdir(ctx.projectRoot);
|
|
190
|
+
for (const name of projectEntries) {
|
|
191
|
+
if (name.startsWith(STAGE_PREFIX) || name === ROLLBACK_NAME) issues.push("unfinished migration workspace: " + name);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
return issues;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async function inspect(projectRoot) {
|
|
198
|
+
const ctx = await context(projectRoot);
|
|
199
|
+
const issues = await validateBackup(ctx);
|
|
200
|
+
return {
|
|
201
|
+
ok: issues.length === 0,
|
|
202
|
+
pending: ctx.marker,
|
|
203
|
+
backup: {
|
|
204
|
+
source_version: ctx.manifest.source_version,
|
|
205
|
+
target_version: ctx.manifest.target_version,
|
|
206
|
+
entries: ctx.manifest.files.length,
|
|
207
|
+
manifest_sha256: await sha256(ctx.manifestPath),
|
|
208
|
+
files: ctx.manifest.files,
|
|
209
|
+
},
|
|
210
|
+
issues,
|
|
211
|
+
};
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function allowedTarget(target, installedWorkflows) {
|
|
215
|
+
safeRelative(target, "target");
|
|
216
|
+
if (target === "config.json") return true;
|
|
217
|
+
if (!target.startsWith(".speculo/")) return false;
|
|
218
|
+
for (const protectedPath of [
|
|
219
|
+
".speculo/back",
|
|
220
|
+
".speculo/workspace.json",
|
|
221
|
+
".speculo/install.json",
|
|
222
|
+
".speculo/migration.json",
|
|
223
|
+
".speculo/README.md",
|
|
224
|
+
]) {
|
|
225
|
+
if (target === protectedPath || target.startsWith(protectedPath + "/")) return false;
|
|
226
|
+
}
|
|
227
|
+
if (target.startsWith(".speculo/commands/")) return true;
|
|
228
|
+
return installedWorkflows.some((workflow) => target === `.speculo/${workflow}` || target.startsWith(`.speculo/${workflow}/`));
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
function allowedDecisionTarget(target, disposition, installedWorkflows) {
|
|
232
|
+
safeRelative(target, "decision target");
|
|
233
|
+
if (allowedTarget(target, installedWorkflows)) return true;
|
|
234
|
+
if (disposition !== "keep-current") return false;
|
|
235
|
+
return new Set([
|
|
236
|
+
".speculo/README.md",
|
|
237
|
+
".speculo/workspace.json",
|
|
238
|
+
".speculo/install.json",
|
|
239
|
+
]).has(target);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function pathsOverlap(left, right) {
|
|
243
|
+
return left === right || left.startsWith(right + "/") || right.startsWith(left + "/");
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
async function validatePlan(ctx, plan) {
|
|
247
|
+
if (plan.schema_version !== 1 || !Array.isArray(plan.source_decisions) || !Array.isArray(plan.actions)) {
|
|
248
|
+
throw new Error("Plan must use schema_version 1 and contain source_decisions and actions");
|
|
249
|
+
}
|
|
250
|
+
if (plan.backup_manifest_sha256 !== await sha256(ctx.manifestPath)) throw new Error("Plan backup manifest fingerprint does not match");
|
|
251
|
+
const install = await readJson(join(ctx.stateRoot, "install.json"));
|
|
252
|
+
const workflows = Array.isArray(install.workflows) ? install.workflows.filter((item) => typeof item === "string") : [];
|
|
253
|
+
const expectedSources = new Set(ctx.manifest.files.map((entry) => entry.path));
|
|
254
|
+
const seenSources = new Set();
|
|
255
|
+
for (const [index, decision] of plan.source_decisions.entries()) {
|
|
256
|
+
if (!decision || typeof decision !== "object" || !VALID_DECISIONS.has(decision.disposition)) {
|
|
257
|
+
throw new Error(`source_decisions[${index}] has an invalid disposition`);
|
|
258
|
+
}
|
|
259
|
+
const source = safeRelative(decision.path, `source_decisions[${index}] path`);
|
|
260
|
+
if (!expectedSources.has(source)) throw new Error(`source_decisions[${index}] is not in the backup manifest: ${source}`);
|
|
261
|
+
if (seenSources.has(source)) throw new Error(`source_decisions[${index}] repeats ${source}`);
|
|
262
|
+
if (typeof decision.target !== "string" || !allowedDecisionTarget(decision.target, decision.disposition, workflows)) {
|
|
263
|
+
throw new Error(`source_decisions[${index}] target is outside runtime ownership: ${decision.target}`);
|
|
264
|
+
}
|
|
265
|
+
seenSources.add(source);
|
|
266
|
+
}
|
|
267
|
+
for (const source of expectedSources) {
|
|
268
|
+
if (!seenSources.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
|
|
269
|
+
}
|
|
270
|
+
const seenTargets = new Set();
|
|
271
|
+
for (const [index, action] of plan.actions.entries()) {
|
|
272
|
+
if (!action || typeof action !== "object" || !VALID_ACTIONS.has(action.kind)) throw new Error(`actions[${index}] has an invalid kind`);
|
|
273
|
+
if (!allowedTarget(action.to, workflows)) throw new Error(`actions[${index}] target is outside runtime ownership: ${action.to}`);
|
|
274
|
+
for (const target of seenTargets) {
|
|
275
|
+
if (pathsOverlap(target, action.to)) throw new Error(`actions[${index}] overlaps target ${target}`);
|
|
276
|
+
}
|
|
277
|
+
seenTargets.add(action.to);
|
|
278
|
+
if (action.kind === "copy") {
|
|
279
|
+
const sourcePath = safeRelative(action.from, `actions[${index}] source`);
|
|
280
|
+
if (sourcePath !== "config.json" && !sourcePath.startsWith("state/")) throw new Error(`actions[${index}] source is outside backup data: ${action.from}`);
|
|
281
|
+
const source = inside(ctx.backupRoot, action.from);
|
|
282
|
+
if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${action.from}`);
|
|
283
|
+
}
|
|
284
|
+
if (action.kind === "replace-json") {
|
|
285
|
+
if (!action.to.endsWith(".json") || action.value === undefined) throw new Error(`actions[${index}] replace-json needs a JSON target and value`);
|
|
286
|
+
JSON.stringify(action.value);
|
|
287
|
+
}
|
|
288
|
+
if (typeof action.expected_target !== "string") throw new Error(`actions[${index}] must contain expected_target`);
|
|
289
|
+
const currentFingerprint = await fingerprint(inside(ctx.speculoRoot, action.to));
|
|
290
|
+
if (currentFingerprint !== action.expected_target) throw new Error(`actions[${index}] target drifted: ${action.to}`);
|
|
291
|
+
}
|
|
292
|
+
return workflows;
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
async function validateJsonTree(root) {
|
|
296
|
+
const failures = [];
|
|
297
|
+
for (const entry of await walk(root, root, { exclude: (item) => item === ".speculo/back" || item.startsWith(".speculo/back/") })) {
|
|
298
|
+
if (entry.type !== "file" || !entry.path.endsWith(".json")) continue;
|
|
299
|
+
try {
|
|
300
|
+
await readJson(join(root, entry.path));
|
|
301
|
+
} catch (error) {
|
|
302
|
+
failures.push(entry.path + ": " + String(error));
|
|
303
|
+
}
|
|
304
|
+
}
|
|
305
|
+
return failures;
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
async function validateSpecdev(speculoRoot) {
|
|
309
|
+
const statusPath = join(speculoRoot, ".speculo", "specdev", "status.json");
|
|
310
|
+
if (!(await exists(statusPath))) return [];
|
|
311
|
+
const failures = [];
|
|
312
|
+
const status = await readJson(statusPath);
|
|
313
|
+
if (status.schema_version !== 4 || status.workflow !== "specdev" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
|
|
314
|
+
return [".speculo/specdev/status.json is not SpecDev global status v4"];
|
|
315
|
+
}
|
|
316
|
+
const active = new Set();
|
|
317
|
+
for (const entry of status.active) {
|
|
318
|
+
if (!entry || typeof entry.change !== "string") {
|
|
319
|
+
failures.push("SpecDev active entry has no change name");
|
|
320
|
+
continue;
|
|
321
|
+
}
|
|
322
|
+
if (active.has(entry.change)) failures.push("duplicate SpecDev active entry: " + entry.change);
|
|
323
|
+
active.add(entry.change);
|
|
324
|
+
const path = join(speculoRoot, ".speculo", "specdev", "changes", entry.change, ".status.json");
|
|
325
|
+
if (!(await exists(path))) {
|
|
326
|
+
failures.push("missing active change state: " + entry.change);
|
|
327
|
+
} else {
|
|
328
|
+
const changeStatus = await readJson(path);
|
|
329
|
+
if (
|
|
330
|
+
changeStatus.schema_version !== 3 ||
|
|
331
|
+
changeStatus.artifact !== "change-status" ||
|
|
332
|
+
changeStatus.change !== entry.change ||
|
|
333
|
+
!new Set(["active", "blocked", "completed"]).has(changeStatus.change_status)
|
|
334
|
+
) failures.push("invalid active change state: " + entry.change);
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
const archived = new Set();
|
|
338
|
+
for (const name of status.archived) {
|
|
339
|
+
if (typeof name !== "string") {
|
|
340
|
+
failures.push("SpecDev archived entry is not a string");
|
|
341
|
+
continue;
|
|
342
|
+
}
|
|
343
|
+
if (archived.has(name)) failures.push("duplicate SpecDev archived entry: " + name);
|
|
344
|
+
archived.add(name);
|
|
345
|
+
if (active.has(name)) failures.push("SpecDev active/archive overlap: " + name);
|
|
346
|
+
const path = join(speculoRoot, ".speculo", "specdev", "archive", name.slice(0, 7), name, ".status.json");
|
|
347
|
+
if (!(await exists(path))) {
|
|
348
|
+
failures.push("missing archived change state: " + name);
|
|
349
|
+
} else {
|
|
350
|
+
const archivedStatus = await readJson(path);
|
|
351
|
+
if (
|
|
352
|
+
archivedStatus.schema_version !== 3 ||
|
|
353
|
+
archivedStatus.artifact !== "change-status" ||
|
|
354
|
+
archivedStatus.change !== name ||
|
|
355
|
+
archivedStatus.change_status !== "archived"
|
|
356
|
+
) failures.push("invalid archived change state: " + name);
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
const changesRoot = join(speculoRoot, ".speculo", "specdev", "changes");
|
|
360
|
+
if (await exists(changesRoot)) {
|
|
361
|
+
for (const entry of await readdir(changesRoot, { withFileTypes: true })) {
|
|
362
|
+
if (!entry.isDirectory()) continue;
|
|
363
|
+
if (!(await exists(join(changesRoot, entry.name, ".status.json")))) failures.push("change directory has no state: " + entry.name);
|
|
364
|
+
else if (!active.has(entry.name)) failures.push("unindexed active change: " + entry.name);
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
const archiveRoot = join(speculoRoot, ".speculo", "specdev", "archive");
|
|
368
|
+
if (await exists(archiveRoot)) {
|
|
369
|
+
for (const monthEntry of await readdir(archiveRoot, { withFileTypes: true })) {
|
|
370
|
+
if (!monthEntry.isDirectory()) continue;
|
|
371
|
+
const monthRoot = join(archiveRoot, monthEntry.name);
|
|
372
|
+
for (const changeEntry of await readdir(monthRoot, { withFileTypes: true })) {
|
|
373
|
+
if (!changeEntry.isDirectory()) continue;
|
|
374
|
+
if (!(await exists(join(monthRoot, changeEntry.name, ".status.json")))) failures.push("archived change directory has no state: " + changeEntry.name);
|
|
375
|
+
else if (!archived.has(changeEntry.name)) failures.push("unindexed archived change: " + changeEntry.name);
|
|
376
|
+
}
|
|
377
|
+
}
|
|
378
|
+
}
|
|
379
|
+
const configPath = join(speculoRoot, ".speculo", "specdev", "config.json");
|
|
380
|
+
if (await exists(configPath)) {
|
|
381
|
+
const config = await readJson(configPath);
|
|
382
|
+
if (config.schema_version !== 3) failures.push(".speculo/specdev/config.json is not schema v3");
|
|
383
|
+
}
|
|
384
|
+
return failures;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
async function validatePerson(speculoRoot) {
|
|
388
|
+
const path = join(speculoRoot, ".speculo", "person", "status.json");
|
|
389
|
+
if (!(await exists(path))) return [];
|
|
390
|
+
const status = await readJson(path);
|
|
391
|
+
return status.schema_version === 1 && status.workflow === "person" && Array.isArray(status.active)
|
|
392
|
+
? []
|
|
393
|
+
: [".speculo/person/status.json is not person status schema v1"];
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
async function validateActive(speculoRoot, allowPending = false) {
|
|
397
|
+
const failures = [];
|
|
398
|
+
let config;
|
|
399
|
+
let workspace;
|
|
400
|
+
let install;
|
|
401
|
+
try {
|
|
402
|
+
config = await readJson(join(speculoRoot, "config.json"));
|
|
403
|
+
if (config.schema_version !== 1) failures.push("config.json is not schema v1");
|
|
404
|
+
} catch (error) {
|
|
405
|
+
failures.push("config.json: " + String(error));
|
|
406
|
+
}
|
|
407
|
+
try {
|
|
408
|
+
workspace = await readJson(join(speculoRoot, ".speculo", "workspace.json"));
|
|
409
|
+
const roots = workspace.roots;
|
|
410
|
+
if (
|
|
411
|
+
workspace.schema_version !== 1 || workspace.path_base !== "project-root" ||
|
|
412
|
+
!roots || ["config", "speculo", "state", "commands", "skills", "workflows"].some((key) => typeof roots[key] !== "string")
|
|
413
|
+
) failures.push(".speculo/workspace.json is not a project-root schema-v1 workspace");
|
|
414
|
+
} catch (error) {
|
|
415
|
+
failures.push(".speculo/workspace.json: " + String(error));
|
|
416
|
+
}
|
|
417
|
+
try {
|
|
418
|
+
install = await readJson(join(speculoRoot, ".speculo", "install.json"));
|
|
419
|
+
if (
|
|
420
|
+
install.schema_version !== 1 || typeof install.package_version !== "string" ||
|
|
421
|
+
!Array.isArray(install.workflows) || install.workflows.some((item) => typeof item !== "string") ||
|
|
422
|
+
new Set(install.workflows).size !== install.workflows.length
|
|
423
|
+
) {
|
|
424
|
+
failures.push(".speculo/install.json is not a valid schema-v1 install manifest");
|
|
425
|
+
} else {
|
|
426
|
+
for (const workflow of install.workflows) {
|
|
427
|
+
if (!(await exists(join(speculoRoot, "workflows", workflow, "INDEX.md")))) failures.push("missing installed workflow INDEX: " + workflow);
|
|
428
|
+
if (!(await exists(join(speculoRoot, ".speculo", workflow, "status.json")))) failures.push("missing installed workflow state: " + workflow);
|
|
429
|
+
}
|
|
430
|
+
}
|
|
431
|
+
} catch (error) {
|
|
432
|
+
failures.push(".speculo/install.json: " + String(error));
|
|
433
|
+
}
|
|
434
|
+
if (!allowPending && await exists(join(speculoRoot, ".speculo", "migration.json"))) failures.push("pending migration marker still exists");
|
|
435
|
+
failures.push(...await validateJsonTree(speculoRoot));
|
|
436
|
+
failures.push(...await validateSpecdev(speculoRoot));
|
|
437
|
+
failures.push(...await validatePerson(speculoRoot));
|
|
438
|
+
if (failures.length) throw new Error("Migrated runtime validation failed:\n- " + failures.join("\n- "));
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
async function applyAction(ctx, stagedSpeculo, action) {
|
|
442
|
+
if (action.kind === "keep-current") return;
|
|
443
|
+
const destination = inside(stagedSpeculo, action.to);
|
|
444
|
+
await assertNoSymlinkPath(stagedSpeculo, action.to);
|
|
445
|
+
if (action.kind === "remove-current") {
|
|
446
|
+
await rm(destination, { recursive: true, force: true });
|
|
447
|
+
return;
|
|
448
|
+
}
|
|
449
|
+
await mkdir(dirname(destination), { recursive: true });
|
|
450
|
+
if (action.kind === "copy") {
|
|
451
|
+
const source = inside(ctx.backupRoot, action.from);
|
|
452
|
+
const stat = await lstat(source);
|
|
453
|
+
await rm(destination, { recursive: true, force: true });
|
|
454
|
+
await cp(source, destination, { recursive: stat.isDirectory(), force: true });
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
await writeFile(destination, JSON.stringify(action.value, null, 2) + "\n", "utf8");
|
|
458
|
+
}
|
|
459
|
+
|
|
460
|
+
async function apply(projectRoot, planPath, confirmed) {
|
|
461
|
+
if (!confirmed) throw new Error("apply requires --confirmed");
|
|
462
|
+
if (!planPath) throw new Error("apply requires --plan");
|
|
463
|
+
const ctx = await context(projectRoot);
|
|
464
|
+
const issues = await validateBackup(ctx);
|
|
465
|
+
if (issues.length) throw new Error("Backup validation failed:\n- " + issues.join("\n- "));
|
|
466
|
+
const plan = await readJson(resolve(planPath));
|
|
467
|
+
await validatePlan(ctx, plan);
|
|
468
|
+
|
|
469
|
+
const stageContainer = await mkdtemp(join(ctx.projectRoot, STAGE_PREFIX));
|
|
470
|
+
const stagedSpeculo = join(stageContainer, "speculo");
|
|
471
|
+
const rollbackRoot = join(ctx.projectRoot, ROLLBACK_NAME);
|
|
472
|
+
let oldMoved = false;
|
|
473
|
+
let newInstalled = false;
|
|
474
|
+
try {
|
|
475
|
+
await cp(ctx.speculoRoot, stagedSpeculo, { recursive: true, force: true });
|
|
476
|
+
for (const action of plan.actions) await applyAction(ctx, stagedSpeculo, action);
|
|
477
|
+
await validateActive(stagedSpeculo, true);
|
|
478
|
+
await rm(join(stagedSpeculo, ".speculo", "migration.json"), { force: true });
|
|
479
|
+
await rename(ctx.speculoRoot, rollbackRoot);
|
|
480
|
+
oldMoved = true;
|
|
481
|
+
await rename(stagedSpeculo, ctx.speculoRoot);
|
|
482
|
+
newInstalled = true;
|
|
483
|
+
await validateActive(ctx.speculoRoot);
|
|
484
|
+
const installedCtx = await contextWithCompletedMigration(ctx.projectRoot);
|
|
485
|
+
const postIssues = await validateBackup(installedCtx, false);
|
|
486
|
+
if (postIssues.length) throw new Error("Backup changed during migration:\n- " + postIssues.join("\n- "));
|
|
487
|
+
await rm(rollbackRoot, { recursive: true, force: true });
|
|
488
|
+
await rm(stageContainer, { recursive: true, force: true });
|
|
489
|
+
return { ok: true, actions: plan.actions.length, rollback: "not-required", backup: "speculo/.speculo/back" };
|
|
490
|
+
} catch (error) {
|
|
491
|
+
if (newInstalled && await exists(ctx.speculoRoot)) await rm(ctx.speculoRoot, { recursive: true, force: true });
|
|
492
|
+
if (oldMoved && await exists(rollbackRoot)) await rename(rollbackRoot, ctx.speculoRoot);
|
|
493
|
+
await rm(stageContainer, { recursive: true, force: true });
|
|
494
|
+
throw error;
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
async function contextWithCompletedMigration(projectRoot) {
|
|
499
|
+
const speculoRoot = join(projectRoot, "speculo");
|
|
500
|
+
const stateRoot = join(speculoRoot, ".speculo");
|
|
501
|
+
const backupRoot = join(stateRoot, "back");
|
|
502
|
+
const manifestPath = join(backupRoot, "manifest.json");
|
|
503
|
+
return {
|
|
504
|
+
projectRoot,
|
|
505
|
+
speculoRoot,
|
|
506
|
+
stateRoot,
|
|
507
|
+
backupRoot,
|
|
508
|
+
manifestPath,
|
|
509
|
+
manifest: await readJson(manifestPath),
|
|
510
|
+
};
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
async function main(argv) {
|
|
514
|
+
let args;
|
|
515
|
+
try {
|
|
516
|
+
args = parseArgs(argv);
|
|
517
|
+
} catch (error) {
|
|
518
|
+
process.stderr.write(String(error) + "\n");
|
|
519
|
+
return usage();
|
|
520
|
+
}
|
|
521
|
+
if (args.help || !args.operation) return usage();
|
|
522
|
+
try {
|
|
523
|
+
if (args.operation === "inspect") {
|
|
524
|
+
process.stdout.write(JSON.stringify(await inspect(args.project_root), null, 2) + "\n");
|
|
525
|
+
return 0;
|
|
526
|
+
}
|
|
527
|
+
if (args.operation === "fingerprint") {
|
|
528
|
+
if (!args.target) throw new Error("fingerprint requires --target");
|
|
529
|
+
const ctx = await context(args.project_root);
|
|
530
|
+
if (!allowedTarget(args.target, (await readJson(join(ctx.stateRoot, "install.json"))).workflows ?? [])) throw new Error("target is outside runtime ownership");
|
|
531
|
+
process.stdout.write(await fingerprint(inside(ctx.speculoRoot, args.target)) + "\n");
|
|
532
|
+
return 0;
|
|
533
|
+
}
|
|
534
|
+
if (args.operation === "apply") {
|
|
535
|
+
process.stdout.write(JSON.stringify(await apply(args.project_root, args.plan, args.confirmed), null, 2) + "\n");
|
|
536
|
+
return 0;
|
|
537
|
+
}
|
|
538
|
+
return usage();
|
|
539
|
+
} catch (error) {
|
|
540
|
+
process.stderr.write((error instanceof Error ? error.message : String(error)) + "\n");
|
|
541
|
+
return 1;
|
|
542
|
+
}
|
|
543
|
+
}
|
|
544
|
+
|
|
545
|
+
process.exitCode = await main(process.argv.slice(2));
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: optimize-codex-config
|
|
3
|
+
description: 体检并优化本机 Codex 配置;当任务涉及 config.toml、auth.json、自定义模型供应商、权限、Agent、MCP、Hook、配置漂移,或 Codex 的 401、403、404、413、SSE、超时与 compaction 故障时使用。
|
|
4
|
+
---
|
|
5
|
+
|
|
6
|
+
# Optimize Codex Config
|
|
7
|
+
|
|
8
|
+
以**体检**为主导词:先建立脱敏事实,再提出配置变更。默认只读;修改本机配置前必须向用户展示完整目标和脱敏 diff,并取得本次修改的明确确认。
|
|
9
|
+
|
|
10
|
+
## 1. 锁定范围与权限
|
|
11
|
+
|
|
12
|
+
1. 解析实际 `CODEX_HOME`;未显式设置时使用当前用户的 `~/.codex`。将它转成绝对路径并确认目标是目录且不是符号链接。
|
|
13
|
+
2. 将请求归类为只读体检、故障诊断或配置修改。只读体检和诊断不取得写权限。
|
|
14
|
+
3. 将本 skill 的写入边界限制为用户明确指定的本机 Codex 文件。CC Switch 数据库、远端 API、反向代理和 Nginx 只输出归因与交接建议。
|
|
15
|
+
4. 在任何可能写入前记录 `config.toml` 的哈希、大小和修改时间,并检查目标文件是否存在已证明的可写句柄。普通 Codex CLI、ChatGPT/Codex 应用进程及其 helper 的存在不构成 writer 证据。
|
|
16
|
+
|
|
17
|
+
**完成标准:** 实际 `CODEX_HOME`、任务类型、允许写入的文件和外部边界均已明确;符号链接、已证明的活跃 writer、不明确目标,或修改任务无法取得 writer 观测时已成为 blocker。只读任务可以把不可用探针记录为 unknown 后继续。
|
|
18
|
+
|
|
19
|
+
## 2. 建立只读基线
|
|
20
|
+
|
|
21
|
+
从本 `SKILL.md` 所在目录运行:
|
|
22
|
+
|
|
23
|
+
```bash
|
|
24
|
+
node scripts/audit-codex-config.mjs --codex-home <absolute-directory> --json
|
|
25
|
+
```
|
|
26
|
+
|
|
27
|
+
需要离线或可复现 fixture 时加入 `--no-command-probes`;需要缩小会话扫描范围时使用 `--since-days <N>`。先运行 `--help` 核对当前接口;CLI 不在导出的 `PATH` 中时,用 `command -v codex` 取得绝对路径并传给 `--codex-bin`。
|
|
28
|
+
|
|
29
|
+
1. 保留审计脚本的结构化结果;不得把 `auth.json` 内容、提示词、工具输出、完整接口 URL 或 bearer token复制进报告。
|
|
30
|
+
2. 直接查看配置时,先遮蔽 `experimental_bearer_token`、静态认证 header、环境变量值和 URL 主机。只检查 `auth.json` 的存在、文件类型、权限和 Codex 报告的认证模式,不读取或打印文件内容。
|
|
31
|
+
3. 对配置、供应商、认证、权限、Agent、MCP、Hook 或历史设置提出判断前,读取 [configuration contract](references/configuration-contract.md),并用已安装 CLI 与当前官方配置参考验证每个拟使用的键。
|
|
32
|
+
4. 把用户提供的既有设置视为需要保留或评估的事实,不把个人模型、认证方式或权限策略提升为通用默认值。
|
|
33
|
+
|
|
34
|
+
**完成标准:** 当前版本、配置指纹、认证存储模式、供应商契约、权限、Agent、MCP、Hook、会话故障和 writer 状态均有脱敏证据;无法取得的事实被标为 unknown。
|
|
35
|
+
|
|
36
|
+
## 3. 归因故障
|
|
37
|
+
|
|
38
|
+
当请求包含 HTTP 状态码、SSE、超时或 compaction 失败时,读取 [troubleshooting](references/troubleshooting.md),按其中证据梯度完成归因。
|
|
39
|
+
|
|
40
|
+
1. 关联错误发生前最近一次 `token_count`,但只保留 token 数和模型上下文窗口。
|
|
41
|
+
2. 区分本机配置、认证、供应商 wire API、远端模型服务和前置代理。HTML 代理错误页属于代理证据,不归因给模型。
|
|
42
|
+
3. 对外部问题给出可复现证据、影响、临时本机缓解和服务端交接项。本 skill 不探测或修改用户未授权的远端系统。
|
|
43
|
+
|
|
44
|
+
**完成标准:** 每个错误只有一个主要归属域,证据与推断分开,所有本机缓解都标明质量、成本或频率代价。
|
|
45
|
+
|
|
46
|
+
## 4. 设计目标状态
|
|
47
|
+
|
|
48
|
+
只询问审计无法发现且会改变方案的偏好:模型与推理等级、认证存储、审批与沙箱、网络访问、Agent 并发、供应商认证方式、历史保留,以及 MCP/Hook 的保留意图。
|
|
49
|
+
|
|
50
|
+
输出确认包:
|
|
51
|
+
|
|
52
|
+
1. 当前状态和问题证据;
|
|
53
|
+
2. 目标状态及每项理由;
|
|
54
|
+
3. 逐文件脱敏 diff;
|
|
55
|
+
4. 明确保留的未知项、MCP、Hook、profile 和兼容设置;
|
|
56
|
+
5. 备份名、原子写入方法、验证命令和回滚条件;
|
|
57
|
+
6. 不在本机范围内的外部 blocker。
|
|
58
|
+
|
|
59
|
+
只采用当前官方参考与已安装 CLI 均能验证的键。项目级 `.codex/config.toml` 不承载 provider、auth 或其他被 Codex 忽略的机器级设置。
|
|
60
|
+
|
|
61
|
+
**完成标准:** 用户无需猜测任何目标值;diff 不含 secret;未关联的现有设置不会被清理;外部问题不会伪装成本机可修复项。
|
|
62
|
+
|
|
63
|
+
## 5. 确认后原子写入
|
|
64
|
+
|
|
65
|
+
只有用户在看到确认包后明确同意本次变更,才执行以下动作:
|
|
66
|
+
|
|
67
|
+
1. 重读指纹;若配置已变化、存在目标文件的可写句柄,或 writer 探针仍为 unknown,停止并重新体检。
|
|
68
|
+
2. 为每个待改文件创建不覆盖的 `*.pre-optimize-<YYYYMMDD-HHMMSS>.bak`,并将包含凭据的文件权限设为 `0600`。
|
|
69
|
+
3. 在同一目录写临时文件、解析或加载验证成功后 rename 到目标,保留与任务无关的表和注释。
|
|
70
|
+
4. 仅在用户明确要求且已安装 Codex 能验证格式时处理 `auth.json`。文件存储是有效选择,不强制迁移钥匙串;不得自行发明认证 JSON schema。
|
|
71
|
+
|
|
72
|
+
**完成标准:** 写入前后的指纹、备份和确认可对应;目标文件是原子替换结果;没有越出已确认文件集合。
|
|
73
|
+
|
|
74
|
+
## 6. 验证与交付
|
|
75
|
+
|
|
76
|
+
1. 运行 `codex doctor --json`,再核对 `codex features list` 和 `codex debug models --bundled` 中与目标相关的能力。
|
|
77
|
+
2. 验证配置加载、认证模式、权限与沙箱、MCP、Hook、Agent 和旧会话恢复。只有用户授权可能计费的网络请求后,才执行最小第三方 API 请求。
|
|
78
|
+
3. 任一必须验证项失败时恢复备份,重跑相同检查并报告原始失败与回滚结果。
|
|
79
|
+
4. 报告已改变、已保留、已验证、未验证和外部 blocker;不回显任何 secret 或完整 URL。
|
|
80
|
+
|
|
81
|
+
**完成标准:** 所有已确认变更通过本机验证,或已完整回滚;报告包含命令、退出码和关键脱敏证据。
|