@namewta/speculo 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (57) hide show
  1. package/README.md +2 -1
  2. package/dist/src/migrations.js +604 -23
  3. package/dist/src/migrations.js.map +1 -1
  4. package/package.json +1 -1
  5. package/template/canonical/canonical-specdev-engineering-cognitive-mentor.md +171 -277
  6. package/template/canonical/canonical-specdev-goal-plan.md +714 -833
  7. package/template/canonical/canonical-specdev-grill-with-docs.md +172 -278
  8. package/template/canonical/canonical-specdev-spec.md +199 -315
  9. package/template/canonical/canonical-specdev-tickets.md +405 -398
  10. package/template/canonical/canonical-specdev-wayfinder.md +170 -276
  11. package/template/skills/migrate-runtime-state/SKILL.md +6 -6
  12. package/template/skills/migrate-runtime-state/references/migration-contract.md +9 -3
  13. package/template/skills/migrate-runtime-state/scripts/migrate-runtime-state.mjs +322 -33
  14. package/template/skills/optimize-codex-config/SKILL.md +81 -0
  15. package/template/skills/optimize-codex-config/references/configuration-contract.md +103 -0
  16. package/template/skills/optimize-codex-config/references/troubleshooting.md +79 -0
  17. package/template/skills/optimize-codex-config/scripts/audit-codex-config.mjs +747 -0
  18. package/template/workflows/specdev/I-implement/I-implement.md +97 -142
  19. package/template/workflows/specdev/I-implement/evidence-template.md +60 -48
  20. package/template/workflows/specdev/I-implement/execution-preflight.md +29 -19
  21. package/template/workflows/specdev/I-implement/merge-conflict-protocol.md +12 -11
  22. package/template/workflows/specdev/I-init-setup/I-init-setup.md +4 -5
  23. package/template/workflows/specdev/I-init-setup/change-status-template.json +14 -1
  24. package/template/workflows/specdev/I-init-setup/config-template.json +3 -5
  25. package/template/workflows/specdev/I-init-setup/status-template.json +1 -1
  26. package/template/workflows/specdev/INDEX.md +12 -8
  27. package/template/workflows/specdev/P-goal-plan/P-goal-plan.md +76 -93
  28. package/template/workflows/specdev/P-goal-plan/completion-control.md +26 -44
  29. package/template/workflows/specdev/P-goal-plan/goal-plan-template.md +43 -31
  30. package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +34 -0
  31. package/template/workflows/specdev/P-goal-plan/orchestration-protocol.md +31 -42
  32. package/template/workflows/specdev/P-goal-plan/planning-modes.md +42 -61
  33. package/template/workflows/specdev/T-tickets/T-tickets.md +6 -3
  34. package/template/workflows/specdev/T-tickets/ticket-readiness.md +5 -3
  35. package/template/workflows/specdev/T-tickets/ticket-template.md +8 -1
  36. package/template/workflows/specdev/T-tickets/tickets-map-template.md +5 -4
  37. package/template/workflows/specdev/_state/status.json +1 -1
  38. package/template/workflows/specdev/common/README.md +2 -2
  39. package/template/workflows/specdev/common/rules/change-completion.md +17 -19
  40. package/template/workflows/specdev/common/rules/deviation-control.md +1 -1
  41. package/template/workflows/specdev/common/rules/evidence-and-verification.md +27 -37
  42. package/template/workflows/specdev/common/rules/path-ownership.md +21 -23
  43. package/template/workflows/specdev/common/rules/readiness-and-depth.md +1 -1
  44. package/template/workflows/specdev/common/schemas/change-status.schema.json +136 -195
  45. package/template/workflows/specdev/common/schemas/config.schema.json +9 -11
  46. package/template/workflows/specdev/common/schemas/goal-plan.schema.json +24 -6
  47. package/template/workflows/specdev/common/schemas/status.schema.json +7 -63
  48. package/template/workflows/specdev/common/skills/dev-worktree/SKILL.md +42 -21
  49. package/template/workflows/specdev/common/skills/dev-worktree/references/create.md +48 -18
  50. package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +47 -12
  51. package/template/workflows/specdev/common/skills/subagent-delivery/SKILL.md +34 -30
  52. package/template/workflows/specdev/common/skills/subagent-delivery/references/external-web-subagent.md +10 -23
  53. package/template/workflows/specdev/common/skills/subagent-delivery/references/native-subagent.md +14 -25
  54. package/template/workflows/specdev/common/tools/validate-specdev.mjs +507 -117
  55. package/template/workflows/specdev/I-implement/delegated-evidence-template.md +0 -11
  56. package/template/workflows/specdev/P-goal-plan/delegated-execution-template.md +0 -33
  57. package/template/workflows/specdev/P-goal-plan/delegated-execution.md +0 -53
@@ -8,6 +8,7 @@ import {
8
8
  mkdtemp,
9
9
  readFile,
10
10
  readdir,
11
+ readlink,
11
12
  rename,
12
13
  rm,
13
14
  writeFile,
@@ -26,7 +27,7 @@ function usage() {
26
27
  " node migrate-runtime-state.mjs fingerprint --project-root <path> --target <relative-path>",
27
28
  " node migrate-runtime-state.mjs apply --project-root <path> --plan <plan.json> --confirmed",
28
29
  "",
29
- "inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v1 plan.",
30
+ "inspect and fingerprint are read-only. apply requires an explicit confirmed schema-v2 plan.",
30
31
  "",
31
32
  ].join("\n"));
32
33
  return 2;
@@ -101,7 +102,7 @@ async function walk(root, current = root, options = {}) {
101
102
  values.push({ path: item, type: "directory" });
102
103
  values.push(...await walk(root, path, options));
103
104
  } else if (entry.isSymbolicLink()) {
104
- values.push({ path: item, type: "symlink" });
105
+ values.push({ path: item, type: "symlink", target: await readlink(path) });
105
106
  } else if (entry.isFile()) {
106
107
  const stat = await lstat(path);
107
108
  values.push({ path: item, type: "file", bytes: stat.size, sha256: await sha256(path) });
@@ -164,6 +165,9 @@ async function validateBackup(ctx, checkMigrationWorkspace = true) {
164
165
  if (entry.type === "file" && (typeof entry.sha256 !== "string" || typeof entry.bytes !== "number")) {
165
166
  throw new Error("file manifest entry has no hash or size: " + item);
166
167
  }
168
+ if (entry.type === "symlink" && typeof entry.target !== "string") {
169
+ throw new Error("symlink manifest entry has no target: " + item);
170
+ }
167
171
  expected.set(item, entry);
168
172
  } catch (error) {
169
173
  issues.push(String(error));
@@ -177,8 +181,10 @@ async function validateBackup(ctx, checkMigrationWorkspace = true) {
177
181
  issues.push("undeclared backup entry: " + entry.path);
178
182
  continue;
179
183
  }
180
- if (entry.type === "symlink" || declared.type === "symlink") {
181
- issues.push("backup symlink requires manual recovery outside this command: " + entry.path);
184
+ if (entry.type !== declared.type) {
185
+ issues.push("backup entry type mismatch: " + entry.path);
186
+ } else if (entry.type === "symlink") {
187
+ if (entry.target !== declared.target) issues.push("backup symlink target mismatch: " + entry.path);
182
188
  } else if (entry.sha256 !== declared.sha256 || entry.bytes !== declared.bytes) {
183
189
  issues.push("backup hash or size mismatch: " + entry.path);
184
190
  }
@@ -244,51 +250,74 @@ function pathsOverlap(left, right) {
244
250
  }
245
251
 
246
252
  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");
253
+ if (plan.schema_version !== 2 || !Array.isArray(plan.source_decisions) || !Array.isArray(plan.actions)) {
254
+ throw new Error("Plan must use schema_version 2 and contain source_decisions and actions");
249
255
  }
250
256
  if (plan.backup_manifest_sha256 !== await sha256(ctx.manifestPath)) throw new Error("Plan backup manifest fingerprint does not match");
251
257
  const install = await readJson(join(ctx.stateRoot, "install.json"));
252
258
  const workflows = Array.isArray(install.workflows) ? install.workflows.filter((item) => typeof item === "string") : [];
253
259
  const expectedSources = new Set(ctx.manifest.files.map((entry) => entry.path));
254
- const seenSources = new Set();
260
+ const decisions = new Map();
255
261
  for (const [index, decision] of plan.source_decisions.entries()) {
256
262
  if (!decision || typeof decision !== "object" || !VALID_DECISIONS.has(decision.disposition)) {
257
263
  throw new Error(`source_decisions[${index}] has an invalid disposition`);
258
264
  }
259
265
  const source = safeRelative(decision.path, `source_decisions[${index}] path`);
260
266
  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}`);
267
+ if (decisions.has(source)) throw new Error(`source_decisions[${index}] repeats ${source}`);
262
268
  if (typeof decision.target !== "string" || !allowedDecisionTarget(decision.target, decision.disposition, workflows)) {
263
269
  throw new Error(`source_decisions[${index}] target is outside runtime ownership: ${decision.target}`);
264
270
  }
265
- seenSources.add(source);
271
+ decisions.set(source, decision);
266
272
  }
267
273
  for (const source of expectedSources) {
268
- if (!seenSources.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
274
+ if (!decisions.has(source)) throw new Error("Plan has no decision for backup entry: " + source);
269
275
  }
276
+
277
+ const actionSources = new Set();
270
278
  const seenTargets = new Set();
271
279
  for (const [index, action] of plan.actions.entries()) {
272
280
  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}`);
281
+ if (typeof action.source_decision !== "string") throw new Error(`actions[${index}] must explicitly name source_decision`);
282
+ const actionSource = safeRelative(action.source_decision, `actions[${index}] source_decision`);
283
+ const decision = decisions.get(actionSource);
284
+ if (!decision) throw new Error(`actions[${index}] source_decision is not in source_decisions: ${actionSource}`);
285
+ if (actionSources.has(actionSource)) throw new Error(`actions[${index}] duplicates source action: ${actionSource}`);
286
+ actionSources.add(actionSource);
287
+ if (typeof action.to !== "string" || !allowedTarget(action.to, workflows)) {
288
+ throw new Error(`actions[${index}] target is outside runtime ownership: ${action.to}`);
289
+ }
290
+ if (decision.target !== action.to) throw new Error(`actions[${index}] target must match source decision target`);
274
291
  for (const target of seenTargets) {
275
292
  if (pathsOverlap(target, action.to)) throw new Error(`actions[${index}] overlaps target ${target}`);
276
293
  }
277
294
  seenTargets.add(action.to);
278
295
  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") {
296
+ if (decision.disposition !== "restore") throw new Error(`actions[${index}] copy must implement a restore decision`);
297
+ if (action.from !== actionSource) throw new Error(`actions[${index}] copy source must match source_decision`);
298
+ const source = inside(ctx.backupRoot, actionSource);
299
+ if (!(await exists(source))) throw new Error(`actions[${index}] source does not exist: ${actionSource}`);
300
+ } else if (action.kind === "replace-json") {
301
+ if (!new Set(["merge-json", "replace-json"]).has(decision.disposition)) {
302
+ throw new Error(`actions[${index}] replace-json must implement a merge-json or replace-json decision`);
303
+ }
285
304
  if (!action.to.endsWith(".json") || action.value === undefined) throw new Error(`actions[${index}] replace-json needs a JSON target and value`);
286
305
  JSON.stringify(action.value);
306
+ } else if (action.kind === "keep-current") {
307
+ if (decision.disposition !== "keep-current") throw new Error(`actions[${index}] keep-current must implement a keep-current decision`);
308
+ } else if (decision.disposition !== "remove-current") {
309
+ throw new Error(`actions[${index}] remove-current must implement a remove-current decision`);
287
310
  }
288
311
  if (typeof action.expected_target !== "string") throw new Error(`actions[${index}] must contain expected_target`);
289
312
  const currentFingerprint = await fingerprint(inside(ctx.speculoRoot, action.to));
290
313
  if (currentFingerprint !== action.expected_target) throw new Error(`actions[${index}] target drifted: ${action.to}`);
291
314
  }
315
+
316
+ for (const [source, decision] of decisions) {
317
+ if (decision.disposition !== "keep-current" && !actionSources.has(source)) {
318
+ throw new Error(`source_decisions entry requires an action: ${source}`);
319
+ }
320
+ }
292
321
  return workflows;
293
322
  }
294
323
 
@@ -305,13 +334,265 @@ async function validateJsonTree(root) {
305
334
  return failures;
306
335
  }
307
336
 
337
+ function isObject(value) {
338
+ return Boolean(value) && typeof value === "object" && !Array.isArray(value);
339
+ }
340
+
341
+ function hasExactKeys(value, expected) {
342
+ const actual = Object.keys(value).sort();
343
+ const wanted = [...expected].sort();
344
+ return actual.length === wanted.length && actual.every((key, index) => key === wanted[index]);
345
+ }
346
+
347
+ function nonEmptyString(value) {
348
+ return typeof value === "string" && value.length > 0;
349
+ }
350
+
351
+ function stringOrNull(value) {
352
+ return value === null || typeof value === "string";
353
+ }
354
+
355
+ function stringArray(value) {
356
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
357
+ }
358
+
359
+ const CHANGE_NAME_PATTERN = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
360
+ const ARCHIVE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/archive\/[^<]+<\/Path>$/;
361
+ const EVIDENCE_PATH_PATTERN = /^<Path>\{roots\.state\}\/specdev\/changes\/[^<]+\/evidence\/T-[0-9]{2,}\.md<\/Path>$/;
362
+
363
+ function validIntegrationV4(integration, worktreeStatus, sourceCheckpoint, change, ticketId) {
364
+ const required = [
365
+ "status", "parent_before_sha", "source_sha", "candidate_sha", "candidate_branch",
366
+ "candidate_workspace_ref", "result_sha", "method", "conflict_paths", "verification",
367
+ "e2e", "evidence", "attempts",
368
+ ];
369
+ if (!isObject(integration) || !hasExactKeys(integration, required)) return false;
370
+ if (!new Set(["pending", "candidate", "passed", "failed", "stale"]).has(integration.status)) return false;
371
+ if (!new Set([null, "fast-forward", "merge-commit"]).has(integration.method)) return false;
372
+ if (!new Set(["pending", "passed", "failed"]).has(integration.verification)) return false;
373
+ for (const key of ["parent_before_sha", "source_sha", "candidate_sha", "candidate_branch", "result_sha"]) {
374
+ if (!stringOrNull(integration[key])) return false;
375
+ }
376
+ if (
377
+ integration.candidate_workspace_ref !== null &&
378
+ (typeof integration.candidate_workspace_ref !== "string" ||
379
+ !/^specdev-worktree\/\.integration\/T-[0-9]{2,}$/.test(integration.candidate_workspace_ref))
380
+ ) return false;
381
+ if (!stringArray(integration.conflict_paths)) return false;
382
+ if (!Number.isInteger(integration.attempts) || integration.attempts < 0) return false;
383
+ if (
384
+ typeof integration.evidence !== "string" ||
385
+ !EVIDENCE_PATH_PATTERN.test(integration.evidence) ||
386
+ integration.evidence !== `<Path>{roots.state}/specdev/changes/${change}/evidence/${ticketId}.md</Path>`
387
+ ) return false;
388
+
389
+ const e2e = integration.e2e;
390
+ if (!isObject(e2e) || !hasExactKeys(e2e, ["required", "status", "evidence"])) return false;
391
+ if (typeof e2e.required !== "boolean" || !new Set(["not-required", "pending", "passed", "failed"]).has(e2e.status)) return false;
392
+ if (!stringOrNull(e2e.evidence)) return false;
393
+ if (e2e.required === false && e2e.status !== "not-required") return false;
394
+ if (e2e.required === true && e2e.status === "not-required") return false;
395
+ if (e2e.required === true && e2e.status === "passed" && !nonEmptyString(e2e.evidence)) return false;
396
+
397
+ if (new Set(["integrating", "integrated", "removed"]).has(worktreeStatus)) {
398
+ if (
399
+ !nonEmptyString(integration.parent_before_sha) ||
400
+ !nonEmptyString(integration.source_sha) ||
401
+ integration.source_sha !== sourceCheckpoint ||
402
+ !nonEmptyString(integration.candidate_sha) ||
403
+ integration.candidate_branch !== `speculo/integration/${change}/${ticketId}` ||
404
+ integration.candidate_workspace_ref !== `specdev-worktree/.integration/${ticketId}` ||
405
+ !new Set(["fast-forward", "merge-commit"]).has(integration.method) ||
406
+ !Number.isInteger(integration.attempts) ||
407
+ integration.attempts < 1
408
+ ) return false;
409
+ }
410
+ if (worktreeStatus === "integrating" && integration.status !== "candidate") return false;
411
+ if (new Set(["integrated", "removed"]).has(worktreeStatus)) {
412
+ if (
413
+ integration.status !== "passed" ||
414
+ integration.verification !== "passed" ||
415
+ !nonEmptyString(integration.result_sha) ||
416
+ integration.result_sha !== integration.candidate_sha ||
417
+ !new Set(["not-required", "passed"]).has(e2e.status)
418
+ ) return false;
419
+ if (integration.method === "fast-forward" && (integration.candidate_sha !== sourceCheckpoint || integration.conflict_paths.length > 0)) return false;
420
+ if (integration.method === "merge-commit" && (integration.candidate_sha === sourceCheckpoint || integration.candidate_sha === integration.parent_before_sha)) return false;
421
+ }
422
+ return true;
423
+ }
424
+
425
+ function validChangeStatusV4(status, expectedChange, expectedStatus) {
426
+ const required = [
427
+ "schema_version", "artifact", "change", "change_status", "current_work", "created_at",
428
+ "updated_at", "completed_at", "archived", "archive_path", "blockers", "deviations", "worktrees",
429
+ ];
430
+ if (
431
+ !isObject(status) ||
432
+ !hasExactKeys(status, required) ||
433
+ status.schema_version !== 4 ||
434
+ status.artifact !== "change-status" ||
435
+ status.change !== expectedChange ||
436
+ !CHANGE_NAME_PATTERN.test(String(status.change)) ||
437
+ !expectedStatus.has(status.change_status) ||
438
+ !(status.current_work === null || typeof status.current_work === "string") ||
439
+ !nonEmptyString(status.created_at) ||
440
+ !nonEmptyString(status.updated_at) ||
441
+ !(status.completed_at === null || nonEmptyString(status.completed_at)) ||
442
+ typeof status.archived !== "boolean" ||
443
+ !(status.archive_path === null || (typeof status.archive_path === "string" && ARCHIVE_PATH_PATTERN.test(status.archive_path))) ||
444
+ !stringArray(status.blockers) ||
445
+ !stringArray(status.deviations) ||
446
+ !Array.isArray(status.worktrees)
447
+ ) return false;
448
+ if (status.change_status === "archived") {
449
+ if (status.archived !== true || typeof status.archive_path !== "string" || !ARCHIVE_PATH_PATTERN.test(status.archive_path)) return false;
450
+ } else if (status.archived !== false) {
451
+ return false;
452
+ }
453
+
454
+ const seenTickets = new Set();
455
+ return status.worktrees.every((worktree) => {
456
+ const requiredWorktree = [
457
+ "ticket_id", "owner", "implementation_owner", "integration_owner", "provider", "base_sha",
458
+ "parent_branch", "branch", "workspace_ref", "source_checkpoint", "integration", "status", "updated_at",
459
+ ];
460
+ if (!isObject(worktree) || !hasExactKeys(worktree, requiredWorktree) || worktree.provider !== "git") return false;
461
+ if (typeof worktree.ticket_id !== "string" || !/^T-[0-9]{2,}$/.test(worktree.ticket_id)) return false;
462
+ if (seenTickets.has(worktree.ticket_id)) return false;
463
+ seenTickets.add(worktree.ticket_id);
464
+ for (const key of ["owner", "implementation_owner", "integration_owner", "base_sha", "parent_branch", "branch", "updated_at"]) {
465
+ if (!nonEmptyString(worktree[key])) return false;
466
+ }
467
+ if (worktree.parent_branch === worktree.branch) return false;
468
+ if (worktree.workspace_ref !== `specdev-worktree/${worktree.ticket_id}`) return false;
469
+ if (!new Set(["planned", "active", "review", "integrating", "integrated", "removed", "blocked"]).has(worktree.status)) return false;
470
+ const sourceRequired = new Set(["review", "integrating", "integrated", "removed"]).has(worktree.status);
471
+ if (sourceRequired ? !nonEmptyString(worktree.source_checkpoint) : !stringOrNull(worktree.source_checkpoint)) return false;
472
+ return validIntegrationV4(worktree.integration, worktree.status, worktree.source_checkpoint, expectedChange, worktree.ticket_id);
473
+ });
474
+ }
475
+
476
+ function validateChangeStatusV4(status, expectedChange, expectedStatus) {
477
+ return validChangeStatusV4(status, expectedChange, expectedStatus)
478
+ ? []
479
+ : ["invalid or incomplete change-status v4 contract: " + expectedChange];
480
+ }
481
+
482
+ function parseGoalPlanScalar(raw) {
483
+ const value = raw.trim();
484
+ if (value === "true") return true;
485
+ if (value === "false") return false;
486
+ if (/^-?\d+$/.test(value)) return Number(value);
487
+ if (value.startsWith("[") && value.endsWith("]")) {
488
+ const inner = value.slice(1, -1).trim();
489
+ return inner ? inner.split(",").map((item) => parseGoalPlanScalar(item)) : [];
490
+ }
491
+ if ((value.startsWith('"') && value.endsWith('"')) || (value.startsWith("'") && value.endsWith("'"))) return value.slice(1, -1);
492
+ return value;
493
+ }
494
+
495
+ function parseGoalPlanFrontmatter(text) {
496
+ const lines = text.split(/\r?\n/);
497
+ if (lines[0]?.trim() !== "---") return null;
498
+ const end = lines.findIndex((line, index) => index > 0 && line.trim() === "---");
499
+ if (end < 0) return null;
500
+ const meta = {};
501
+ let currentListKey = null;
502
+ for (const line of lines.slice(1, end)) {
503
+ const trimmed = line.trim();
504
+ if (!trimmed || trimmed.startsWith("#")) continue;
505
+ if (currentListKey && /^\s+-\s+/.test(line)) {
506
+ meta[currentListKey].push(parseGoalPlanScalar(line.replace(/^\s+-\s+/, "")));
507
+ continue;
508
+ }
509
+ currentListKey = null;
510
+ const colon = line.indexOf(":");
511
+ if (colon < 1) return null;
512
+ const key = line.slice(0, colon).trim();
513
+ if (key in meta) return null;
514
+ const raw = line.slice(colon + 1).trim();
515
+ if (!raw) {
516
+ meta[key] = [];
517
+ currentListKey = key;
518
+ } else {
519
+ meta[key] = parseGoalPlanScalar(raw);
520
+ }
521
+ }
522
+ return meta;
523
+ }
524
+
525
+ function validGoalPlanV4(meta, change) {
526
+ const required = [
527
+ "schema_version", "artifact", "change", "status", "modes", "orchestration", "lead",
528
+ "implementation_agent_limit", "ticket_workspace_policy", "integration_gate", "ready_for_execution",
529
+ ];
530
+ if (!isObject(meta) || !hasExactKeys(meta, required)) return false;
531
+ if (
532
+ meta.schema_version !== 4 ||
533
+ meta.artifact !== "goal-plan" ||
534
+ meta.change !== change ||
535
+ !new Set(["draft", "ready", "in_progress", "completed", "blocked"]).has(meta.status) ||
536
+ meta.orchestration !== "lead-directed" ||
537
+ !nonEmptyString(meta.lead) ||
538
+ !Number.isInteger(meta.implementation_agent_limit) ||
539
+ meta.implementation_agent_limit < 1 ||
540
+ meta.implementation_agent_limit > 3 ||
541
+ meta.ticket_workspace_policy !== "required" ||
542
+ meta.integration_gate !== "candidate-merge" ||
543
+ typeof meta.ready_for_execution !== "boolean" ||
544
+ !Array.isArray(meta.modes)
545
+ ) return false;
546
+ return meta.modes.every((mode) => new Set(["migration", "high-assurance", "reference-conformance", "release-coordination"]).has(mode)) &&
547
+ new Set(meta.modes).size === meta.modes.length;
548
+ }
549
+
550
+ async function validateGoalPlanV4(changeRoot, change) {
551
+ const path = join(changeRoot, "goal-plan.md");
552
+ if (!(await exists(path))) return [];
553
+ const text = await readFile(path, "utf8");
554
+ return validGoalPlanV4(parseGoalPlanFrontmatter(text), change)
555
+ ? []
556
+ : ["Goal Plan is not the complete fixed Lead/candidate-integration v4 contract: " + change];
557
+ }
558
+
559
+ function validSpecdevConfigV4(config) {
560
+ const rootKeys = ["schema_version", "interaction_language", "artifact_language", "git", "execution", "verification", "planning"];
561
+ if (
562
+ !isObject(config) ||
563
+ !hasExactKeys(config, rootKeys) ||
564
+ config.schema_version !== 4 ||
565
+ !nonEmptyString(config.interaction_language) ||
566
+ !nonEmptyString(config.artifact_language) ||
567
+ !isObject(config.git) ||
568
+ !isObject(config.execution) ||
569
+ !isObject(config.verification) ||
570
+ !isObject(config.planning)
571
+ ) return false;
572
+ if (!hasExactKeys(config.git, ["default_branch"]) || !(config.git.default_branch === null || typeof config.git.default_branch === "string")) return false;
573
+ if (!hasExactKeys(config.execution, ["max_implementation_agents", "deep_ticket_human_approval", "shared_path_owner"])) return false;
574
+ if (
575
+ !Number.isInteger(config.execution.max_implementation_agents) ||
576
+ config.execution.max_implementation_agents < 1 ||
577
+ config.execution.max_implementation_agents > 3 ||
578
+ typeof config.execution.deep_ticket_human_approval !== "boolean" ||
579
+ !nonEmptyString(config.execution.shared_path_owner)
580
+ ) return false;
581
+ for (const key of ["test", "typecheck", "lint", "build"]) {
582
+ if (!(key in config.verification) || !stringOrNull(config.verification[key])) return false;
583
+ }
584
+ return new Set(["lite", "standard", "deep"]).has(config.planning.default_depth) &&
585
+ typeof config.planning.require_ready_gate === "boolean" &&
586
+ typeof config.planning.require_evidence === "boolean";
587
+ }
588
+
308
589
  async function validateSpecdev(speculoRoot) {
309
590
  const statusPath = join(speculoRoot, ".speculo", "specdev", "status.json");
310
591
  if (!(await exists(statusPath))) return [];
311
592
  const failures = [];
312
593
  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"];
594
+ if (status.schema_version !== 5 || status.workflow !== "specdev" || !Array.isArray(status.active) || !Array.isArray(status.archived)) {
595
+ return [".speculo/specdev/status.json is not SpecDev global status v5"];
315
596
  }
316
597
  const active = new Set();
317
598
  for (const entry of status.active) {
@@ -326,12 +607,12 @@ async function validateSpecdev(speculoRoot) {
326
607
  failures.push("missing active change state: " + entry.change);
327
608
  } else {
328
609
  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);
610
+ failures.push(...validateChangeStatusV4(
611
+ changeStatus,
612
+ entry.change,
613
+ new Set(["active", "blocked", "completed"]),
614
+ ));
615
+ failures.push(...await validateGoalPlanV4(dirname(path), entry.change));
335
616
  }
336
617
  }
337
618
  const archived = new Set();
@@ -348,12 +629,7 @@ async function validateSpecdev(speculoRoot) {
348
629
  failures.push("missing archived change state: " + name);
349
630
  } else {
350
631
  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);
632
+ failures.push(...validateChangeStatusV4(archivedStatus, name, new Set(["archived"])));
357
633
  }
358
634
  }
359
635
  const changesRoot = join(speculoRoot, ".speculo", "specdev", "changes");
@@ -379,7 +655,7 @@ async function validateSpecdev(speculoRoot) {
379
655
  const configPath = join(speculoRoot, ".speculo", "specdev", "config.json");
380
656
  if (await exists(configPath)) {
381
657
  const config = await readJson(configPath);
382
- if (config.schema_version !== 3) failures.push(".speculo/specdev/config.json is not schema v3");
658
+ if (!validSpecdevConfigV4(config)) failures.push(".speculo/specdev/config.json is not the complete schema-v4 execution contract");
383
659
  }
384
660
  return failures;
385
661
  }
@@ -438,6 +714,12 @@ async function validateActive(speculoRoot, allowPending = false) {
438
714
  if (failures.length) throw new Error("Migrated runtime validation failed:\n- " + failures.join("\n- "));
439
715
  }
440
716
 
717
+ async function assertNoSymlinks(root) {
718
+ for (const entry of await walk(root)) {
719
+ if (entry.type === "symlink") throw new Error("Runtime contains a symbolic link: " + entry.path);
720
+ }
721
+ }
722
+
441
723
  async function applyAction(ctx, stagedSpeculo, action) {
442
724
  if (action.kind === "keep-current") return;
443
725
  const destination = inside(stagedSpeculo, action.to);
@@ -451,7 +733,11 @@ async function applyAction(ctx, stagedSpeculo, action) {
451
733
  const source = inside(ctx.backupRoot, action.from);
452
734
  const stat = await lstat(source);
453
735
  await rm(destination, { recursive: true, force: true });
454
- await cp(source, destination, { recursive: stat.isDirectory(), force: true });
736
+ await cp(source, destination, {
737
+ recursive: stat.isDirectory(),
738
+ force: true,
739
+ verbatimSymlinks: true,
740
+ });
455
741
  return;
456
742
  }
457
743
  await writeFile(destination, JSON.stringify(action.value, null, 2) + "\n", "utf8");
@@ -474,12 +760,15 @@ async function apply(projectRoot, planPath, confirmed) {
474
760
  try {
475
761
  await cp(ctx.speculoRoot, stagedSpeculo, { recursive: true, force: true });
476
762
  for (const action of plan.actions) await applyAction(ctx, stagedSpeculo, action);
763
+ await assertNoSymlinks(stagedSpeculo);
477
764
  await validateActive(stagedSpeculo, true);
478
765
  await rm(join(stagedSpeculo, ".speculo", "migration.json"), { force: true });
766
+ await assertNoSymlinks(stagedSpeculo);
479
767
  await rename(ctx.speculoRoot, rollbackRoot);
480
768
  oldMoved = true;
481
769
  await rename(stagedSpeculo, ctx.speculoRoot);
482
770
  newInstalled = true;
771
+ await assertNoSymlinks(ctx.speculoRoot);
483
772
  await validateActive(ctx.speculoRoot);
484
773
  const installedCtx = await contextWithCompletedMigration(ctx.projectRoot);
485
774
  const postIssues = await validateBackup(installedCtx, false);
@@ -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
+ **完成标准:** 所有已确认变更通过本机验证,或已完整回滚;报告包含命令、退出码和关键脱敏证据。