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.
- package/LICENSE +21 -0
- package/README.md +193 -0
- package/agents/makdoong2-analyzer.md +135 -0
- package/agents/makdoong2-engineer.md +165 -0
- package/agents/makdoong2-planner.md +267 -0
- package/agents/makdoong2-publisher.md +481 -0
- package/agents/makdoong2-team-leader.md +249 -0
- package/agents/makdoong2-verifier.md +353 -0
- package/assets/makdoong2-team.default.json +46 -0
- package/assets/makdoong2-team.schema.json +357 -0
- package/bin/cli.js +404 -0
- package/dist/agent-stage-config.d.ts +15 -0
- package/dist/agent-stage-config.js +119 -0
- package/dist/config.d.ts +79 -0
- package/dist/config.js +96 -0
- package/dist/logger.d.ts +14 -0
- package/dist/logger.js +131 -0
- package/dist/mcp-secret-injector.d.ts +56 -0
- package/dist/mcp-secret-injector.js +89 -0
- package/dist/model-chain-cli.d.ts +1 -0
- package/dist/model-chain-cli.js +21 -0
- package/dist/model-fallback-policy.d.ts +69 -0
- package/dist/model-fallback-policy.js +211 -0
- package/dist/opencode-plugin.d.ts +8 -0
- package/dist/opencode-plugin.js +2457 -0
- package/dist/poll-sub-session.d.ts +139 -0
- package/dist/poll-sub-session.js +494 -0
- package/dist/redact-secrets.d.ts +3 -0
- package/dist/redact-secrets.js +68 -0
- package/dist/session-index.d.ts +11 -0
- package/dist/session-index.js +71 -0
- package/dist/skill-mcp-registry.d.ts +59 -0
- package/dist/skill-mcp-registry.js +178 -0
- package/dist/stall-escalation.d.ts +1 -0
- package/dist/stall-escalation.js +22 -0
- package/dist/tmux-monitor.d.ts +193 -0
- package/dist/tmux-monitor.js +694 -0
- package/dist/verdict-hash.d.ts +1 -0
- package/dist/verdict-hash.js +62 -0
- package/gates/stage-analysis-verify.sh +84 -0
- package/gates/stage2-requirements-verify.sh +13 -0
- package/gates/stage3-scope-verify.sh +45 -0
- package/gates/stage4-dev-post-verify.sh +64 -0
- package/gates/stage4-dev-verify.sh +36 -0
- package/gates/stage5-coverage-verify.sh +36 -0
- package/gates/stage5-test-verify.sh +24 -0
- package/gates/stage6-commit-verify.sh +41 -0
- package/gates/stage6-post-commit-verify.sh +131 -0
- package/gates/stage7-post-pr-verify.sh +53 -0
- package/gates/stage7-pr-verify.sh +48 -0
- package/gates/stage8-post-review-verify.sh +84 -0
- package/gates/stage8-review-verify.sh +45 -0
- package/gates/verify.sh +44 -0
- package/opencode.json.example +40 -0
- package/package.json +84 -0
- package/postinstall.mjs +56 -0
- package/references/commit-convention.md +130 -0
- package/references/jira-issue-templates.md +203 -0
- package/references/pr-template.md +381 -0
- package/scripts/config.sh +46 -0
- package/scripts/coverage-record.sh +67 -0
- package/scripts/gate-policy-test.sh +152 -0
- package/scripts/install-lib.mjs +1029 -0
- package/scripts/lint-agent-prompts.sh +74 -0
- package/scripts/log-event.sh +44 -0
- package/scripts/model-policy.mjs +183 -0
- package/scripts/publish-if-changed.sh +207 -0
- package/scripts/release.sh +276 -0
- package/scripts/rollback-commits.sh +35 -0
- package/scripts/smoke-test.mjs +194 -0
- package/scripts/state.sh +192 -0
- package/scripts/test-postinstall.mjs +141 -0
- package/scripts/with-fallback.sh +56 -0
- package/scripts/wt-sync-ignored.sh +193 -0
- package/skills/_lib/load-secret.sh +149 -0
- package/skills/bamboo-ci/SKILL.md +81 -0
- package/skills/bamboo-ci/run-bamboo.sh +23 -0
- package/skills/bitbucket-research/SKILL.md +87 -0
- package/skills/bitbucket-research/run-repos.sh +23 -0
- package/skills/confluence-research/SKILL.md +75 -0
- package/skills/confluence-research/run-docs.sh +23 -0
- package/skills/github-oss-research/SKILL.md +59 -0
- package/skills/jira-research/SKILL.md +75 -0
- package/skills/jira-research/run-works.sh +23 -0
- package/src/hooks/guard-bash.sh +67 -0
- package/src/hooks/session-start.sh +96 -0
- package/src/hooks/sync-state.sh +47 -0
- package/stages/01-jira.md +43 -0
- package/stages/01-planning.md +229 -0
- package/stages/02-requirements.md +298 -0
- package/stages/03-scope.md +81 -0
- package/stages/04-analysis.md +281 -0
- package/stages/05-worktree-dev.md +124 -0
- package/stages/06-test.md +161 -0
- package/stages/07-commit.md +229 -0
- package/stages/08-pr.md +177 -0
- package/stages/09-review-comments.md +277 -0
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
const SECRET_KEY_SUBSTRINGS = [
|
|
2
|
+
"TOKEN",
|
|
3
|
+
"SECRET",
|
|
4
|
+
"PASSWORD",
|
|
5
|
+
"PASSWD",
|
|
6
|
+
"APIKEY",
|
|
7
|
+
"API_KEY",
|
|
8
|
+
"API_TOKEN",
|
|
9
|
+
"CREDENTIAL",
|
|
10
|
+
"CREDENTIALS",
|
|
11
|
+
"AUTH",
|
|
12
|
+
"PRIVATE_KEY",
|
|
13
|
+
"PRIVKEY",
|
|
14
|
+
"ACCESS_KEY",
|
|
15
|
+
"SESSION_KEY",
|
|
16
|
+
"COOKIE",
|
|
17
|
+
"PAT",
|
|
18
|
+
];
|
|
19
|
+
const KEY_NAME_ALTERNATION = SECRET_KEY_SUBSTRINGS.join("|");
|
|
20
|
+
const IDENT_CORE = `[A-Za-z0-9_]*(?:${KEY_NAME_ALTERNATION})[A-Za-z0-9_]*`;
|
|
21
|
+
export const REDACTED = "***REDACTED***";
|
|
22
|
+
// Shell KEY=VALUE with optional `export` prefix. `(?:^|[\s;&|(])` anchors the
|
|
23
|
+
// match to a shell-boundary so URL query strings / JSON blobs are not eaten.
|
|
24
|
+
// Value alternatives capture double-quoted, single-quoted, and bare forms —
|
|
25
|
+
// bare stops at whitespace or shell operators to preserve command boundaries.
|
|
26
|
+
const SHELL_ASSIGN_RE = new RegExp(`((?:^|[\\s;&|(])(?:export\\s+)?)(${IDENT_CORE})(=)(?:"([^"]*)"|'([^']*)'|([^\\s;&|)>]+))`, "gi");
|
|
27
|
+
// CLI long-flag forms: --token=... / --api-key ... / --password="..." etc.
|
|
28
|
+
// Handles both `=`-joined and space-separated values, matches secret-substring
|
|
29
|
+
// keys plus a small set of common CLI-only names not covered by env-style
|
|
30
|
+
// identifiers (token, api-key, api_key, bearer, cookie).
|
|
31
|
+
const CLI_FLAG_RE = new RegExp(`(--(?:${KEY_NAME_ALTERNATION}|token|api[-_]?key|api[-_]?token|password|secret|auth|bearer|cookie)(?:=|\\s+))(?:"([^"]*)"|'([^']*)'|([^\\s;&|)>]+))`, "gi");
|
|
32
|
+
// HTTP Auth scheme literals as they appear in curl -H / logged request bodies.
|
|
33
|
+
// The 8-char minimum on the value guards against matching short scheme names
|
|
34
|
+
// used as words (e.g. "Bearer strategy").
|
|
35
|
+
const HTTP_AUTH_RE = /((?:Authorization\s*:\s*)?(?:Bearer|Basic|Token|Digest))\s+([A-Za-z0-9+/=._~\-]{8,})/g;
|
|
36
|
+
// Well-known token prefixes with distinctive shapes: Slack, GitHub, AWS IAM,
|
|
37
|
+
// npm registry, Google API, Atlassian PAT. Loose length filters chosen to
|
|
38
|
+
// avoid matching short identifiers that happen to share the prefix.
|
|
39
|
+
const KNOWN_PREFIX_RE = /\b((?:xox[abps]-[A-Za-z0-9-]{10,})|(?:gh[posur]_[A-Za-z0-9]{16,})|(?:AKIA[0-9A-Z]{12,})|(?:npm_[A-Za-z0-9]{20,})|(?:AIza[0-9A-Za-z_-]{20,})|(?:AT[AB][AT]T[A-Za-z0-9._~\-]{20,}))\b/g;
|
|
40
|
+
export function redactSecrets(input) {
|
|
41
|
+
if (!input || typeof input !== "string")
|
|
42
|
+
return input;
|
|
43
|
+
return input
|
|
44
|
+
.replace(SHELL_ASSIGN_RE, (_m, prefix, key, eq, dq, sq) => {
|
|
45
|
+
if (dq !== undefined)
|
|
46
|
+
return `${prefix}${key}${eq}"${REDACTED}"`;
|
|
47
|
+
if (sq !== undefined)
|
|
48
|
+
return `${prefix}${key}${eq}'${REDACTED}'`;
|
|
49
|
+
return `${prefix}${key}${eq}${REDACTED}`;
|
|
50
|
+
})
|
|
51
|
+
.replace(CLI_FLAG_RE, (_m, flagAndSep, dq, sq) => {
|
|
52
|
+
if (dq !== undefined)
|
|
53
|
+
return `${flagAndSep}"${REDACTED}"`;
|
|
54
|
+
if (sq !== undefined)
|
|
55
|
+
return `${flagAndSep}'${REDACTED}'`;
|
|
56
|
+
return `${flagAndSep}${REDACTED}`;
|
|
57
|
+
})
|
|
58
|
+
.replace(HTTP_AUTH_RE, (_m, scheme) => `${scheme} ${REDACTED}`)
|
|
59
|
+
.replace(KNOWN_PREFIX_RE, () => REDACTED);
|
|
60
|
+
}
|
|
61
|
+
export function redactAndTruncate(input, maxChars) {
|
|
62
|
+
if (!input)
|
|
63
|
+
return input;
|
|
64
|
+
const redacted = redactSecrets(input);
|
|
65
|
+
if (redacted.length <= maxChars)
|
|
66
|
+
return redacted;
|
|
67
|
+
return `${redacted.slice(0, maxChars)}…(truncated ${redacted.length - maxChars} chars)`;
|
|
68
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export type SessionIndexEntry = {
|
|
2
|
+
sessionID: string;
|
|
3
|
+
agent: string;
|
|
4
|
+
worktree: string;
|
|
5
|
+
issue: string;
|
|
6
|
+
stage?: string;
|
|
7
|
+
createdAt: string;
|
|
8
|
+
};
|
|
9
|
+
export declare function appendSessionIndex(entry: SessionIndexEntry): void;
|
|
10
|
+
export declare function findWorktreeRoot(startFile: string): string | null;
|
|
11
|
+
export declare function lookupSessionFromIndex(worktreeRoot: string, sessionID: string): SessionIndexEntry | null;
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { appendFileSync, existsSync, mkdirSync, readdirSync, readFileSync, statSync, } from "node:fs";
|
|
2
|
+
import { resolve as pathResolve, dirname as pathDirname } from "node:path";
|
|
3
|
+
import { logger } from "./logger.js";
|
|
4
|
+
const SESSION_INDEX_FILE = "session-index.ndjson";
|
|
5
|
+
export function appendSessionIndex(entry) {
|
|
6
|
+
try {
|
|
7
|
+
const dir = `${entry.worktree}/.makdoong2-team/${entry.issue}`;
|
|
8
|
+
mkdirSync(dir, { recursive: true });
|
|
9
|
+
appendFileSync(`${dir}/${SESSION_INDEX_FILE}`, JSON.stringify(entry) + "\n");
|
|
10
|
+
}
|
|
11
|
+
catch (err) {
|
|
12
|
+
logger.debug(`[session-index] append failed sessionID=${entry.sessionID}: ${err.message}`);
|
|
13
|
+
}
|
|
14
|
+
}
|
|
15
|
+
export function findWorktreeRoot(startFile) {
|
|
16
|
+
if (typeof startFile !== "string" || startFile.length === 0)
|
|
17
|
+
return null;
|
|
18
|
+
let cur = pathResolve(startFile);
|
|
19
|
+
cur = pathDirname(cur);
|
|
20
|
+
for (let i = 0; i < 64; i++) {
|
|
21
|
+
try {
|
|
22
|
+
const st = statSync(`${cur}/.git`);
|
|
23
|
+
if (st.isDirectory() || st.isFile())
|
|
24
|
+
return cur;
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* .git not here — keep walking up */
|
|
28
|
+
}
|
|
29
|
+
const parent = pathDirname(cur);
|
|
30
|
+
if (parent === cur)
|
|
31
|
+
return null;
|
|
32
|
+
cur = parent;
|
|
33
|
+
}
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
export function lookupSessionFromIndex(worktreeRoot, sessionID) {
|
|
37
|
+
if (typeof worktreeRoot !== "string" || typeof sessionID !== "string")
|
|
38
|
+
return null;
|
|
39
|
+
try {
|
|
40
|
+
const base = `${worktreeRoot}/.makdoong2-team`;
|
|
41
|
+
if (!existsSync(base))
|
|
42
|
+
return null;
|
|
43
|
+
const issues = readdirSync(base, { withFileTypes: true })
|
|
44
|
+
.filter(d => d.isDirectory())
|
|
45
|
+
.map(d => d.name);
|
|
46
|
+
for (const issue of issues) {
|
|
47
|
+
const idx = `${base}/${issue}/${SESSION_INDEX_FILE}`;
|
|
48
|
+
if (!existsSync(idx))
|
|
49
|
+
continue;
|
|
50
|
+
const content = readFileSync(idx, "utf8");
|
|
51
|
+
const lines = content.split("\n");
|
|
52
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
53
|
+
const line = lines[i].trim();
|
|
54
|
+
if (!line)
|
|
55
|
+
continue;
|
|
56
|
+
try {
|
|
57
|
+
const obj = JSON.parse(line);
|
|
58
|
+
if (obj?.sessionID === sessionID)
|
|
59
|
+
return obj;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
/* malformed line — skip */
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
catch {
|
|
68
|
+
/* index root inaccessible */
|
|
69
|
+
}
|
|
70
|
+
return null;
|
|
71
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
export interface SkillMcpEntry {
|
|
2
|
+
/** skill 디렉토리 이름 (SKILL.md 의 name 필드와 동일해야 함). */
|
|
3
|
+
skillName: string;
|
|
4
|
+
/** frontmatter mcp: 블록에서 선언된 MCP 서버 이름들. */
|
|
5
|
+
mcpNames: readonly string[];
|
|
6
|
+
/** 원본 SKILL.md 절대 경로 (디버깅용). */
|
|
7
|
+
path: string;
|
|
8
|
+
}
|
|
9
|
+
export interface SkillMcpRegistry {
|
|
10
|
+
/** mcp_name → 이 MCP를 embedded 로 선언한 skill 이름 (첫 번째 매칭). */
|
|
11
|
+
readonly byMcp: ReadonlyMap<string, string>;
|
|
12
|
+
/** skill_name → SkillMcpEntry. */
|
|
13
|
+
readonly bySkill: ReadonlyMap<string, SkillMcpEntry>;
|
|
14
|
+
/** 스캔한 skills 루트 경로 (디버깅용). */
|
|
15
|
+
readonly root: string;
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* SKILL.md 하나에서 frontmatter 를 추출한다. `---` 로 감싼 YAML 프론트매터가
|
|
19
|
+
* 없으면 null 을 반환한다.
|
|
20
|
+
*
|
|
21
|
+
* 파서는 다음 두 필드만 최소 파싱한다:
|
|
22
|
+
* - `name: <string>`
|
|
23
|
+
* - `mcp:` 하위의 최상위 키(들) — 각 키가 MCP 서버 이름
|
|
24
|
+
*
|
|
25
|
+
* 예시:
|
|
26
|
+
* ```
|
|
27
|
+
* ---
|
|
28
|
+
* name: jira-research
|
|
29
|
+
* mcp:
|
|
30
|
+
* works:
|
|
31
|
+
* command: bash
|
|
32
|
+
* args: ["./run-works.sh"]
|
|
33
|
+
* ---
|
|
34
|
+
* ```
|
|
35
|
+
* → { name: "jira-research", mcpNames: ["works"] }
|
|
36
|
+
*/
|
|
37
|
+
export declare function parseSkillFrontmatter(src: string): {
|
|
38
|
+
name?: string;
|
|
39
|
+
mcpNames: string[];
|
|
40
|
+
} | null;
|
|
41
|
+
/**
|
|
42
|
+
* `skillsRoot` 하위의 SKILL.md 를 모두 스캔해 registry 를 만든다.
|
|
43
|
+
*
|
|
44
|
+
* 디렉토리가 없거나 접근 불가면 빈 registry 를 반환한다 (fail-open).
|
|
45
|
+
* 훅 로직은 registry 가 비어 있으면 정보성 힌트를 생략하고 opencode 기본
|
|
46
|
+
* 에러를 그대로 전달한다.
|
|
47
|
+
*/
|
|
48
|
+
export declare function scanSkillMcpRegistry(skillsRoot: string): SkillMcpRegistry;
|
|
49
|
+
/**
|
|
50
|
+
* skill_mcp 호출 인자에서 mcp_name 을 안전하게 추출한다.
|
|
51
|
+
* 호출 형식은 { mcp_name: string, ... } 이며, 미제공이면 undefined.
|
|
52
|
+
*/
|
|
53
|
+
export declare function extractMcpName(args: unknown): string | undefined;
|
|
54
|
+
/**
|
|
55
|
+
* opencode 가 반환하는 "MCP server ... not found" 에러 문자열을 감지한다.
|
|
56
|
+
* 정확한 문구는 opencode 버전에 따라 달라질 수 있으므로 관용적으로 매칭한다.
|
|
57
|
+
*/
|
|
58
|
+
export declare function looksLikeMcpNotFound(output: string): boolean;
|
|
59
|
+
export declare function looksLikeMcpConnectionFailed(output: string): boolean;
|
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
// skill-mcp-registry.ts — mcp_name → skill_name 매핑 스캐너.
|
|
2
|
+
//
|
|
3
|
+
// 배경
|
|
4
|
+
// ----
|
|
5
|
+
// 이 프로젝트의 skill 은 SKILL.md frontmatter 에 MCP 서버를 embedded 로 선언한다
|
|
6
|
+
// (lazy-load 아키텍처). skill 이 실제로 `skill(name="...")` 로 로드되기 전에는
|
|
7
|
+
// 해당 MCP 서버가 세션에 스폰되지 않는다.
|
|
8
|
+
//
|
|
9
|
+
// 사용자(막둥이 에이전트)가 순서 강제 문구를 놓치고 `skill_mcp(mcp_name="works", ...)`
|
|
10
|
+
// 를 먼저 호출하면 opencode 가 "MCP server not found" 로 튕기지만, 그 에러
|
|
11
|
+
// 메시지는 어떤 skill 을 로드해야 하는지 명시하지 않는다.
|
|
12
|
+
//
|
|
13
|
+
// 이 모듈은 install 된 skills 디렉토리를 스캔해 frontmatter 의 `mcp:` 블록에서
|
|
14
|
+
// 서버 이름을 추출하고, `mcp_name → skill_name` 룩업 테이블을 만든다.
|
|
15
|
+
// tool.execute.before / .after 훅이 이 테이블로 사용자에게 정확한 skill 이름을
|
|
16
|
+
// 지시하는 에러를 낼 수 있게 한다.
|
|
17
|
+
//
|
|
18
|
+
// 파서는 최소 구현으로 유지한다 — SKILL.md frontmatter 는
|
|
19
|
+
// name / description / mcp / temperature / tools 등 얕은 구조만 사용하며,
|
|
20
|
+
// YAML full spec 을 의존성으로 도입할 필요가 없다.
|
|
21
|
+
import { readFileSync, readdirSync, statSync } from "node:fs";
|
|
22
|
+
import { join } from "node:path";
|
|
23
|
+
/**
|
|
24
|
+
* SKILL.md 하나에서 frontmatter 를 추출한다. `---` 로 감싼 YAML 프론트매터가
|
|
25
|
+
* 없으면 null 을 반환한다.
|
|
26
|
+
*
|
|
27
|
+
* 파서는 다음 두 필드만 최소 파싱한다:
|
|
28
|
+
* - `name: <string>`
|
|
29
|
+
* - `mcp:` 하위의 최상위 키(들) — 각 키가 MCP 서버 이름
|
|
30
|
+
*
|
|
31
|
+
* 예시:
|
|
32
|
+
* ```
|
|
33
|
+
* ---
|
|
34
|
+
* name: jira-research
|
|
35
|
+
* mcp:
|
|
36
|
+
* works:
|
|
37
|
+
* command: bash
|
|
38
|
+
* args: ["./run-works.sh"]
|
|
39
|
+
* ---
|
|
40
|
+
* ```
|
|
41
|
+
* → { name: "jira-research", mcpNames: ["works"] }
|
|
42
|
+
*/
|
|
43
|
+
export function parseSkillFrontmatter(src) {
|
|
44
|
+
// frontmatter 는 파일 시작의 `---` 로 감싸진 첫 블록.
|
|
45
|
+
const m = src.match(/^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/);
|
|
46
|
+
if (!m)
|
|
47
|
+
return null;
|
|
48
|
+
const front = m[1];
|
|
49
|
+
const lines = front.split(/\r?\n/);
|
|
50
|
+
let name;
|
|
51
|
+
const mcpNames = [];
|
|
52
|
+
let inMcp = false;
|
|
53
|
+
let mcpIndent = -1;
|
|
54
|
+
for (const raw of lines) {
|
|
55
|
+
if (!raw.trim())
|
|
56
|
+
continue;
|
|
57
|
+
// 최상위 name: <value>
|
|
58
|
+
const topLevel = raw.match(/^([A-Za-z_][A-Za-z0-9_-]*)\s*:\s*(.*)$/);
|
|
59
|
+
// 들여쓰기 계산 (leading spaces 개수).
|
|
60
|
+
const indent = raw.match(/^(\s*)/)?.[1].length ?? 0;
|
|
61
|
+
if (inMcp) {
|
|
62
|
+
// mcp: 블록 종료 조건 — indent 가 mcp 헤더보다 크지 않으면 블록 밖.
|
|
63
|
+
if (indent <= mcpIndent) {
|
|
64
|
+
inMcp = false;
|
|
65
|
+
}
|
|
66
|
+
else {
|
|
67
|
+
// mcp: 블록 안. mcpIndent + 2 (표준 YAML 2-space 들여쓰기) 정확히
|
|
68
|
+
// 매칭되는 라인만 서버 이름 후보. 더 깊은 들여쓰기는 서버의 하위 옵션.
|
|
69
|
+
if (indent === mcpIndent + 2) {
|
|
70
|
+
const kv = raw.trim().match(/^([A-Za-z_][A-Za-z0-9_.-]*)\s*:/);
|
|
71
|
+
if (kv)
|
|
72
|
+
mcpNames.push(kv[1]);
|
|
73
|
+
}
|
|
74
|
+
continue;
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
if (!inMcp && indent === 0 && topLevel) {
|
|
78
|
+
const key = topLevel[1];
|
|
79
|
+
const value = topLevel[2].trim();
|
|
80
|
+
if (key === "name" && value) {
|
|
81
|
+
name = value.replace(/^["']|["']$/g, "");
|
|
82
|
+
}
|
|
83
|
+
else if (key === "mcp" && value === "") {
|
|
84
|
+
inMcp = true;
|
|
85
|
+
mcpIndent = indent;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
return { name, mcpNames };
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* `skillsRoot` 하위의 SKILL.md 를 모두 스캔해 registry 를 만든다.
|
|
93
|
+
*
|
|
94
|
+
* 디렉토리가 없거나 접근 불가면 빈 registry 를 반환한다 (fail-open).
|
|
95
|
+
* 훅 로직은 registry 가 비어 있으면 정보성 힌트를 생략하고 opencode 기본
|
|
96
|
+
* 에러를 그대로 전달한다.
|
|
97
|
+
*/
|
|
98
|
+
export function scanSkillMcpRegistry(skillsRoot) {
|
|
99
|
+
const byMcp = new Map();
|
|
100
|
+
const bySkill = new Map();
|
|
101
|
+
let entries;
|
|
102
|
+
try {
|
|
103
|
+
entries = readdirSync(skillsRoot);
|
|
104
|
+
}
|
|
105
|
+
catch {
|
|
106
|
+
return { byMcp, bySkill, root: skillsRoot };
|
|
107
|
+
}
|
|
108
|
+
for (const dirName of entries) {
|
|
109
|
+
const skillPath = join(skillsRoot, dirName);
|
|
110
|
+
let s;
|
|
111
|
+
try {
|
|
112
|
+
s = statSync(skillPath);
|
|
113
|
+
}
|
|
114
|
+
catch {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
if (!s.isDirectory())
|
|
118
|
+
continue;
|
|
119
|
+
const mdPath = join(skillPath, "SKILL.md");
|
|
120
|
+
let src;
|
|
121
|
+
try {
|
|
122
|
+
src = readFileSync(mdPath, "utf8");
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
continue; // SKILL.md 없는 디렉토리는 스킵.
|
|
126
|
+
}
|
|
127
|
+
const parsed = parseSkillFrontmatter(src);
|
|
128
|
+
if (!parsed)
|
|
129
|
+
continue;
|
|
130
|
+
// frontmatter 의 name 필드가 있으면 그것을, 없으면 디렉토리명을 사용.
|
|
131
|
+
// opencode 는 skill(name=...) 호출 시 SKILL.md name 필드를 정본으로 삼는다.
|
|
132
|
+
const skillName = parsed.name || dirName;
|
|
133
|
+
const entry = {
|
|
134
|
+
skillName,
|
|
135
|
+
mcpNames: parsed.mcpNames,
|
|
136
|
+
path: mdPath,
|
|
137
|
+
};
|
|
138
|
+
bySkill.set(skillName, entry);
|
|
139
|
+
for (const mcp of parsed.mcpNames) {
|
|
140
|
+
// 첫 번째 매칭 skill 만 등록. 동일 mcp_name 을 여러 skill 이 선언하면
|
|
141
|
+
// 뒤에 오는 것은 무시하고 경고성 로그는 훅 쪽에서 처리.
|
|
142
|
+
if (!byMcp.has(mcp))
|
|
143
|
+
byMcp.set(mcp, skillName);
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
return { byMcp, bySkill, root: skillsRoot };
|
|
147
|
+
}
|
|
148
|
+
/**
|
|
149
|
+
* skill_mcp 호출 인자에서 mcp_name 을 안전하게 추출한다.
|
|
150
|
+
* 호출 형식은 { mcp_name: string, ... } 이며, 미제공이면 undefined.
|
|
151
|
+
*/
|
|
152
|
+
export function extractMcpName(args) {
|
|
153
|
+
if (args && typeof args === "object" && "mcp_name" in args) {
|
|
154
|
+
const v = args.mcp_name;
|
|
155
|
+
if (typeof v === "string" && v.trim())
|
|
156
|
+
return v.trim();
|
|
157
|
+
}
|
|
158
|
+
return undefined;
|
|
159
|
+
}
|
|
160
|
+
/**
|
|
161
|
+
* opencode 가 반환하는 "MCP server ... not found" 에러 문자열을 감지한다.
|
|
162
|
+
* 정확한 문구는 opencode 버전에 따라 달라질 수 있으므로 관용적으로 매칭한다.
|
|
163
|
+
*/
|
|
164
|
+
export function looksLikeMcpNotFound(output) {
|
|
165
|
+
if (!output)
|
|
166
|
+
return false;
|
|
167
|
+
// "MCP server \"works\" not found" / "MCP server 'works' not found" /
|
|
168
|
+
// "MCP server works not found" 모두 커버.
|
|
169
|
+
return /MCP\s+server\s+["']?[A-Za-z0-9_.-]+["']?\s+not\s+found/i.test(output);
|
|
170
|
+
}
|
|
171
|
+
// "Failed to connect to MCP server \"works\"." / "MCP error -32000: Connection closed" 패턴.
|
|
172
|
+
// opencode 가 embedded MCP 프로세스 종료(exit non-zero) 시 반환하는 문구.
|
|
173
|
+
export function looksLikeMcpConnectionFailed(output) {
|
|
174
|
+
if (!output)
|
|
175
|
+
return false;
|
|
176
|
+
return (/Failed\s+to\s+connect\s+to\s+MCP\s+server/i.test(output) ||
|
|
177
|
+
/MCP\s+error\s+-32000[^:]*:\s*Connection\s+closed/i.test(output));
|
|
178
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export declare function shouldEscalateStall(hangCount: number, threshold: number): boolean;
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
// Cross-call stall escalation for dispatch_stage.
|
|
2
|
+
//
|
|
3
|
+
// Lives in its own module rather than opencode-plugin.ts because opencode's
|
|
4
|
+
// plugin loader invokes EVERY named export of the plugin entry file as a
|
|
5
|
+
// plugin factory (see test/plugin-exports-shape.test.mjs and the PROJ-40406
|
|
6
|
+
// root-cause analysis). Helpers must therefore be imported, never re-exported
|
|
7
|
+
// from the entry file.
|
|
8
|
+
// dispatch_stage bounds retries with MAX_ATTEMPTS, but that budget lives
|
|
9
|
+
// inside a single tool call. When the orchestrator reacts to a failure by
|
|
10
|
+
// calling dispatch_stage again the budget resets, so a substage whose model
|
|
11
|
+
// keeps hanging loops forever. hang_history is the only counter that survives
|
|
12
|
+
// across calls, which makes it the escalation signal.
|
|
13
|
+
//
|
|
14
|
+
// hangCount comes from `state.sh get … | length`, so an unreadable or corrupt
|
|
15
|
+
// state.json yields NaN. Treating NaN as "escalate" would deadlock every
|
|
16
|
+
// workflow that cannot read its state file, so unreadable input fails open —
|
|
17
|
+
// blocking happens only on a value we actually trust.
|
|
18
|
+
export function shouldEscalateStall(hangCount, threshold) {
|
|
19
|
+
if (!Number.isFinite(hangCount) || !Number.isFinite(threshold))
|
|
20
|
+
return false;
|
|
21
|
+
return hangCount >= threshold;
|
|
22
|
+
}
|
|
@@ -0,0 +1,193 @@
|
|
|
1
|
+
type ShellTag = (strings: TemplateStringsArray, ...values: unknown[]) => {
|
|
2
|
+
cwd(dir: string): ReturnType<ShellTag>;
|
|
3
|
+
quiet(): ReturnType<ShellTag>;
|
|
4
|
+
nothrow(): ReturnType<ShellTag>;
|
|
5
|
+
then<T>(onfulfilled: (value: ShellResult) => T): Promise<T>;
|
|
6
|
+
};
|
|
7
|
+
interface ShellResult {
|
|
8
|
+
exitCode: number;
|
|
9
|
+
stdout?: {
|
|
10
|
+
toString(): string;
|
|
11
|
+
};
|
|
12
|
+
stderr?: {
|
|
13
|
+
toString(): string;
|
|
14
|
+
};
|
|
15
|
+
}
|
|
16
|
+
export type TmuxLayout = "main-vertical" | "main-horizontal" | "tiled" | "even-horizontal" | "even-vertical";
|
|
17
|
+
export type TmuxPlacement = "window" | "pane";
|
|
18
|
+
export declare const FOCUS_POLL_INTERVAL_MS = 2000;
|
|
19
|
+
export interface TmuxMonitorConfig {
|
|
20
|
+
enabled: boolean;
|
|
21
|
+
placement: TmuxPlacement;
|
|
22
|
+
layout: TmuxLayout;
|
|
23
|
+
mainPaneSize: number;
|
|
24
|
+
agentPaneMinWidth: number;
|
|
25
|
+
splitDirection: "-h" | "-v";
|
|
26
|
+
attachCommand: string;
|
|
27
|
+
serverUrl?: string;
|
|
28
|
+
keepPaneOnSuccess: boolean;
|
|
29
|
+
autoCloseOnFailure: boolean;
|
|
30
|
+
paneCloseDelaySeconds: number;
|
|
31
|
+
}
|
|
32
|
+
export declare function isInsideTmux(): boolean;
|
|
33
|
+
export declare function buildWindowName(stage: string, sessionId: string): string;
|
|
34
|
+
export declare function buildPlaceholderCommand(stage: string, agent: string, sessionId: string): string;
|
|
35
|
+
export declare const MARKER_SESSION = "@mdn2_session";
|
|
36
|
+
export declare const MARKER_PID = "@mdn2_pid";
|
|
37
|
+
export declare const MARKER_STAGE = "@mdn2_stage";
|
|
38
|
+
export declare const MARKER_STARTED = "@mdn2_started_at";
|
|
39
|
+
export declare const PARENT_MARKER_PATTERN: RegExp;
|
|
40
|
+
export interface TmuxConfigBlock {
|
|
41
|
+
enabled?: boolean;
|
|
42
|
+
placement?: string;
|
|
43
|
+
layout?: string;
|
|
44
|
+
main_pane_size?: number;
|
|
45
|
+
agent_pane_min_width?: number;
|
|
46
|
+
split_direction?: string;
|
|
47
|
+
attach_command?: string;
|
|
48
|
+
server_url?: string | null;
|
|
49
|
+
keep_pane_on_success?: boolean;
|
|
50
|
+
auto_close_on_failure?: boolean;
|
|
51
|
+
pane_close_delay_seconds?: number;
|
|
52
|
+
}
|
|
53
|
+
export declare function readTmuxConfig(block?: TmuxConfigBlock): TmuxMonitorConfig;
|
|
54
|
+
export interface OrphanScanResult {
|
|
55
|
+
paneId: string;
|
|
56
|
+
sessionId: string;
|
|
57
|
+
stage?: string;
|
|
58
|
+
startedAt?: number;
|
|
59
|
+
ownerPid?: number;
|
|
60
|
+
startCommand?: string;
|
|
61
|
+
}
|
|
62
|
+
export interface CleanupOrphansOptions {
|
|
63
|
+
graceSeconds?: number;
|
|
64
|
+
now?: () => number;
|
|
65
|
+
}
|
|
66
|
+
/** EPERM means the pid exists but belongs to another user — still alive. */
|
|
67
|
+
export declare function isPidAlive(pid: number): boolean;
|
|
68
|
+
/**
|
|
69
|
+
* Grace window applied to freshly spawned panes during the orphan-scan tick.
|
|
70
|
+
* opencode's session.status() is scoped to the requesting directory, so a
|
|
71
|
+
* sub-session created inside a worktree never appears in the parent's status
|
|
72
|
+
* map. Without a spawn grace the very first tick after split-window reads
|
|
73
|
+
* `status=undefined` and kills a perfectly healthy pane.
|
|
74
|
+
*/
|
|
75
|
+
export declare const ORPHAN_SPAWN_GRACE_MS = 120000;
|
|
76
|
+
export type OrphanGuardReason = "foreign-live-owner" | "spawn-grace" | "tool-activity";
|
|
77
|
+
export interface OrphanGuardContext {
|
|
78
|
+
nowMs: number;
|
|
79
|
+
ownerPid: number;
|
|
80
|
+
lastToolExecuteAtMs?: number;
|
|
81
|
+
activeToolCount?: number;
|
|
82
|
+
spawnGraceMs?: number;
|
|
83
|
+
toolAliveWindowMs?: number;
|
|
84
|
+
isPidAlive?: (pid: number) => boolean;
|
|
85
|
+
}
|
|
86
|
+
/** @returns the guard that fired, or undefined when the pane may be cleaned up. */
|
|
87
|
+
export declare function orphanCleanupGuard(pane: Pick<OrphanScanResult, "ownerPid" | "startedAt">, ctx: OrphanGuardContext): OrphanGuardReason | undefined;
|
|
88
|
+
export interface CleanupOrphansReport {
|
|
89
|
+
tracked_closed: number;
|
|
90
|
+
orphans_closed: number;
|
|
91
|
+
parents_skipped: number;
|
|
92
|
+
fresh_skipped: number;
|
|
93
|
+
total_closed: number;
|
|
94
|
+
}
|
|
95
|
+
export declare class TmuxMonitor {
|
|
96
|
+
private readonly panes;
|
|
97
|
+
private readonly sourcePaneId;
|
|
98
|
+
private readonly $;
|
|
99
|
+
private readonly ownerPid;
|
|
100
|
+
private tmuxVersionCache;
|
|
101
|
+
private versionBlocked;
|
|
102
|
+
private focusTimer;
|
|
103
|
+
private focusPollInFlight;
|
|
104
|
+
readonly config: TmuxMonitorConfig;
|
|
105
|
+
constructor(shell: ShellTag, config: TmuxMonitorConfig, ownerPid?: number);
|
|
106
|
+
get active(): boolean;
|
|
107
|
+
get trackedPaneCount(): number;
|
|
108
|
+
get ownerProcessId(): number;
|
|
109
|
+
checkTmuxVersion(): Promise<{
|
|
110
|
+
ok: boolean;
|
|
111
|
+
version: string;
|
|
112
|
+
}>;
|
|
113
|
+
spawnPane(sessionId: string, stage: string, agent: string, worktree: string): Promise<string | null>;
|
|
114
|
+
private spawnPaneInner;
|
|
115
|
+
/**
|
|
116
|
+
* Swap a placeholder pane over to the real `opencode attach` TUI.
|
|
117
|
+
* Pane user options (@mdn2_*) survive respawn-pane, so ownership tracking is
|
|
118
|
+
* unaffected — verified against tmux 3.6.
|
|
119
|
+
*/
|
|
120
|
+
activatePane(sessionId: string): Promise<boolean>;
|
|
121
|
+
/**
|
|
122
|
+
* One focus sweep. A pane counts as focused only when it is the active pane
|
|
123
|
+
* of the active window — `pane_active` alone is 1 for every window's own
|
|
124
|
+
* active pane, so both flags are required.
|
|
125
|
+
*/
|
|
126
|
+
pollFocusOnce(): Promise<number>;
|
|
127
|
+
private startFocusWatch;
|
|
128
|
+
private stopFocusWatchIfIdle;
|
|
129
|
+
/**
|
|
130
|
+
* placement=window — own tmux window; `-d` leaves the active window (and thus
|
|
131
|
+
* 부장님's pane geometry) untouched. ARCHITECTURE.md §17.6.
|
|
132
|
+
*/
|
|
133
|
+
private spawnInNewWindow;
|
|
134
|
+
private resolveSourceWindowTarget;
|
|
135
|
+
/** placement=pane — legacy split of 부장님's window. Resizes the source pane. */
|
|
136
|
+
private spawnInSplitPane;
|
|
137
|
+
private rebalanceLayout;
|
|
138
|
+
/**
|
|
139
|
+
* Sub-session 완료 시 pane 처리.
|
|
140
|
+
* - opts.success === true: paneCloseDelaySeconds 초 대기 후 kill (성공 로그 확인 시간 확보).
|
|
141
|
+
* - opts.success === false: autoCloseOnFailure이 true면 kill, 아니면 유지 (실패 진단용).
|
|
142
|
+
* - opts 미지정: 기존 호환 동작 — 즉시 kill.
|
|
143
|
+
*
|
|
144
|
+
* Fallback: if the in-memory Map lost the entry (plugin re-init between spawn
|
|
145
|
+
* and close), a tmux marker scan finds the pane by @mdn2_session and kills it.
|
|
146
|
+
*/
|
|
147
|
+
closePane(sessionId: string, opts?: {
|
|
148
|
+
success: boolean;
|
|
149
|
+
}): Promise<void>;
|
|
150
|
+
/** Kill every tracked pane — used when the plugin shuts down. */
|
|
151
|
+
cleanup(): Promise<void>;
|
|
152
|
+
reapDeadOwnerPanes(opts?: {
|
|
153
|
+
graceSeconds?: number;
|
|
154
|
+
now?: () => number;
|
|
155
|
+
}): Promise<number>;
|
|
156
|
+
/**
|
|
157
|
+
* Discover every pane on the tmux server that carries our @mdn2_session
|
|
158
|
+
* marker. Parent opencode panes (--port) are NEVER returned regardless of
|
|
159
|
+
* markers — safety guard against accidental self-kill if the parent ever
|
|
160
|
+
* inherited a stale marker.
|
|
161
|
+
*/
|
|
162
|
+
scanOrphans(): Promise<OrphanScanResult[]>;
|
|
163
|
+
/**
|
|
164
|
+
* Cleanup entry point invoked by the cleanup_panes tool. Kills every
|
|
165
|
+
* @mdn2_session-marked pane on the tmux server.
|
|
166
|
+
*
|
|
167
|
+
* Panes newer than opts.graceSeconds (default 0) are skipped to avoid
|
|
168
|
+
* killing panes still in the spawn window.
|
|
169
|
+
*/
|
|
170
|
+
cleanupOrphans(opts?: CleanupOrphansOptions): Promise<CleanupOrphansReport>;
|
|
171
|
+
/**
|
|
172
|
+
* Locate a pane by its @mdn2_session marker. Survives plugin re-init
|
|
173
|
+
* (markers persist on the tmux server even when the in-memory Map is empty).
|
|
174
|
+
* Parent opencode panes (--port) are filtered by scanOrphans.
|
|
175
|
+
*/
|
|
176
|
+
private findPaneBySession;
|
|
177
|
+
/**
|
|
178
|
+
* Force-kill the marker-matched pane for the given session id. Unlike
|
|
179
|
+
* closePane this bypasses in-memory tracking entirely and only cares
|
|
180
|
+
* about the tmux state, so it survives plugin restarts.
|
|
181
|
+
*
|
|
182
|
+
* Never touches parent opencode panes — the scanOrphans PARENT filter
|
|
183
|
+
* excludes any `--port` process.
|
|
184
|
+
*/
|
|
185
|
+
forceKillBySessionId(sessionId: string): Promise<boolean>;
|
|
186
|
+
private writePaneMarkers;
|
|
187
|
+
private isPidAlive;
|
|
188
|
+
private killPane;
|
|
189
|
+
private discoverServerUrl;
|
|
190
|
+
/** Maximum number of agent panes that fit beside the main pane. */
|
|
191
|
+
private computeCapacity;
|
|
192
|
+
}
|
|
193
|
+
export {};
|