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
@@ -0,0 +1,74 @@
1
+ #!/usr/bin/env bash
2
+ # lint-agent-prompts.sh — agent 프롬프트 및 게이트 스크립트에서 state.json flat 표기 재유입 감지.
3
+ #
4
+ # 검증 대상: agents/*.md, gates/*.sh 파일
5
+ # 감지 패턴 (BUG):
6
+ # .stages."<PHASE>.<SUBSTAGE>" 예: .stages."1_planning.jira"
7
+ #
8
+ # 정상 패턴:
9
+ # .stages."<PHASE>".substages."<SUBSTAGE>" 예: .stages."1_planning".substages."jira"
10
+ #
11
+ # 허용 예외 (의도적):
12
+ # .policy.auto_approve."<PHASE>.<SUBSTAGE>" (opencode-plugin.ts STAGE_ORDER 는 flat)
13
+ # .policy.categorized_by = "<PHASE>.<SUBSTAGE>" (policy 값)
14
+ #
15
+ # 발견 시 파일:라인 + 정확한 hierarchical 대체 경로를 출력하고 exit 1.
16
+ # CI/pre-commit 훅에서 호출한다.
17
+
18
+ set -euo pipefail
19
+
20
+ REPO_ROOT="$(cd "$(dirname "$0")/.." && pwd)"
21
+ AGENTS_DIR="${REPO_ROOT}/agents"
22
+ GATES_DIR="${REPO_ROOT}/gates"
23
+
24
+ if [ ! -d "$AGENTS_DIR" ]; then
25
+ echo "[lint-agent-prompts] agents/ directory not found at $AGENTS_DIR" >&2
26
+ exit 2
27
+ fi
28
+
29
+ if [ ! -d "$GATES_DIR" ]; then
30
+ echo "[lint-agent-prompts] gates/ directory not found at $GATES_DIR" >&2
31
+ exit 2
32
+ fi
33
+
34
+ # grep -P (PCRE) 를 사용해 negative lookbehind 로 policy 예외를 제외한다.
35
+ # 매칭: `.stages."<phase>.<sub>"` (앞에 .policy 가 붙지 않은 경우만).
36
+ # 부수적으로 `.stages.\"<phase>.<sub>\"` (escape 형태) 도 잡는다.
37
+ PATTERN='(?<!policy)\.stages\.\\?"[0-9]+_[a-z_]+\.[a-z]+\\?"'
38
+
39
+ matches_agents=$(grep -rnP "$PATTERN" "$AGENTS_DIR" || true)
40
+ matches_gates=$(grep -rnP "$PATTERN" "$GATES_DIR" || true)
41
+ matches="${matches_agents}${matches_gates}"
42
+
43
+ if [ -z "$matches" ]; then
44
+ echo "[lint-agent-prompts] OK — no flat stage notation found in agents/ or gates/"
45
+ exit 0
46
+ fi
47
+
48
+ echo "[lint-agent-prompts] FAIL — flat stage notation detected in agent prompts:" >&2
49
+ echo "" >&2
50
+ echo "$matches" | while IFS=: read -r file line rest; do
51
+ # 힌트: 발견된 flat path 에서 phase.substage 를 파싱해 대체안 제시.
52
+ hint=$(echo "$rest" | grep -oP '\.stages\.\\?"([0-9]+_[a-z_]+)\.([a-z]+)\\?"' | head -n1 || true)
53
+ suggest=""
54
+ if [ -n "$hint" ]; then
55
+ phase=$(echo "$hint" | sed -E 's|.*"([0-9]+_[a-z_]+)\.([a-z]+)".*|\1|')
56
+ sub=$(echo "$hint" | sed -E 's|.*"([0-9]+_[a-z_]+)\.([a-z]+)".*|\2|')
57
+ suggest=".stages.\"${phase}\".substages.\"${sub}\""
58
+ fi
59
+ echo " ${file}:${line}" >&2
60
+ echo " line : ${rest}" >&2
61
+ [ -n "$suggest" ] && echo " fix : ${suggest}" >&2
62
+ echo "" >&2
63
+ done
64
+
65
+ cat >&2 <<EOF
66
+ state.json 스키마 규약 (AGENTS.md "state.json 스키마 규약 (hardrule)" 참조):
67
+ hierarchical : .stages."<PHASE>".substages."<SUBSTAGE>".<field>
68
+ flat (금지) : .stages."<PHASE>.<SUBSTAGE>".<field>
69
+
70
+ 허용 예외:
71
+ .policy.auto_approve."<PHASE>.<SUBSTAGE>" — opencode-plugin.ts STAGE_ORDER 매핑용 flat 키.
72
+ EOF
73
+
74
+ exit 1
@@ -0,0 +1,44 @@
1
+ #!/usr/bin/env bash
2
+ # log-event.sh — append a structured NDJSON event for an issue.
3
+ #
4
+ # 목적: state.json은 *현재 상태*만 보관한다. 단계 전이·폴백·게이트 차단 같은
5
+ # *이력*은 append-only `events.ndjson`으로 남겨 회고·디버깅·메트릭 산출에 사용한다.
6
+ #
7
+ # 사용:
8
+ # log-event.sh <issue> <kind> [key=value ...]
9
+ # 예:
10
+ # log-event.sh PROJ-1 stage_dispatched stage=4_dev agent=makdoong2-dev model=gpt-5.1-codex
11
+ # log-event.sh PROJ-1 gate_blocked stage=2_requirements reason=jira_validation_failed
12
+ # log-event.sh PROJ-1 fallback agent=makdoong2-dev from=gpt-5.1-codex to=claude-haiku-4.5
13
+ #
14
+ # 저장 위치: <git toplevel>/.makdoong2-team/<issue>/events.ndjson
15
+ # 의존: jq
16
+ set -euo pipefail
17
+
18
+ ISSUE="${1:?usage: log-event.sh <issue> <kind> [key=value ...]}"
19
+ KIND="${2:?usage: log-event.sh <issue> <kind> [key=value ...]}"
20
+ shift 2
21
+
22
+ _STATE="$(cd "$(dirname "$0")" && pwd)/state.sh"
23
+ ROOT="$("$_STATE" root)"
24
+ DIR="$ROOT/.makdoong2-team/$ISSUE"
25
+ mkdir -p "$DIR"
26
+ LOG="$DIR/events.ndjson"
27
+ TS="$(date -u +%Y-%m-%dT%H:%M:%SZ)"
28
+
29
+ # Accumulate key=value pairs into a JSON object via jq.
30
+ ATTRS='{}'
31
+ for kv in "$@"; do
32
+ if [[ "$kv" == *"="* ]]; then
33
+ k="${kv%%=*}"
34
+ v="${kv#*=}"
35
+ ATTRS=$(printf '%s' "$ATTRS" | jq -c --arg k "$k" --arg v "$v" '. + {($k): $v}')
36
+ fi
37
+ done
38
+
39
+ jq -nc \
40
+ --arg ts "$TS" \
41
+ --arg issue "$ISSUE" \
42
+ --arg kind "$KIND" \
43
+ --argjson attrs "$ATTRS" \
44
+ '{ts:$ts, issue:$issue, kind:$kind} + $attrs' >> "$LOG"
@@ -0,0 +1,183 @@
1
+ // model-policy.mjs — JS mirror of src/model-fallback-policy.ts.
2
+ //
3
+ // Why mirror: bin/cli.js and scripts/smoke-test.mjs need to run under plain
4
+ // `node` (no bun, no transpile) — npx-friendly. The TS source is the canonical
5
+ // definition; this mirror MUST be kept structurally in sync. The smoke test
6
+ // asserts the mirror matches the runtime behavior described in the TS source.
7
+ //
8
+ // Shared by:
9
+ // - scripts/smoke-test.mjs (chain logic tests)
10
+ // - bin/cli.js (`validate` subcommand — checks user config without bun)
11
+
12
+
13
+
14
+ export const DEFAULT_ALLOWED_PRIMARIES = new Set([
15
+ "github-copilot/claude-haiku-4.5",
16
+ "github-copilot/claude-opus-4.5",
17
+ "github-copilot/claude-opus-4.6",
18
+ "github-copilot/claude-opus-4.6-fast",
19
+ "github-copilot/claude-opus-4.7",
20
+ "github-copilot/claude-opus-4.7-fast",
21
+ "github-copilot/claude-opus-4.8",
22
+ "github-copilot/claude-opus-4.8-fast",
23
+ "github-copilot/claude-opus-41",
24
+ "github-copilot/claude-sonnet-4",
25
+ "github-copilot/claude-sonnet-4.5",
26
+ "github-copilot/claude-sonnet-4.6",
27
+ "github-copilot/claude-sonnet-5",
28
+ "github-copilot/gemini-2.5-pro",
29
+ "github-copilot/gemini-3-flash-preview",
30
+ "github-copilot/gemini-3-pro-preview",
31
+ "github-copilot/gemini-3.1-pro-preview",
32
+ "github-copilot/gemini-3.5-flash",
33
+ "github-copilot/gpt-4.1",
34
+ "github-copilot/gpt-4o",
35
+ "github-copilot/gpt-5",
36
+ "github-copilot/gpt-5-mini",
37
+ "github-copilot/gpt-5.1",
38
+ "github-copilot/gpt-5.1-codex",
39
+ "github-copilot/gpt-5.1-codex-max",
40
+ "github-copilot/gpt-5.1-codex-mini",
41
+ "github-copilot/gpt-5.2",
42
+ "github-copilot/gpt-5.2-codex",
43
+ "github-copilot/gpt-5.3-codex",
44
+ "github-copilot/gpt-5.4",
45
+ "github-copilot/gpt-5.4-mini",
46
+ "github-copilot/gpt-5.5",
47
+ "github-copilot/gpt-5.6-luna",
48
+ "github-copilot/gpt-5.6-sol",
49
+ "github-copilot/gpt-5.6-terra",
50
+ "github-copilot/grok-code-fast-1",
51
+ "github-copilot/kimi-k2.7-code",
52
+ "github-copilot/mai-code-1-flash-picker",
53
+ "local/qwen3.6-27b",
54
+ "local/qwen3.6-35b-a3b",
55
+ ]);
56
+
57
+ export const TIER_RANK = { low: 1, medium: 2, high: 3, max: 4 };
58
+
59
+ // Mirrors POLICIES in src/model-fallback-policy.ts. 5 agents matching
60
+ // agent-stage-config.ts (team-leader + planner/engineer/publisher/verifier).
61
+ export const DEFAULT_POLICIES = Object.freeze({
62
+ "makdoong2-team-leader": {
63
+ primary: { id: "local/qwen3.6-27b", variant: "high", tier: "medium" },
64
+ fallbacks: [{ id: "github-copilot/claude-haiku-4.5", tier: "low" }],
65
+ },
66
+ "makdoong2-analyzer": {
67
+ primary: { id: "local/qwen3.6-27b", variant: "high", tier: "medium" },
68
+ fallbacks: [{ id: "github-copilot/claude-haiku-4.5", tier: "low" }],
69
+ },
70
+ "makdoong2-planner": {
71
+ primary: { id: "local/qwen3.6-27b", variant: "high", tier: "medium" },
72
+ fallbacks: [{ id: "github-copilot/claude-haiku-4.5", tier: "low" }],
73
+ },
74
+ "makdoong2-engineer": {
75
+ primary: { id: "local/qwen3.6-27b", variant: "medium", tier: "medium" },
76
+ fallbacks: [{ id: "github-copilot/claude-haiku-4.5", tier: "low" }],
77
+ },
78
+ "makdoong2-publisher": {
79
+ primary: { id: "local/qwen3.6-27b", variant: "high", tier: "medium" },
80
+ fallbacks: [{ id: "github-copilot/claude-haiku-4.5", tier: "low" }],
81
+ },
82
+ "makdoong2-verifier": {
83
+ primary: { id: "local/qwen3.6-27b", tier: "medium" },
84
+ fallbacks: [{ id: "github-copilot/claude-haiku-4.5", tier: "low" }],
85
+ },
86
+ });
87
+
88
+ function clonePolicies(src) {
89
+ const out = {};
90
+ for (const [k, v] of Object.entries(src)) {
91
+ out[k] = { primary: { ...v.primary }, fallbacks: v.fallbacks.map(f => ({ ...f })) };
92
+ }
93
+ return out;
94
+ }
95
+
96
+ export function nextModel({ agent, current, reason }, policies) {
97
+ const pol = (policies ?? DEFAULT_POLICIES)[agent];
98
+ if (!pol) return { next: null, exhausted: true, chain: [], reasonAccepted: "unknown agent" };
99
+ const chain = [pol.primary, ...pol.fallbacks];
100
+ const idx = chain.findIndex(m => m.id === current);
101
+ const nextIdx = idx < 0 ? 0 : idx + 1;
102
+ if (nextIdx >= chain.length) {
103
+ return { next: null, exhausted: true, chain, reasonAccepted: reason ?? "unknown" };
104
+ }
105
+ return { next: chain[nextIdx], exhausted: false, chain, reasonAccepted: reason ?? "unknown" };
106
+ }
107
+
108
+ export function validatePolicies(policies, allowedPrimaries) {
109
+ const allowed = allowedPrimaries ?? DEFAULT_ALLOWED_PRIMARIES;
110
+ for (const [agent, pol] of Object.entries(policies)) {
111
+ if (!allowed.has(pol.primary.id)) {
112
+ throw new Error(
113
+ `agent '${agent}' primary '${pol.primary.id}' violates policy ` +
114
+ `(allowed: ${[...allowed].join(", ")})`
115
+ );
116
+ }
117
+ const primaryRank = TIER_RANK[pol.primary.tier];
118
+ if (primaryRank === undefined) {
119
+ throw new Error(`agent '${agent}' primary tier '${pol.primary.tier}' is not a valid tier`);
120
+ }
121
+ for (const fb of pol.fallbacks) {
122
+ const r = TIER_RANK[fb.tier];
123
+ if (r === undefined) {
124
+ throw new Error(`agent '${agent}' fallback '${fb.id}' tier '${fb.tier}' is not a valid tier`);
125
+ }
126
+ if (r >= primaryRank) {
127
+ throw new Error(
128
+ `agent '${agent}' fallback '${fb.id}' (tier=${fb.tier}) is not strictly lower than ` +
129
+ `primary (tier=${pol.primary.tier})`
130
+ );
131
+ }
132
+ }
133
+ }
134
+ }
135
+
136
+ function normalizeFallback(entry) {
137
+ if (typeof entry === "string") return { id: entry, tier: "low" };
138
+ if (entry && typeof entry === "object" && typeof entry.id === "string") {
139
+ return { id: entry.id, tier: entry.tier ?? "low" };
140
+ }
141
+ throw new Error(`invalid fallback_models entry: ${JSON.stringify(entry)}`);
142
+ }
143
+
144
+ /**
145
+ * Apply agents + model_policy overrides to a fresh copy of DEFAULT_POLICIES.
146
+ * Returns { policies, allowed } on success.
147
+ * Throws on validation failure (CALLER must decide whether to surface or roll back).
148
+ *
149
+ * This mirrors src/model-fallback-policy.ts:applyConfigOverrides except it
150
+ * returns a new structure instead of mutating module-level state — CLI tools
151
+ * need pure functions for "validate-without-applying" semantics.
152
+ */
153
+ export function buildPoliciesFromConfig({ agents, model_policy } = {}) {
154
+ const policies = clonePolicies(DEFAULT_POLICIES);
155
+ const allowed = new Set(DEFAULT_ALLOWED_PRIMARIES);
156
+
157
+ if (model_policy && Array.isArray(model_policy.allowed_primaries)) {
158
+ for (const id of model_policy.allowed_primaries) {
159
+ if (typeof id === "string" && id.length > 0) allowed.add(id);
160
+ }
161
+ }
162
+
163
+ if (agents && typeof agents === "object") {
164
+ for (const [agent, ov] of Object.entries(agents)) {
165
+ if (!ov || typeof ov !== "object" || !ov.model) continue;
166
+ const existing = policies[agent];
167
+ const tier = existing?.primary.tier ?? "medium";
168
+ const rawFb =
169
+ ov.fallback_models == null
170
+ ? (existing?.fallbacks.map(f => ({ id: f.id, tier: f.tier })) ?? [])
171
+ : Array.isArray(ov.fallback_models)
172
+ ? ov.fallback_models
173
+ : [ov.fallback_models];
174
+ policies[agent] = {
175
+ primary: { id: ov.model, variant: ov.variant ?? existing?.primary.variant, tier },
176
+ fallbacks: rawFb.map(normalizeFallback),
177
+ };
178
+ }
179
+ }
180
+
181
+ validatePolicies(policies, allowed);
182
+ return { policies, allowed };
183
+ }
@@ -0,0 +1,207 @@
1
+ #!/usr/bin/env bash
2
+ # publish-if-changed.sh - push 시점에 version 변경을 감지해 npm 공개 배포를 트리거
3
+ #
4
+ # 동작:
5
+ # 1. push 대상 커밋들 중 package.json version이 변경되었는지 확인
6
+ # 2. registry에 이미 해당 버전이 있으면 skip (release.sh 경유 push 재실행 대응)
7
+ # 3. 변경되었고 registry에 없으면 승인 게이트 2회 후 npm publish
8
+ #
9
+ # 호출 경로:
10
+ # - .husky/pre-push 에서 stdin 으로 <local_ref> <local_sha> <remote_ref> <remote_sha> 4개씩 수신
11
+ # - 직접 실행 시 HEAD~1..HEAD 범위로 검사
12
+ #
13
+ # 옵션:
14
+ # HOOK_STDIN=1 stdin에서 git push ref 정보 읽기 (husky hook)
15
+ # AUTO_YES=1 대화형 승인 스킵 (CI 전용)
16
+ # SKIP_PUBLISH=1 감지 로직만 실행, publish 스킵 (테스트)
17
+
18
+ set -euo pipefail
19
+
20
+ RED=$'\033[0;31m'
21
+ GREEN=$'\033[0;32m'
22
+ YELLOW=$'\033[0;33m'
23
+ BLUE=$'\033[0;34m'
24
+ CYAN=$'\033[0;36m'
25
+ BOLD=$'\033[1m'
26
+ RESET=$'\033[0m'
27
+
28
+ log() { printf "%s[auto-release]%s %s\n" "$CYAN" "$RESET" "$*"; }
29
+ info() { printf "%s[info]%s %s\n" "$BLUE" "$RESET" "$*"; }
30
+ ok() { printf "%s[ok]%s %s\n" "$GREEN" "$RESET" "$*"; }
31
+ warn() { printf "%s[warn]%s %s\n" "$YELLOW" "$RESET" "$*"; }
32
+ err() { printf "%s[error]%s %s\n" "$RED" "$RESET" "$*" >&2; }
33
+ prompt() { printf "%s[confirm]%s %s" "$BOLD" "$RESET" "$*"; }
34
+
35
+ AUTO_YES="${AUTO_YES:-0}"
36
+ HOOK_STDIN="${HOOK_STDIN:-0}"
37
+ SKIP_PUBLISH="${SKIP_PUBLISH:-0}"
38
+
39
+ confirm() {
40
+ local message="$1"
41
+ if [ "$AUTO_YES" = "1" ]; then
42
+ info "[AUTO_YES=1] 자동 승인: $message"
43
+ return 0
44
+ fi
45
+ if [ ! -t 0 ] && [ ! -e /dev/tty ]; then
46
+ err "TTY 없음 - 대화형 승인 불가. AUTO_YES=1 명시 필요."
47
+ return 1
48
+ fi
49
+ local reply
50
+ prompt "$message [y/N]: "
51
+ read -r reply </dev/tty
52
+ case "$reply" in
53
+ [yY]|[yY][eE][sS]) return 0 ;;
54
+ *) return 1 ;;
55
+ esac
56
+ }
57
+
58
+ SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
59
+ PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
60
+ cd "$PROJECT_ROOT"
61
+
62
+ # ---------- STEP 1: push 범위 파악 ----------
63
+ # husky pre-push는 stdin으로 4개 필드씩 여러 줄을 전달한다:
64
+ # <local_ref> <local_sha> <remote_ref> <remote_sha>
65
+ # 새 브랜치의 경우 remote_sha가 0000...0000 (40 zeros)
66
+ push_ranges=()
67
+ if [ "$HOOK_STDIN" = "1" ]; then
68
+ while IFS=' ' read -r local_ref local_sha remote_ref remote_sha; do
69
+ [ -z "$local_sha" ] && continue
70
+ [ "$local_sha" = "0000000000000000000000000000000000000000" ] && continue
71
+
72
+ if [ "$remote_sha" = "0000000000000000000000000000000000000000" ]; then
73
+ # 새 브랜치: 원격에 없는 모든 커밋
74
+ range="$(git rev-list "$local_sha" --not --remotes | tr '\n' ' ')"
75
+ [ -n "$range" ] && push_ranges+=("$remote_sha..$local_sha")
76
+ else
77
+ push_ranges+=("$remote_sha..$local_sha")
78
+ fi
79
+ done
80
+ else
81
+ push_ranges+=("HEAD~1..HEAD")
82
+ fi
83
+
84
+ if [ ${#push_ranges[@]} -eq 0 ]; then
85
+ info "push할 커밋 없음 - 릴리스 건너뜀"
86
+ exit 0
87
+ fi
88
+
89
+ # ---------- STEP 2: version 변경 감지 ----------
90
+ version_changed=0
91
+ new_version=""
92
+ old_version=""
93
+
94
+ for range in "${push_ranges[@]}"; do
95
+ # range 형식: <old_sha>..<new_sha>
96
+ from_sha="${range%..*}"
97
+ to_sha="${range#*..}"
98
+
99
+ if ! git diff --name-only "$from_sha" "$to_sha" 2>/dev/null | grep -q '^package.json$'; then
100
+ continue
101
+ fi
102
+
103
+ # package.json은 dependencies 변경으로도 diff에 잡히므로 version 필드만 별도 파싱하여 비교
104
+ from_ver="$(git show "$from_sha:package.json" 2>/dev/null | node -e "
105
+ let d=''; process.stdin.on('data', c=>d+=c).on('end', ()=>{
106
+ try { console.log(JSON.parse(d).version || ''); } catch(e) { console.log(''); }
107
+ });
108
+ " 2>/dev/null || echo "")"
109
+ to_ver="$(git show "$to_sha:package.json" 2>/dev/null | node -e "
110
+ let d=''; process.stdin.on('data', c=>d+=c).on('end', ()=>{
111
+ try { console.log(JSON.parse(d).version || ''); } catch(e) { console.log(''); }
112
+ });
113
+ " 2>/dev/null || echo "")"
114
+
115
+ if [ -n "$to_ver" ] && [ "$from_ver" != "$to_ver" ]; then
116
+ version_changed=1
117
+ new_version="$to_ver"
118
+ old_version="$from_ver"
119
+ break
120
+ fi
121
+ done
122
+
123
+ if [ "$version_changed" = "0" ]; then
124
+ info "version 변경 없음 - 릴리스 건너뜀"
125
+ exit 0
126
+ fi
127
+
128
+ log "=========================================="
129
+ log "버전 변경 감지: ${old_version:-<없음>} → $new_version"
130
+ log "=========================================="
131
+
132
+ # ---------- STEP 3: registry에 이미 배포되었는지 확인 ----------
133
+ # release.sh 로 이미 publish 후 push 하는 경우 중복 시도 방지
134
+ PACKAGE_NAME="$(node -p "require('./package.json').name")"
135
+ info "registry 확인 중: $PACKAGE_NAME@$new_version"
136
+
137
+ if npm view "${PACKAGE_NAME}@${new_version}" version >/dev/null 2>&1; then
138
+ ok "registry에 이미 $new_version 존재 - 재-publish 건너뜀"
139
+ info "(release.sh 로 이미 배포 후 push 하는 경우입니다)"
140
+ exit 0
141
+ fi
142
+
143
+ info "registry에 $new_version 없음 - 배포 진행 필요"
144
+
145
+ # ---------- STEP 4: 승인 게이트 #1 (버전 확인) ----------
146
+ log "=========================================="
147
+ log "승인 게이트 #1: 버전 확인"
148
+ log "=========================================="
149
+
150
+ info "이 push에는 다음 버전 변경이 포함되어 있습니다:"
151
+ info " ${old_version:-<없음>} → ${BOLD}${new_version}${RESET}"
152
+
153
+ if ! confirm "${BOLD}${YELLOW}이 버전을 공개 npm registry에 배포하시겠습니까?${RESET}"; then
154
+ err "사용자가 배포를 거부함 - push 차단"
155
+ err "이 버전을 배포하지 않으려면:"
156
+ err " 1) package.json 의 version을 이전 값으로 되돌린 커밋을 추가"
157
+ err " 2) 또는 git push --no-verify 로 hook 우회 (권장하지 않음)"
158
+ exit 1
159
+ fi
160
+
161
+ # ---------- STEP 5: dry-run 검증 ----------
162
+ log "=========================================="
163
+ log "npm publish --dry-run 검증"
164
+ log "=========================================="
165
+
166
+ if ! npm publish --dry-run 2>&1 | tail -30; then
167
+ err "Dry-run 실패 - 배포 중단, push 차단"
168
+ exit 1
169
+ fi
170
+ ok "Dry-run 통과"
171
+
172
+ # ---------- STEP 6: 승인 게이트 #2 (돌이킬 수 없는 배포) ----------
173
+ log "=========================================="
174
+ log "승인 게이트 #2: npm 공개 배포 확인"
175
+ log "=========================================="
176
+
177
+ warn "이제 공개 npm registry (registry.npmjs.org) 에 $new_version 을(를) 배포합니다."
178
+ warn "배포 후에는 동일 버전 재-publish 가 registry에 의해 거부됩니다."
179
+ warn "돌이킬 수 없는 작업입니다."
180
+
181
+ if ! confirm "${BOLD}${RED}정말 공개 npm registry에 $new_version 을(를) 배포하시겠습니까?${RESET}"; then
182
+ err "사용자가 최종 배포를 거부함 - push 차단"
183
+ err "다시 시도하려면 git push 를 재실행하세요."
184
+ exit 1
185
+ fi
186
+
187
+ # ---------- STEP 7: 실제 publish ----------
188
+ if [ "$SKIP_PUBLISH" = "1" ]; then
189
+ warn "[SKIP_PUBLISH=1] publish 건너뜀 (테스트 모드)"
190
+ exit 0
191
+ fi
192
+
193
+ log "=========================================="
194
+ log "npm publish 실행 → public npm registry"
195
+ log "=========================================="
196
+
197
+ if ! npm publish; then
198
+ err "npm publish 실패 - push 차단"
199
+ err "수동 확인 필요:"
200
+ err " - npm 로그인 상태 (npm whoami)"
201
+ err " - 동일 버전 중복 배포 여부"
202
+ err " - 문제 해결 후 git push 재실행"
203
+ exit 1
204
+ fi
205
+
206
+ ok "${BOLD}${GREEN}npm 배포 완료: ${PACKAGE_NAME}@${new_version}${RESET}"
207
+ info "push를 계속 진행합니다..."