makdoong2-team 1.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (97) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +193 -0
  3. package/agents/makdoong2-analyzer.md +135 -0
  4. package/agents/makdoong2-engineer.md +165 -0
  5. package/agents/makdoong2-planner.md +267 -0
  6. package/agents/makdoong2-publisher.md +481 -0
  7. package/agents/makdoong2-team-leader.md +249 -0
  8. package/agents/makdoong2-verifier.md +353 -0
  9. package/assets/makdoong2-team.default.json +46 -0
  10. package/assets/makdoong2-team.schema.json +357 -0
  11. package/bin/cli.js +404 -0
  12. package/dist/agent-stage-config.d.ts +15 -0
  13. package/dist/agent-stage-config.js +119 -0
  14. package/dist/config.d.ts +79 -0
  15. package/dist/config.js +96 -0
  16. package/dist/logger.d.ts +14 -0
  17. package/dist/logger.js +131 -0
  18. package/dist/mcp-secret-injector.d.ts +56 -0
  19. package/dist/mcp-secret-injector.js +89 -0
  20. package/dist/model-chain-cli.d.ts +1 -0
  21. package/dist/model-chain-cli.js +21 -0
  22. package/dist/model-fallback-policy.d.ts +69 -0
  23. package/dist/model-fallback-policy.js +211 -0
  24. package/dist/opencode-plugin.d.ts +8 -0
  25. package/dist/opencode-plugin.js +2457 -0
  26. package/dist/poll-sub-session.d.ts +139 -0
  27. package/dist/poll-sub-session.js +494 -0
  28. package/dist/redact-secrets.d.ts +3 -0
  29. package/dist/redact-secrets.js +68 -0
  30. package/dist/session-index.d.ts +11 -0
  31. package/dist/session-index.js +71 -0
  32. package/dist/skill-mcp-registry.d.ts +59 -0
  33. package/dist/skill-mcp-registry.js +178 -0
  34. package/dist/stall-escalation.d.ts +1 -0
  35. package/dist/stall-escalation.js +22 -0
  36. package/dist/tmux-monitor.d.ts +193 -0
  37. package/dist/tmux-monitor.js +694 -0
  38. package/dist/verdict-hash.d.ts +1 -0
  39. package/dist/verdict-hash.js +62 -0
  40. package/gates/stage-analysis-verify.sh +84 -0
  41. package/gates/stage2-requirements-verify.sh +13 -0
  42. package/gates/stage3-scope-verify.sh +45 -0
  43. package/gates/stage4-dev-post-verify.sh +64 -0
  44. package/gates/stage4-dev-verify.sh +36 -0
  45. package/gates/stage5-coverage-verify.sh +36 -0
  46. package/gates/stage5-test-verify.sh +24 -0
  47. package/gates/stage6-commit-verify.sh +41 -0
  48. package/gates/stage6-post-commit-verify.sh +131 -0
  49. package/gates/stage7-post-pr-verify.sh +53 -0
  50. package/gates/stage7-pr-verify.sh +48 -0
  51. package/gates/stage8-post-review-verify.sh +84 -0
  52. package/gates/stage8-review-verify.sh +45 -0
  53. package/gates/verify.sh +44 -0
  54. package/opencode.json.example +40 -0
  55. package/package.json +84 -0
  56. package/postinstall.mjs +56 -0
  57. package/references/commit-convention.md +130 -0
  58. package/references/jira-issue-templates.md +203 -0
  59. package/references/pr-template.md +381 -0
  60. package/scripts/config.sh +46 -0
  61. package/scripts/coverage-record.sh +67 -0
  62. package/scripts/gate-policy-test.sh +152 -0
  63. package/scripts/install-lib.mjs +1029 -0
  64. package/scripts/lint-agent-prompts.sh +74 -0
  65. package/scripts/log-event.sh +44 -0
  66. package/scripts/model-policy.mjs +183 -0
  67. package/scripts/publish-if-changed.sh +207 -0
  68. package/scripts/release.sh +276 -0
  69. package/scripts/rollback-commits.sh +35 -0
  70. package/scripts/smoke-test.mjs +194 -0
  71. package/scripts/state.sh +192 -0
  72. package/scripts/test-postinstall.mjs +141 -0
  73. package/scripts/with-fallback.sh +56 -0
  74. package/scripts/wt-sync-ignored.sh +193 -0
  75. package/skills/_lib/load-secret.sh +149 -0
  76. package/skills/bamboo-ci/SKILL.md +81 -0
  77. package/skills/bamboo-ci/run-bamboo.sh +23 -0
  78. package/skills/bitbucket-research/SKILL.md +87 -0
  79. package/skills/bitbucket-research/run-repos.sh +23 -0
  80. package/skills/confluence-research/SKILL.md +75 -0
  81. package/skills/confluence-research/run-docs.sh +23 -0
  82. package/skills/github-oss-research/SKILL.md +59 -0
  83. package/skills/jira-research/SKILL.md +75 -0
  84. package/skills/jira-research/run-works.sh +23 -0
  85. package/src/hooks/guard-bash.sh +67 -0
  86. package/src/hooks/session-start.sh +96 -0
  87. package/src/hooks/sync-state.sh +47 -0
  88. package/stages/01-jira.md +43 -0
  89. package/stages/01-planning.md +229 -0
  90. package/stages/02-requirements.md +298 -0
  91. package/stages/03-scope.md +81 -0
  92. package/stages/04-analysis.md +281 -0
  93. package/stages/05-worktree-dev.md +124 -0
  94. package/stages/06-test.md +161 -0
  95. package/stages/07-commit.md +229 -0
  96. package/stages/08-pr.md +177 -0
  97. package/stages/09-review-comments.md +277 -0
package/dist/config.js ADDED
@@ -0,0 +1,96 @@
1
+ // config.ts — single source of configuration for makdoong2-team.
2
+ //
3
+ // Replaces every MAKDOONG2 environment variable with ONE JSON file:
4
+ // ${XDG_CONFIG_HOME:-$HOME/.config}/opencode/makdoong2-team.json
5
+ //
6
+ // The shell-side mirror reader is scripts/config.sh (jq). Both resolve the
7
+ // same path and the same defaults, so gates (shell) and the plugin (TS) agree.
8
+ //
9
+ // HOME / XDG_CONFIG_HOME are read ONLY to *locate* the config dir (the same
10
+ // convention opencode itself follows) — they are not tunable settings.
11
+ import { readFileSync } from "node:fs";
12
+ import { homedir } from "node:os";
13
+ import { dirname, join } from "node:path";
14
+ import { fileURLToPath } from "node:url";
15
+ export const DEFAULT_STALL_ESCALATE_THRESHOLD = 5;
16
+ export const LOG_LEVELS = [
17
+ "silent",
18
+ "error",
19
+ "warn",
20
+ "info",
21
+ "debug",
22
+ "trace",
23
+ ];
24
+ export const LOG_MODES = ["stdin", "file"];
25
+ export const DEFAULT_LOG_MAX_BYTES = 10 * 1024 * 1024;
26
+ export function readLoggingConfig(block) {
27
+ const rawLevel = block?.level;
28
+ const level = typeof rawLevel === "string" && LOG_LEVELS.includes(rawLevel)
29
+ ? rawLevel
30
+ : "error";
31
+ const rawMode = block?.mode;
32
+ const mode = typeof rawMode === "string" && LOG_MODES.includes(rawMode)
33
+ ? rawMode
34
+ : "stdin";
35
+ const rawEventMax = block?.event_max_chars;
36
+ const eventMaxChars = typeof rawEventMax === "number" && Number.isFinite(rawEventMax) && rawEventMax > 0
37
+ ? Math.floor(rawEventMax)
38
+ : 300;
39
+ const rawMaxBytes = block?.max_bytes;
40
+ const maxBytes = typeof rawMaxBytes === "number" && Number.isFinite(rawMaxBytes) && rawMaxBytes > 0
41
+ ? Math.floor(rawMaxBytes)
42
+ : DEFAULT_LOG_MAX_BYTES;
43
+ if (mode === "stdin") {
44
+ return { level, mode, path: null, eventMaxChars, maxBytes };
45
+ }
46
+ const rawPath = block?.path;
47
+ if (typeof rawPath !== "string" || rawPath.trim() === "") {
48
+ throw new Error(`[makdoong2-team config] logging.mode="file" requires logging.path to be a non-empty string. ` +
49
+ `Edit \${XDG_CONFIG_HOME:-\$HOME/.config}/opencode/makdoong2-team.json and set .logging.path, ` +
50
+ `or change .logging.mode to "stdin".`);
51
+ }
52
+ return { level, mode, path: rawPath, eventMaxChars, maxBytes };
53
+ }
54
+ function configDir() {
55
+ const xdg = process.env.XDG_CONFIG_HOME;
56
+ const base = xdg && xdg.trim() ? xdg : join(homedir(), ".config");
57
+ return join(base, "opencode");
58
+ }
59
+ // Locate the npm-installed makdoong2-team package root by walking up from this
60
+ // file (src/config.ts → package root). Works for both npm global installs
61
+ // (…/lib/node_modules/makdoong2-team/) and local dev checkouts.
62
+ function packageRoot() {
63
+ const here = dirname(fileURLToPath(import.meta.url));
64
+ return join(here, "..");
65
+ }
66
+ let _cache;
67
+ /** Load + cache makdoong2-team.json. Returns {} when absent or invalid (defaults apply). */
68
+ export function loadConfig() {
69
+ if (_cache !== undefined)
70
+ return _cache ?? {};
71
+ try {
72
+ const path = join(configDir(), "makdoong2-team.json");
73
+ _cache = JSON.parse(readFileSync(path, "utf8"));
74
+ }
75
+ catch {
76
+ _cache = null; // absent / unreadable / invalid JSON → defaults
77
+ }
78
+ return _cache ?? {};
79
+ }
80
+ /** Resolve the five runtime path roots (JSON paths.* override → default).
81
+ *
82
+ * `skills` default is the opencode config dir (`${XDG_CONFIG_HOME:-$HOME/.config}/opencode/skills`)
83
+ * because scripts/install-lib.mjs deploys skill directories there, not into the
84
+ * package root. All other paths default to package-root subdirs.
85
+ */
86
+ export function resolvePaths() {
87
+ const p = loadConfig().paths ?? {};
88
+ const root = packageRoot();
89
+ return {
90
+ hooks: p.hooks ?? join(root, "src", "hooks"),
91
+ gates: p.gates ?? join(root, "gates"),
92
+ scripts: p.scripts ?? join(root, "scripts"),
93
+ stages: p.stages ?? join(root, "stages"),
94
+ skills: p.skills ?? join(configDir(), "skills"),
95
+ };
96
+ }
@@ -0,0 +1,14 @@
1
+ import { type LogLevel, type ResolvedLoggingConfig } from "./config.ts";
2
+ export declare const logger: {
3
+ error(...args: unknown[]): void;
4
+ warn(...args: unknown[]): void;
5
+ info(...args: unknown[]): void;
6
+ debug(...args: unknown[]): void;
7
+ trace(...args: unknown[]): void;
8
+ isDebug(): boolean;
9
+ isTrace(): boolean;
10
+ _resetForTests(): void;
11
+ _setLevelForTests(level: LogLevel): void;
12
+ _setConfigForTests(cfg: ResolvedLoggingConfig): void;
13
+ };
14
+ export type Logger = typeof logger;
package/dist/logger.js ADDED
@@ -0,0 +1,131 @@
1
+ import { appendFileSync, chmodSync, existsSync, mkdirSync, renameSync, statSync } from "node:fs";
2
+ import { dirname } from "node:path";
3
+ import { loadConfig, readLoggingConfig, DEFAULT_LOG_MAX_BYTES, LOG_LEVELS, } from "./config.js";
4
+ let _cachedResolved;
5
+ function currentConfig() {
6
+ if (_cachedResolved !== undefined)
7
+ return _cachedResolved;
8
+ _cachedResolved = readLoggingConfig(loadConfig().logging);
9
+ return _cachedResolved;
10
+ }
11
+ function shouldEmit(target) {
12
+ const cur = LOG_LEVELS.indexOf(currentConfig().level);
13
+ const tgt = LOG_LEVELS.indexOf(target);
14
+ return tgt > 0 && tgt <= cur;
15
+ }
16
+ function formatArgs(args) {
17
+ return args
18
+ .map((a) => {
19
+ if (typeof a === "string")
20
+ return a;
21
+ if (a instanceof Error)
22
+ return a.stack ?? a.message;
23
+ try {
24
+ return JSON.stringify(a);
25
+ }
26
+ catch {
27
+ return String(a);
28
+ }
29
+ })
30
+ .join(" ");
31
+ }
32
+ // File emit: append-only, with size-triggered rotation to `<path>.1`.
33
+ // Synchronous I/O — matches console.* blocking semantics and guarantees flush
34
+ // on process.exit without a graceful-shutdown handshake.
35
+ //
36
+ // Never truncate. One `path` is shared by every opencode process on the host
37
+ // (main TUI, each tmux sub-agent pane, `npm test`), so a per-process truncate
38
+ // makes each new process erase the history the others are still writing.
39
+ //
40
+ // Rotation races are benign by construction: renameSync is atomic, so if two
41
+ // processes both observe an oversized file, the loser simply re-renames a file
42
+ // that is already small and at worst overwrites `<path>.1`. Never partial.
43
+ //
44
+ // Permissions: when we create the file we chmod it to 0o600 (owner read/write
45
+ // only). Hook logs may contain redaction fallbacks or sub-agent command
46
+ // captures that must not be world-readable — the redactSecrets sanitizer is
47
+ // best-effort, so file mode is the last-line defense against credential leak
48
+ // via `/var/log/opencode/opencode.log`.
49
+ function rotateIfOversized(path, maxBytes) {
50
+ if (!Number.isFinite(maxBytes) || maxBytes <= 0)
51
+ return;
52
+ try {
53
+ if (statSync(path).size < maxBytes)
54
+ return;
55
+ renameSync(path, `${path}.1`);
56
+ }
57
+ catch {
58
+ // Missing file (nothing to rotate) or a concurrent rotation by another
59
+ // process — either way the append below still lands in a valid file.
60
+ }
61
+ }
62
+ function emitToFile(target, args) {
63
+ const cfg = currentConfig();
64
+ const path = cfg.path;
65
+ if (!path) {
66
+ throw new Error("[makdoong2-team logger] file mode active but path is null (readLoggingConfig invariant violation)");
67
+ }
68
+ const line = `[${new Date().toISOString()}] [${target}] [pid=${process.pid}] ${formatArgs(args)}\n`;
69
+ try {
70
+ mkdirSync(dirname(path), { recursive: true });
71
+ rotateIfOversized(path, cfg.maxBytes);
72
+ const isNewFile = !existsSync(path);
73
+ appendFileSync(path, line);
74
+ if (isNewFile) {
75
+ try {
76
+ chmodSync(path, 0o600);
77
+ }
78
+ catch {
79
+ // chmod failure is non-fatal (e.g. read-only mount, foreign owner) —
80
+ // log emission itself succeeded and the file remains writable by us.
81
+ }
82
+ }
83
+ }
84
+ catch (err) {
85
+ throw new Error(`[makdoong2-team logger] failed to write to log file "${path}": ${err.message}`);
86
+ }
87
+ }
88
+ function emit(target, args) {
89
+ if (!shouldEmit(target))
90
+ return;
91
+ const cfg = currentConfig();
92
+ if (cfg.mode === "file") {
93
+ emitToFile(target, args);
94
+ return;
95
+ }
96
+ switch (target) {
97
+ case "error":
98
+ console.error(...args);
99
+ return;
100
+ case "warn":
101
+ console.warn(...args);
102
+ return;
103
+ default:
104
+ console.log(...args);
105
+ return;
106
+ }
107
+ }
108
+ export const logger = {
109
+ error(...args) { emit("error", args); },
110
+ warn(...args) { emit("warn", args); },
111
+ info(...args) { emit("info", args); },
112
+ debug(...args) { emit("debug", args); },
113
+ trace(...args) { emit("trace", args); },
114
+ isDebug() { return shouldEmit("debug"); },
115
+ isTrace() { return shouldEmit("trace"); },
116
+ _resetForTests() {
117
+ _cachedResolved = undefined;
118
+ },
119
+ _setLevelForTests(level) {
120
+ _cachedResolved = {
121
+ level,
122
+ mode: "stdin",
123
+ path: null,
124
+ eventMaxChars: 300,
125
+ maxBytes: DEFAULT_LOG_MAX_BYTES,
126
+ };
127
+ },
128
+ _setConfigForTests(cfg) {
129
+ _cachedResolved = cfg;
130
+ },
131
+ };
@@ -0,0 +1,56 @@
1
+ /**
2
+ * Which MCP server key in opencode.json's `mcp` object maps to which
3
+ * (env-var name, secrets.<key> name) pair. This is the same variable name
4
+ * both in opencode.json environment and in makdoong2-team.json .secrets.
5
+ */
6
+ export interface McpSecretMapping {
7
+ /** opencode.json's `mcp.<key>` — e.g. "repos" */
8
+ mcpKey: string;
9
+ /** env var name injected into MCP process, and .secrets.<varName> lookup key */
10
+ varName: string;
11
+ }
12
+ /**
13
+ * Fixed mapping — one row per MCP that makdoong2-team owns credentials for.
14
+ *
15
+ * Do NOT extend this list to arbitrary MCPs. If a user adds their own MCP to
16
+ * opencode.json, we leave it alone (unmanaged). Only these four are considered
17
+ * SSoT-managed because their tokens are declared in makdoong2-team.json.
18
+ */
19
+ export declare const MCP_SECRET_MAPPINGS: readonly McpSecretMapping[];
20
+ export type SecretsSource = Readonly<Record<string, string | undefined>>;
21
+ /**
22
+ * `status` semantics (consumers branch on these):
23
+ * - "injected": secret was written (fresh or matched existing)
24
+ * - "overridden": secret differed from opencode.json's value; replaced
25
+ * - "skipped-no-secret": makdoong2-team.json had no value → left alone
26
+ * - "skipped-no-mcp": opencodeConfig.mcp[key] not present → nothing to do
27
+ *
28
+ * `tokenPrefix` is the first 8 chars of the applied token — safe to log for
29
+ * audit; never contains a full secret.
30
+ */
31
+ export interface InjectResult {
32
+ mcpKey: string;
33
+ varName: string;
34
+ status: "injected" | "overridden" | "skipped-no-secret" | "skipped-no-mcp";
35
+ tokenPrefix?: string;
36
+ }
37
+ /**
38
+ * Apply one mapping in-place to the given opencodeConfig object.
39
+ *
40
+ * SAFETY: this function is `any`-typed on purpose. opencode does not export
41
+ * a stable public schema for its config object, and we only touch a narrow
42
+ * `mcp[key].environment[varName]` path. We defensively ensure the parent
43
+ * objects exist before writing.
44
+ *
45
+ * Returns a summary so the caller can log a coherent audit line.
46
+ */
47
+ export declare function injectOneSecret(opencodeConfig: any, secrets: SecretsSource, mapping: McpSecretMapping): InjectResult;
48
+ /**
49
+ * Apply every known mapping to the shared config object. Idempotent.
50
+ *
51
+ * Ordering: mappings are applied in declaration order. Missing MCPs and
52
+ * missing secrets are silently skipped — MCP entries opencode-side is the
53
+ * user's responsibility (they must have added the MCP to opencode.json for
54
+ * makdoong2-team to be able to override its token).
55
+ */
56
+ export declare function injectAllSecrets(opencodeConfig: any, secrets: SecretsSource): InjectResult[];
@@ -0,0 +1,89 @@
1
+ // mcp-secret-injector.ts — inject makdoong2-team.json .secrets.* into opencode's
2
+ // MCP env at plugin `config` hook time.
3
+ //
4
+ // **Motivation**
5
+ // opencode reads `mcp.<name>.environment.<VAR>` from opencode.json once and
6
+ // snapshots it as env when spawning the MCP subprocess. That value is *frozen*
7
+ // for the lifetime of the process. makdoong2-team.json (SSoT for secrets)
8
+ // therefore cannot influence direct MCP tool calls (`repos_*`, `works_*`, …)
9
+ // unless we override the config *before* MCP initialization.
10
+ //
11
+ // **Mechanism (opencode 1.4.14+)**
12
+ // The `config` plugin hook receives the shared Config object *by reference*
13
+ // (Config.get() returns `s.config` directly, and Config.Info is DeepMutable
14
+ // on purpose). MCP.state() later calls the same cfgSvc.get() and reads
15
+ // `cfg.mcp[key].environment` on connectLocal(). Mutating `cfg.mcp[k].environment.<VAR>`
16
+ // in the `config` hook therefore reaches the MCP spawn call site.
17
+ //
18
+ // **Coexistence with non-makdoong2 users**
19
+ // - If makdoong2-team.json is absent or `.secrets.<VAR>` is empty/null, we
20
+ // leave opencode.json's `environment.<VAR>` alone → other plugins /
21
+ // standalone opencode users are unaffected.
22
+ // - If both differ, makdoong2-team.json wins (SSoT) and a warning is emitted
23
+ // so the user can reconcile — same policy as skills/_lib/load-secret.sh
24
+ // already enforces for the skill_mcp code path.
25
+ //
26
+ // **Scope**
27
+ // Only the four MCPs that makdoong2-team manages credentials for. Foreign MCPs
28
+ // (chrome-devtools-mcp, site-wide entries, user's own) are untouched.
29
+ /**
30
+ * Fixed mapping — one row per MCP that makdoong2-team owns credentials for.
31
+ *
32
+ * Do NOT extend this list to arbitrary MCPs. If a user adds their own MCP to
33
+ * opencode.json, we leave it alone (unmanaged). Only these four are considered
34
+ * SSoT-managed because their tokens are declared in makdoong2-team.json.
35
+ */
36
+ export const MCP_SECRET_MAPPINGS = [
37
+ { mcpKey: "repos", varName: "BITBUCKET_API_TOKEN" },
38
+ { mcpKey: "works", varName: "JIRA_API_TOKEN" },
39
+ { mcpKey: "docs", varName: "CONFLUENCE_API_TOKEN" },
40
+ { mcpKey: "bamboo", varName: "BAMBOO_TOKEN" },
41
+ ];
42
+ /**
43
+ * Apply one mapping in-place to the given opencodeConfig object.
44
+ *
45
+ * SAFETY: this function is `any`-typed on purpose. opencode does not export
46
+ * a stable public schema for its config object, and we only touch a narrow
47
+ * `mcp[key].environment[varName]` path. We defensively ensure the parent
48
+ * objects exist before writing.
49
+ *
50
+ * Returns a summary so the caller can log a coherent audit line.
51
+ */
52
+ export function injectOneSecret(opencodeConfig, secrets, mapping) {
53
+ const { mcpKey, varName } = mapping;
54
+ const token = secrets[varName];
55
+ if (typeof token !== "string" || token.length === 0) {
56
+ return { mcpKey, varName, status: "skipped-no-secret" };
57
+ }
58
+ const mcpBlock = opencodeConfig?.mcp;
59
+ if (!mcpBlock || typeof mcpBlock !== "object") {
60
+ return { mcpKey, varName, status: "skipped-no-mcp" };
61
+ }
62
+ const entry = mcpBlock[mcpKey];
63
+ if (!entry || typeof entry !== "object") {
64
+ return { mcpKey, varName, status: "skipped-no-mcp" };
65
+ }
66
+ if (!entry.environment || typeof entry.environment !== "object") {
67
+ entry.environment = {};
68
+ }
69
+ const existing = entry.environment[varName];
70
+ const wasDifferent = typeof existing === "string" && existing.length > 0 && existing !== token;
71
+ entry.environment[varName] = token;
72
+ return {
73
+ mcpKey,
74
+ varName,
75
+ status: wasDifferent ? "overridden" : "injected",
76
+ tokenPrefix: token.slice(0, 8),
77
+ };
78
+ }
79
+ /**
80
+ * Apply every known mapping to the shared config object. Idempotent.
81
+ *
82
+ * Ordering: mappings are applied in declaration order. Missing MCPs and
83
+ * missing secrets are silently skipped — MCP entries opencode-side is the
84
+ * user's responsibility (they must have added the MCP to opencode.json for
85
+ * makdoong2-team to be able to override its token).
86
+ */
87
+ export function injectAllSecrets(opencodeConfig, secrets) {
88
+ return MCP_SECRET_MAPPINGS.map((m) => injectOneSecret(opencodeConfig, secrets, m));
89
+ }
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,21 @@
1
+ // model-chain-cli.ts — tiny CLI that emits an agent's model fallback chain as JSON.
2
+ // Used by scripts/with-fallback.sh. Keeps the chain definition single-sourced
3
+ // in model-fallback-policy.ts.
4
+ //
5
+ // Usage: node dist/model-chain-cli.js <agent-id>
6
+ import { POLICIES, applyConfigOverrides } from "./model-fallback-policy.js";
7
+ import { loadConfig } from "./config.js";
8
+ const _cfg = loadConfig();
9
+ applyConfigOverrides(_cfg.agents, _cfg.model_policy);
10
+ const agent = process.argv[2];
11
+ if (!agent) {
12
+ console.error("usage: cli-chain.ts <agent-id>");
13
+ process.exit(2);
14
+ }
15
+ const policy = POLICIES[agent];
16
+ if (!policy) {
17
+ console.log("[]");
18
+ process.exit(0);
19
+ }
20
+ const chain = [policy.primary, ...policy.fallbacks];
21
+ console.log(JSON.stringify(chain));
@@ -0,0 +1,69 @@
1
+ export type ModelTier = "low" | "medium" | "high" | "max";
2
+ export interface ModelSpec {
3
+ id: string;
4
+ variant?: "low" | "medium" | "high" | "xhigh" | "max";
5
+ tier: ModelTier;
6
+ }
7
+ export interface AgentModelPolicy {
8
+ primary: ModelSpec;
9
+ fallbacks: ModelSpec[];
10
+ }
11
+ export declare const DEFAULT_ALLOWED_PRIMARIES: ReadonlySet<string>;
12
+ export declare let ALLOWED_PRIMARIES: ReadonlySet<string>;
13
+ /** Current runtime allow-list (defaults ∪ extras). */
14
+ export declare function getAllowedPrimaries(): ReadonlySet<string>;
15
+ /** Public read-only accessor — single place documenting tier ordering. */
16
+ export declare function tierRank(tier: ModelTier): number;
17
+ export declare const POLICIES: Record<string, AgentModelPolicy>;
18
+ export interface NextModelInput {
19
+ agent: string;
20
+ current: string;
21
+ reason?: string;
22
+ }
23
+ export interface NextModelResult {
24
+ next: ModelSpec | null;
25
+ exhausted: boolean;
26
+ chain: ModelSpec[];
27
+ reasonAccepted: string;
28
+ }
29
+ /** Picks the next model in the chain. Idempotent — call with the same `current` and you get the same `next`. */
30
+ export declare function nextModel(input: NextModelInput): NextModelResult;
31
+ /**
32
+ * Validates the two policy invariants. Throws on violation so a malformed
33
+ * default table or env override fails fast at plugin load.
34
+ * 1. policy.primary.id ∈ ALLOWED_PRIMARIES (= defaults ∪ extras)
35
+ * 2. ∀ fb ∈ policy.fallbacks: TIER_RANK[fb.tier] < TIER_RANK[policy.primary.tier]
36
+ */
37
+ export declare function validatePolicies(policies?: Record<string, AgentModelPolicy>): void;
38
+ /**
39
+ * Fallback override spec — either a bare model ID (tier defaults to "low" for
40
+ * backward compatibility) or an object with an explicit tier.
41
+ */
42
+ export type FallbackOverride = string | {
43
+ id: string;
44
+ tier?: ModelTier;
45
+ };
46
+ /** omo-style flat per-agent override read from makdoong2-team.json `agents` block. */
47
+ export interface AgentOverrideInput {
48
+ model?: string;
49
+ variant?: ModelSpec["variant"];
50
+ /** Either ID(s) (tier defaults to "low") or {id, tier?} objects. */
51
+ fallback_models?: FallbackOverride | FallbackOverride[];
52
+ }
53
+ /** Site-level model policy overrides (extends built-in defaults). */
54
+ export interface ModelPolicyOverrideInput {
55
+ /** Extra primary model IDs to allow on top of DEFAULT_ALLOWED_PRIMARIES. */
56
+ allowed_primaries?: string[];
57
+ }
58
+ /**
59
+ * Apply per-agent overrides + model_policy overrides from makdoong2-team.json
60
+ * onto POLICIES and the runtime ALLOWED_PRIMARIES, then re-validate.
61
+ *
62
+ * Atomicity: on any validation failure, BOTH POLICIES and ALLOWED_PRIMARIES
63
+ * are restored to their pre-call snapshot so the runtime can keep loading
64
+ * with the built-in defaults instead of a half-applied corrupt state.
65
+ *
66
+ * Kept dependency-free (the blocks are passed in by the caller, not read here)
67
+ * so scripts/smoke-test.mjs can mirror this module without a config dependency.
68
+ */
69
+ export declare function applyConfigOverrides(agents?: Record<string, AgentOverrideInput | undefined>, modelPolicy?: ModelPolicyOverrideInput): void;