@vibecodetown/mcp-server 2.1.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 (172) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +269 -0
  3. package/build/auth/gate.js +225 -0
  4. package/build/auth/index.js +55 -0
  5. package/build/auth/public_key.js +27 -0
  6. package/build/auth/token_cache.js +122 -0
  7. package/build/auth/token_verifier.js +103 -0
  8. package/build/bootstrap/doctor.js +115 -0
  9. package/build/bootstrap/installer.js +673 -0
  10. package/build/bootstrap/lock.js +37 -0
  11. package/build/bootstrap/platform.js +26 -0
  12. package/build/bootstrap/registry.js +37 -0
  13. package/build/cache/index.js +147 -0
  14. package/build/cli.js +101 -0
  15. package/build/contracts.js +22 -0
  16. package/build/control_plane/gate.js +161 -0
  17. package/build/control_plane/index.js +6 -0
  18. package/build/dx/activity.js +139 -0
  19. package/build/engine.js +106 -0
  20. package/build/errors.js +171 -0
  21. package/build/generated/activate_input.js +2 -0
  22. package/build/generated/activate_output.js +57 -0
  23. package/build/generated/advisory_review_input.js +2 -0
  24. package/build/generated/advisory_review_output.js +35 -0
  25. package/build/generated/auth_token_file.js +2 -0
  26. package/build/generated/briefing_input.js +2 -0
  27. package/build/generated/briefing_output.js +2 -0
  28. package/build/generated/clinic_bridge_file.js +13 -0
  29. package/build/generated/contracts_bundle_info.js +5 -0
  30. package/build/generated/create_work_order_input.js +2 -0
  31. package/build/generated/create_work_order_output.js +2 -0
  32. package/build/generated/current_work_order_file.js +2 -0
  33. package/build/generated/doctor_input.js +2 -0
  34. package/build/generated/doctor_output.js +24 -0
  35. package/build/generated/execution_result.js +2 -0
  36. package/build/generated/execution_task.js +2 -0
  37. package/build/generated/export_output_input.js +2 -0
  38. package/build/generated/export_output_output.js +2 -0
  39. package/build/generated/finalize_work_input.js +2 -0
  40. package/build/generated/finalize_work_output.js +2 -0
  41. package/build/generated/gate_input.js +2 -0
  42. package/build/generated/gate_output.js +2 -0
  43. package/build/generated/gate_result_v1.js +2 -0
  44. package/build/generated/get_decision_input.js +2 -0
  45. package/build/generated/get_decision_output.js +13 -0
  46. package/build/generated/handoff_to_clinic.js +2 -0
  47. package/build/generated/index.js +75 -0
  48. package/build/generated/inspect_code_input.js +2 -0
  49. package/build/generated/inspect_code_output.js +13 -0
  50. package/build/generated/memory_retrieve_output.js +2 -0
  51. package/build/generated/memory_state_file.js +2 -0
  52. package/build/generated/memory_status_input.js +2 -0
  53. package/build/generated/memory_status_output.js +13 -0
  54. package/build/generated/memory_sync_input.js +2 -0
  55. package/build/generated/memory_sync_output.js +13 -0
  56. package/build/generated/plugin_result.js +2 -0
  57. package/build/generated/react_perf_check_patterns_input.js +2 -0
  58. package/build/generated/react_perf_check_patterns_output.js +2 -0
  59. package/build/generated/react_perf_generate_report_input.js +2 -0
  60. package/build/generated/react_perf_generate_report_output.js +2 -0
  61. package/build/generated/repair_plan_input.js +2 -0
  62. package/build/generated/repair_plan_output.js +2 -0
  63. package/build/generated/run_app_input.js +2 -0
  64. package/build/generated/run_app_output.js +2 -0
  65. package/build/generated/run_state_file.js +13 -0
  66. package/build/generated/scaffold_input.js +2 -0
  67. package/build/generated/scaffold_output.js +2 -0
  68. package/build/generated/search_oss_input.js +2 -0
  69. package/build/generated/search_oss_output.js +2 -0
  70. package/build/generated/selection_validation_result.js +2 -0
  71. package/build/generated/signal_agent_input.js +2 -0
  72. package/build/generated/spec_high_ask_queue_items_file.js +2 -0
  73. package/build/generated/spec_high_clinic_bridge_output.js +2 -0
  74. package/build/generated/spec_high_decision_draft_output.js +2 -0
  75. package/build/generated/spec_high_validate_output.js +2 -0
  76. package/build/generated/status_input.js +2 -0
  77. package/build/generated/status_output.js +2 -0
  78. package/build/generated/submit_decision_input.js +2 -0
  79. package/build/generated/submit_decision_output.js +2 -0
  80. package/build/generated/tool_error_output.js +2 -0
  81. package/build/generated/undo_last_task_input.js +2 -0
  82. package/build/generated/undo_last_task_output.js +2 -0
  83. package/build/generated/update_input.js +2 -0
  84. package/build/generated/update_output.js +2 -0
  85. package/build/generated/vibe_pm_inspection_result.js +2 -0
  86. package/build/generated/vibe_pm_report_markdown.js +2 -0
  87. package/build/generated/vibe_pm_verdict.js +2 -0
  88. package/build/generated/vibe_repo_config.js +2 -0
  89. package/build/generated/vibecoding_helper_answer_output.js +2 -0
  90. package/build/generated/vibecoding_helper_one_loop_selection_output.js +2 -0
  91. package/build/generated/vibecoding_helper_show_ask_queue_output.js +2 -0
  92. package/build/generated/work_order_v1.js +2 -0
  93. package/build/generated/zoekt_evidence_input.js +2 -0
  94. package/build/generated/zoekt_evidence_output.js +2 -0
  95. package/build/index.js +111 -0
  96. package/build/legacy_alias.js +65 -0
  97. package/build/local-mode/bash.js +61 -0
  98. package/build/local-mode/config.js +171 -0
  99. package/build/local-mode/git.js +33 -0
  100. package/build/local-mode/init.js +110 -0
  101. package/build/local-mode/paths.js +24 -0
  102. package/build/local-mode/templates.js +856 -0
  103. package/build/local-mode/work-order.js +41 -0
  104. package/build/resources/index.js +246 -0
  105. package/build/security/input-validator.js +119 -0
  106. package/build/security/path-policy.js +289 -0
  107. package/build/security/sandbox.js +228 -0
  108. package/build/tools/react_perf/check_patterns.js +172 -0
  109. package/build/tools/react_perf/generate_report.js +337 -0
  110. package/build/tools/react_perf/index.js +119 -0
  111. package/build/tools/react_perf/rules/advanced.js +325 -0
  112. package/build/tools/react_perf/rules/async.js +104 -0
  113. package/build/tools/react_perf/rules/bundle.js +101 -0
  114. package/build/tools/react_perf/rules/client.js +186 -0
  115. package/build/tools/react_perf/rules/index.js +74 -0
  116. package/build/tools/react_perf/rules/js.js +148 -0
  117. package/build/tools/react_perf/rules/rendering.js +166 -0
  118. package/build/tools/react_perf/rules/rerender.js +161 -0
  119. package/build/tools/react_perf/rules/server.js +141 -0
  120. package/build/tools/react_perf/types.js +127 -0
  121. package/build/tools/vibe_pm/activate.js +102 -0
  122. package/build/tools/vibe_pm/advisory_review.js +77 -0
  123. package/build/tools/vibe_pm/briefing.js +178 -0
  124. package/build/tools/vibe_pm/context.js +439 -0
  125. package/build/tools/vibe_pm/create_work_order.js +271 -0
  126. package/build/tools/vibe_pm/doc_status_gate.js +370 -0
  127. package/build/tools/vibe_pm/doctor.js +262 -0
  128. package/build/tools/vibe_pm/entity_gate/preflight.js +78 -0
  129. package/build/tools/vibe_pm/export_output.js +135 -0
  130. package/build/tools/vibe_pm/finalize_work.js +393 -0
  131. package/build/tools/vibe_pm/gate.js +33 -0
  132. package/build/tools/vibe_pm/get_decision.js +281 -0
  133. package/build/tools/vibe_pm/index.js +593 -0
  134. package/build/tools/vibe_pm/inspect_code.js +828 -0
  135. package/build/tools/vibe_pm/intent/generator.js +294 -0
  136. package/build/tools/vibe_pm/intent/index.js +5 -0
  137. package/build/tools/vibe_pm/intent/prompt_density.js +227 -0
  138. package/build/tools/vibe_pm/intent/types.js +70 -0
  139. package/build/tools/vibe_pm/intent/verifier.js +237 -0
  140. package/build/tools/vibe_pm/kce/doc_usage.js +51 -0
  141. package/build/tools/vibe_pm/kce/on_finalize.js +11 -0
  142. package/build/tools/vibe_pm/kce/preflight.js +232 -0
  143. package/build/tools/vibe_pm/local_memory.js +26 -0
  144. package/build/tools/vibe_pm/memory_status.js +82 -0
  145. package/build/tools/vibe_pm/memory_sync.js +134 -0
  146. package/build/tools/vibe_pm/modules/decision_snapshot.js +29 -0
  147. package/build/tools/vibe_pm/modules/ensure.js +100 -0
  148. package/build/tools/vibe_pm/modules/fingerprint.js +30 -0
  149. package/build/tools/vibe_pm/modules/fix_dependencies.js +394 -0
  150. package/build/tools/vibe_pm/modules/planning_v1.js +110 -0
  151. package/build/tools/vibe_pm/modules/repo_context.js +56 -0
  152. package/build/tools/vibe_pm/modules/research_v1.js +114 -0
  153. package/build/tools/vibe_pm/modules/skills_v1.js +100 -0
  154. package/build/tools/vibe_pm/pm_language.js +222 -0
  155. package/build/tools/vibe_pm/repair_plan.js +199 -0
  156. package/build/tools/vibe_pm/run_app.js +597 -0
  157. package/build/tools/vibe_pm/run_app_podman.js +64 -0
  158. package/build/tools/vibe_pm/scaffold.js +550 -0
  159. package/build/tools/vibe_pm/search_oss.js +124 -0
  160. package/build/tools/vibe_pm/status.js +153 -0
  161. package/build/tools/vibe_pm/submit_decision.js +87 -0
  162. package/build/tools/vibe_pm/system_design/issue_mapping.js +47 -0
  163. package/build/tools/vibe_pm/system_design/rulebook.js +112 -0
  164. package/build/tools/vibe_pm/system_design/semgrep.js +132 -0
  165. package/build/tools/vibe_pm/types.js +229 -0
  166. package/build/tools/vibe_pm/undo_last_task.js +163 -0
  167. package/build/tools/vibe_pm/update.js +146 -0
  168. package/build/tools/vibe_pm/zoekt_evidence.js +96 -0
  169. package/build/tools.js +269 -0
  170. package/build/version-check.js +239 -0
  171. package/build/vibe-cli.js +631 -0
  172. package/package.json +76 -0
@@ -0,0 +1,271 @@
1
+ // adapters/mcp-ts/src/tools/vibe_pm/create_work_order.ts
2
+ // vibe_pm.create_work_order - Issue work order for implementation
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { parse as parseYaml } from "yaml";
6
+ import { runEngine, invalidateEngineCache } from "../../engine.js";
7
+ import { safeJsonParse } from "../../cli.js";
8
+ import { resolveRunId, resolveProjectId, getRunContext, readRunState, invalidateStateCache, getRunsDir } from "./context.js";
9
+ import { generateWorkOrderText, transformAskQueueText } from "./pm_language.js";
10
+ import { validateToolInput } from "../../security/input-validator.js";
11
+ import { checkGate, formatViolations } from "../../control_plane/gate.js";
12
+ import { ensureModulesForWorkOrder, readFileIfExists } from "./modules/ensure.js";
13
+ import { ClinicBridgeFileSchema } from "../../generated/clinic_bridge_file.js";
14
+ import { loadPromptDensity } from "./intent/prompt_density.js";
15
+ import { runKceRetrieve, runKceStatus } from "./kce/preflight.js";
16
+ import { updateKceDocUsage } from "./kce/doc_usage.js";
17
+ /**
18
+ * vibe_pm.create_work_order - Issue work order for implementation
19
+ *
20
+ * Internal mapping:
21
+ * → spec-high clinic-bridge <run_id> --write --format yaml
22
+ * → paste_to_agent text generation
23
+ */
24
+ export async function createWorkOrder(input) {
25
+ const basePath = process.cwd();
26
+ // Security: Validate input first
27
+ validateToolInput({
28
+ project_id: input.project_id,
29
+ additional_instructions: input.additional_instructions,
30
+ });
31
+ // Resolve run_id
32
+ const { run_id } = resolveRunId(input.project_id, basePath);
33
+ const project_id = resolveProjectId(run_id, basePath);
34
+ const context = getRunContext(run_id, basePath);
35
+ const runState = readRunState(run_id, basePath);
36
+ const promptDensityMode = loadPromptDensity(getRunsDir(basePath), run_id)?.mode ?? "mode_0";
37
+ // Generate clinic bridge
38
+ const bridgeResult = await runEngine("spec-high", ["--root", "engines/spec_high", "clinic-bridge", run_id, "--write", "--format", "yaml"], { timeoutMs: 120_000 });
39
+ if (bridgeResult.code !== 0) {
40
+ throw new Error(`작업 지시서 생성 실패: ${bridgeResult.stderr || "알 수 없는 오류"}`);
41
+ }
42
+ // Generate intent documents from resolved decision cards
43
+ try {
44
+ await runEngine("spec-high", ["--root", "engines/spec_high", "intent-generate", run_id, "--write", "--force"], { timeoutMs: 60_000 });
45
+ }
46
+ catch {
47
+ // Intent generation is optional - continue if it fails
48
+ }
49
+ // Invalidate caches after state mutation
50
+ invalidateEngineCache(new RegExp(run_id));
51
+ invalidateStateCache(run_id, basePath);
52
+ // Get bridge path from response or use default
53
+ let bridgePath = `engines/spec_high/runs/${run_id}/handoff/clinic_bridge.yaml`;
54
+ const bridgeParsed = safeJsonParse(bridgeResult.stdout);
55
+ if (bridgeParsed.ok && bridgeParsed.value.bridge_path) {
56
+ bridgePath = bridgeParsed.value.bridge_path;
57
+ }
58
+ // Read and parse bridge file
59
+ const fullBridgePath = path.isAbsolute(bridgePath)
60
+ ? bridgePath
61
+ : path.join(basePath, bridgePath);
62
+ let bridgeData = {};
63
+ try {
64
+ if (fs.existsSync(fullBridgePath)) {
65
+ const content = fs.readFileSync(fullBridgePath, "utf-8");
66
+ bridgeData = fullBridgePath.endsWith(".json") ? JSON.parse(content) : parseYaml(content);
67
+ const validated = ClinicBridgeFileSchema.safeParse(bridgeData);
68
+ if (!validated.success) {
69
+ throw new Error("작업 지시서 원본 파일 형식이 예상과 다릅니다. 업데이트 후 다시 시도해주세요.");
70
+ }
71
+ bridgeData = validated.data;
72
+ }
73
+ }
74
+ catch (e) {
75
+ // Fail closed (contract drift): do not issue a work order from an unknown/unsafe file shape.
76
+ throw e instanceof Error
77
+ ? e
78
+ : new Error("작업 지시서 원본 파일을 읽을 수 없습니다. 업데이트 후 다시 시도해주세요.");
79
+ }
80
+ // Extract data from bridge
81
+ const projectName = project_id;
82
+ const mode = runState?.mode ?? "balanced";
83
+ const headline = "프로젝트 구현";
84
+ const dg = bridgeData?.decision_guard;
85
+ const legacyScope = bridgeData?.scope;
86
+ const scopeInclude = Array.isArray(dg?.allow_paths)
87
+ ? dg.allow_paths
88
+ : Array.isArray(legacyScope?.include)
89
+ ? legacyScope.include
90
+ : [];
91
+ const scopeExclude = Array.isArray(dg?.deny_paths)
92
+ ? dg.deny_paths
93
+ : Array.isArray(legacyScope?.exclude)
94
+ ? legacyScope.exclude
95
+ : [];
96
+ const doNotTouch = Array.isArray(dg?.read_only_paths)
97
+ ? dg.read_only_paths
98
+ : Array.isArray(bridgeData?.do_not_touch)
99
+ ? bridgeData.do_not_touch
100
+ : [];
101
+ const verifyCriteria = Array.isArray(bridgeData?.verification?.required)
102
+ ? bridgeData.verification.required
103
+ .map((c) => (typeof c?.cmd === "string" ? c.cmd.trim() : ""))
104
+ .filter(Boolean)
105
+ : Array.isArray(bridgeData?.verify_criteria)
106
+ ? bridgeData.verify_criteria.filter((s) => typeof s === "string" && s.trim())
107
+ : [];
108
+ // Generate paste_to_agent text
109
+ let pasteToAgent = generateWorkOrderText({
110
+ projectName,
111
+ mode,
112
+ headline,
113
+ includes: scopeInclude,
114
+ excludes: scopeExclude,
115
+ doNotTouch,
116
+ verifyCriteria
117
+ });
118
+ if (input.additional_instructions && input.additional_instructions.trim()) {
119
+ pasteToAgent += `\n\n[추가 지시]\n- ${input.additional_instructions.trim()}\n`;
120
+ }
121
+ // Ensure versioned modules (Research/Skills/Planning) exist and link them to output
122
+ const modules = ensureModulesForWorkOrder({ basePath, runId: run_id });
123
+ // Append SKILLS_PACK / PLAN_STEPS into paste_to_agent (still user-friendly, no engine terms)
124
+ const skillsPack = readFileIfExists(modules.skill_bundle_prompt_path);
125
+ if (skillsPack) {
126
+ pasteToAgent += `\n\n[SKILLS_PACK]\n${skillsPack.trim()}\n`;
127
+ }
128
+ const planRaw = readFileIfExists(modules.planning.path);
129
+ if (planRaw) {
130
+ const planSummary = formatPlanSteps(planRaw);
131
+ if (planSummary) {
132
+ pasteToAgent += `\n\n[PLAN_STEPS]\n${planSummary.trim()}\n`;
133
+ }
134
+ }
135
+ // Attach KCE references (best-effort; unless prompt density explicitly disables it)
136
+ if (promptDensityMode !== "mode_2") {
137
+ const query = (input.additional_instructions ?? "").trim() ||
138
+ `${projectName}\n${headline}\n${verifyCriteria.slice(0, 3).join("\n")}`.trim();
139
+ try {
140
+ // Free policy: only inject when KCE is READY. Never auto-heal (no sync) from core tools.
141
+ const status = await runKceStatus();
142
+ if (status.readiness_state !== "READY") {
143
+ throw new Error("KCE_NOT_READY");
144
+ }
145
+ const out = await runKceRetrieve({ query, maxItems: 3, limit: 10, maxTotalChars: 2000, maxChunkChars: 800 });
146
+ const refsRaw = out.context_block.trim();
147
+ if (refsRaw) {
148
+ const sanitized = transformAskQueueText(refsRaw);
149
+ const clipped = sanitized.length > 2000 ? `${sanitized.slice(0, 2000).trimEnd()}…` : sanitized;
150
+ pasteToAgent += `\n\n[참고 문헌]\n${clipped}\n`;
151
+ }
152
+ // Evidence (best-effort): record what was injected
153
+ try {
154
+ const kceDir = path.join(context.runs_path, "kce");
155
+ fs.mkdirSync(kceDir, { recursive: true });
156
+ fs.writeFileSync(path.join(kceDir, "kb_query_evidence.json"), JSON.stringify({
157
+ version: "kce_query_evidence.v1",
158
+ created_at: new Date().toISOString(),
159
+ query,
160
+ hits: out.hits,
161
+ kce_status: status
162
+ }, null, 2) + "\n", "utf-8");
163
+ }
164
+ catch {
165
+ // ignore
166
+ }
167
+ // Evidence (best-effort): record doc usage for deterministic recency signals.
168
+ try {
169
+ const injectedDocs = (out.hits ?? [])
170
+ .map((h) => (typeof h.path === "string" ? h.path : ""))
171
+ .map((p) => p.replace(/\\\\/g, "/"))
172
+ .filter((p) => p.endsWith(".md") || p.endsWith(".mdx"));
173
+ const referencedEntities = injectedDocs
174
+ .filter((p) => p.startsWith("entities/") && p.endsWith(".entity.json"))
175
+ .map((p) => path.basename(p, ".entity.json"))
176
+ .filter(Boolean);
177
+ updateKceDocUsage({ basePath, run_id, injected_docs: injectedDocs, referenced_entities: referencedEntities });
178
+ }
179
+ catch {
180
+ // ignore
181
+ }
182
+ }
183
+ catch {
184
+ // Best-effort: never block work order issuance when local memory is unavailable.
185
+ }
186
+ }
187
+ // Build work order
188
+ const workOrder = {
189
+ headline,
190
+ scope: {
191
+ include: scopeInclude,
192
+ exclude: scopeExclude
193
+ },
194
+ do_not_touch: doNotTouch,
195
+ verify_criteria: verifyCriteria,
196
+ paste_to_agent: pasteToAgent
197
+ };
198
+ // Run System Design Gate check
199
+ // Convert to WorkOrderV1 format for gate validation
200
+ const workOrderV1 = {
201
+ intent: `${headline}: ${pasteToAgent.slice(0, 100)}...`,
202
+ scope: {
203
+ include: scopeInclude.length > 0 ? scopeInclude : ["src/**/*"],
204
+ exclude: scopeExclude
205
+ },
206
+ tool_style: "EXEC",
207
+ idempotency_key: `${run_id}-${Date.now()}`
208
+ };
209
+ try {
210
+ const gateResult = await checkGate(workOrderV1, { repoRoot: basePath });
211
+ if (!gateResult.allowed && gateResult.result) {
212
+ const violations = formatViolations(gateResult.result);
213
+ throw new Error(`작업 지시서가 Gate 검사를 통과하지 못했습니다:\n${violations}`);
214
+ }
215
+ if (gateResult.error) {
216
+ // Log but don't block - gate check failure shouldn't prevent work order issuance
217
+ console.warn(`Gate check warning: ${gateResult.error}`);
218
+ }
219
+ }
220
+ catch (gateError) {
221
+ // If gate throws (not just returns blocked), log warning but continue
222
+ // This ensures backwards compatibility when Python CLI is not available
223
+ if (gateError instanceof Error && gateError.message.includes("Gate 검사")) {
224
+ throw gateError; // Re-throw actual gate violations
225
+ }
226
+ console.warn(`Gate check skipped: ${gateError instanceof Error ? gateError.message : String(gateError)}`);
227
+ }
228
+ return {
229
+ project_id,
230
+ run_id,
231
+ work_order: workOrder,
232
+ paths: {
233
+ bridge_path: bridgePath,
234
+ intent_dir: `engines/spec_high/runs/${run_id}/intent`
235
+ },
236
+ modules: {
237
+ research: modules.research,
238
+ skills: modules.skills,
239
+ planning: modules.planning
240
+ },
241
+ next_action: {
242
+ tool: "vibe_pm.inspect_code",
243
+ reason: "구현 후 검수를 요청하세요",
244
+ when: "after_implementation"
245
+ }
246
+ };
247
+ }
248
+ function formatPlanSteps(planJsonText) {
249
+ try {
250
+ const doc = JSON.parse(planJsonText);
251
+ const steps = doc.plan ?? [];
252
+ if (!Array.isArray(steps) || steps.length === 0)
253
+ return null;
254
+ const lines = [];
255
+ for (const s of steps) {
256
+ const n = s.step ?? 0;
257
+ const title = (s.title ?? "").trim();
258
+ const v = Array.isArray(s.verification) ? s.verification.filter(Boolean) : [];
259
+ if (title) {
260
+ lines.push(`${n}. ${title}`);
261
+ }
262
+ if (v.length > 0) {
263
+ lines.push(` - 확인: ${v[0]}`);
264
+ }
265
+ }
266
+ return lines.join("\n");
267
+ }
268
+ catch {
269
+ return null;
270
+ }
271
+ }
@@ -0,0 +1,370 @@
1
+ // adapters/mcp-ts/src/tools/vibe_pm/doc_status_gate.ts
2
+ // Internal: policy-based doc status tagging + safe archiving (no delete).
3
+ import * as fs from "node:fs";
4
+ import * as path from "node:path";
5
+ import { minimatch } from "minimatch";
6
+ function readJsonIfExists(p) {
7
+ try {
8
+ if (!fs.existsSync(p))
9
+ return null;
10
+ return JSON.parse(fs.readFileSync(p, "utf-8"));
11
+ }
12
+ catch {
13
+ return null;
14
+ }
15
+ }
16
+ function defaultPolicy() {
17
+ return {
18
+ version: "doc_status_policy.v1",
19
+ scope: { include_globs: ["docs/**/*.md", "docs/**/*.mdx"], exclude_globs: ["docs/archive/**"] },
20
+ signals: {
21
+ entity_link: { enabled: true, weight: 50, link_markers: ["<!-- ENTITY:", "entities/", "entity_id:"] },
22
+ evidence_link: { enabled: true, weight: 25, evidence_markers: ["runs/", "fixtures/", "schemas/"] },
23
+ recent_usage: { enabled: true, lookback_days: 30, weight: 25 }
24
+ },
25
+ classification: {
26
+ ACTIVE: { min_score: 70, required_any: ["entity_link", "evidence_link"] },
27
+ DORMANT: { min_score: 35, required_any: ["entity_link", "evidence_link"] },
28
+ ORPHAN: { max_score: 34, required_any: [], required_none: ["entity_link"] }
29
+ },
30
+ actions: {
31
+ tag_header: {
32
+ enabled: true,
33
+ format: "<!-- DOC_STATUS: {status} | score={score} | reason={reason} | last_seen={last_seen} -->"
34
+ },
35
+ archive: {
36
+ enabled: false,
37
+ only_status: ["DORMANT", "ORPHAN"],
38
+ grace_days: 14,
39
+ archive_root: "docs/archive",
40
+ archive_prefix: "{yyyy}-{mm}-{dd}__",
41
+ write_tombstone: true,
42
+ tombstone_mode: "overwrite_source",
43
+ tombstone_content: "# Moved\n\nThis document has been archived to: [{archive_path}]({archive_relative_path})\n\nReason: {reason}\nDate: {yyyy}-{mm}-{dd}\n"
44
+ }
45
+ }
46
+ };
47
+ }
48
+ function nowParts(now) {
49
+ const yyyy = String(now.getFullYear()).padStart(4, "0");
50
+ const mm = String(now.getMonth() + 1).padStart(2, "0");
51
+ const dd = String(now.getDate()).padStart(2, "0");
52
+ return { yyyy, mm, dd };
53
+ }
54
+ function renderTemplate(tpl, vars) {
55
+ let out = tpl;
56
+ for (const [k, v] of Object.entries(vars)) {
57
+ out = out.split(`{${k}}`).join(v);
58
+ }
59
+ return out;
60
+ }
61
+ function prependArchiveObsoleteMarker(body) {
62
+ const marker = "<!-- OBSOLETE: This document is archived and no longer authoritative -->\n" +
63
+ "<!-- Reference: docs/SSOT.md is the ultimate authority -->\n\n" +
64
+ "> **ARCHIVED** - 이 문서는 더 이상 기준이 아닙니다.\n" +
65
+ "> 기준: docs/SSOT.md\n\n" +
66
+ "---\n\n";
67
+ return marker + body;
68
+ }
69
+ function ensureArchiveMarker(absPath) {
70
+ const raw = fs.readFileSync(absPath, "utf-8");
71
+ const head = raw.split("\n").slice(0, 12).join("\n");
72
+ const hasObsolete = /OBSOLETE/i.test(head);
73
+ const hasRef = head.includes("docs/SSOT.md");
74
+ if (hasObsolete && hasRef)
75
+ return;
76
+ fs.writeFileSync(absPath, prependArchiveObsoleteMarker(raw), "utf-8");
77
+ }
78
+ function upsertStatusHeader(absPath, headerLine) {
79
+ const raw = fs.readFileSync(absPath, "utf-8");
80
+ const lines = raw.split("\n");
81
+ const first = lines[0] ?? "";
82
+ const isExisting = first.trim().startsWith("<!-- DOC_STATUS:");
83
+ const next = isExisting ? [headerLine, ...lines.slice(1)] : [headerLine, ...lines];
84
+ const out = next.join("\n");
85
+ if (out === raw)
86
+ return { changed: false, existed: isExisting };
87
+ fs.writeFileSync(absPath, out, "utf-8");
88
+ return { changed: true, existed: isExisting };
89
+ }
90
+ function collectDocsUnder(basePath) {
91
+ const docsRoot = path.join(basePath, "docs");
92
+ if (!fs.existsSync(docsRoot))
93
+ return [];
94
+ const out = [];
95
+ const stack = [docsRoot];
96
+ while (stack.length > 0) {
97
+ const cur = stack.pop();
98
+ let entries = [];
99
+ try {
100
+ entries = fs.readdirSync(cur, { withFileTypes: true });
101
+ }
102
+ catch {
103
+ continue;
104
+ }
105
+ for (const e of entries) {
106
+ const abs = path.join(cur, e.name);
107
+ if (e.isDirectory()) {
108
+ // Hard safety: skip common heavy dirs even if policy is wrong.
109
+ if (e.name === "node_modules" || e.name === ".git" || e.name === "target" || e.name === ".vibe")
110
+ continue;
111
+ stack.push(abs);
112
+ continue;
113
+ }
114
+ if (!e.isFile())
115
+ continue;
116
+ if (!abs.toLowerCase().endsWith(".md") && !abs.toLowerCase().endsWith(".mdx"))
117
+ continue;
118
+ out.push(abs);
119
+ }
120
+ }
121
+ return out;
122
+ }
123
+ function matchesAny(relPosix, globs) {
124
+ for (const g of globs) {
125
+ if (minimatch(relPosix, g, { dot: true }))
126
+ return true;
127
+ }
128
+ return false;
129
+ }
130
+ function readHeadBlock(absPath, maxLines) {
131
+ try {
132
+ const raw = fs.readFileSync(absPath, "utf-8");
133
+ return raw.split("\n").slice(0, maxLines).join("\n");
134
+ }
135
+ catch {
136
+ return "";
137
+ }
138
+ }
139
+ function addRunDirsFromRoot(runsRoot, out) {
140
+ try {
141
+ if (!fs.existsSync(runsRoot))
142
+ return;
143
+ const entries = fs.readdirSync(runsRoot, { withFileTypes: true });
144
+ for (const e of entries) {
145
+ if (!e.isDirectory())
146
+ continue;
147
+ const abs = path.join(runsRoot, e.name);
148
+ if (e.name === "examples" || e.name === "fixtures") {
149
+ addRunDirsFromRoot(abs, out);
150
+ continue;
151
+ }
152
+ out.push(abs);
153
+ }
154
+ }
155
+ catch {
156
+ // ignore
157
+ }
158
+ }
159
+ function collectRecentUsedDocs(basePath, lookbackDays, now) {
160
+ const out = new Set();
161
+ const lookbackMs = Math.max(0, lookbackDays) * 24 * 60 * 60 * 1000;
162
+ const cutoff = now.getTime() - lookbackMs;
163
+ const runDirs = [];
164
+ addRunDirsFromRoot(path.join(basePath, "runs"), runDirs);
165
+ addRunDirsFromRoot(path.join(basePath, "engines", "spec_high", "runs"), runDirs);
166
+ for (const runDir of runDirs) {
167
+ const p = path.join(runDir, "kce", "kce_doc_usage.json");
168
+ try {
169
+ if (!fs.existsSync(p))
170
+ continue;
171
+ const st = fs.statSync(p);
172
+ if (st.mtime.getTime() < cutoff)
173
+ continue;
174
+ const doc = readJsonIfExists(p);
175
+ const touched = Array.isArray(doc?.touched_docs) ? doc.touched_docs : [];
176
+ const injected = Array.isArray(doc?.injected_docs) ? doc.injected_docs : [];
177
+ for (const v of [...touched, ...injected]) {
178
+ if (typeof v !== "string")
179
+ continue;
180
+ const rel = v.replace(/\\\\/g, "/").trim();
181
+ if (rel)
182
+ out.add(rel);
183
+ }
184
+ }
185
+ catch {
186
+ // ignore
187
+ }
188
+ }
189
+ return out;
190
+ }
191
+ function classifyDoc(args) {
192
+ const { policy, absPath, relPosix, now, touchedNow, recentUsedDocs } = args;
193
+ const head = readHeadBlock(absPath, 80);
194
+ const signals = {
195
+ entity_link: false,
196
+ evidence_link: false,
197
+ recent_usage: false
198
+ };
199
+ if (policy.signals.entity_link.enabled) {
200
+ const markers = policy.signals.entity_link.link_markers ?? [];
201
+ signals.entity_link = markers.some((m) => m && head.includes(m));
202
+ }
203
+ if (policy.signals.evidence_link.enabled) {
204
+ const markers = policy.signals.evidence_link.evidence_markers ?? [];
205
+ signals.evidence_link = markers.some((m) => m && head.includes(m));
206
+ }
207
+ if (policy.signals.recent_usage.enabled) {
208
+ const lookbackMs = policy.signals.recent_usage.lookback_days * 24 * 60 * 60 * 1000;
209
+ let recent = false;
210
+ if (touchedNow.has(relPosix)) {
211
+ recent = true;
212
+ }
213
+ else if (recentUsedDocs.has(relPosix)) {
214
+ recent = true;
215
+ }
216
+ else {
217
+ try {
218
+ const mtime = fs.statSync(absPath).mtime.getTime();
219
+ recent = now.getTime() - mtime <= lookbackMs;
220
+ }
221
+ catch {
222
+ recent = false;
223
+ }
224
+ }
225
+ signals.recent_usage = recent;
226
+ }
227
+ const score = (signals.entity_link ? policy.signals.entity_link.weight : 0) +
228
+ (signals.evidence_link ? policy.signals.evidence_link.weight : 0) +
229
+ (signals.recent_usage ? policy.signals.recent_usage.weight : 0);
230
+ const active = policy.classification.ACTIVE;
231
+ const dormant = policy.classification.DORMANT;
232
+ const orphan = policy.classification.ORPHAN;
233
+ const any = (keys) => keys.length === 0 || keys.some((k) => signals[k] === true);
234
+ const none = (keys) => keys.length === 0 || keys.every((k) => signals[k] !== true);
235
+ let status = "ORPHAN";
236
+ if (score >= active.min_score && any(active.required_any))
237
+ status = "ACTIVE";
238
+ else if (score >= dormant.min_score && any(dormant.required_any))
239
+ status = "DORMANT";
240
+ else if (orphan.required_none && !none(orphan.required_none))
241
+ status = "DORMANT";
242
+ else
243
+ status = "ORPHAN";
244
+ const reason = Object.entries(signals)
245
+ .filter(([, v]) => v)
246
+ .map(([k]) => k)
247
+ .join(",");
248
+ const lastSeen = touchedNow.has(relPosix) || recentUsedDocs.has(relPosix) ? now.toISOString() : "";
249
+ return { status, score, reason: reason || "none", lastSeen };
250
+ }
251
+ export function runDocStatusTriage(args) {
252
+ const now = args.now ?? new Date();
253
+ const policyPath = path.join(args.basePath, "schemas", "doc_status_policy.v1.json");
254
+ const loaded = readJsonIfExists(policyPath);
255
+ const policy = loaded ?? defaultPolicy();
256
+ const include = policy.scope.include_globs ?? ["docs/**/*.md"];
257
+ const exclude = policy.scope.exclude_globs ?? [];
258
+ const touchedNow = new Set((args.touched_paths ?? []).map((p) => p.replace(/\\\\/g, "/")));
259
+ const recentUsedDocs = policy.signals.recent_usage.enabled
260
+ ? collectRecentUsedDocs(args.basePath, policy.signals.recent_usage.lookback_days, now)
261
+ : new Set();
262
+ const tagged = [];
263
+ const archived = [];
264
+ const tombstoned = [];
265
+ const hadStatusTagBefore = new Set();
266
+ let active = 0;
267
+ let dormant = 0;
268
+ let orphan = 0;
269
+ const candidates = collectDocsUnder(args.basePath);
270
+ for (const abs of candidates) {
271
+ const rel = path.relative(args.basePath, abs).replace(/\\\\/g, "/");
272
+ if (!matchesAny(rel, include))
273
+ continue;
274
+ if (matchesAny(rel, exclude))
275
+ continue;
276
+ const { status, score, reason, lastSeen } = classifyDoc({
277
+ relPosix: rel,
278
+ absPath: abs,
279
+ policy,
280
+ now,
281
+ touchedNow,
282
+ recentUsedDocs
283
+ });
284
+ if (status === "ACTIVE")
285
+ active += 1;
286
+ else if (status === "DORMANT")
287
+ dormant += 1;
288
+ else
289
+ orphan += 1;
290
+ if (policy.actions.tag_header.enabled) {
291
+ const header = renderTemplate(policy.actions.tag_header.format, {
292
+ status,
293
+ score: String(score),
294
+ reason,
295
+ last_seen: lastSeen || "-"
296
+ });
297
+ try {
298
+ const res = upsertStatusHeader(abs, header);
299
+ if (res.existed)
300
+ hadStatusTagBefore.add(rel);
301
+ if (res.changed)
302
+ tagged.push(rel);
303
+ }
304
+ catch {
305
+ // ignore
306
+ }
307
+ }
308
+ // Archive is intentionally conservative; v1 uses grace_days only with mtime heuristic.
309
+ if (policy.actions.archive.enabled && policy.actions.archive.only_status.includes(status)) {
310
+ // Safety: do not archive a doc the same run it gets tagged the first time.
311
+ // This prevents mass moves on first enable and keeps the pipeline idempotent.
312
+ if (!hadStatusTagBefore.has(rel))
313
+ continue;
314
+ const graceMs = policy.actions.archive.grace_days * 24 * 60 * 60 * 1000;
315
+ let eligible = false;
316
+ try {
317
+ const mtime = fs.statSync(abs).mtime.getTime();
318
+ eligible = now.getTime() - mtime >= graceMs;
319
+ }
320
+ catch {
321
+ eligible = false;
322
+ }
323
+ if (!eligible)
324
+ continue;
325
+ const { yyyy, mm, dd } = nowParts(now);
326
+ const archiveRootAbs = path.join(args.basePath, policy.actions.archive.archive_root);
327
+ const prefix = renderTemplate(policy.actions.archive.archive_prefix, { yyyy, mm, dd });
328
+ const fileName = path.basename(abs);
329
+ const archiveName = `${prefix}${fileName}`;
330
+ const archiveAbs = path.join(archiveRootAbs, archiveName);
331
+ try {
332
+ fs.mkdirSync(path.dirname(archiveAbs), { recursive: true });
333
+ fs.renameSync(abs, archiveAbs);
334
+ ensureArchiveMarker(archiveAbs);
335
+ archived.push(path.relative(args.basePath, archiveAbs).replace(/\\\\/g, "/"));
336
+ if (policy.actions.archive.write_tombstone) {
337
+ const relArchiveFromRepo = path.relative(args.basePath, archiveAbs).replace(/\\\\/g, "/");
338
+ const relFromSourceDir = path
339
+ .relative(path.dirname(abs), archiveAbs)
340
+ .replace(/\\\\/g, "/");
341
+ const tombTpl = policy.actions.archive.tombstone_content ??
342
+ policy.actions.archive.tombstone_format ??
343
+ "# Moved\n\nThis document has been archived.\n";
344
+ const tomb = renderTemplate(tombTpl, {
345
+ yyyy,
346
+ mm,
347
+ dd,
348
+ status,
349
+ reason,
350
+ path: rel,
351
+ archive_path: relArchiveFromRepo,
352
+ archive_relative_path: relFromSourceDir
353
+ });
354
+ fs.mkdirSync(path.dirname(abs), { recursive: true });
355
+ fs.writeFileSync(abs, tomb + "\n", "utf-8");
356
+ tombstoned.push(rel);
357
+ }
358
+ }
359
+ catch {
360
+ // ignore
361
+ }
362
+ }
363
+ }
364
+ return {
365
+ tagged,
366
+ archived,
367
+ tombstoned,
368
+ summary: { active, dormant, orphan }
369
+ };
370
+ }