@guanlin4924/devflow-pi 0.1.83 → 0.1.86
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/package.json +1 -1
- package/plugins/devflow-core/extensions/devflow-core.ts +12 -6
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/execution_contract.rb +28 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/inspect_state.rb +4 -0
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/parallel_run.rb +52 -2
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/validate_state.rb +7 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/scripts/workflow_gate_contract.rb +30 -1
- package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml +0 -3
- package/src/constants.ts +1 -1
package/package.json
CHANGED
|
@@ -923,6 +923,12 @@ function compactStep(value: unknown): Record<string, unknown> | null {
|
|
|
923
923
|
};
|
|
924
924
|
}
|
|
925
925
|
|
|
926
|
+
function selectedWorkflowStep(snapshot: Record<string, unknown>): Record<string, unknown> | null {
|
|
927
|
+
return snapshot.action === "run_parallel_window"
|
|
928
|
+
? compactStep(snapshot.next_step) ?? compactStep(snapshot.current_step)
|
|
929
|
+
: compactStep(snapshot.current_step) ?? compactStep(snapshot.next_step);
|
|
930
|
+
}
|
|
931
|
+
|
|
926
932
|
/**
|
|
927
933
|
* The approval prompt is session-local, while the completed step outputs are
|
|
928
934
|
* durable. After a rollover, compaction, or runtime restart the in-memory
|
|
@@ -968,7 +974,7 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
|
|
|
968
974
|
|
|
969
975
|
const feature = typeof snapshot.current_feature === "string" ? snapshot.current_feature : "";
|
|
970
976
|
const revision = snapshot.revision;
|
|
971
|
-
const selectedStep =
|
|
977
|
+
const selectedStep = selectedWorkflowStep(snapshot);
|
|
972
978
|
const stepId = typeof selectedStep?.id === "string" ? selectedStep.id : "";
|
|
973
979
|
if (!feature || !stepId || typeof revision !== "number" || !Number.isInteger(revision)) return "idle";
|
|
974
980
|
|
|
@@ -985,7 +991,7 @@ export function workflowResumeDisposition(snapshot: Record<string, unknown>): Wo
|
|
|
985
991
|
}
|
|
986
992
|
|
|
987
993
|
function workflowDirective(snapshot: Record<string, unknown>, source: "start" | "approval", requirementDraft?: string): string {
|
|
988
|
-
const selectedStep =
|
|
994
|
+
const selectedStep = selectedWorkflowStep(snapshot);
|
|
989
995
|
const envelope = {
|
|
990
996
|
source,
|
|
991
997
|
action: snapshot.action,
|
|
@@ -1037,7 +1043,7 @@ function projectInitializationDirective(summary: string): string {
|
|
|
1037
1043
|
|
|
1038
1044
|
function explainWorkflowSnapshot(snapshot: Record<string, unknown> | null): string {
|
|
1039
1045
|
if (!snapshot) return "Devflow 当前没有可解释的工作流状态。可使用 /devflow start <功能名> 创建新功能。";
|
|
1040
|
-
const step =
|
|
1046
|
+
const step = selectedWorkflowStep(snapshot);
|
|
1041
1047
|
const lines = [
|
|
1042
1048
|
`功能:${String(snapshot.current_feature ?? "未开始")}`,
|
|
1043
1049
|
`阶段:${workflowPhaseName(snapshot.current_phase)}`,
|
|
@@ -2045,7 +2051,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2045
2051
|
snapshot: Record<string, unknown>,
|
|
2046
2052
|
): Promise<WorkflowResumeDisposition> {
|
|
2047
2053
|
const disposition = workflowResumeDisposition(snapshot);
|
|
2048
|
-
const stepId = String((
|
|
2054
|
+
const stepId = String(selectedWorkflowStep(snapshot)?.id ?? "当前步骤");
|
|
2049
2055
|
if (disposition === "execute") {
|
|
2050
2056
|
setControlState(ctx, executingState(`恢复并执行 ${stepId}`));
|
|
2051
2057
|
ctx.ui.notify(`Devflow 已恢复到 ${stepId},当前步骤产物尚未完成,正在继续执行。`, "info");
|
|
@@ -2598,7 +2604,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
2598
2604
|
}
|
|
2599
2605
|
const revision = typeof inspected.snapshot.revision === "number" ? inspected.snapshot.revision : undefined;
|
|
2600
2606
|
const checkpoint = inspected.snapshot.status === "waiting_checkpoint";
|
|
2601
|
-
const step =
|
|
2607
|
+
const step = selectedWorkflowStep(inspected.snapshot);
|
|
2602
2608
|
const stepId = typeof step?.id === "string" ? step.id : undefined;
|
|
2603
2609
|
if (revision === undefined || (!checkpoint && !stepId)) {
|
|
2604
2610
|
ctx.ui.notify("当前状态缺少可确认的 revision 或步骤,拒绝推进。", "error");
|
|
@@ -3971,7 +3977,7 @@ export default function devflowCore(pi: ExtensionAPI) {
|
|
|
3971
3977
|
const revision = plan?.expected_revision;
|
|
3972
3978
|
if (!inspected.result.ok || !plan || plan.classification !== "safe_repairable" || typeof revision !== "number") {
|
|
3973
3979
|
const status = typeof inspected.snapshot?.status === "string" ? inspected.snapshot.status : "unknown";
|
|
3974
|
-
const step =
|
|
3980
|
+
const step = inspected.snapshot ? selectedWorkflowStep(inspected.snapshot) : null;
|
|
3975
3981
|
const stepId = typeof step?.id === "string" ? step.id : "当前步骤";
|
|
3976
3982
|
if (inspected.result.ok && status === "in_progress") {
|
|
3977
3983
|
ctx.ui.notify(`控制面状态一致,无需恢复。当前步骤是 ${stepId};请审查当前产物后明确回复“批准执行”或提出修改意见。`, "info");
|
|
@@ -15,6 +15,12 @@ POLICY = {
|
|
|
15
15
|
"requirement-baseline" => { "checkers" => %w[requirement-graph-check business-closure-check slice-independence-check acceptance-coverage-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[requirement rule boundary slice acceptance] },
|
|
16
16
|
"api-contract" => { "checkers" => %w[requirement-graph-check contract-coverage-check api-test-schema-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[api] },
|
|
17
17
|
"architecture-feasibility" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[workload_shape workload_constraint decision risk metric read_model] },
|
|
18
|
+
"backend-tech-spec" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[decision risk] },
|
|
19
|
+
"api-call-guide" => { "checkers" => %w[requirement-graph-check contract-coverage-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[api decision] },
|
|
20
|
+
"ui-spec" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[decision risk] },
|
|
21
|
+
"feature-breakdown" => { "checkers" => %w[requirement-graph-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[requirement rule] },
|
|
22
|
+
"frontend-tech-spec" => { "checkers" => %w[requirement-graph-check change-impact-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[decision risk] },
|
|
23
|
+
"api-supplement" => { "checkers" => %w[requirement-graph-check contract-coverage-check], "agents" => ["implementation-worker"], "budget" => 1, "allowed_graph_delta" => %w[api decision] },
|
|
18
24
|
"integration-verify" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check change-impact-check cross-track-consistency-check business-claim-reference-check business-cross-model-check], "agents" => ["verification-runner"], "budget" => 1, "allowed_graph_delta" => %w[requirement risk decision] },
|
|
19
25
|
"acceptance-test-design" => { "checkers" => %w[requirement-graph-check contract-coverage-check acceptance-coverage-check api-test-matrix-check test-coverage-check test-obligation-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[test_case] },
|
|
20
26
|
"change-plan" => { "checkers" => %w[requirement-graph-check slice-independence-check contract-coverage-check api-test-matrix-check test-coverage-check test-obligation-check test-task-binding-check change-impact-check], "agents" => [], "budget" => 0, "allowed_graph_delta" => %w[acceptance] },
|
|
@@ -45,6 +51,27 @@ def interpolate(value, state)
|
|
|
45
51
|
value.gsub("{feature}", state.fetch("current_feature").to_s).gsub("{phase}", state.fetch("current_phase").to_s)
|
|
46
52
|
end
|
|
47
53
|
|
|
54
|
+
def render_execution_contract_markdown(contract)
|
|
55
|
+
lines = [
|
|
56
|
+
"# Execution Contract",
|
|
57
|
+
"",
|
|
58
|
+
"> Core-generated derived view. The immutable contract JSON under `.planning/contracts/` is authoritative.",
|
|
59
|
+
"",
|
|
60
|
+
"- Contract ID: `#{contract.fetch("contract_id")}`",
|
|
61
|
+
"- Feature: `#{contract.fetch("feature")}`",
|
|
62
|
+
"- Step: `#{contract.fetch("step")}`",
|
|
63
|
+
"- STATE revision: `#{contract.fetch("state_revision")}`",
|
|
64
|
+
"- Requirement graph revision: `#{contract.fetch("graph_revision")}`",
|
|
65
|
+
"- Frozen hash: `#{contract.fetch("frozen_hash")}`",
|
|
66
|
+
"",
|
|
67
|
+
"## Allowed paths",
|
|
68
|
+
"",
|
|
69
|
+
]
|
|
70
|
+
lines.concat(Array(contract["allowed_paths"]).map { |path| "- `#{path}`" })
|
|
71
|
+
lines.concat(["", "## Allowed agents", "", *Array(contract["allowed_agents"]).map { |agent| "- `#{agent}`" }, "", "## Checkers", "", *Array(contract["checker_ids"]).map { |checker| "- `#{checker}`" }, "", "## Allowed tools", "", *Array(contract["allowed_tools"]).map { |tool| "- `#{tool}`" }, ""])
|
|
72
|
+
lines.join("\n")
|
|
73
|
+
end
|
|
74
|
+
|
|
48
75
|
root = Dir.pwd
|
|
49
76
|
lock = %w[create inspect].include?(command) ? DevflowControlIO.acquire_project_lock(root) : nil
|
|
50
77
|
begin
|
|
@@ -92,6 +119,7 @@ begin
|
|
|
92
119
|
raise ArgumentError, "--write is required" unless options[:write]
|
|
93
120
|
destination = File.join(root, ".planning/contracts", options[:feature], "#{contract_id}.json")
|
|
94
121
|
DevflowControlIO.atomic_write(destination, JSON.pretty_generate(frozen) + "\n")
|
|
122
|
+
DevflowControlIO.atomic_write(File.join(root, ".planning/execution-contract.md"), render_execution_contract_markdown(frozen) + "\n")
|
|
95
123
|
audit = File.join(root, ".planning/contracts", options[:feature], "control-events.jsonl")
|
|
96
124
|
existing = File.exist?(audit) ? File.read(audit) : ""
|
|
97
125
|
DevflowControlIO.atomic_write(audit, existing + JSON.generate({ "event" => "execution_contract_frozen", "contract_id" => contract_id, "state_revision" => state["revision"], "graph_revision" => graph["revision"], "frozen_hash" => frozen["frozen_hash"] }) + "\n")
|
|
@@ -212,6 +212,10 @@ def track_boundary(state, pending_ids, completed_ids)
|
|
|
212
212
|
end
|
|
213
213
|
|
|
214
214
|
def active_parallel_window(workflow, state, root, step_by_id, completed_ids, pending_ids, artifact_by_path)
|
|
215
|
+
# A failed/unsupported parallel dispatch may be explicitly converted to the
|
|
216
|
+
# declared serial fallback. Keep the join topology in the workflow, but do
|
|
217
|
+
# not re-enter the same parallel window on every inspection.
|
|
218
|
+
return nil if state["parallel_execution_mode"].to_s == "serial_fallback"
|
|
215
219
|
Array(workflow["parallel_windows"]).each do |window|
|
|
216
220
|
after_step = window["after_step"]
|
|
217
221
|
branch_map = window["branches"].is_a?(Hash) ? window["branches"] : {}
|
|
@@ -206,7 +206,7 @@ if $PROGRAM_NAME == __FILE__
|
|
|
206
206
|
begin
|
|
207
207
|
options = { action: nil, feature: nil, state_revision: nil, run_revision: nil, branch_id: nil, status: nil, workflow_window: nil, branches: [], state_path: ".planning/STATE.md", json: false }
|
|
208
208
|
OptionParser.new do |opts|
|
|
209
|
-
opts.on("--action ACTION", %w[inspect prepare update retry rebase]) { |value| options[:action] = value }
|
|
209
|
+
opts.on("--action ACTION", %w[inspect prepare update retry rebase serial-fallback]) { |value| options[:action] = value }
|
|
210
210
|
opts.on("--feature FEATURE") { |value| options[:feature] = value }
|
|
211
211
|
opts.on("--state-revision REV", Integer) { |value| options[:state_revision] = value }
|
|
212
212
|
opts.on("--run-revision REV", Integer) { |value| options[:run_revision] = value }
|
|
@@ -214,6 +214,8 @@ if $PROGRAM_NAME == __FILE__
|
|
|
214
214
|
opts.on("--status STATUS") { |value| options[:status] = value }
|
|
215
215
|
opts.on("--workflow-window NAME") { |value| options[:workflow_window] = value }
|
|
216
216
|
opts.on("--state PATH") { |value| options[:state_path] = value }
|
|
217
|
+
opts.on("--step STEP") { |value| options[:serial_step] = value }
|
|
218
|
+
opts.on("--expected-state-revision REV", Integer) { |value| options[:expected_state_revision] = value }
|
|
217
219
|
opts.on("--branch STEP:TRACK", "Register a branch as workflow_step:track") do |value|
|
|
218
220
|
workflow_step, track = value.split(":", 2)
|
|
219
221
|
raise ArgumentError, "--branch must be WORKFLOW_STEP:TRACK" if workflow_step.to_s.empty? || track.to_s.empty?
|
|
@@ -233,7 +235,55 @@ if $PROGRAM_NAME == __FILE__
|
|
|
233
235
|
ParallelRun.update_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision], status: options[:status])
|
|
234
236
|
when "retry"
|
|
235
237
|
ParallelRun.retry_branch(root: Dir.pwd, feature: options[:feature], branch_id: options[:branch_id], expected_revision: options[:run_revision])
|
|
236
|
-
|
|
238
|
+
when "serial-fallback"
|
|
239
|
+
root = Dir.pwd
|
|
240
|
+
feature = ParallelRun.safe_feature(options[:feature])
|
|
241
|
+
lock = DevflowControlIO.acquire_project_lock(root)
|
|
242
|
+
begin
|
|
243
|
+
record = ParallelRun.load(root, feature) || raise(ArgumentError, "parallel run not found")
|
|
244
|
+
state_path, = DevflowControlIO.resolve_control_path(root, options[:state_path], :state)
|
|
245
|
+
state_text = File.read(state_path)
|
|
246
|
+
front = state_text.match(/\A---\n(.*?)\n---\n/m)&.[](1)
|
|
247
|
+
raise ArgumentError, "STATE.md has no YAML front matter" unless front
|
|
248
|
+
state = YAML.safe_load(front, permitted_classes: [], aliases: false)
|
|
249
|
+
raise ArgumentError, "STATE feature mismatch" unless state["current_feature"].to_s == feature
|
|
250
|
+
raise ArgumentError, "STATE does not reference this parallel run" unless state["parallel_run_ref"].to_s == record["run_id"].to_s
|
|
251
|
+
actual_revision = Integer(state.fetch("revision"))
|
|
252
|
+
if options[:expected_state_revision] && actual_revision != options[:expected_state_revision]
|
|
253
|
+
raise ArgumentError, "STATE revision conflict"
|
|
254
|
+
end
|
|
255
|
+
branches = Array(record["branches"])
|
|
256
|
+
raise ArgumentError, "serial fallback is only safe before any branch succeeds or runs" if branches.any? { |branch| %w[running succeeded].include?(branch["status"].to_s) }
|
|
257
|
+
workflow_path, = DevflowControlIO.resolve_control_path(root, state.fetch("workflow_config"), :workflow)
|
|
258
|
+
workflow = YAML.safe_load(File.read(workflow_path), permitted_classes: [], aliases: false)
|
|
259
|
+
policy = Array(workflow["parallel_windows"]).find { |window| window.is_a?(Hash) && window["id"].to_s == record["workflow_window"].to_s }&.dig("execution_policy", "serial_fallback_order")
|
|
260
|
+
order = Array(policy).map(&:to_s)
|
|
261
|
+
selected = options[:serial_step].to_s
|
|
262
|
+
selected = order.find do |step_id|
|
|
263
|
+
branch = branches.find { |item| item["workflow_step"].to_s == step_id }
|
|
264
|
+
branch && branch["status"].to_s == "pending"
|
|
265
|
+
end if selected.empty?
|
|
266
|
+
raise ArgumentError, "serial fallback has no pending branch" if selected.to_s.empty?
|
|
267
|
+
raise ArgumentError, "serial fallback step is not a pending branch" unless branches.any? { |branch| branch["workflow_step"].to_s == selected && branch["status"].to_s == "pending" }
|
|
268
|
+
|
|
269
|
+
state.delete("parallel_run_ref")
|
|
270
|
+
state["parallel_execution_mode"] = "serial_fallback"
|
|
271
|
+
state["current_step"] = selected
|
|
272
|
+
state["revision"] = actual_revision + 1
|
|
273
|
+
state["state_updated_at"] = Date.today.to_s
|
|
274
|
+
body = state_text.sub(/\A---\n.*?\n---\n/m, "---\n#{state.to_yaml.sub(/\A---\n/, "")}---\n")
|
|
275
|
+
DevflowControlIO.atomic_write(state_path, body)
|
|
276
|
+
record["status"] = "abandoned_serial_fallback"
|
|
277
|
+
record["abandoned_at"] = Time.now.utc.iso8601
|
|
278
|
+
record["abandon_reason"] = "no child Agent lifecycle started; converted to declared serial_fallback_order"
|
|
279
|
+
record["serial_fallback_step"] = selected
|
|
280
|
+
record["updated_at"] = Time.now.utc.iso8601
|
|
281
|
+
ParallelRun.write(root, feature, record)
|
|
282
|
+
result = { "ok" => true, "state_revision" => state["revision"], "serial_step" => selected, "run" => record }
|
|
283
|
+
ensure
|
|
284
|
+
DevflowControlIO.release_lock(lock)
|
|
285
|
+
end
|
|
286
|
+
when "rebase"
|
|
237
287
|
raise ArgumentError, "--state-revision is required" if options[:state_revision].nil?
|
|
238
288
|
ParallelRun.rebase_state_revision(root: Dir.pwd, feature: options[:feature], state_revision: options[:state_revision])
|
|
239
289
|
.then { |run| run ? { "ok" => true, "run" => run } : { "ok" => false, "reason" => "parallel_run_not_found" } }
|
|
@@ -175,7 +175,13 @@ unless %w[prepared claimed consumed superseded failed].include?(status)
|
|
|
175
175
|
return nil
|
|
176
176
|
end
|
|
177
177
|
|
|
178
|
-
|
|
178
|
+
raw = File.read(path)
|
|
179
|
+
if code == "workflow_config"
|
|
180
|
+
WorkflowGateContract.duplicate_yaml_keys(raw).each do |key_path|
|
|
181
|
+
issues << issue("error", "duplicate_workflow_yaml_key", "Workflow contains a duplicate YAML mapping key", path: key_path)
|
|
182
|
+
end
|
|
183
|
+
end
|
|
184
|
+
YAML.safe_load(raw, permitted_classes: [], aliases: false)
|
|
179
185
|
rescue Psych::SyntaxError => e
|
|
180
186
|
issues << issue("error", "invalid_#{code}", e.message, path: path)
|
|
181
187
|
nil
|
|
@@ -14,6 +14,31 @@ module WorkflowGateContract
|
|
|
14
14
|
|
|
15
15
|
module_function
|
|
16
16
|
|
|
17
|
+
# Psych silently keeps the last value for duplicate mapping keys. Detect
|
|
18
|
+
# duplicates before loading a workflow so a checker declaration can never be
|
|
19
|
+
# overwritten without a gate failure.
|
|
20
|
+
def duplicate_yaml_keys(yaml)
|
|
21
|
+
duplicates = []
|
|
22
|
+
walk = lambda do |node, path|
|
|
23
|
+
return unless node
|
|
24
|
+
if node.is_a?(Psych::Nodes::Mapping)
|
|
25
|
+
seen = {}
|
|
26
|
+
children = node.children
|
|
27
|
+
children.each_slice(2) do |key_node, value_node|
|
|
28
|
+
key = key_node.respond_to?(:value) ? key_node.value.to_s : key_node.to_s
|
|
29
|
+
key_path = path.empty? ? key : "#{path}.#{key}"
|
|
30
|
+
duplicates << key_path if seen.key?(key)
|
|
31
|
+
seen[key] = true
|
|
32
|
+
walk.call(value_node, key_path)
|
|
33
|
+
end
|
|
34
|
+
elsif node.respond_to?(:children)
|
|
35
|
+
Array(node.children).each { |child| walk.call(child, path) }
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
Psych.parse_stream(yaml).children.each { |document| walk.call(document.root, "") }
|
|
39
|
+
duplicates.uniq
|
|
40
|
+
end
|
|
41
|
+
|
|
17
42
|
def validate(workflow)
|
|
18
43
|
errors = []
|
|
19
44
|
unless workflow.is_a?(Hash)
|
|
@@ -139,7 +164,11 @@ if $PROGRAM_NAME == __FILE__
|
|
|
139
164
|
opts.on("--json") { options[:json] = true }
|
|
140
165
|
end.parse!
|
|
141
166
|
raise ArgumentError, "--workflow is required" unless options[:workflow]
|
|
142
|
-
|
|
167
|
+
raw = File.read(options[:workflow])
|
|
168
|
+
duplicate_errors = WorkflowGateContract.duplicate_yaml_keys(raw).map do |path|
|
|
169
|
+
WorkflowGateContract.error("DUPLICATE_YAML_KEY", "workflow contains a duplicate YAML mapping key", path: path)
|
|
170
|
+
end
|
|
171
|
+
result = duplicate_errors + WorkflowGateContract.validate(YAML.safe_load(raw, permitted_classes: [], aliases: false))
|
|
143
172
|
puts(options[:json] ? JSON.generate(result) : YAML.dump(result))
|
|
144
173
|
rescue StandardError => e
|
|
145
174
|
warn e.message
|
package/plugins/devflow-core/skills/workflow-orchestrator/templates/feature-development.yaml
CHANGED
|
@@ -70,10 +70,7 @@ governance:
|
|
|
70
70
|
- api-test-matrix-check
|
|
71
71
|
- test-coverage-check
|
|
72
72
|
- acceptance-test-results-check
|
|
73
|
-
- change-impact-check
|
|
74
|
-
run-acceptance-tests:
|
|
75
73
|
- acceptance-coverage-check
|
|
76
|
-
- acceptance-test-results-check
|
|
77
74
|
- change-impact-check
|
|
78
75
|
|
|
79
76
|
artifact_registry:
|
package/src/constants.ts
CHANGED