@massa-ai/cursor-plugin 1.21.0 → 1.23.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/.cursor-plugin/plugin.json +1 -1
- package/package.json +1 -1
- package/skills/massa-ai/SKILL.md +9 -0
- package/skills/massa-ai/references/evidence-gate.md +1 -1
- package/skills/massa-ai/references/figma-pre-analysis.md +69 -0
- package/skills/massa-ai/references/hook-enforcement.md +2 -2
- package/skills/massa-ai/references/implementation-delivery.md +17 -3
- package/skills/massa-ai/references/lessons.md +9 -10
- package/skills/massa-ai/references/mcp-tools.md +1 -1
- package/skills/massa-ai/references/mobile-context.md +19 -0
- package/skills/massa-ai/references/mobile-diagnosis.md +1 -1
- package/skills/massa-ai/references/project-context.md +1 -1
- package/skills/massa-ai/references/spec-driven/artifact-store.md +7 -8
- package/skills/massa-ai/references/spec-driven/design.md +1 -1
- package/skills/massa-ai/references/spec-driven/execute.md +5 -5
- package/skills/massa-ai/references/spec-driven/specify.md +6 -6
- package/skills/massa-ai/references/spec-driven/sub-agents.md +1 -1
- package/skills/massa-ai/references/spec-driven/tasks.md +2 -2
- package/skills/massa-ai/references/spec-driven/validate.md +3 -3
- package/skills/massa-ai/scripts/check_commit.ts +231 -0
- package/skills/massa-ai/scripts/check_specs_delivered.ts +209 -0
- package/skills/massa-ai/scripts/lessons.ts +907 -0
- package/skills/massa-ai/scripts/validate_spec.ts +413 -0
- package/skills/massa-ai/scripts/validate_state.ts +276 -0
- package/skills/massa-ai/scripts/validate_tasks.ts +498 -0
- package/skills/massa-ai/workflows/architecture/architecture-fix.md +1 -1
- package/skills/massa-ai/workflows/bugs/bugs-fix.md +1 -1
- package/skills/massa-ai/workflows/code-quality/code-quality-fix.md +1 -1
- package/skills/massa-ai/workflows/debug.md +1 -1
- package/skills/massa-ai/workflows/design.md +1 -1
- package/skills/massa-ai/workflows/feature.md +2 -2
- package/skills/massa-ai/workflows/general.md +2 -2
- package/skills/massa-ai/workflows/implementation/implementation-fix.md +1 -1
- package/skills/massa-ai/workflows/maestro/maestro-fix.md +1 -1
- package/skills/massa-ai/workflows/mobile-figma/mobile-figma-audit.md +1 -0
- package/skills/massa-ai/workflows/mobile-figma/mobile-figma-fix.md +2 -1
- package/skills/massa-ai/workflows/refactor.md +1 -1
- package/skills/massa-ai/workflows/requirements/requirements-fix.md +1 -1
- package/skills/massa-ai/workflows/security/security-fix.md +1 -1
- package/skills/massa-ai/workflows/spec-driven.md +6 -6
- package/skills/massa-ai/workflows/tests/tests-fix.md +1 -1
- package/skills/massa-ai/scripts/check_commit.py +0 -128
- package/skills/massa-ai/scripts/check_specs_delivered.py +0 -137
- package/skills/massa-ai/scripts/lessons.py +0 -630
- package/skills/massa-ai/scripts/validate_spec.py +0 -272
- package/skills/massa-ai/scripts/validate_state.py +0 -183
- package/skills/massa-ai/scripts/validate_tasks.py +0 -302
|
@@ -0,0 +1,907 @@
|
|
|
1
|
+
#!/usr/bin/env bun
|
|
2
|
+
/**
|
|
3
|
+
* Deterministic bookkeeping for the massa-ai spec-driven lessons layer.
|
|
4
|
+
*
|
|
5
|
+
* The LLM supplies judgment (which failure happened, how to phrase the lesson, what
|
|
6
|
+
* signal grounds it). This script owns everything mechanical: IDs, distinct-feature
|
|
7
|
+
* recurrence counting, candidate->confirmed promotion, pruning, demotion. Bookkeeping
|
|
8
|
+
* by hand is exactly what rots a lessons file, so it lives here, not in a prompt.
|
|
9
|
+
*
|
|
10
|
+
* Canonical state: .specs/lessons.json (machine-owned - do NOT hand-edit)
|
|
11
|
+
*
|
|
12
|
+
* Bun builtins only. No new dependencies. Pass --root with the target workspace
|
|
13
|
+
* root so the package-local script writes that workspace's .specs directory.
|
|
14
|
+
*
|
|
15
|
+
* Commands:
|
|
16
|
+
* add Record a grounded lesson from a verification signal.
|
|
17
|
+
* list Print lessons (default: confirmed) for loading at Specify/Design.
|
|
18
|
+
* penalize Mark a confirmed lesson as having failed when applied (-> quarantine).
|
|
19
|
+
* prune Drop stale uncorroborated candidates (also runs automatically on add/list).
|
|
20
|
+
* status Print counts (used by the self-check in validate.md).
|
|
21
|
+
* init Create empty store.
|
|
22
|
+
* observe Ingest a JSON observation into the gitignored observations buffer.
|
|
23
|
+
* export Export the lessons store as JSON (round-trips with import).
|
|
24
|
+
* import Import lessons from JSON (merge by dedup key; best-effort massa-ai memory).
|
|
25
|
+
* selftest Run stdlib regressions (normalization).
|
|
26
|
+
*
|
|
27
|
+
* Exit codes: 0 ok, 2 usage/validation error (e.g. missing grounding).
|
|
28
|
+
*/
|
|
29
|
+
|
|
30
|
+
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
|
31
|
+
import { join, resolve } from "node:path";
|
|
32
|
+
|
|
33
|
+
const STORE_REL = join(".specs", "lessons.json");
|
|
34
|
+
const OBS_REL = join(".specs", "observations.json");
|
|
35
|
+
|
|
36
|
+
const SIGNALS: Record<string, string> = {
|
|
37
|
+
ac_gap: "Acceptance criterion not covered / failed",
|
|
38
|
+
surviving_mutant: "Discrimination sensor mutant survived (weak test)",
|
|
39
|
+
spec_precision_gap: "Spec did not define a precise outcome",
|
|
40
|
+
spec_deviation: "Implementation diverged from spec/design (SPEC_DEVIATION)",
|
|
41
|
+
gate_fail: "Build-level gate check failed",
|
|
42
|
+
};
|
|
43
|
+
const SIGNAL_KEYS_SORTED = Object.keys(SIGNALS).sort();
|
|
44
|
+
|
|
45
|
+
const DEFAULTS = { promote_threshold: 2, window_days: 45, quarantine_threshold: 2 };
|
|
46
|
+
|
|
47
|
+
// massa-ai supported memory types (references/mcp-tools.md). `procedural` is a
|
|
48
|
+
// TAG, never a type. Lessons are procedural knowledge -> type `pattern`.
|
|
49
|
+
const MASSA_AI_LESSON_TYPE = "pattern";
|
|
50
|
+
|
|
51
|
+
interface Lesson {
|
|
52
|
+
id: string;
|
|
53
|
+
key: string;
|
|
54
|
+
text: string;
|
|
55
|
+
signal: string;
|
|
56
|
+
scope: string;
|
|
57
|
+
status: "candidate" | "confirmed" | "quarantined";
|
|
58
|
+
features: string[];
|
|
59
|
+
recurrence: number;
|
|
60
|
+
harmful: number;
|
|
61
|
+
evidence: string[];
|
|
62
|
+
created: string;
|
|
63
|
+
last_seen: string;
|
|
64
|
+
confidence?: number;
|
|
65
|
+
project?: string;
|
|
66
|
+
session?: string;
|
|
67
|
+
workflow?: string;
|
|
68
|
+
entity?: string;
|
|
69
|
+
[key: string]: unknown;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
interface Store {
|
|
73
|
+
schema: number;
|
|
74
|
+
promote_threshold: number;
|
|
75
|
+
window_days: number;
|
|
76
|
+
quarantine_threshold: number;
|
|
77
|
+
next_id: number;
|
|
78
|
+
lessons: Lesson[];
|
|
79
|
+
[key: string]: unknown;
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// ---------------------------------------------------------------------------
|
|
83
|
+
// Python-parity helpers
|
|
84
|
+
// ---------------------------------------------------------------------------
|
|
85
|
+
|
|
86
|
+
function now(): string {
|
|
87
|
+
const d = new Date();
|
|
88
|
+
const pad = (n: number, w = 2) => String(n).padStart(w, "0");
|
|
89
|
+
return `${d.getUTCFullYear()}-${pad(d.getUTCMonth() + 1)}-${pad(d.getUTCDate())}T${pad(d.getUTCHours())}:${pad(d.getUTCMinutes())}:${pad(d.getUTCSeconds())}Z`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function parseDate(s: string): Date {
|
|
93
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})T(\d{2}):(\d{2}):(\d{2})Z$/.exec(s);
|
|
94
|
+
if (!m) return new Date();
|
|
95
|
+
const [, y, mo, da, h, mi, se] = m;
|
|
96
|
+
const d = new Date(Date.UTC(Number(y), Number(mo) - 1, Number(da), Number(h), Number(mi), Number(se)));
|
|
97
|
+
if (Number.isNaN(d.getTime())) return new Date();
|
|
98
|
+
return d;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
/** Mirrors Python's repr() for plain-text strings (single-quoted, backslash/quote/control escapes). */
|
|
102
|
+
function pyRepr(s: string): string {
|
|
103
|
+
const hasSingle = s.includes("'");
|
|
104
|
+
const hasDouble = s.includes('"');
|
|
105
|
+
const quote = hasSingle && !hasDouble ? '"' : "'";
|
|
106
|
+
let out = quote;
|
|
107
|
+
for (const ch of s) {
|
|
108
|
+
if (ch === "\\") out += "\\\\";
|
|
109
|
+
else if (ch === quote) out += "\\" + quote;
|
|
110
|
+
else if (ch === "\n") out += "\\n";
|
|
111
|
+
else if (ch === "\r") out += "\\r";
|
|
112
|
+
else if (ch === "\t") out += "\\t";
|
|
113
|
+
else {
|
|
114
|
+
const code = ch.codePointAt(0)!;
|
|
115
|
+
if (code < 0x20 || code === 0x7f) {
|
|
116
|
+
out += "\\x" + code.toString(16).padStart(2, "0");
|
|
117
|
+
} else {
|
|
118
|
+
out += ch;
|
|
119
|
+
}
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
out += quote;
|
|
123
|
+
return out;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Mirrors Python's `str(list_of_strings)` (a list repr: ['a', 'b']). */
|
|
127
|
+
function pyListRepr(items: string[]): string {
|
|
128
|
+
return `[${items.map(pyRepr).join(", ")}]`;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/**
|
|
132
|
+
* Exact rational (numerator/denominator) value of a finite, non-zero IEEE 754
|
|
133
|
+
* double, derived from its bit pattern (mantissa/exponent).
|
|
134
|
+
*/
|
|
135
|
+
function doubleToExactFraction(x: number): { numerator: bigint; denominator: bigint } {
|
|
136
|
+
const buf = new ArrayBuffer(8);
|
|
137
|
+
new Float64Array(buf)[0] = x;
|
|
138
|
+
const bits = new BigUint64Array(buf)[0]!;
|
|
139
|
+
const exponentBits = Number((bits >> 52n) & 0x7ffn);
|
|
140
|
+
const mantissaBits = bits & 0xfffffffffffffn;
|
|
141
|
+
let mantissa: bigint;
|
|
142
|
+
let expPow: bigint;
|
|
143
|
+
if (exponentBits === 0) {
|
|
144
|
+
// subnormal
|
|
145
|
+
mantissa = mantissaBits;
|
|
146
|
+
expPow = -1074n;
|
|
147
|
+
} else {
|
|
148
|
+
mantissa = mantissaBits | (1n << 52n);
|
|
149
|
+
expPow = BigInt(exponentBits) - 1075n;
|
|
150
|
+
}
|
|
151
|
+
let numerator = mantissa;
|
|
152
|
+
let denominator = 1n;
|
|
153
|
+
if (expPow >= 0n) {
|
|
154
|
+
numerator <<= expPow;
|
|
155
|
+
} else {
|
|
156
|
+
denominator <<= -expPow;
|
|
157
|
+
}
|
|
158
|
+
return { numerator, denominator };
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
/**
|
|
162
|
+
* Mirrors Python's round(x, 2) for floats: correctly-rounded to 2 decimal
|
|
163
|
+
* places based on the EXACT binary value of the double, ties-to-even
|
|
164
|
+
* (banker's rounding). A naive `Math.round(x * 100) / 100` gets the
|
|
165
|
+
* reachable 0.625 confidence-boundary case wrong (0.63 instead of 0.62) -
|
|
166
|
+
* see design D4 / Plan Challenge F1.
|
|
167
|
+
*/
|
|
168
|
+
function roundHalfEven2(x: number): number {
|
|
169
|
+
if (x === 0 || !Number.isFinite(x)) return x;
|
|
170
|
+
const neg = x < 0;
|
|
171
|
+
const ax = Math.abs(x);
|
|
172
|
+
const { numerator, denominator } = doubleToExactFraction(ax);
|
|
173
|
+
const scaledNum = numerator * 100n;
|
|
174
|
+
const q = scaledNum / denominator;
|
|
175
|
+
const r = scaledNum % denominator;
|
|
176
|
+
const twiceR = r * 2n;
|
|
177
|
+
let roundedInt: bigint;
|
|
178
|
+
if (twiceR < denominator) {
|
|
179
|
+
roundedInt = q;
|
|
180
|
+
} else if (twiceR > denominator) {
|
|
181
|
+
roundedInt = q + 1n;
|
|
182
|
+
} else {
|
|
183
|
+
roundedInt = q % 2n === 0n ? q : q + 1n;
|
|
184
|
+
}
|
|
185
|
+
const result = Number(roundedInt) / 100;
|
|
186
|
+
return neg ? -result : result;
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/**
|
|
190
|
+
* Mirrors Python's str(float): a whole-number float still prints its
|
|
191
|
+
* trailing ".0" (Python's round() always returns a float, so a confidence
|
|
192
|
+
* of exactly 1.0 or 0.0 prints "1.0"/"0.0", not "1"/"0" - JS numbers have no
|
|
193
|
+
* int/float distinction and collapse this by default.
|
|
194
|
+
*/
|
|
195
|
+
function pyFloatStr(x: number): string {
|
|
196
|
+
return Number.isInteger(x) ? `${x}.0` : String(x);
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/**
|
|
200
|
+
* Mirrors Python's json.dump for a store/export object containing a
|
|
201
|
+
* `confidence` float field: JSON.stringify collapses a whole-number float
|
|
202
|
+
* (1.0 -> "1") the same way string interpolation does. `confidence` is the
|
|
203
|
+
* only float-typed field this store ever writes; every other numeric field
|
|
204
|
+
* (schema, next_id, recurrence, harmful, promote_threshold, window_days,
|
|
205
|
+
* quarantine_threshold) is always a Python int and needs no fixup.
|
|
206
|
+
*/
|
|
207
|
+
function pyJsonStringify(data: unknown): string {
|
|
208
|
+
const text = JSON.stringify(data, null, 2);
|
|
209
|
+
return text.replace(/("confidence": )(-?\d+)([,\n])/g, (_m, prefix: string, num: string, suffix: string) => `${prefix}${num}.0${suffix}`);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function pySetDefault<T extends Record<string, unknown>>(obj: T, key: string, value: unknown): void {
|
|
213
|
+
if (!(key in obj)) {
|
|
214
|
+
(obj as Record<string, unknown>)[key] = value;
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
function pyStringCompare(a: string, b: string): number {
|
|
219
|
+
return a < b ? -1 : a > b ? 1 : 0;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------------------
|
|
223
|
+
// Store I/O
|
|
224
|
+
// ---------------------------------------------------------------------------
|
|
225
|
+
|
|
226
|
+
function storePath(root: string): string {
|
|
227
|
+
return join(root, STORE_REL);
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
function load(root: string): Store {
|
|
231
|
+
const path = storePath(root);
|
|
232
|
+
if (!existsSync(path)) {
|
|
233
|
+
return {
|
|
234
|
+
schema: 1,
|
|
235
|
+
promote_threshold: DEFAULTS.promote_threshold,
|
|
236
|
+
window_days: DEFAULTS.window_days,
|
|
237
|
+
quarantine_threshold: DEFAULTS.quarantine_threshold,
|
|
238
|
+
next_id: 1,
|
|
239
|
+
lessons: [],
|
|
240
|
+
};
|
|
241
|
+
}
|
|
242
|
+
const raw = readFileSync(path, "utf-8");
|
|
243
|
+
const data = JSON.parse(raw) as Store;
|
|
244
|
+
for (const [k, v] of Object.entries(DEFAULTS)) {
|
|
245
|
+
pySetDefault(data as unknown as Record<string, unknown>, k, v);
|
|
246
|
+
}
|
|
247
|
+
pySetDefault(data as unknown as Record<string, unknown>, "schema", 1);
|
|
248
|
+
pySetDefault(data as unknown as Record<string, unknown>, "next_id", 1);
|
|
249
|
+
pySetDefault(data as unknown as Record<string, unknown>, "lessons", []);
|
|
250
|
+
return data;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
function save(root: string, data: Store): void {
|
|
254
|
+
mkdirSync(join(root, ".specs"), { recursive: true });
|
|
255
|
+
writeFileSync(storePath(root), `${pyJsonStringify(data)}\n`, "utf-8");
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
function confidence(lesson: Lesson, data: Store): number {
|
|
259
|
+
const recurrence = lesson.recurrence ?? 1;
|
|
260
|
+
const recCap = Math.min(recurrence / Math.max(data.promote_threshold, 1), 1.0);
|
|
261
|
+
const sigWeight = 0.15;
|
|
262
|
+
const scopeWeight = lesson.scope ? 0.1 : 0.0;
|
|
263
|
+
return roundHalfEven2(Math.min(recCap * 0.75 + sigWeight + scopeWeight, 1.0));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
function obsPath(root: string): string {
|
|
267
|
+
return join(root, OBS_REL);
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
function obsLoad(root: string): unknown[] {
|
|
271
|
+
const path = obsPath(root);
|
|
272
|
+
if (!existsSync(path)) return [];
|
|
273
|
+
try {
|
|
274
|
+
const data = JSON.parse(readFileSync(path, "utf-8"));
|
|
275
|
+
return Array.isArray(data) ? data : [];
|
|
276
|
+
} catch {
|
|
277
|
+
return [];
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
function obsAppend(root: string, item: unknown): void {
|
|
282
|
+
mkdirSync(join(root, ".specs"), { recursive: true });
|
|
283
|
+
const items = obsLoad(root);
|
|
284
|
+
items.push(item);
|
|
285
|
+
writeFileSync(obsPath(root), `${JSON.stringify(items, null, 2)}\n`, "utf-8");
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Best-effort massa-ai memory write via REST (fetch, Bun builtin only).
|
|
290
|
+
*
|
|
291
|
+
* massa-ai MCP is agent-side only; a CLI subprocess cannot call MCP. massa-ai exposes
|
|
292
|
+
* REST at MASSA_AI_API_URL. Type is always `pattern` (lessons are procedural
|
|
293
|
+
* knowledge); `procedural` is a tag, not a type. Returns true on success,
|
|
294
|
+
* false (silent) when unavailable - the file store remains source of truth.
|
|
295
|
+
* Its failure must never change exit codes or stdout contract.
|
|
296
|
+
*/
|
|
297
|
+
async function rememberBestEffort(
|
|
298
|
+
content: string,
|
|
299
|
+
tags: string[],
|
|
300
|
+
projectId = "",
|
|
301
|
+
sessionId = "",
|
|
302
|
+
): Promise<boolean> {
|
|
303
|
+
const apiUrl = process.env.MASSA_AI_API_URL;
|
|
304
|
+
if (!apiUrl) return false;
|
|
305
|
+
const path = process.env.MASSA_AI_MEMORY_PATH || "/api/v1/memory";
|
|
306
|
+
const url = apiUrl.replace(/\/+$/, "") + path;
|
|
307
|
+
const body = JSON.stringify({
|
|
308
|
+
content,
|
|
309
|
+
type: MASSA_AI_LESSON_TYPE,
|
|
310
|
+
importance: 0.6,
|
|
311
|
+
projectId,
|
|
312
|
+
sessionId,
|
|
313
|
+
tags,
|
|
314
|
+
});
|
|
315
|
+
const headers: Record<string, string> = { "Content-Type": "application/json" };
|
|
316
|
+
const key = process.env.MASSA_AI_API_KEY;
|
|
317
|
+
if (key) headers["x-api-key"] = key;
|
|
318
|
+
try {
|
|
319
|
+
const resp = await fetch(url, {
|
|
320
|
+
method: "POST",
|
|
321
|
+
body,
|
|
322
|
+
headers,
|
|
323
|
+
signal: AbortSignal.timeout(1500),
|
|
324
|
+
});
|
|
325
|
+
return resp.status >= 200 && resp.status < 300;
|
|
326
|
+
} catch {
|
|
327
|
+
return false;
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/** massa-ai persistence tag contract for a lesson's massa-ai memory. */
|
|
332
|
+
function lessonTags(lesson: Lesson): string[] {
|
|
333
|
+
return [
|
|
334
|
+
`project:${lesson.project || ""}`,
|
|
335
|
+
`session:${lesson.session || ""}`,
|
|
336
|
+
`workflow:${lesson.workflow || "unset"}`,
|
|
337
|
+
`entity:${lesson.entity || "unset"}`,
|
|
338
|
+
"memory:procedural",
|
|
339
|
+
];
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
// ---------------------------------------------------------------------------
|
|
343
|
+
// Normalization
|
|
344
|
+
// ---------------------------------------------------------------------------
|
|
345
|
+
|
|
346
|
+
// Approximates Python's isalnum()-or-isspace() per-codepoint filter: Unicode
|
|
347
|
+
// Letter/Number categories plus whitespace. Known dialect risk (D4): Python's
|
|
348
|
+
// str.casefold() vs JS's toLowerCase() diverge on ß/ſ-class codepoints - the
|
|
349
|
+
// live-key dual-run (all 15 .specs/lessons.json keys) is the sensor; none of
|
|
350
|
+
// them reach that class as of this port.
|
|
351
|
+
const ALNUM_OR_SPACE_RE = /[\p{L}\p{N}\s]/u;
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Normalized dedup key for lesson text.
|
|
355
|
+
*
|
|
356
|
+
* - casefold (approximated by toLowerCase()) + NFD, strip combining marks (so
|
|
357
|
+
* Portuguese diacritics match ASCII peers)
|
|
358
|
+
* - keep characters where Unicode Letter/Number or whitespace (any script)
|
|
359
|
+
* - drop other punctuation, collapse whitespace
|
|
360
|
+
*
|
|
361
|
+
* Exact-after-normalization only - no semantic matching (this is a direct
|
|
362
|
+
* behavioral port of the stdlib-only Python original).
|
|
363
|
+
*/
|
|
364
|
+
export function norm(text: string): string {
|
|
365
|
+
let t = text.toLowerCase();
|
|
366
|
+
t = t.normalize("NFD");
|
|
367
|
+
t = t.replace(/\p{Mn}/gu, "");
|
|
368
|
+
const chars = Array.from(t);
|
|
369
|
+
t = chars.map((c) => (ALNUM_OR_SPACE_RE.test(c) ? c : " ")).join("");
|
|
370
|
+
t = t.replace(/\s+/g, " ").trim();
|
|
371
|
+
return t;
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
function selftestNorm(): number {
|
|
375
|
+
const failures: string[] = [];
|
|
376
|
+
const check = (cond: boolean, msg: string) => {
|
|
377
|
+
if (!cond) failures.push(msg);
|
|
378
|
+
};
|
|
379
|
+
|
|
380
|
+
const a = norm("Não use datas locais");
|
|
381
|
+
const b = norm("Nao use datas locais");
|
|
382
|
+
check(a === b && b === "nao use datas locais", `PT diacritics: ${pyRepr(a)} vs ${pyRepr(b)}`);
|
|
383
|
+
|
|
384
|
+
const jp1 = norm("日本語の文です");
|
|
385
|
+
const jp2 = norm("別の日本語文");
|
|
386
|
+
check(jp1 !== "", `JP1 empty: ${pyRepr(jp1)}`);
|
|
387
|
+
check(jp2 !== "", `JP2 empty: ${pyRepr(jp2)}`);
|
|
388
|
+
check(jp1 !== jp2, `JP sentences collapsed: ${pyRepr(jp1)} == ${pyRepr(jp2)}`);
|
|
389
|
+
|
|
390
|
+
const cafe1 = norm("café");
|
|
391
|
+
const cafe2 = norm("cafe");
|
|
392
|
+
check(cafe1 === cafe2 && cafe2 === "cafe", `cafe: ${pyRepr(cafe1)}`);
|
|
393
|
+
|
|
394
|
+
if (failures.length) {
|
|
395
|
+
for (const f of failures) console.error(`FAIL: ${f}`);
|
|
396
|
+
return 1;
|
|
397
|
+
}
|
|
398
|
+
console.log("selftest_norm: ok");
|
|
399
|
+
return 0;
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
function keyOf(signal: string, text: string): string {
|
|
403
|
+
return `${signal}::${norm(text)}`;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** Drop candidates that never recurred within the window. Mutates data. */
|
|
407
|
+
function autoPrune(data: Store): string[] {
|
|
408
|
+
const threshold = data.promote_threshold;
|
|
409
|
+
const window = data.window_days;
|
|
410
|
+
const nowMs = Date.now();
|
|
411
|
+
const kept: Lesson[] = [];
|
|
412
|
+
const dropped: string[] = [];
|
|
413
|
+
for (const l of data.lessons) {
|
|
414
|
+
if (l.status === "candidate" && l.recurrence < threshold) {
|
|
415
|
+
const lastSeen = l.last_seen ?? l.created ?? now();
|
|
416
|
+
const ageDays = Math.floor((nowMs - parseDate(lastSeen).getTime()) / 86400000);
|
|
417
|
+
if (ageDays > window) {
|
|
418
|
+
dropped.push(l.id);
|
|
419
|
+
continue;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
kept.push(l);
|
|
423
|
+
}
|
|
424
|
+
data.lessons = kept;
|
|
425
|
+
return dropped;
|
|
426
|
+
}
|
|
427
|
+
|
|
428
|
+
function find(data: Store, signal: string, text: string): Lesson | null {
|
|
429
|
+
const k = keyOf(signal, text);
|
|
430
|
+
for (const l of data.lessons) {
|
|
431
|
+
if (l.key === k) return l;
|
|
432
|
+
}
|
|
433
|
+
return null;
|
|
434
|
+
}
|
|
435
|
+
|
|
436
|
+
// ---------------------------------------------------------------------------
|
|
437
|
+
// Commands
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
|
|
440
|
+
function cmdInit(root: string): number {
|
|
441
|
+
const data = load(root);
|
|
442
|
+
save(root, data);
|
|
443
|
+
console.log(`Initialized lessons store at ${storePath(root)}`);
|
|
444
|
+
return 0;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
interface AddArgs {
|
|
448
|
+
feature: string;
|
|
449
|
+
signal: string;
|
|
450
|
+
source: string;
|
|
451
|
+
text: string;
|
|
452
|
+
scope: string;
|
|
453
|
+
project: string;
|
|
454
|
+
session: string;
|
|
455
|
+
workflow: string;
|
|
456
|
+
entity: string;
|
|
457
|
+
}
|
|
458
|
+
|
|
459
|
+
async function cmdAdd(root: string, args: AddArgs): Promise<number> {
|
|
460
|
+
const signal = args.signal;
|
|
461
|
+
const source = (args.source || "").trim();
|
|
462
|
+
const text = (args.text || "").trim();
|
|
463
|
+
const feature = (args.feature || "").trim();
|
|
464
|
+
|
|
465
|
+
// Grounding is enforced here, deterministically - not left to the prompt.
|
|
466
|
+
if (!(signal in SIGNALS)) {
|
|
467
|
+
console.error(`ERROR: --signal must be one of ${pyListRepr(SIGNAL_KEYS_SORTED)}`);
|
|
468
|
+
return 2;
|
|
469
|
+
}
|
|
470
|
+
if (!feature) {
|
|
471
|
+
console.error("ERROR: --feature is required (the feature the signal came from).");
|
|
472
|
+
return 2;
|
|
473
|
+
}
|
|
474
|
+
if (!source) {
|
|
475
|
+
console.error("ERROR: --source is required (file:line / AC id / mutant id / SPEC_DEVIATION ref).");
|
|
476
|
+
console.error(" A lesson with no grounding in validation.md is an opinion, not a lesson. Refused.");
|
|
477
|
+
return 2;
|
|
478
|
+
}
|
|
479
|
+
if (text.length < 12) {
|
|
480
|
+
console.error("ERROR: --text too short. State the actionable lesson in one terse sentence.");
|
|
481
|
+
return 2;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
const data = load(root);
|
|
485
|
+
autoPrune(data);
|
|
486
|
+
const existing = find(data, signal, text);
|
|
487
|
+
const nowStr = now();
|
|
488
|
+
const project = (args.project || "").trim();
|
|
489
|
+
const session = (args.session || "").trim();
|
|
490
|
+
const workflow = (args.workflow || "").trim();
|
|
491
|
+
const entity = (args.entity || "").trim();
|
|
492
|
+
|
|
493
|
+
const applyCtx = (lesson: Lesson) => {
|
|
494
|
+
if (project) lesson.project = project;
|
|
495
|
+
if (session) lesson.session = session;
|
|
496
|
+
if (workflow) lesson.workflow = workflow;
|
|
497
|
+
if (entity) lesson.entity = entity;
|
|
498
|
+
};
|
|
499
|
+
|
|
500
|
+
if (existing) {
|
|
501
|
+
if (!existing.features.includes(feature)) {
|
|
502
|
+
existing.features.push(feature);
|
|
503
|
+
}
|
|
504
|
+
existing.recurrence = existing.features.length;
|
|
505
|
+
existing.last_seen = nowStr;
|
|
506
|
+
applyCtx(existing);
|
|
507
|
+
existing.confidence = confidence(existing, data);
|
|
508
|
+
const ev = args.scope ? `${source} (${args.scope})` : source;
|
|
509
|
+
if (!existing.evidence.includes(ev)) {
|
|
510
|
+
existing.evidence.push(ev);
|
|
511
|
+
}
|
|
512
|
+
let promoted = false;
|
|
513
|
+
if (existing.status === "candidate" && existing.recurrence >= data.promote_threshold) {
|
|
514
|
+
existing.status = "confirmed";
|
|
515
|
+
promoted = true;
|
|
516
|
+
}
|
|
517
|
+
save(root, data);
|
|
518
|
+
await rememberBestEffort(`${existing.id} [${signal}] ${text}`, lessonTags(existing), project, session);
|
|
519
|
+
let msg = `UPDATED ${existing.id} (recurrence=${existing.recurrence}, status=${existing.status}, confidence=${pyFloatStr(existing.confidence!)})`;
|
|
520
|
+
if (promoted) msg += " - PROMOTED to confirmed";
|
|
521
|
+
console.log(msg);
|
|
522
|
+
} else {
|
|
523
|
+
const lid = `L-${String(data.next_id).padStart(3, "0")}`;
|
|
524
|
+
data.next_id += 1;
|
|
525
|
+
const lesson: Lesson = {
|
|
526
|
+
id: lid,
|
|
527
|
+
key: keyOf(signal, text),
|
|
528
|
+
text,
|
|
529
|
+
signal,
|
|
530
|
+
scope: (args.scope || "").trim(),
|
|
531
|
+
status: "candidate",
|
|
532
|
+
features: [feature],
|
|
533
|
+
recurrence: 1,
|
|
534
|
+
harmful: 0,
|
|
535
|
+
evidence: [args.scope ? `${source} (${args.scope})` : source],
|
|
536
|
+
created: nowStr,
|
|
537
|
+
last_seen: nowStr,
|
|
538
|
+
};
|
|
539
|
+
applyCtx(lesson);
|
|
540
|
+
lesson.confidence = confidence(lesson, data);
|
|
541
|
+
data.lessons.push(lesson);
|
|
542
|
+
save(root, data);
|
|
543
|
+
await rememberBestEffort(`${lid} [${signal}] ${text}`, lessonTags(lesson), project, session);
|
|
544
|
+
console.log(`ADDED ${lid} (status=candidate, recurrence=1, confidence=${pyFloatStr(lesson.confidence!)})`);
|
|
545
|
+
}
|
|
546
|
+
return 0;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
function cmdPenalize(root: string, id: string): number {
|
|
550
|
+
const data = load(root);
|
|
551
|
+
let target: Lesson | null = null;
|
|
552
|
+
for (const l of data.lessons) {
|
|
553
|
+
if (l.id.toLowerCase() === id.toLowerCase()) {
|
|
554
|
+
target = l;
|
|
555
|
+
break;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
if (!target) {
|
|
559
|
+
console.error(`ERROR: no lesson with id ${id}`);
|
|
560
|
+
return 2;
|
|
561
|
+
}
|
|
562
|
+
target.harmful = (target.harmful ?? 0) + 1;
|
|
563
|
+
target.last_seen = now();
|
|
564
|
+
if (target.harmful >= data.quarantine_threshold) {
|
|
565
|
+
target.status = "quarantined";
|
|
566
|
+
}
|
|
567
|
+
save(root, data);
|
|
568
|
+
console.log(`PENALIZED ${target.id} (harmful=${target.harmful}, status=${target.status})`);
|
|
569
|
+
return 0;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
interface ListArgs {
|
|
573
|
+
status: string;
|
|
574
|
+
query: string;
|
|
575
|
+
scope: string;
|
|
576
|
+
project: string;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
function cmdList(root: string, args: ListArgs): number {
|
|
580
|
+
const data = load(root);
|
|
581
|
+
if (autoPrune(data).length) {
|
|
582
|
+
save(root, data);
|
|
583
|
+
}
|
|
584
|
+
const want = args.status;
|
|
585
|
+
const q = (args.query || "").toLowerCase().trim();
|
|
586
|
+
const scope = (args.scope || "").toLowerCase().trim();
|
|
587
|
+
const project = (args.project || "").toLowerCase().trim();
|
|
588
|
+
const rows: Lesson[] = [];
|
|
589
|
+
for (const l of data.lessons) {
|
|
590
|
+
if (want !== "all" && l.status !== want) continue;
|
|
591
|
+
if (q && !l.text.toLowerCase().includes(q)) continue;
|
|
592
|
+
if (scope && !(l.scope || "").toLowerCase().includes(scope)) continue;
|
|
593
|
+
if (project && !(l.project || "").toLowerCase().includes(project)) continue;
|
|
594
|
+
rows.push(l);
|
|
595
|
+
}
|
|
596
|
+
if (!rows.length) {
|
|
597
|
+
const flt = [q, scope, project].filter((f) => f).join(" ");
|
|
598
|
+
console.log(`(no ${want} lessons` + (flt ? ` matching '${flt}'` : "") + ")");
|
|
599
|
+
return 0;
|
|
600
|
+
}
|
|
601
|
+
const sorted = [...rows].sort((x, y) => pyStringCompare(x.id, y.id));
|
|
602
|
+
for (const l of sorted) {
|
|
603
|
+
const sc = l.scope ? ` [scope:${l.scope}]` : "";
|
|
604
|
+
const conf = l.confidence ?? confidence(l, data);
|
|
605
|
+
console.log(`${l.id} (${l.status}, x${l.recurrence}, conf=${pyFloatStr(conf)})${sc}: ${l.text}`);
|
|
606
|
+
}
|
|
607
|
+
return 0;
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
/**
|
|
611
|
+
* Ingest a JSON observation into the gitignored observations buffer.
|
|
612
|
+
*
|
|
613
|
+
* Grounding is NOT enforced here; it is enforced when `add` consumes the
|
|
614
|
+
* buffer. Observation fields: signal, text, source, feature, scope, project,
|
|
615
|
+
* session, workflow, entity.
|
|
616
|
+
*/
|
|
617
|
+
function cmdObserve(root: string, jsonArg: string): number {
|
|
618
|
+
const raw = jsonArg ? jsonArg : readFileSync(0, "utf-8");
|
|
619
|
+
let item: unknown;
|
|
620
|
+
try {
|
|
621
|
+
item = JSON.parse(raw);
|
|
622
|
+
} catch (exc) {
|
|
623
|
+
console.error(`ERROR: observation is not valid JSON: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
624
|
+
return 2;
|
|
625
|
+
}
|
|
626
|
+
if (typeof item !== "object" || item === null || Array.isArray(item)) {
|
|
627
|
+
console.error("ERROR: observation must be a JSON object");
|
|
628
|
+
return 2;
|
|
629
|
+
}
|
|
630
|
+
pySetDefault(item as Record<string, unknown>, "observed_at", now());
|
|
631
|
+
obsAppend(root, item);
|
|
632
|
+
console.log(`OBSERVED buffer=1 (total=${obsLoad(root).length})`);
|
|
633
|
+
return 0;
|
|
634
|
+
}
|
|
635
|
+
|
|
636
|
+
/** Export the lessons store as JSON (stdout or --out). Round-trips with import. */
|
|
637
|
+
function cmdExport(root: string, out: string): number {
|
|
638
|
+
const data = load(root);
|
|
639
|
+
const text = `${pyJsonStringify(data)}\n`;
|
|
640
|
+
if (out) {
|
|
641
|
+
writeFileSync(out, text, "utf-8");
|
|
642
|
+
console.log(`EXPORTED ${data.lessons.length} lessons -> ${out}`);
|
|
643
|
+
} else {
|
|
644
|
+
process.stdout.write(text);
|
|
645
|
+
}
|
|
646
|
+
return 0;
|
|
647
|
+
}
|
|
648
|
+
|
|
649
|
+
/**
|
|
650
|
+
* Import lessons from JSON (stdin or --in), merging by dedup key.
|
|
651
|
+
*
|
|
652
|
+
* Re-emits massa-ai memory best-effort (type `pattern`, tag `memory:procedural`)
|
|
653
|
+
* for each imported lesson so the file store and massa-ai memory stay consistent.
|
|
654
|
+
*/
|
|
655
|
+
async function cmdImport(root: string, inPath: string | null): Promise<number> {
|
|
656
|
+
const raw = inPath === null ? readFileSync(0, "utf-8") : readFileSync(inPath, "utf-8");
|
|
657
|
+
let incoming: unknown;
|
|
658
|
+
try {
|
|
659
|
+
incoming = JSON.parse(raw);
|
|
660
|
+
} catch (exc) {
|
|
661
|
+
console.error(`ERROR: import payload is not valid JSON: ${exc instanceof Error ? exc.message : String(exc)}`);
|
|
662
|
+
return 2;
|
|
663
|
+
}
|
|
664
|
+
if (
|
|
665
|
+
typeof incoming !== "object" ||
|
|
666
|
+
incoming === null ||
|
|
667
|
+
!Array.isArray((incoming as Record<string, unknown>).lessons)
|
|
668
|
+
) {
|
|
669
|
+
console.error("ERROR: import payload must be a lessons store object with `lessons`");
|
|
670
|
+
return 2;
|
|
671
|
+
}
|
|
672
|
+
const data = load(root);
|
|
673
|
+
autoPrune(data);
|
|
674
|
+
const nowStr = now();
|
|
675
|
+
let added = 0;
|
|
676
|
+
let merged = 0;
|
|
677
|
+
const incomingLessons = (incoming as { lessons: Record<string, unknown>[] }).lessons;
|
|
678
|
+
for (const l of incomingLessons) {
|
|
679
|
+
const key = (l.key as string) || keyOf((l.signal as string) || "", (l.text as string) || "");
|
|
680
|
+
const existing = data.lessons.find((x) => x.key === key) ?? null;
|
|
681
|
+
if (existing) {
|
|
682
|
+
const incomingFeatures = Array.isArray(l.features) ? (l.features as string[]) : [];
|
|
683
|
+
for (const f of incomingFeatures) {
|
|
684
|
+
if (!existing.features.includes(f)) existing.features.push(f);
|
|
685
|
+
}
|
|
686
|
+
existing.recurrence = existing.features.length;
|
|
687
|
+
existing.last_seen = nowStr;
|
|
688
|
+
existing.confidence = confidence(existing, data);
|
|
689
|
+
merged += 1;
|
|
690
|
+
} else {
|
|
691
|
+
const lid = `L-${String(data.next_id).padStart(3, "0")}`;
|
|
692
|
+
data.next_id += 1;
|
|
693
|
+
pySetDefault(l, "id", lid);
|
|
694
|
+
l.id = lid;
|
|
695
|
+
l.key = key;
|
|
696
|
+
pySetDefault(l, "status", "candidate");
|
|
697
|
+
const featLen = Array.isArray(l.features) ? (l.features as unknown[]).length : 0;
|
|
698
|
+
pySetDefault(l, "recurrence", featLen || 1);
|
|
699
|
+
pySetDefault(l, "harmful", 0);
|
|
700
|
+
pySetDefault(l, "created", nowStr);
|
|
701
|
+
l.last_seen = nowStr;
|
|
702
|
+
(l as unknown as Lesson).confidence = confidence(l as unknown as Lesson, data);
|
|
703
|
+
data.lessons.push(l as unknown as Lesson);
|
|
704
|
+
added += 1;
|
|
705
|
+
}
|
|
706
|
+
const target = (existing ?? (l as unknown as Lesson)) as Lesson;
|
|
707
|
+
await rememberBestEffort(
|
|
708
|
+
`${target.id ?? ""} [${target.signal ?? ""}] ${target.text ?? ""}`,
|
|
709
|
+
lessonTags(target),
|
|
710
|
+
(target.project as string) || "",
|
|
711
|
+
(target.session as string) || "",
|
|
712
|
+
);
|
|
713
|
+
}
|
|
714
|
+
save(root, data);
|
|
715
|
+
console.log(`IMPORTED added=${added} merged=${merged} massa-ai=best-effort`);
|
|
716
|
+
return 0;
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
function cmdPrune(root: string): number {
|
|
720
|
+
const data = load(root);
|
|
721
|
+
const dropped = autoPrune(data);
|
|
722
|
+
save(root, data);
|
|
723
|
+
console.log(`Pruned ${dropped.length} stale candidate(s): ${dropped.length ? dropped.join(", ") : "-"}`);
|
|
724
|
+
return 0;
|
|
725
|
+
}
|
|
726
|
+
|
|
727
|
+
function cmdStatus(root: string): number {
|
|
728
|
+
const data = load(root);
|
|
729
|
+
const counts: Record<string, number> = { confirmed: 0, candidate: 0, quarantined: 0 };
|
|
730
|
+
for (const l of data.lessons) {
|
|
731
|
+
counts[l.status] = (counts[l.status] ?? 0) + 1;
|
|
732
|
+
}
|
|
733
|
+
const total = data.lessons.length;
|
|
734
|
+
console.log(
|
|
735
|
+
`lessons: ${total} total | confirmed=${counts.confirmed} candidate=${counts.candidate} quarantined=${counts.quarantined}`,
|
|
736
|
+
);
|
|
737
|
+
return 0;
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
// ---------------------------------------------------------------------------
|
|
741
|
+
// CLI
|
|
742
|
+
// ---------------------------------------------------------------------------
|
|
743
|
+
|
|
744
|
+
const PROG = "lessons.ts";
|
|
745
|
+
|
|
746
|
+
function usageError(msg: string): void {
|
|
747
|
+
process.stderr.write(`usage: ${PROG} [-h] [--root ROOT] {init,add,penalize,list,observe,export,import,prune,status,selftest} ...\n${PROG}: error: ${msg}\n`);
|
|
748
|
+
}
|
|
749
|
+
|
|
750
|
+
interface FlagSpec {
|
|
751
|
+
name: string; // "--feature"
|
|
752
|
+
required?: boolean;
|
|
753
|
+
default?: string;
|
|
754
|
+
choices?: string[];
|
|
755
|
+
}
|
|
756
|
+
|
|
757
|
+
function parseFlags(rest: string[], specs: FlagSpec[]): Record<string, string> | null {
|
|
758
|
+
const result: Record<string, string> = {};
|
|
759
|
+
for (const spec of specs) {
|
|
760
|
+
if (spec.default !== undefined) result[spec.name.slice(2)] = spec.default;
|
|
761
|
+
}
|
|
762
|
+
const specByName = new Map(specs.map((s) => [s.name, s]));
|
|
763
|
+
for (let i = 0; i < rest.length; i++) {
|
|
764
|
+
const a = rest[i]!;
|
|
765
|
+
let flagName = a;
|
|
766
|
+
let inlineValue: string | undefined;
|
|
767
|
+
if (a.startsWith("--")) {
|
|
768
|
+
const eqIdx = a.indexOf("=");
|
|
769
|
+
if (eqIdx !== -1) {
|
|
770
|
+
flagName = a.slice(0, eqIdx);
|
|
771
|
+
inlineValue = a.slice(eqIdx + 1);
|
|
772
|
+
}
|
|
773
|
+
}
|
|
774
|
+
const spec = specByName.get(flagName);
|
|
775
|
+
if (!spec) {
|
|
776
|
+
usageError(`unrecognized arguments: ${a}`);
|
|
777
|
+
return null;
|
|
778
|
+
}
|
|
779
|
+
let value: string;
|
|
780
|
+
if (inlineValue !== undefined) {
|
|
781
|
+
value = inlineValue;
|
|
782
|
+
} else {
|
|
783
|
+
if (i + 1 >= rest.length) {
|
|
784
|
+
usageError(`argument ${flagName}: expected one argument`);
|
|
785
|
+
return null;
|
|
786
|
+
}
|
|
787
|
+
value = rest[++i]!;
|
|
788
|
+
}
|
|
789
|
+
if (spec.choices && !spec.choices.includes(value)) {
|
|
790
|
+
usageError(`argument ${flagName}: invalid choice: ${pyRepr(value)} (choose from ${spec.choices.map(pyRepr).join(", ")})`);
|
|
791
|
+
return null;
|
|
792
|
+
}
|
|
793
|
+
result[spec.name.slice(2)] = value;
|
|
794
|
+
}
|
|
795
|
+
for (const spec of specs) {
|
|
796
|
+
if (spec.required && result[spec.name.slice(2)] === undefined) {
|
|
797
|
+
usageError(`the following arguments are required: ${spec.name}`);
|
|
798
|
+
return null;
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
return result;
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async function main(argv: string[]): Promise<number> {
|
|
805
|
+
let root = ".";
|
|
806
|
+
let i = 0;
|
|
807
|
+
while (i < argv.length) {
|
|
808
|
+
const a = argv[i]!;
|
|
809
|
+
if (a === "--root") {
|
|
810
|
+
if (i + 1 >= argv.length) {
|
|
811
|
+
usageError("argument --root: expected one argument");
|
|
812
|
+
return 2;
|
|
813
|
+
}
|
|
814
|
+
root = argv[i + 1]!;
|
|
815
|
+
i += 2;
|
|
816
|
+
continue;
|
|
817
|
+
}
|
|
818
|
+
if (a.startsWith("--root=")) {
|
|
819
|
+
root = a.slice("--root=".length);
|
|
820
|
+
i += 1;
|
|
821
|
+
continue;
|
|
822
|
+
}
|
|
823
|
+
break;
|
|
824
|
+
}
|
|
825
|
+
if (i >= argv.length) {
|
|
826
|
+
usageError("the following arguments are required: cmd");
|
|
827
|
+
return 2;
|
|
828
|
+
}
|
|
829
|
+
const cmd = argv[i]!;
|
|
830
|
+
const rest = argv.slice(i + 1);
|
|
831
|
+
const absRoot = resolve(root);
|
|
832
|
+
|
|
833
|
+
switch (cmd) {
|
|
834
|
+
case "init":
|
|
835
|
+
return cmdInit(absRoot);
|
|
836
|
+
|
|
837
|
+
case "add": {
|
|
838
|
+
const parsed = parseFlags(rest, [
|
|
839
|
+
{ name: "--feature", required: true },
|
|
840
|
+
{ name: "--signal", required: true, choices: SIGNAL_KEYS_SORTED },
|
|
841
|
+
{ name: "--source", required: true },
|
|
842
|
+
{ name: "--text", required: true },
|
|
843
|
+
{ name: "--scope", default: "" },
|
|
844
|
+
{ name: "--project", default: "" },
|
|
845
|
+
{ name: "--session", default: "" },
|
|
846
|
+
{ name: "--workflow", default: "" },
|
|
847
|
+
{ name: "--entity", default: "" },
|
|
848
|
+
]);
|
|
849
|
+
if (!parsed) return 2;
|
|
850
|
+
return cmdAdd(absRoot, parsed as unknown as AddArgs);
|
|
851
|
+
}
|
|
852
|
+
|
|
853
|
+
case "penalize": {
|
|
854
|
+
const parsed = parseFlags(rest, [{ name: "--id", required: true }]);
|
|
855
|
+
if (!parsed) return 2;
|
|
856
|
+
return cmdPenalize(absRoot, parsed.id!);
|
|
857
|
+
}
|
|
858
|
+
|
|
859
|
+
case "list": {
|
|
860
|
+
const parsed = parseFlags(rest, [
|
|
861
|
+
{ name: "--status", default: "confirmed", choices: ["confirmed", "candidate", "quarantined", "all"] },
|
|
862
|
+
{ name: "--query", default: "" },
|
|
863
|
+
{ name: "--scope", default: "" },
|
|
864
|
+
{ name: "--project", default: "" },
|
|
865
|
+
]);
|
|
866
|
+
if (!parsed) return 2;
|
|
867
|
+
return cmdList(absRoot, parsed as unknown as ListArgs);
|
|
868
|
+
}
|
|
869
|
+
|
|
870
|
+
case "observe": {
|
|
871
|
+
const parsed = parseFlags(rest, [{ name: "--json", default: "" }]);
|
|
872
|
+
if (!parsed) return 2;
|
|
873
|
+
return cmdObserve(absRoot, parsed.json!);
|
|
874
|
+
}
|
|
875
|
+
|
|
876
|
+
case "export": {
|
|
877
|
+
const parsed = parseFlags(rest, [{ name: "--out", default: "" }]);
|
|
878
|
+
if (!parsed) return 2;
|
|
879
|
+
return cmdExport(absRoot, parsed.out!);
|
|
880
|
+
}
|
|
881
|
+
|
|
882
|
+
case "import": {
|
|
883
|
+
const parsed = parseFlags(rest, [{ name: "--in", default: "" }]);
|
|
884
|
+
if (!parsed) return 2;
|
|
885
|
+
return cmdImport(absRoot, parsed.in ? parsed.in : null);
|
|
886
|
+
}
|
|
887
|
+
|
|
888
|
+
case "prune":
|
|
889
|
+
return cmdPrune(absRoot);
|
|
890
|
+
|
|
891
|
+
case "status":
|
|
892
|
+
return cmdStatus(absRoot);
|
|
893
|
+
|
|
894
|
+
case "selftest":
|
|
895
|
+
return selftestNorm();
|
|
896
|
+
|
|
897
|
+
default:
|
|
898
|
+
usageError(
|
|
899
|
+
`argument cmd: invalid choice: ${pyRepr(cmd)} (choose from 'init', 'add', 'penalize', 'list', 'observe', 'export', 'import', 'prune', 'status', 'selftest')`,
|
|
900
|
+
);
|
|
901
|
+
return 2;
|
|
902
|
+
}
|
|
903
|
+
}
|
|
904
|
+
|
|
905
|
+
if (import.meta.main) {
|
|
906
|
+
main(process.argv.slice(2)).then((code) => process.exit(code));
|
|
907
|
+
}
|