ultra-builder-pro-cli 0.5.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/.ultra-template/delivery-report.json +9 -0
- package/.ultra-template/docs/research/README.md +53 -0
- package/.ultra-template/specs/architecture.md +250 -0
- package/.ultra-template/specs/discovery.md +248 -0
- package/.ultra-template/specs/product.md +223 -0
- package/.ultra-template/tasks/contexts/TEMPLATE.md +57 -0
- package/.ultra-template/tasks/tasks.json +33 -0
- package/.ultra-template/test-report.json +8 -0
- package/CHANGELOG.md +194 -0
- package/LICENSE +21 -0
- package/README.md +187 -0
- package/adapters/_shared/codex-assets.cjs +1128 -0
- package/adapters/_shared/codex-ultra-tools-entry.cjs +11 -0
- package/adapters/_shared/file-ops.cjs +115 -0
- package/adapters/_shared/frontmatter.cjs +52 -0
- package/adapters/_shared/handbook.cjs +144 -0
- package/adapters/_shared/md-to-toml.cjs +54 -0
- package/adapters/_shared/path-rewrite.cjs +79 -0
- package/adapters/_shared/rtk-detect.cjs +86 -0
- package/adapters/_shared/runtime-assets.cjs +70 -0
- package/adapters/_shared/settings-merge.cjs +76 -0
- package/adapters/_shared/tests/file-ops.test.cjs +54 -0
- package/adapters/_shared/tests/frontmatter.test.cjs +47 -0
- package/adapters/_shared/tests/handbook.test.cjs +86 -0
- package/adapters/_shared/tests/md-to-toml.test.cjs +48 -0
- package/adapters/_shared/tests/path-rewrite.test.cjs +60 -0
- package/adapters/_shared/tests/resolve-target.test.cjs +85 -0
- package/adapters/_shared/tests/rtk-detect.test.cjs +116 -0
- package/adapters/_shared/tests/runtime-assets.test.cjs +107 -0
- package/adapters/_shared/tests/settings-merge.test.cjs +85 -0
- package/adapters/_shared/tests/validate.test.cjs +30 -0
- package/adapters/_shared/validate.cjs +24 -0
- package/adapters/claude.js +182 -0
- package/adapters/codex.js +385 -0
- package/adapters/gemini.js +204 -0
- package/adapters/opencode.js +506 -0
- package/adapters/tests/claude.test.cjs +117 -0
- package/adapters/tests/codex-hook.test.cjs +90 -0
- package/adapters/tests/codex.test.cjs +356 -0
- package/adapters/tests/gemini.test.cjs +112 -0
- package/adapters/tests/opencode.test.cjs +246 -0
- package/agents/code-reviewer.md +102 -0
- package/agents/debugger.md +127 -0
- package/agents/review-code.md +125 -0
- package/agents/review-comments.md +98 -0
- package/agents/review-coordinator.md +146 -0
- package/agents/review-design.md +133 -0
- package/agents/review-errors.md +87 -0
- package/agents/review-tests.md +93 -0
- package/agents/tdd-runner.md +92 -0
- package/bin/handbook.js +54 -0
- package/bin/install.js +254 -0
- package/bin/orchestrator.js +214 -0
- package/commands/learn.md +41 -0
- package/commands/ultra-deliver.md +45 -0
- package/commands/ultra-dev.md +50 -0
- package/commands/ultra-init.md +51 -0
- package/commands/ultra-plan.md +49 -0
- package/commands/ultra-research.md +59 -0
- package/commands/ultra-status.md +46 -0
- package/commands/ultra-test.md +48 -0
- package/commands/ultra-think.md +45 -0
- package/docs/AGENT-CONTEXT.md +143 -0
- package/docs/ARCHITECTURE.md +197 -0
- package/docs/COMMIT-HASH-BACKFILL.md +101 -0
- package/docs/LEGACY-HERMES.md +1175 -0
- package/docs/PLAN.zh-CN.md +1683 -0
- package/docs/ROADMAP.md +102 -0
- package/docs/RUNTIME-COMPAT-MATRIX.md +98 -0
- package/docs/STATE-DB-ACCESS-POLICY.md +109 -0
- package/docs/USER-HANDBOOK-CONTRACT.md +61 -0
- package/hooks/README.md +24 -0
- package/hooks/active_task_context.py +44 -0
- package/hooks/adapters/codex.py +302 -0
- package/hooks/health_check.py +61 -0
- package/hooks/pre_stop_check.py +56 -0
- package/hooks/subagent_tracker.py +116 -0
- package/hooks/workflow_checkpoint.py +56 -0
- package/hooks/workflow_context.py +63 -0
- package/hooks/workflow_resume.py +50 -0
- package/mcp-server/lib/init-project.cjs +162 -0
- package/mcp-server/lib/llm-client.cjs +157 -0
- package/mcp-server/lib/llm-client.test.cjs +156 -0
- package/mcp-server/lib/plan-store.cjs +96 -0
- package/mcp-server/lib/plan-store.test.cjs +132 -0
- package/mcp-server/lib/prd-parser.cjs +110 -0
- package/mcp-server/lib/prd-parser.test.cjs +171 -0
- package/mcp-server/lib/pricing.cjs +53 -0
- package/mcp-server/lib/projector.cjs +158 -0
- package/mcp-server/lib/state-db.cjs +113 -0
- package/mcp-server/lib/state-db.test.cjs +116 -0
- package/mcp-server/lib/state-ops.cjs +725 -0
- package/mcp-server/lib/task-expander.cjs +123 -0
- package/mcp-server/lib/task-expander.test.cjs +253 -0
- package/mcp-server/lib/telemetry.cjs +72 -0
- package/mcp-server/lib/topo.cjs +131 -0
- package/mcp-server/lib/topo.test.cjs +158 -0
- package/mcp-server/server.cjs +482 -0
- package/mcp-server/tests/circuit-breaker.test.cjs +125 -0
- package/mcp-server/tests/commit-hash-flow.test.cjs +117 -0
- package/mcp-server/tests/concurrency-update.test.cjs +96 -0
- package/mcp-server/tests/concurrency.test.cjs +112 -0
- package/mcp-server/tests/db-ops.test.cjs +117 -0
- package/mcp-server/tests/init-project.test.cjs +141 -0
- package/mcp-server/tests/migrate.test.cjs +144 -0
- package/mcp-server/tests/projector.test.cjs +130 -0
- package/mcp-server/tests/server-contract.test.cjs +750 -0
- package/mcp-server/tests/staleness.test.cjs +190 -0
- package/mcp-server/tests/state-ops.test.cjs +286 -0
- package/mcp-server/tests/tagged-tasks.test.cjs +163 -0
- package/mcp-server/tests/telemetry.test.cjs +205 -0
- package/orchestrator/auto-merge.cjs +109 -0
- package/orchestrator/code-graph-watcher.cjs +121 -0
- package/orchestrator/daemon.cjs +112 -0
- package/orchestrator/dispatch-rules.cjs +93 -0
- package/orchestrator/parallel-orchestrator.cjs +195 -0
- package/orchestrator/planner/expand-prompt.cjs +62 -0
- package/orchestrator/planner/expand-prompt.test.cjs +52 -0
- package/orchestrator/planner/plan-builder.cjs +116 -0
- package/orchestrator/planner/plan-builder.test.cjs +165 -0
- package/orchestrator/planner/prd-prompt.cjs +44 -0
- package/orchestrator/planner/prd-prompt.test.cjs +30 -0
- package/orchestrator/recovery.cjs +70 -0
- package/orchestrator/session-runner.cjs +224 -0
- package/orchestrator/skill-miner.cjs +133 -0
- package/orchestrator/tests/auto-merge.test.cjs +216 -0
- package/orchestrator/tests/code-graph-watcher.test.cjs +183 -0
- package/orchestrator/tests/daemon.test.cjs +260 -0
- package/orchestrator/tests/dispatch-rules.test.cjs +122 -0
- package/orchestrator/tests/parallel-orchestrator.test.cjs +233 -0
- package/orchestrator/tests/phase-8b-gate.test.cjs +183 -0
- package/orchestrator/tests/recovery.test.cjs +190 -0
- package/orchestrator/tests/session-runner.test.cjs +314 -0
- package/orchestrator/tests/skill-miner.test.cjs +147 -0
- package/orchestrator/tests/worktree-manager.test.cjs +148 -0
- package/orchestrator/worktree-manager.cjs +131 -0
- package/output-styles/coding-vibes.md +67 -0
- package/output-styles/structural-thinking.md +61 -0
- package/package.json +109 -0
- package/settings.json +8 -0
- package/skills/cc-collab/SKILL.md +41 -0
- package/skills/code-review-expert/SKILL.md +118 -0
- package/skills/code-review-expert/references/code-quality-checklist.md +112 -0
- package/skills/code-review-expert/references/integration-checklist.md +46 -0
- package/skills/code-review-expert/references/removal-plan.md +52 -0
- package/skills/code-review-expert/references/security-checklist.md +100 -0
- package/skills/code-review-expert/references/solid-checklist.md +65 -0
- package/skills/codex-collab/SKILL.md +52 -0
- package/skills/codex-collab/evals/evals.json +40 -0
- package/skills/gemini-collab/SKILL.md +51 -0
- package/skills/gemini-collab/evals/evals.json +42 -0
- package/skills/integration-rules/SKILL.md +71 -0
- package/skills/learn/SKILL.md +117 -0
- package/skills/security-rules/SKILL.md +60 -0
- package/skills/testing-rules/SKILL.md +56 -0
- package/skills/ultra-deliver/SKILL.md +171 -0
- package/skills/ultra-dev/SKILL.md +324 -0
- package/skills/ultra-init/SKILL.md +148 -0
- package/skills/ultra-plan/SKILL.md +314 -0
- package/skills/ultra-research/SKILL.md +156 -0
- package/skills/ultra-research/steps/step-00-problem-validation.md +154 -0
- package/skills/ultra-research/steps/step-01-opportunity-discovery.md +172 -0
- package/skills/ultra-research/steps/step-02-market-assessment.md +195 -0
- package/skills/ultra-research/steps/step-03-competitive-landscape.md +197 -0
- package/skills/ultra-research/steps/step-04-product-strategy.md +196 -0
- package/skills/ultra-research/steps/step-05-assumptions-validation.md +234 -0
- package/skills/ultra-research/steps/step-10-user-personas.md +169 -0
- package/skills/ultra-research/steps/step-11-user-scenarios.md +181 -0
- package/skills/ultra-research/steps/step-20-user-stories.md +156 -0
- package/skills/ultra-research/steps/step-21-features-scope.md +157 -0
- package/skills/ultra-research/steps/step-22-success-metrics.md +195 -0
- package/skills/ultra-research/steps/step-30-architecture-context.md +154 -0
- package/skills/ultra-research/steps/step-31-solution-strategy.md +172 -0
- package/skills/ultra-research/steps/step-32-building-blocks.md +188 -0
- package/skills/ultra-research/steps/step-40-deployment.md +123 -0
- package/skills/ultra-research/steps/step-41-quality-risks.md +135 -0
- package/skills/ultra-research/steps/step-99-synthesis.md +188 -0
- package/skills/ultra-review/SKILL.md +437 -0
- package/skills/ultra-review/references/unified-schema.md +127 -0
- package/skills/ultra-review/scripts/review_verdict_update.py +99 -0
- package/skills/ultra-review/scripts/review_wait.py +120 -0
- package/skills/ultra-status/SKILL.md +162 -0
- package/skills/ultra-test/SKILL.md +195 -0
- package/skills/ultra-think/SKILL.md +109 -0
- package/skills/ultra-verify/SKILL.md +111 -0
- package/skills/ultra-verify/evals/evals.json +51 -0
- package/skills/ultra-verify/references/confidence-system.md +38 -0
- package/skills/ultra-verify/references/cross-verify-modes.md +73 -0
- package/skills/ultra-verify/scripts/verify_wait.py +131 -0
- package/spec/cli-protocol.md +118 -0
- package/spec/mcp-tools.yaml +658 -0
- package/spec/schemas/command-manifest.schema.json +47 -0
- package/spec/schemas/context-file.v4.5.schema.json +54 -0
- package/spec/schemas/mcp-tools.schema.json +136 -0
- package/spec/schemas/skill-manifest.schema.json +94 -0
- package/spec/schemas/state-db.sql +154 -0
- package/spec/schemas/tasks.v4.5.schema.json +66 -0
- package/ultra-tools/cli.cjs +121 -0
- package/ultra-tools/commands/db.cjs +175 -0
- package/ultra-tools/commands/legacy-memory.cjs +159 -0
- package/ultra-tools/commands/migrate.cjs +354 -0
- package/ultra-tools/commands/session.cjs +180 -0
- package/ultra-tools/commands/status.cjs +158 -0
- package/ultra-tools/commands/task.cjs +95 -0
- package/ultra-tools/legacy-memory.test.cjs +80 -0
- package/ultra-tools/status-command.test.cjs +132 -0
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Restore only the current Ultra workflow boundary after compaction."""
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
TERMINAL = {"committed", "completed", "done", "cancelled"}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def main() -> None:
|
|
12
|
+
try:
|
|
13
|
+
data = json.loads(sys.stdin.read() or "{}")
|
|
14
|
+
data = data if isinstance(data, dict) else {}
|
|
15
|
+
except json.JSONDecodeError as exc:
|
|
16
|
+
print(f"[workflow_resume] invalid hook input: {exc}", file=sys.stderr)
|
|
17
|
+
data = {}
|
|
18
|
+
start = Path(data.get("cwd") or Path.cwd()).resolve()
|
|
19
|
+
for root in (start, *start.parents):
|
|
20
|
+
state_file = root / ".ultra" / "workflow-state.json"
|
|
21
|
+
if not state_file.is_file():
|
|
22
|
+
continue
|
|
23
|
+
try:
|
|
24
|
+
state = json.loads(state_file.read_text(encoding="utf-8"))
|
|
25
|
+
except (OSError, json.JSONDecodeError) as exc:
|
|
26
|
+
print(f"[workflow_resume] cannot read {state_file}: {exc}", file=sys.stderr)
|
|
27
|
+
break
|
|
28
|
+
if not isinstance(state, dict) or state.get("status") in TERMINAL:
|
|
29
|
+
break
|
|
30
|
+
text = (
|
|
31
|
+
"[Ultra workflow resumed] "
|
|
32
|
+
f"command={state.get('command', 'unknown')}; "
|
|
33
|
+
f"task={state.get('task_id', state.get('task', 'unknown'))}; "
|
|
34
|
+
f"step={state.get('step', 'unknown')}; status={state.get('status', 'active')}. "
|
|
35
|
+
"Continue from .ultra/workflow-state.json and verify state through .ultra/state.db."
|
|
36
|
+
)
|
|
37
|
+
event = data.get("hook_event_name", "SessionStart")
|
|
38
|
+
if event == "SessionStart":
|
|
39
|
+
print(json.dumps({"hookSpecificOutput": {
|
|
40
|
+
"hookEventName": "SessionStart",
|
|
41
|
+
"additionalContext": text,
|
|
42
|
+
}}))
|
|
43
|
+
else:
|
|
44
|
+
print(json.dumps({"additionalContext": text}))
|
|
45
|
+
return
|
|
46
|
+
print(json.dumps({}))
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
if __name__ == "__main__":
|
|
50
|
+
main()
|
|
@@ -0,0 +1,162 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Implementation for MCP tool `task.init_project` and the CLI fallback
|
|
4
|
+
// `ultra-tools init`. Copies the bundled templates/.ultra/ skeleton to the
|
|
5
|
+
// target project root, injecting project metadata into tasks.json.
|
|
6
|
+
//
|
|
7
|
+
// Error contract matches spec/mcp-tools.yaml#task.init_project:
|
|
8
|
+
// ULTRA_DIR_EXISTS | TEMPLATE_MISSING | TARGET_NOT_DIR | IO_ERROR
|
|
9
|
+
//
|
|
10
|
+
// Not a pure function — touches the filesystem. Kept in lib/ for parity with
|
|
11
|
+
// state-ops/projector modules; Phase 4+ may move cross-tool helpers to
|
|
12
|
+
// mcp-server/tools/ if the catalogue grows.
|
|
13
|
+
|
|
14
|
+
const fs = require('node:fs');
|
|
15
|
+
const path = require('node:path');
|
|
16
|
+
|
|
17
|
+
const REPO_ROOT = process.env.UBP_RUNTIME_ROOT
|
|
18
|
+
? path.resolve(process.env.UBP_RUNTIME_ROOT)
|
|
19
|
+
: path.resolve(__dirname, '..', '..');
|
|
20
|
+
const DEFAULT_TEMPLATE = path.join(REPO_ROOT, 'templates', '.ultra');
|
|
21
|
+
|
|
22
|
+
class InitProjectError extends Error {
|
|
23
|
+
constructor(code, message, { retriable = false, cause } = {}) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'InitProjectError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
this.retriable = retriable;
|
|
28
|
+
if (cause) this.cause = cause;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function resolveTargetDir(target) {
|
|
33
|
+
if (!target) throw new InitProjectError('TARGET_NOT_DIR', 'target_dir must be a non-empty string');
|
|
34
|
+
return path.isAbsolute(target) ? target : path.resolve(process.cwd(), target);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function ensureTargetDir(absTarget) {
|
|
38
|
+
if (!fs.existsSync(absTarget)) {
|
|
39
|
+
try { fs.mkdirSync(absTarget, { recursive: true }); }
|
|
40
|
+
catch (err) { throw new InitProjectError('IO_ERROR', `cannot create target_dir: ${err.message}`, { retriable: true, cause: err }); }
|
|
41
|
+
return;
|
|
42
|
+
}
|
|
43
|
+
const stat = fs.statSync(absTarget);
|
|
44
|
+
if (!stat.isDirectory()) {
|
|
45
|
+
throw new InitProjectError('TARGET_NOT_DIR', `target_dir exists but is not a directory: ${absTarget}`);
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function ensureTemplate(sourceOverride) {
|
|
50
|
+
const source = sourceOverride ? path.resolve(sourceOverride) : DEFAULT_TEMPLATE;
|
|
51
|
+
if (!fs.existsSync(source) || !fs.statSync(source).isDirectory()) {
|
|
52
|
+
throw new InitProjectError('TEMPLATE_MISSING', `source_template not found: ${source}`);
|
|
53
|
+
}
|
|
54
|
+
return source;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function timestampSlug(date = new Date()) {
|
|
58
|
+
const pad = (n) => String(n).padStart(2, '0');
|
|
59
|
+
return [
|
|
60
|
+
date.getFullYear(),
|
|
61
|
+
pad(date.getMonth() + 1),
|
|
62
|
+
pad(date.getDate()),
|
|
63
|
+
].join('') + '-' + [
|
|
64
|
+
pad(date.getHours()),
|
|
65
|
+
pad(date.getMinutes()),
|
|
66
|
+
pad(date.getSeconds()),
|
|
67
|
+
].join('');
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function walkRelative(root) {
|
|
71
|
+
const out = [];
|
|
72
|
+
function walk(dir) {
|
|
73
|
+
for (const entry of fs.readdirSync(dir, { withFileTypes: true })) {
|
|
74
|
+
if (entry.name === '.DS_Store') continue;
|
|
75
|
+
const full = path.join(dir, entry.name);
|
|
76
|
+
const rel = path.relative(root, full);
|
|
77
|
+
if (entry.isDirectory()) walk(full);
|
|
78
|
+
else if (entry.isFile()) out.push(rel);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
walk(root);
|
|
82
|
+
return out.sort();
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function copyTemplate(source, dest) {
|
|
86
|
+
const files = walkRelative(source);
|
|
87
|
+
for (const rel of files) {
|
|
88
|
+
const from = path.join(source, rel);
|
|
89
|
+
const to = path.join(dest, rel);
|
|
90
|
+
fs.mkdirSync(path.dirname(to), { recursive: true });
|
|
91
|
+
fs.copyFileSync(from, to);
|
|
92
|
+
}
|
|
93
|
+
return files;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
function injectTasksJson(ultraDir, { project_name, project_type, stack }) {
|
|
97
|
+
const tasksFile = path.join(ultraDir, 'tasks', 'tasks.json');
|
|
98
|
+
if (!fs.existsSync(tasksFile)) return;
|
|
99
|
+
let data;
|
|
100
|
+
try { data = JSON.parse(fs.readFileSync(tasksFile, 'utf8')); }
|
|
101
|
+
catch (err) { throw new InitProjectError('IO_ERROR', `tasks.json malformed: ${err.message}`, { cause: err }); }
|
|
102
|
+
const nowIso = new Date().toISOString();
|
|
103
|
+
data.created = nowIso;
|
|
104
|
+
data.updated = nowIso;
|
|
105
|
+
data.project = data.project || {};
|
|
106
|
+
data.project.name = project_name;
|
|
107
|
+
if (project_type) data.project.type = project_type;
|
|
108
|
+
if (stack) data.project.stack = stack;
|
|
109
|
+
fs.writeFileSync(tasksFile, JSON.stringify(data, null, 2) + '\n');
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
function initProject({
|
|
113
|
+
target_dir,
|
|
114
|
+
project_name,
|
|
115
|
+
project_type,
|
|
116
|
+
stack,
|
|
117
|
+
overwrite = false,
|
|
118
|
+
source_template,
|
|
119
|
+
} = {}) {
|
|
120
|
+
if (typeof project_name !== 'string' || project_name.length === 0) {
|
|
121
|
+
throw new InitProjectError('VALIDATION_ERROR', 'project_name must be non-empty');
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const absTarget = resolveTargetDir(target_dir);
|
|
125
|
+
ensureTargetDir(absTarget);
|
|
126
|
+
const source = ensureTemplate(source_template);
|
|
127
|
+
const ultraDir = path.join(absTarget, '.ultra');
|
|
128
|
+
|
|
129
|
+
let status = 'created';
|
|
130
|
+
let backupPath;
|
|
131
|
+
if (fs.existsSync(ultraDir)) {
|
|
132
|
+
if (!overwrite) {
|
|
133
|
+
throw new InitProjectError('ULTRA_DIR_EXISTS', `.ultra/ already exists at ${ultraDir}; pass overwrite=true to back up and recreate`);
|
|
134
|
+
}
|
|
135
|
+
backupPath = path.join(absTarget, `.ultra.backup.${timestampSlug()}`);
|
|
136
|
+
try { fs.renameSync(ultraDir, backupPath); }
|
|
137
|
+
catch (err) { throw new InitProjectError('IO_ERROR', `backup rename failed: ${err.message}`, { retriable: true, cause: err }); }
|
|
138
|
+
status = 'overwritten';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
try { fs.mkdirSync(ultraDir, { recursive: true }); }
|
|
142
|
+
catch (err) { throw new InitProjectError('IO_ERROR', `cannot create .ultra/: ${err.message}`, { retriable: true, cause: err }); }
|
|
143
|
+
|
|
144
|
+
let copiedFiles;
|
|
145
|
+
try {
|
|
146
|
+
copiedFiles = copyTemplate(source, ultraDir);
|
|
147
|
+
} catch (err) {
|
|
148
|
+
throw new InitProjectError('IO_ERROR', `template copy failed: ${err.message}`, { retriable: true, cause: err });
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
injectTasksJson(ultraDir, { project_name, project_type, stack });
|
|
152
|
+
|
|
153
|
+
const result = {
|
|
154
|
+
created_path: ultraDir,
|
|
155
|
+
status,
|
|
156
|
+
copied_files: copiedFiles,
|
|
157
|
+
};
|
|
158
|
+
if (backupPath) result.backup_path = backupPath;
|
|
159
|
+
return result;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
module.exports = { initProject, InitProjectError, DEFAULT_TEMPLATE };
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 8A.1 — Unified LLM client over @anthropic-ai/sdk and openai.
|
|
4
|
+
//
|
|
5
|
+
// Provider is inferred from model prefix (claude/opus/sonnet/haiku → Anthropic,
|
|
6
|
+
// gpt/o1/o3 → OpenAI). API keys come from env. The single surface the rest of
|
|
7
|
+
// the code depends on is `client.completeJson({ system, user, maxTokens })`,
|
|
8
|
+
// which returns `{ json, raw, usage, model, provider }` — the model's reply
|
|
9
|
+
// already parsed as JSON (caller handles schema validation on `.json`).
|
|
10
|
+
|
|
11
|
+
const Anthropic = require('@anthropic-ai/sdk');
|
|
12
|
+
const OpenAI = require('openai');
|
|
13
|
+
|
|
14
|
+
const PROVIDER_RULES = Object.freeze([
|
|
15
|
+
{ pattern: /^(claude|opus|sonnet|haiku)/i, provider: 'anthropic' },
|
|
16
|
+
{ pattern: /^(gpt|o1|o3)/i, provider: 'openai' },
|
|
17
|
+
]);
|
|
18
|
+
|
|
19
|
+
const DEFAULT_MODEL = 'claude-sonnet-4-6';
|
|
20
|
+
const DEFAULT_MAX_TOKENS = 4096;
|
|
21
|
+
|
|
22
|
+
class LlmClientError extends Error {
|
|
23
|
+
constructor(code, message, cause) {
|
|
24
|
+
super(message);
|
|
25
|
+
this.name = 'LlmClientError';
|
|
26
|
+
this.code = code;
|
|
27
|
+
if (cause) this.cause = cause;
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function inferProvider(model) {
|
|
32
|
+
if (!model || typeof model !== 'string') return null;
|
|
33
|
+
for (const rule of PROVIDER_RULES) {
|
|
34
|
+
if (rule.pattern.test(model)) return rule.provider;
|
|
35
|
+
}
|
|
36
|
+
return null;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function resolveProviderKey(provider, apiKey) {
|
|
40
|
+
if (apiKey) return apiKey;
|
|
41
|
+
return provider === 'anthropic' ? process.env.ANTHROPIC_API_KEY : process.env.OPENAI_API_KEY;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
function createLlmClient({ model, apiKey } = {}) {
|
|
45
|
+
const resolvedModel = model || process.env.UBP_PLANNER_MODEL || DEFAULT_MODEL;
|
|
46
|
+
const provider = inferProvider(resolvedModel);
|
|
47
|
+
if (!provider) {
|
|
48
|
+
throw new LlmClientError(
|
|
49
|
+
'LLM_UNSUPPORTED_MODEL',
|
|
50
|
+
`cannot infer provider from model "${resolvedModel}"`,
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
const key = resolveProviderKey(provider, apiKey);
|
|
54
|
+
if (!key) {
|
|
55
|
+
const envName = provider === 'anthropic' ? 'ANTHROPIC_API_KEY' : 'OPENAI_API_KEY';
|
|
56
|
+
throw new LlmClientError('NO_LLM_CREDENTIALS', `${envName} not set`);
|
|
57
|
+
}
|
|
58
|
+
return provider === 'anthropic'
|
|
59
|
+
? buildAnthropicAdapter({ model: resolvedModel, apiKey: key })
|
|
60
|
+
: buildOpenAiAdapter({ model: resolvedModel, apiKey: key });
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function buildAnthropicAdapter({ model, apiKey }) {
|
|
64
|
+
const sdk = new Anthropic({ apiKey });
|
|
65
|
+
return {
|
|
66
|
+
provider: 'anthropic',
|
|
67
|
+
model,
|
|
68
|
+
async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
69
|
+
let message;
|
|
70
|
+
try {
|
|
71
|
+
message = await sdk.messages.create({
|
|
72
|
+
model,
|
|
73
|
+
max_tokens: maxTokens,
|
|
74
|
+
system,
|
|
75
|
+
messages: [{ role: 'user', content: user }],
|
|
76
|
+
});
|
|
77
|
+
} catch (err) {
|
|
78
|
+
throw new LlmClientError('LLM_API_ERROR', `Anthropic API error: ${err.message}`, err);
|
|
79
|
+
}
|
|
80
|
+
const text = extractAnthropicText(message.content);
|
|
81
|
+
const usage = {
|
|
82
|
+
input_tokens: message.usage?.input_tokens ?? 0,
|
|
83
|
+
output_tokens: message.usage?.output_tokens ?? 0,
|
|
84
|
+
};
|
|
85
|
+
return parseCompletionJson({ raw: text, usage, model, provider: 'anthropic' });
|
|
86
|
+
},
|
|
87
|
+
};
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function buildOpenAiAdapter({ model, apiKey }) {
|
|
91
|
+
const sdk = new OpenAI({ apiKey });
|
|
92
|
+
return {
|
|
93
|
+
provider: 'openai',
|
|
94
|
+
model,
|
|
95
|
+
async completeJson({ system, user, maxTokens = DEFAULT_MAX_TOKENS }) {
|
|
96
|
+
let completion;
|
|
97
|
+
try {
|
|
98
|
+
completion = await sdk.chat.completions.create({
|
|
99
|
+
model,
|
|
100
|
+
max_tokens: maxTokens,
|
|
101
|
+
response_format: { type: 'json_object' },
|
|
102
|
+
messages: [
|
|
103
|
+
{ role: 'system', content: system },
|
|
104
|
+
{ role: 'user', content: user },
|
|
105
|
+
],
|
|
106
|
+
});
|
|
107
|
+
} catch (err) {
|
|
108
|
+
throw new LlmClientError('LLM_API_ERROR', `OpenAI API error: ${err.message}`, err);
|
|
109
|
+
}
|
|
110
|
+
const text = completion.choices?.[0]?.message?.content ?? '';
|
|
111
|
+
const usage = {
|
|
112
|
+
input_tokens: completion.usage?.prompt_tokens ?? 0,
|
|
113
|
+
output_tokens: completion.usage?.completion_tokens ?? 0,
|
|
114
|
+
};
|
|
115
|
+
return parseCompletionJson({ raw: text, usage, model, provider: 'openai' });
|
|
116
|
+
},
|
|
117
|
+
};
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function extractAnthropicText(content) {
|
|
121
|
+
if (!Array.isArray(content)) return '';
|
|
122
|
+
return content.filter((b) => b && b.type === 'text').map((b) => b.text || '').join('');
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function parseCompletionJson({ raw, usage, model, provider }) {
|
|
126
|
+
const trimmed = stripCodeFence(raw).trim();
|
|
127
|
+
if (!trimmed) {
|
|
128
|
+
throw new LlmClientError('LLM_INVALID_JSON', 'LLM returned empty content');
|
|
129
|
+
}
|
|
130
|
+
let json;
|
|
131
|
+
try {
|
|
132
|
+
json = JSON.parse(trimmed);
|
|
133
|
+
} catch (err) {
|
|
134
|
+
throw new LlmClientError(
|
|
135
|
+
'LLM_INVALID_JSON',
|
|
136
|
+
`failed to parse LLM response as JSON: ${err.message}`,
|
|
137
|
+
err,
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
return { json, raw, usage, model, provider };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function stripCodeFence(s) {
|
|
144
|
+
if (!s || typeof s !== 'string') return '';
|
|
145
|
+
const fenced = s.match(/^```(?:json)?\s*\n?([\s\S]*?)\n?```\s*$/);
|
|
146
|
+
return fenced ? fenced[1] : s;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
module.exports = {
|
|
150
|
+
createLlmClient,
|
|
151
|
+
inferProvider,
|
|
152
|
+
LlmClientError,
|
|
153
|
+
parseCompletionJson,
|
|
154
|
+
stripCodeFence,
|
|
155
|
+
DEFAULT_MODEL,
|
|
156
|
+
DEFAULT_MAX_TOKENS,
|
|
157
|
+
};
|
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const test = require('node:test');
|
|
4
|
+
const assert = require('node:assert/strict');
|
|
5
|
+
|
|
6
|
+
const {
|
|
7
|
+
createLlmClient,
|
|
8
|
+
inferProvider,
|
|
9
|
+
LlmClientError,
|
|
10
|
+
parseCompletionJson,
|
|
11
|
+
stripCodeFence,
|
|
12
|
+
DEFAULT_MODEL,
|
|
13
|
+
} = require('./llm-client.cjs');
|
|
14
|
+
|
|
15
|
+
test('inferProvider: claude family → anthropic', () => {
|
|
16
|
+
assert.equal(inferProvider('claude-sonnet-4-6'), 'anthropic');
|
|
17
|
+
assert.equal(inferProvider('claude-opus-4-7'), 'anthropic');
|
|
18
|
+
assert.equal(inferProvider('sonnet-legacy'), 'anthropic');
|
|
19
|
+
assert.equal(inferProvider('opus-anything'), 'anthropic');
|
|
20
|
+
assert.equal(inferProvider('haiku-4-5'), 'anthropic');
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test('inferProvider: GPT / o1 / o3 → openai', () => {
|
|
24
|
+
assert.equal(inferProvider('gpt-4o'), 'openai');
|
|
25
|
+
assert.equal(inferProvider('gpt-5.4'), 'openai');
|
|
26
|
+
assert.equal(inferProvider('o1-preview'), 'openai');
|
|
27
|
+
assert.equal(inferProvider('o3-mini'), 'openai');
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('inferProvider: unknown prefix → null', () => {
|
|
31
|
+
assert.equal(inferProvider('llama-3'), null);
|
|
32
|
+
assert.equal(inferProvider(''), null);
|
|
33
|
+
assert.equal(inferProvider(null), null);
|
|
34
|
+
assert.equal(inferProvider(undefined), null);
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
test('stripCodeFence: removes ```json ... ``` fence', () => {
|
|
38
|
+
const input = '```json\n{"a":1}\n```';
|
|
39
|
+
assert.equal(stripCodeFence(input).trim(), '{"a":1}');
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
test('stripCodeFence: removes plain ``` ... ``` fence', () => {
|
|
43
|
+
const input = '```\n{"a":1}\n```';
|
|
44
|
+
assert.equal(stripCodeFence(input).trim(), '{"a":1}');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
test('stripCodeFence: leaves unfenced content alone', () => {
|
|
48
|
+
assert.equal(stripCodeFence('{"a":1}'), '{"a":1}');
|
|
49
|
+
assert.equal(stripCodeFence(' '), ' ');
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
test('stripCodeFence: empty / nullish → empty string', () => {
|
|
53
|
+
assert.equal(stripCodeFence(''), '');
|
|
54
|
+
assert.equal(stripCodeFence(null), '');
|
|
55
|
+
assert.equal(stripCodeFence(undefined), '');
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('parseCompletionJson: valid JSON → parsed object', () => {
|
|
59
|
+
const r = parseCompletionJson({
|
|
60
|
+
raw: '{"ok":true,"n":2}',
|
|
61
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
62
|
+
model: 'claude-sonnet-4-6',
|
|
63
|
+
provider: 'anthropic',
|
|
64
|
+
});
|
|
65
|
+
assert.deepEqual(r.json, { ok: true, n: 2 });
|
|
66
|
+
assert.equal(r.usage.input_tokens, 10);
|
|
67
|
+
assert.equal(r.model, 'claude-sonnet-4-6');
|
|
68
|
+
assert.equal(r.provider, 'anthropic');
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
test('parseCompletionJson: fenced JSON → parsed object', () => {
|
|
72
|
+
const r = parseCompletionJson({
|
|
73
|
+
raw: '```json\n{"x":42}\n```',
|
|
74
|
+
usage: {},
|
|
75
|
+
model: 'gpt-5',
|
|
76
|
+
provider: 'openai',
|
|
77
|
+
});
|
|
78
|
+
assert.deepEqual(r.json, { x: 42 });
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
test('parseCompletionJson: empty content → LLM_INVALID_JSON', () => {
|
|
82
|
+
assert.throws(
|
|
83
|
+
() => parseCompletionJson({ raw: '', usage: {}, model: 'x', provider: 'anthropic' }),
|
|
84
|
+
(err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
|
|
85
|
+
);
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
test('parseCompletionJson: malformed JSON → LLM_INVALID_JSON', () => {
|
|
89
|
+
assert.throws(
|
|
90
|
+
() => parseCompletionJson({ raw: '{not json', usage: {}, model: 'x', provider: 'anthropic' }),
|
|
91
|
+
(err) => err instanceof LlmClientError && err.code === 'LLM_INVALID_JSON',
|
|
92
|
+
);
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
test('createLlmClient: unknown model → LLM_UNSUPPORTED_MODEL', () => {
|
|
96
|
+
assert.throws(
|
|
97
|
+
() => createLlmClient({ model: 'llama-3', apiKey: 'sk-test' }),
|
|
98
|
+
(err) => err instanceof LlmClientError && err.code === 'LLM_UNSUPPORTED_MODEL',
|
|
99
|
+
);
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
test('createLlmClient: missing key → NO_LLM_CREDENTIALS', () => {
|
|
103
|
+
const savedA = process.env.ANTHROPIC_API_KEY;
|
|
104
|
+
const savedO = process.env.OPENAI_API_KEY;
|
|
105
|
+
delete process.env.ANTHROPIC_API_KEY;
|
|
106
|
+
delete process.env.OPENAI_API_KEY;
|
|
107
|
+
try {
|
|
108
|
+
assert.throws(
|
|
109
|
+
() => createLlmClient({ model: 'claude-sonnet-4-6' }),
|
|
110
|
+
(err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
|
|
111
|
+
);
|
|
112
|
+
assert.throws(
|
|
113
|
+
() => createLlmClient({ model: 'gpt-5' }),
|
|
114
|
+
(err) => err instanceof LlmClientError && err.code === 'NO_LLM_CREDENTIALS',
|
|
115
|
+
);
|
|
116
|
+
} finally {
|
|
117
|
+
if (savedA !== undefined) process.env.ANTHROPIC_API_KEY = savedA;
|
|
118
|
+
if (savedO !== undefined) process.env.OPENAI_API_KEY = savedO;
|
|
119
|
+
}
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('createLlmClient: explicit apiKey bypasses env check + returns adapter shape', () => {
|
|
123
|
+
const anthropicClient = createLlmClient({ model: 'claude-sonnet-4-6', apiKey: 'sk-test' });
|
|
124
|
+
assert.equal(anthropicClient.provider, 'anthropic');
|
|
125
|
+
assert.equal(anthropicClient.model, 'claude-sonnet-4-6');
|
|
126
|
+
assert.equal(typeof anthropicClient.completeJson, 'function');
|
|
127
|
+
|
|
128
|
+
const openaiClient = createLlmClient({ model: 'gpt-5', apiKey: 'sk-test' });
|
|
129
|
+
assert.equal(openaiClient.provider, 'openai');
|
|
130
|
+
assert.equal(openaiClient.model, 'gpt-5');
|
|
131
|
+
assert.equal(typeof openaiClient.completeJson, 'function');
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
test('createLlmClient: defaults model from env UBP_PLANNER_MODEL', () => {
|
|
135
|
+
const saved = process.env.UBP_PLANNER_MODEL;
|
|
136
|
+
process.env.UBP_PLANNER_MODEL = 'gpt-5.4';
|
|
137
|
+
try {
|
|
138
|
+
const c = createLlmClient({ apiKey: 'sk-test' });
|
|
139
|
+
assert.equal(c.model, 'gpt-5.4');
|
|
140
|
+
assert.equal(c.provider, 'openai');
|
|
141
|
+
} finally {
|
|
142
|
+
if (saved === undefined) delete process.env.UBP_PLANNER_MODEL;
|
|
143
|
+
else process.env.UBP_PLANNER_MODEL = saved;
|
|
144
|
+
}
|
|
145
|
+
});
|
|
146
|
+
|
|
147
|
+
test('createLlmClient: falls back to DEFAULT_MODEL when nothing supplied', () => {
|
|
148
|
+
const savedEnv = process.env.UBP_PLANNER_MODEL;
|
|
149
|
+
delete process.env.UBP_PLANNER_MODEL;
|
|
150
|
+
try {
|
|
151
|
+
const c = createLlmClient({ apiKey: 'sk-test' });
|
|
152
|
+
assert.equal(c.model, DEFAULT_MODEL);
|
|
153
|
+
} finally {
|
|
154
|
+
if (savedEnv !== undefined) process.env.UBP_PLANNER_MODEL = savedEnv;
|
|
155
|
+
}
|
|
156
|
+
});
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
// Phase 8A.4b — Persist / load / section-slice the execution-plan artifact.
|
|
4
|
+
//
|
|
5
|
+
// The plan itself is computed by orchestrator/planner/plan-builder.cjs
|
|
6
|
+
// (Functional Core). This module handles IO: atomic write via the shared
|
|
7
|
+
// file-ops helper, read-back, md rendering, and section projection for
|
|
8
|
+
// plan.get.
|
|
9
|
+
|
|
10
|
+
const fs = require('node:fs');
|
|
11
|
+
const path = require('node:path');
|
|
12
|
+
const { writeAtomic } = require('../../adapters/_shared/file-ops.cjs');
|
|
13
|
+
const { buildPlan } = require('../../orchestrator/planner/plan-builder.cjs');
|
|
14
|
+
|
|
15
|
+
const DEFAULT_ARTIFACT_RELPATH = '.ultra/execution-plan.json';
|
|
16
|
+
|
|
17
|
+
function renderPlanMd(plan) {
|
|
18
|
+
const lines = [];
|
|
19
|
+
lines.push('# Execution Plan');
|
|
20
|
+
lines.push('');
|
|
21
|
+
lines.push(`- Waves: ${plan.waves.length}`);
|
|
22
|
+
lines.push(`- Estimated cost: $${plan.estimated_cost_usd}`);
|
|
23
|
+
lines.push(`- Estimated duration: ${plan.estimated_duration_min} min`);
|
|
24
|
+
if (plan.conflict_surface.length > 0) {
|
|
25
|
+
lines.push(`- Conflicts: ${plan.conflict_surface.length}`);
|
|
26
|
+
}
|
|
27
|
+
if (plan.cycles && plan.cycles.length > 0) {
|
|
28
|
+
lines.push(`- Cycles: ${plan.cycles.length}`);
|
|
29
|
+
}
|
|
30
|
+
lines.push('');
|
|
31
|
+
lines.push('## Waves');
|
|
32
|
+
lines.push('');
|
|
33
|
+
for (const w of plan.waves) {
|
|
34
|
+
lines.push(`### Wave ${w.id} ${w.parallel ? '(parallel)' : '(serial)'}`);
|
|
35
|
+
for (const id of w.tasks) lines.push(`- ${id}`);
|
|
36
|
+
if (w.reason) lines.push(`_reason_: ${w.reason}`);
|
|
37
|
+
lines.push('');
|
|
38
|
+
}
|
|
39
|
+
if (plan.conflict_surface.length > 0) {
|
|
40
|
+
lines.push('## Conflict Surface');
|
|
41
|
+
lines.push('');
|
|
42
|
+
for (const c of plan.conflict_surface) {
|
|
43
|
+
lines.push(`- **files**: ${c.files.join(', ')}; **tasks**: ${c.tasks.join(', ')}; **recommend**: ${c.recommend}`);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
return lines.join('\n') + '\n';
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function savePlanArtifact(plan, outPath, format = 'json') {
|
|
50
|
+
if (!outPath) {
|
|
51
|
+
const err = new Error('out_path required');
|
|
52
|
+
err.code = 'WRITE_FAILED';
|
|
53
|
+
throw err;
|
|
54
|
+
}
|
|
55
|
+
const abs = path.resolve(outPath);
|
|
56
|
+
const content = format === 'md' ? renderPlanMd(plan) : JSON.stringify(plan, null, 2);
|
|
57
|
+
try {
|
|
58
|
+
fs.mkdirSync(path.dirname(abs), { recursive: true });
|
|
59
|
+
writeAtomic(abs, content);
|
|
60
|
+
} catch (err) {
|
|
61
|
+
const wrap = new Error(`cannot write plan: ${err.message}`);
|
|
62
|
+
wrap.code = 'WRITE_FAILED';
|
|
63
|
+
wrap.cause = err;
|
|
64
|
+
throw wrap;
|
|
65
|
+
}
|
|
66
|
+
return { plan_path: abs };
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function loadPlanArtifact(projectRoot) {
|
|
70
|
+
const abs = path.resolve(projectRoot, DEFAULT_ARTIFACT_RELPATH);
|
|
71
|
+
if (!fs.existsSync(abs)) return null;
|
|
72
|
+
try {
|
|
73
|
+
return JSON.parse(fs.readFileSync(abs, 'utf8'));
|
|
74
|
+
} catch {
|
|
75
|
+
return null;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function selectSection(plan, section) {
|
|
80
|
+
switch (section || 'all') {
|
|
81
|
+
case 'tasks': return { ownership_forecast: plan.ownership_forecast };
|
|
82
|
+
case 'topo': return { waves: plan.waves };
|
|
83
|
+
case 'conflicts': return { conflict_surface: plan.conflict_surface };
|
|
84
|
+
case 'all':
|
|
85
|
+
default: return plan;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
module.exports = {
|
|
90
|
+
savePlanArtifact,
|
|
91
|
+
loadPlanArtifact,
|
|
92
|
+
selectSection,
|
|
93
|
+
renderPlanMd,
|
|
94
|
+
buildPlan,
|
|
95
|
+
DEFAULT_ARTIFACT_RELPATH,
|
|
96
|
+
};
|