great-cto 2.99.0 → 3.0.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/board/.claude-plugin/plugin.json +1 -1
- package/board/packages/board/lib/alerts.mjs +11 -4
- package/board/packages/board/lib/beads.mjs +133 -6
- package/board/packages/board/lib/fleet.mjs +25 -0
- package/board/packages/board/lib/metrics.mjs +24 -0
- package/board/packages/board/lib/portfolio.mjs +58 -7
- package/board/packages/board/lib/routes.mjs +103 -9
- package/board/packages/board/lib/watchers.mjs +32 -1
- package/board/packages/board/public/index.html +595 -16
- package/board/scripts/lib/agent-budget.mjs +224 -0
- package/board/scripts/lib/pipeline-health.mjs +127 -0
- package/package.json +1 -1
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
// Per-agent spending limits, and the one rule that keeps them honest.
|
|
2
|
+
//
|
|
3
|
+
// A limit that fires on a number nobody measured is worse than no limit: work
|
|
4
|
+
// stops, the operator is told an agent has spent $52 of its $50, and the $52 was
|
|
5
|
+
// derived from how long the agent ran multiplied by a hardcoded rate. This
|
|
6
|
+
// repository already carries that distinction in its cost model —
|
|
7
|
+
// `agents_cost[].cost_source` is `'estimate'` unless verdicts carry real token
|
|
8
|
+
// spend, and `real_llm_usd` is deleted rather than zeroed when there is none.
|
|
9
|
+
//
|
|
10
|
+
// So the states are four, not two:
|
|
11
|
+
//
|
|
12
|
+
// no-limit nothing was declared for this agent — say so, do not invent one
|
|
13
|
+
// within MEASURED spend is under the cap
|
|
14
|
+
// exceeded MEASURED spend is over the cap — the only state that may refuse
|
|
15
|
+
// unmeasured a cap exists and there is no verdict cost data to judge it by
|
|
16
|
+
//
|
|
17
|
+
// `unmeasured` never refuses. It reports the estimate, labelled as an estimate,
|
|
18
|
+
// and the caller decides. That is the same shape as `proof-status.mjs`
|
|
19
|
+
// (passed / failed / not_run / inconclusive) and for the same reason: a check
|
|
20
|
+
// that could not run must not return the answer of one that ran.
|
|
21
|
+
//
|
|
22
|
+
// Declared in the project's own PROJECT.md, next to `monthly-budget`:
|
|
23
|
+
//
|
|
24
|
+
// agent-budgets:
|
|
25
|
+
// senior-dev: $50
|
|
26
|
+
// architect: $20
|
|
27
|
+
// product-owner: $5
|
|
28
|
+
|
|
29
|
+
/** Dollars from `$50`, `50`, `$1,250.50`. Returns null for anything else. */
|
|
30
|
+
function parseUsd(raw) {
|
|
31
|
+
if (raw == null) return null;
|
|
32
|
+
const cleaned = String(raw).replace(/[$\s,]/g, '');
|
|
33
|
+
if (!/^\d+(\.\d+)?$/.test(cleaned)) return null;
|
|
34
|
+
const n = Number(cleaned);
|
|
35
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
/**
|
|
39
|
+
* Read the `agent-budgets:` block out of a PROJECT.md.
|
|
40
|
+
*
|
|
41
|
+
* The block is a key with indented `agent: $amount` lines beneath it — the rest
|
|
42
|
+
* of the file is flat `key: value`, and a flat key per agent would collide with
|
|
43
|
+
* every other setting the moment an agent is called `phase` or `stack`.
|
|
44
|
+
*
|
|
45
|
+
* @returns {{budgets: Map<string, number>, malformed: Array<{line: string, why: string}>}}
|
|
46
|
+
* Malformed lines are RETURNED, not dropped. A budget the operator wrote and
|
|
47
|
+
* this parser silently ignored is a limit they believe they have.
|
|
48
|
+
*/
|
|
49
|
+
export function parseAgentBudgets(text) {
|
|
50
|
+
const budgets = new Map();
|
|
51
|
+
const malformed = [];
|
|
52
|
+
if (!text) return { budgets, malformed, deprecatedKey: null };
|
|
53
|
+
|
|
54
|
+
// Two spellings. `agent-budget:` (singular) predates this module: the board
|
|
55
|
+
// parsed it with its own inline regex in routes.mjs and only displayed it,
|
|
56
|
+
// labelled "$X/run". I then added `agent-budgets:` (plural) with a second
|
|
57
|
+
// parser and a different meaning, which is how a repository ends up with two
|
|
58
|
+
// definitions of one concept differing by a letter — the exact drift the
|
|
59
|
+
// dispatcher's own comment warns about.
|
|
60
|
+
//
|
|
61
|
+
// No project, template or document used the singular form, so consolidating
|
|
62
|
+
// costs nothing. It is still accepted, and reported as deprecated, because a
|
|
63
|
+
// config someone wrote must not stop working silently.
|
|
64
|
+
const lines = String(text).split('\n');
|
|
65
|
+
let start = lines.findIndex((l) => /^agent-budgets:\s*$/.test(l));
|
|
66
|
+
let deprecatedKey = null;
|
|
67
|
+
if (start < 0) {
|
|
68
|
+
start = lines.findIndex((l) => /^agent-budget:\s*$/.test(l));
|
|
69
|
+
if (start >= 0) deprecatedKey = 'agent-budget';
|
|
70
|
+
}
|
|
71
|
+
if (start < 0) return { budgets, malformed, deprecatedKey: null };
|
|
72
|
+
|
|
73
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
74
|
+
const line = lines[i];
|
|
75
|
+
if (/^\S/.test(line)) break; // dedent ends the block
|
|
76
|
+
if (!line.trim()) continue;
|
|
77
|
+
const m = line.match(/^\s+([a-z0-9][a-z0-9-]*)\s*:\s*(.+?)\s*$/i);
|
|
78
|
+
if (!m) { malformed.push({ line: line.trim(), why: 'not `agent: amount`' }); continue; }
|
|
79
|
+
const usd = parseUsd(m[2]);
|
|
80
|
+
if (usd == null) { malformed.push({ line: line.trim(), why: `\`${m[2]}\` is not a dollar amount` }); continue; }
|
|
81
|
+
budgets.set(m[1].toLowerCase(), usd);
|
|
82
|
+
}
|
|
83
|
+
return { budgets, malformed, deprecatedKey };
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Judge one agent against its limit.
|
|
88
|
+
*
|
|
89
|
+
* @param {object} a
|
|
90
|
+
* @param {string} a.agent
|
|
91
|
+
* @param {Map<string, number>} a.budgets
|
|
92
|
+
* @param {object} [a.spend] One entry from metrics' `agents_cost`:
|
|
93
|
+
* `{ llm_usd, real_llm_usd?, cost_source }`. `real_llm_usd` is ABSENT rather
|
|
94
|
+
* than 0 when nothing was measured — that absence is the whole signal here.
|
|
95
|
+
* @returns {{state: 'no-limit'|'within'|'exceeded'|'unmeasured', limitUsd: number|null,
|
|
96
|
+
* measuredUsd: number|null, estimateUsd: number|null, pct: number|null, why: string}}
|
|
97
|
+
*/
|
|
98
|
+
export function judgeAgentBudget({ agent, budgets, spend }) {
|
|
99
|
+
const limitUsd = budgets?.get?.(String(agent || '').toLowerCase()) ?? null;
|
|
100
|
+
const estimateUsd = Number.isFinite(spend?.llm_usd) ? spend.llm_usd : null;
|
|
101
|
+
|
|
102
|
+
if (limitUsd == null) {
|
|
103
|
+
return { state: 'no-limit', limitUsd: null, measuredUsd: null, estimateUsd, pct: null,
|
|
104
|
+
why: `no budget declared for ${agent}` };
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
// Measured means it came from verdicts. `real_llm_usd` is deleted when zero,
|
|
108
|
+
// so `!= null` is the test — `> 0` would read a genuine measured zero as
|
|
109
|
+
// "never measured", which is the confusion this whole module exists to avoid.
|
|
110
|
+
const measuredUsd = spend?.real_llm_usd != null ? spend.real_llm_usd : null;
|
|
111
|
+
if (measuredUsd == null) {
|
|
112
|
+
return { state: 'unmeasured', limitUsd, measuredUsd: null, estimateUsd, pct: null,
|
|
113
|
+
why: `${agent} has a $${limitUsd} budget and no verdict cost data to judge it by`
|
|
114
|
+
+ (estimateUsd != null ? ` — the $${estimateUsd.toFixed(2)} shown is a time-based estimate, not spend` : '') };
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
const pct = Math.round((measuredUsd / limitUsd) * 100);
|
|
118
|
+
return measuredUsd > limitUsd
|
|
119
|
+
? { state: 'exceeded', limitUsd, measuredUsd, estimateUsd, pct,
|
|
120
|
+
why: `${agent} has spent $${measuredUsd.toFixed(2)} of its $${limitUsd} budget (${pct}%), measured from verdicts` }
|
|
121
|
+
: { state: 'within', limitUsd, measuredUsd, estimateUsd, pct,
|
|
122
|
+
why: `${agent} has spent $${measuredUsd.toFixed(2)} of $${limitUsd} (${pct}%)` };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* May this agent be dispatched?
|
|
127
|
+
*
|
|
128
|
+
* The ONLY state that refuses is `exceeded`, and `exceeded` is reachable only
|
|
129
|
+
* from measured spend. Everything else proceeds, because the alternative is
|
|
130
|
+
* halting a pipeline on arithmetic over a rate constant.
|
|
131
|
+
*/
|
|
132
|
+
export function budgetAllowsDispatch(verdict) {
|
|
133
|
+
return verdict?.state !== 'exceeded';
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
// ── Editing the declaration ─────────────────────────────────────────────────
|
|
137
|
+
//
|
|
138
|
+
// The board can set a cap, which means writing to a file the operator owns and
|
|
139
|
+
// git tracks. Two rules govern that, and both are about not surprising them:
|
|
140
|
+
//
|
|
141
|
+
// 1. Everything else in PROJECT.md survives byte for byte. These functions
|
|
142
|
+
// touch the budget block and nothing around it.
|
|
143
|
+
// 2. The key already in the file wins. A project written with the deprecated
|
|
144
|
+
// `agent-budget:` keeps it — silently rewriting somebody's config to a
|
|
145
|
+
// different spelling while they asked for an unrelated change is the kind
|
|
146
|
+
// of helpfulness that loses trust.
|
|
147
|
+
|
|
148
|
+
/** The block header this file uses, or null when it has none. */
|
|
149
|
+
function budgetKeyIn(text) {
|
|
150
|
+
if (/^agent-budgets:\s*$/m.test(text)) return 'agent-budgets';
|
|
151
|
+
if (/^agent-budget:\s*$/m.test(text)) return 'agent-budget';
|
|
152
|
+
return null;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/**
|
|
156
|
+
* Set or replace one agent's cap.
|
|
157
|
+
*
|
|
158
|
+
* @returns {{text: string, created: boolean, previousUsd: number|null}}
|
|
159
|
+
* `created` is true when the block did not exist and was appended.
|
|
160
|
+
* @throws when `limitUsd` is not a positive number — a cap of zero or NaN would
|
|
161
|
+
* hold every dispatch of that agent forever, and an accident should not be
|
|
162
|
+
* able to do that.
|
|
163
|
+
*/
|
|
164
|
+
export function upsertAgentBudget(text, agent, limitUsd) {
|
|
165
|
+
const slug = String(agent || '').trim().toLowerCase();
|
|
166
|
+
if (!/^[a-z0-9][a-z0-9-]*$/.test(slug)) throw new Error(`not an agent slug: ${agent}`);
|
|
167
|
+
const usd = Number(limitUsd);
|
|
168
|
+
if (!Number.isFinite(usd) || usd <= 0) throw new Error(`cap must be a positive number, got: ${limitUsd}`);
|
|
169
|
+
|
|
170
|
+
const before = parseAgentBudgets(text).budgets.get(slug) ?? null;
|
|
171
|
+
const key = budgetKeyIn(text);
|
|
172
|
+
const line = ` ${slug}: $${usd}`;
|
|
173
|
+
|
|
174
|
+
if (!key) {
|
|
175
|
+
const sep = text.endsWith('\n') ? '' : '\n';
|
|
176
|
+
return { text: `${text}${sep}agent-budgets:\n${line}\n`, created: true, previousUsd: null };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
const lines = text.split('\n');
|
|
180
|
+
const start = lines.findIndex((l) => new RegExp(`^${key}:\\s*$`).test(l));
|
|
181
|
+
// The block ends at a dedent OR a blank line. A blank is not a dedent — it
|
|
182
|
+
// starts with no non-space character — so scanning only for `^\S` walked past
|
|
183
|
+
// the gap at the end of the file and inserted the new cap below it, leaving a
|
|
184
|
+
// block split by an empty line. The parser tolerates that; a person reading
|
|
185
|
+
// their own PROJECT.md should not have to.
|
|
186
|
+
let end = start + 1;
|
|
187
|
+
while (end < lines.length && lines[end].trim() && !/^\S/.test(lines[end])) end++;
|
|
188
|
+
|
|
189
|
+
const existing = lines.slice(start + 1, end)
|
|
190
|
+
.findIndex((l) => new RegExp(`^\\s+${slug}\\s*:`, 'i').test(l));
|
|
191
|
+
if (existing >= 0) lines[start + 1 + existing] = line;
|
|
192
|
+
else lines.splice(end, 0, line);
|
|
193
|
+
|
|
194
|
+
return { text: lines.join('\n'), created: false, previousUsd: before };
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* Remove one agent's cap.
|
|
199
|
+
*
|
|
200
|
+
* @returns {{text: string, removed: boolean, previousUsd: number|null}}
|
|
201
|
+
* `removed: false` when the agent had no cap — the caller must be able to tell
|
|
202
|
+
* "there is now no limit" from "there never was one".
|
|
203
|
+
*/
|
|
204
|
+
export function removeAgentBudget(text, agent) {
|
|
205
|
+
const slug = String(agent || '').trim().toLowerCase();
|
|
206
|
+
const before = parseAgentBudgets(text).budgets.get(slug) ?? null;
|
|
207
|
+
const key = budgetKeyIn(text);
|
|
208
|
+
if (!key || before == null) return { text, removed: false, previousUsd: null };
|
|
209
|
+
|
|
210
|
+
const lines = text.split('\n');
|
|
211
|
+
const start = lines.findIndex((l) => new RegExp(`^${key}:\\s*$`).test(l));
|
|
212
|
+
let end = start + 1;
|
|
213
|
+
while (end < lines.length && lines[end].trim() && !/^\S/.test(lines[end])) end++;
|
|
214
|
+
|
|
215
|
+
const kept = lines.slice(start + 1, end)
|
|
216
|
+
.filter((l) => !new RegExp(`^\\s+${slug}\\s*:`, 'i').test(l));
|
|
217
|
+
|
|
218
|
+
// An empty block is removed with its header. A bare `agent-budgets:` reads as
|
|
219
|
+
// a declaration that produced no limits, which is a different and confusing
|
|
220
|
+
// thing from having none.
|
|
221
|
+
const replacement = kept.some((l) => l.trim()) ? [lines[start], ...kept] : [];
|
|
222
|
+
lines.splice(start, end - start, ...replacement);
|
|
223
|
+
return { text: lines.join('\n'), removed: true, previousUsd: before };
|
|
224
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
// Could the dispatcher dispatch here?
|
|
2
|
+
//
|
|
3
|
+
// Every way the pipeline broke this week failed by SILENCE. The map was resolved
|
|
4
|
+
// against the project instead of the plugin, so thirteen of seventeen registered
|
|
5
|
+
// projects hit `return process.exit(0)` and said nothing — no dispatch, no
|
|
6
|
+
// verdict, no task, and nowhere to look. The budget check was wired into
|
|
7
|
+
// `decideNext` and never passed its arguments, so it never ran. In both cases
|
|
8
|
+
// the machinery reported success while being incapable of acting.
|
|
9
|
+
//
|
|
10
|
+
// `guard-parity` asks whether a guard EXECUTES. `declared-consumed` asks whether
|
|
11
|
+
// a declaration is CONSUMED. This asks the question underneath both:
|
|
12
|
+
//
|
|
13
|
+
// **Given a project, would the dispatcher be ABLE to act at all?**
|
|
14
|
+
//
|
|
15
|
+
// It checks the same preconditions the hook checks, in the same order, without
|
|
16
|
+
// running an agent. A project that cannot chain is reported with the reason and
|
|
17
|
+
// what would fix it — because the failure mode being closed here is not a broken
|
|
18
|
+
// pipeline, it is a pipeline that is broken and silent.
|
|
19
|
+
|
|
20
|
+
import { existsSync, readFileSync, readdirSync } from 'node:fs';
|
|
21
|
+
import { join, dirname } from 'node:path';
|
|
22
|
+
import { fileURLToPath } from 'node:url';
|
|
23
|
+
import os from 'node:os';
|
|
24
|
+
|
|
25
|
+
const REPO = join(dirname(fileURLToPath(import.meta.url)), '..', '..');
|
|
26
|
+
|
|
27
|
+
/** The map the dispatcher would use for `cwd`, and where it came from. */
|
|
28
|
+
export function pipelineMapFor(cwd, { pluginMap = join(REPO, 'shared', 'pipeline.toml') } = {}) {
|
|
29
|
+
const local = join(cwd, 'shared', 'pipeline.toml');
|
|
30
|
+
if (existsSync(local)) return { path: local, source: 'project' };
|
|
31
|
+
if (existsSync(pluginMap)) return { path: pluginMap, source: 'plugin' };
|
|
32
|
+
return { path: null, source: 'none' };
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* One project's ability to run a pipeline.
|
|
37
|
+
*
|
|
38
|
+
* @returns {{slug, path, state: 'ready'|'not-a-project'|'blocked', why: string, mapSource: string}}
|
|
39
|
+
* `not-a-project` is a fine and common answer — a directory with no
|
|
40
|
+
* `.great_cto/` is not ours to dispatch in, and must not be reported as a
|
|
41
|
+
* fault. `blocked` means the project IS ours and the dispatcher could not act.
|
|
42
|
+
*/
|
|
43
|
+
export function projectPipelineHealth(entry, opts = {}) {
|
|
44
|
+
const base = { slug: entry.slug || '?', path: entry.path || '', mapSource: 'none' };
|
|
45
|
+
|
|
46
|
+
if (!entry.path || !existsSync(entry.path)) {
|
|
47
|
+
return { ...base, state: 'not-a-project', why: 'the directory does not exist' };
|
|
48
|
+
}
|
|
49
|
+
if (!existsSync(join(entry.path, '.great_cto'))) {
|
|
50
|
+
return { ...base, state: 'not-a-project', why: 'no .great_cto/ — not a great_cto project' };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const map = pipelineMapFor(entry.path, opts);
|
|
54
|
+
if (!map.path) {
|
|
55
|
+
return {
|
|
56
|
+
...base, state: 'blocked',
|
|
57
|
+
why: 'no pipeline map — not in the project and not in the plugin, so the dispatcher exits before reading a verdict',
|
|
58
|
+
};
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
let transitions = 0;
|
|
62
|
+
try {
|
|
63
|
+
const text = readFileSync(map.path, 'utf8');
|
|
64
|
+
transitions = (text.match(/^\[transitions\./gm) || []).length;
|
|
65
|
+
} catch (e) {
|
|
66
|
+
return { ...base, state: 'blocked', mapSource: map.source, why: `the map at ${map.path} could not be read: ${e.message}` };
|
|
67
|
+
}
|
|
68
|
+
if (transitions === 0) {
|
|
69
|
+
// A map that parses to nothing is the same silence with a file behind it.
|
|
70
|
+
return { ...base, state: 'blocked', mapSource: map.source, why: `the map at ${map.path} declares no transitions` };
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return {
|
|
74
|
+
...base, state: 'ready', mapSource: map.source,
|
|
75
|
+
why: `${transitions} transitions, map from the ${map.source}`,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** Every registered project, judged. */
|
|
80
|
+
export function auditPipelineHealth({ registry = join(os.homedir(), '.great_cto', 'projects.json'), ...opts } = {}) {
|
|
81
|
+
let entries = [];
|
|
82
|
+
try {
|
|
83
|
+
const raw = JSON.parse(readFileSync(registry, 'utf8'));
|
|
84
|
+
const list = raw.projects ?? raw;
|
|
85
|
+
entries = Array.isArray(list) ? list : Object.values(list);
|
|
86
|
+
} catch (e) {
|
|
87
|
+
// An unreadable registry is not an empty fleet. Reporting "0 projects, all
|
|
88
|
+
// healthy" from a file we could not open is the defect this module exists
|
|
89
|
+
// to prevent, one level up.
|
|
90
|
+
return { state: 'unreadable', why: `could not read ${registry}: ${e.message}`, rows: [] };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
const rows = entries.map((e) => projectPipelineHealth(e, opts));
|
|
94
|
+
const blocked = rows.filter((r) => r.state === 'blocked');
|
|
95
|
+
return {
|
|
96
|
+
state: blocked.length ? 'blocked' : 'ready',
|
|
97
|
+
why: blocked.length
|
|
98
|
+
? `${blocked.length} of ${rows.filter((r) => r.state !== 'not-a-project').length} project(s) cannot dispatch`
|
|
99
|
+
: `every registered project can dispatch`,
|
|
100
|
+
rows,
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
// ── CLI ─────────────────────────────────────────────────────────────────────
|
|
105
|
+
//
|
|
106
|
+
// node scripts/lib/pipeline-health.mjs [--strict] [--json]
|
|
107
|
+
|
|
108
|
+
if (import.meta.url === `file://${process.argv[1]}`) {
|
|
109
|
+
const r = auditPipelineHealth();
|
|
110
|
+
if (process.argv.includes('--json')) {
|
|
111
|
+
console.log(JSON.stringify(r, null, 2));
|
|
112
|
+
process.exit(r.state === 'ready' ? 0 : 1);
|
|
113
|
+
}
|
|
114
|
+
const projects = r.rows.filter((x) => x.state !== 'not-a-project');
|
|
115
|
+
console.log(`pipeline-health: ${r.why}`);
|
|
116
|
+
for (const x of r.rows.filter((y) => y.state === 'blocked')) {
|
|
117
|
+
console.log(`\n ${x.slug}\n ${x.why}`);
|
|
118
|
+
}
|
|
119
|
+
if (r.state === 'ready') {
|
|
120
|
+
const bySource = projects.reduce((a, x) => ({ ...a, [x.mapSource]: (a[x.mapSource] || 0) + 1 }), {});
|
|
121
|
+
console.log(` ${projects.length} project(s): ` +
|
|
122
|
+
Object.entries(bySource).map(([k, v]) => `${v} using the ${k} map`).join(', '));
|
|
123
|
+
}
|
|
124
|
+
// `unreadable` fails under --strict too: a check that could not look must not
|
|
125
|
+
// exit like one that looked and found nothing.
|
|
126
|
+
process.exit(process.argv.includes('--strict') && r.state !== 'ready' ? 1 : 0);
|
|
127
|
+
}
|
package/package.json
CHANGED