@massa-ai/cursor-plugin 1.20.0 → 1.22.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/agents/audit-specialist/SKILL.md +1 -1
- package/skills/agents/context-curator/SKILL.md +1 -1
- package/skills/agents/furps-analyst/SKILL.md +1 -1
- package/skills/agents/investigator/SKILL.md +1 -1
- package/skills/agents/mobile-specialist/SKILL.md +1 -1
- package/skills/agents/navigator/SKILL.md +1 -1
- package/skills/agents/requirements-analyst/SKILL.md +1 -1
- package/skills/agents/reviewer/SKILL.md +1 -1
- package/skills/agents/verification-agent/SKILL.md +1 -1
- package/skills/massa-ai/SKILL.md +10 -0
- package/skills/massa-ai/references/evidence-gate.md +1 -1
- package/skills/massa-ai/references/hook-enforcement.md +2 -2
- package/skills/massa-ai/references/implementation-delivery.md +12 -1
- package/skills/massa-ai/references/lessons.md +9 -10
- package/skills/massa-ai/references/mcp-tools.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/coding-principles.md +16 -0
- package/skills/massa-ai/references/spec-driven/design.md +3 -3
- package/skills/massa-ai/references/spec-driven/discuss.md +35 -12
- package/skills/massa-ai/references/spec-driven/execute.md +42 -25
- package/skills/massa-ai/references/spec-driven/memory.md +12 -2
- package/skills/massa-ai/references/spec-driven/specify.md +32 -14
- package/skills/massa-ai/references/spec-driven/sub-agents.md +33 -6
- package/skills/massa-ai/references/spec-driven/tasks.md +8 -6
- package/skills/massa-ai/references/spec-driven/validate.md +17 -12
- 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/exploration.md +1 -1
- package/skills/massa-ai/workflows/feature.md +1 -1
- 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-fix.md +1 -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 +7 -6
- package/skills/massa-ai/workflows/tests/tests-fix.md +1 -1
- package/skills/massa-ai/scripts/lessons.py +0 -590
|
@@ -27,6 +27,7 @@ Holds for every task, even if reference files are not opened:
|
|
|
27
27
|
2. The gate must pass (tests pass) before a task is done — the test runner decides, not self-assessment.
|
|
28
28
|
3. One atomic commit per task. Never batch tasks; never weaken, skip, or delete tests to make them pass.
|
|
29
29
|
4. After the last task, a fresh verification-agent always runs automatically (author ≠ verifier) — spec-anchored outcome check plus discrimination sensor. Never optional, never prompted.
|
|
30
|
+
5. **Blast radius (approval ≠ remote authority):** Approving Execute for this feature authorizes local implementation and local commits, and covers one delivery through PR creation — branch push and `gh pr create` — under one explicit go-ahead given at Execute start. Force-push, deploy, production database changes, merges, and any other remote/externally-visible/destructive operation always require a separate explicit go-ahead, even after that authorization.
|
|
30
31
|
|
|
31
32
|
## Auto-Sizing
|
|
32
33
|
|
|
@@ -39,7 +40,7 @@ Complexity determines depth, not a fixed pipeline. Assess scope first, apply onl
|
|
|
39
40
|
| Large | >10 tasks OR multi-component feature | Full spec + requirement IDs | Architecture + components | Full breakdown + deps | Implement + verify per task |
|
|
40
41
|
| Complex | Ambiguity or new domain (unfamiliar vocabulary, no prior pattern) | Full spec + discuss gray areas | Research + architecture | Breakdown + phase plan | Implement + interactive UAT |
|
|
41
42
|
|
|
42
|
-
A "phase" is a group of tasks sharing a dependency boundary or a checkpoint commit — it is distinct from a single task or atomic step. The sub-agent offer fires when a formal `tasks.md`
|
|
43
|
+
A "phase" is a group of tasks sharing a dependency boundary or a checkpoint commit — it is distinct from a single task or atomic step. The sub-agent offer fires when a formal `tasks.md` has more than 3 tasks — packing itself still uses ~7-task batches; a 4–8-task feature is offered as a single batch worker.
|
|
43
44
|
|
|
44
45
|
- Specify and Execute are always required.
|
|
45
46
|
- Design is skipped when straightforward (no architectural decisions, no new patterns).
|
|
@@ -80,7 +81,7 @@ Quick artifacts live under `.specs/quick/NNN-slug/` with a `TASK.md` (one-line i
|
|
|
80
81
|
- Load current canonical artifacts from `.specs/` files: `.specs/project/FEATURES.json`, `.specs/project/STATE.md`, `.specs/HANDOFF.md`, existing `.specs/features/<slug>/` phase artifacts, lessons, and project source. Current repository source and approved `.specs/` artifacts override stale memory, old handoff notes, external summaries, debug exports, or unapproved generated plans.
|
|
81
82
|
- `search` only counts as codebase evidence when it is fresh for the current repository path and commit/worktree state, or when the result is confirmed against source files read in this session.
|
|
82
83
|
- Load confirmed project lessons through `references/lessons.md` when `.specs/lessons.json` exists:
|
|
83
|
-
`
|
|
84
|
+
`bun skills/massa-ai/scripts/lessons.ts --root . list --status confirmed`
|
|
84
85
|
3. Run `Specify` with `references/spec-driven/specify.md`.
|
|
85
86
|
- Capture stable requirement IDs, testable acceptance criteria, edge cases, and explicit out-of-scope items.
|
|
86
87
|
- Run `references/spec-driven/discuss.md` inside Specify when gray areas, implicit requirements, persistence/state, external calls, auth, payments, concurrency, or state transitions affect behavior.
|
|
@@ -93,7 +94,7 @@ Quick artifacts live under `.specs/quick/NNN-slug/` with a `TASK.md` (one-line i
|
|
|
93
94
|
- Run repo-rules discovery from `references/repo-rules-discovery.md` before the first repository mutation: record the harness sources loaded (or `repo-rules: none present`), and implement so every new or changed file conforms to the target repo's module layout, unit-test location, and testing-area conventions. A repo rule wins over a skill default for placement and gate commands; record any deviation with an explicit reason. Never fabricate rules or create `.claude/`/`.cursor/` directories the repo lacks.
|
|
94
95
|
- Use the Test Coverage Matrix and Gate Check Commands from `tasks.md`, or state their inline equivalents when Tasks was skipped.
|
|
95
96
|
- Ask the MCP and skill question in Tasks or inline Execute when tool choice can change correctness or verification.
|
|
96
|
-
- If a formal `tasks.md`
|
|
97
|
+
- If a formal `tasks.md` has more than 3 tasks, present the sub-agent offer from `references/spec-driven/sub-agents.md` before starting Execute — even when packing yields a single batch (a 4–8-task feature is offered as one batch worker). Offer-then-confirm — never auto-spawn; the user must accept before any sub-agent is dispatched. One worker per batch (~7 tasks, whole phases): each batch worker executes all its tasks in order (implement → gate → atomic commit), then reports a compact summary (tasks done, commit hashes, test counts, deviations). Workers never spawn further sub-agents.
|
|
97
98
|
- Implement one atomic step or approved task at a time.
|
|
98
99
|
- For long-running task sequences, create a checkpoint via `create_checkpoint` at task boundaries with `taskId`, `description`, `progressPercent`, `currentStep`, `nextAction`, `fileChanges`, and `checkpointType: "manual"` so progress is resumable after interruption.
|
|
99
100
|
- If resuming after interruption, call `list_checkpoints` with the `taskId` and `restore_checkpoint` to recover task state before continuing. If `create_checkpoint` is unavailable (e.g. `task_checkpoints` table missing), continue with `.specs/` artifact state as the fallback.
|
|
@@ -116,7 +117,7 @@ Quick artifacts live under `.specs/quick/NNN-slug/` with a `TASK.md` (one-line i
|
|
|
116
117
|
- The verification-agent re-derives coverage independently using evidence-or-zero and does not inherit the author's mental model.
|
|
117
118
|
- The fix → re-verify loop is capped at 3 iterations before escalating to `Blocked`.
|
|
118
119
|
- Distill lesson signals through `references/lessons.md` when validation produces grounded reusable failures.
|
|
119
|
-
7.
|
|
120
|
+
7. Before the delivery chain's Propose stage (PR creation), write and commit `.specs/project/STATE.md`, `.specs/HANDOFF.md`, and `.specs/project/FEATURES.json` on the branch — not merely "after meaningful progress" during Execute, but committed before `gh pr create`. **Deterministic backing (run it, do not eyeball it):** `bun skills/massa-ai/scripts/check_specs_delivered.ts <feature> [--root .]` — a non-zero exit blocks Propose (see `references/implementation-delivery.md` stage 3.5 and GATE-02). If no code-execution tool is available, run the same checks by reading the artifact (graceful degradation preserved). Record `references/spec-driven/memory.md` decisions, blockers, handoff, and completion evidence per that reference's write triggers.
|
|
120
121
|
8. When the user splits planning and implementation across clean chats, resume from the canonical `.specs/` artifacts — `.specs/project/STATE.md`, `.specs/project/FEATURES.json`, `.specs/HANDOFF.md`, and the feature's phase files. This workflow owns the spec phase contracts on both sides of the split; there is no separate save/load procedure.
|
|
121
122
|
9. Complete the configured Plan Challenge Gate for non-trivial plans and complete `references/evidence-gate.md` before claiming completion.
|
|
122
123
|
|
|
@@ -127,7 +128,7 @@ Quick artifacts live under `.specs/quick/NNN-slug/` with a `TASK.md` (one-line i
|
|
|
127
128
|
- Feature registry is `.specs/project/FEATURES.json`.
|
|
128
129
|
- Handoff is `.specs/HANDOFF.md`.
|
|
129
130
|
- `.specs/` files are canonical and git-tracked; there is no secondary store.
|
|
130
|
-
- `.specs/lessons.json` is the machine-owned canonical lessons state
|
|
131
|
+
- `.specs/lessons.json` is the machine-owned canonical lessons state, the single lessons store — do not hand-edit; `lessons list` is the on-demand view.
|
|
131
132
|
- Optional `.specs-exports/` projections are untracked debug aids only.
|
|
132
133
|
- `remember` remains canonical for durable cross-session decisions, rejected approaches, reusable patterns, and verification recipes.
|
|
133
134
|
|
|
@@ -150,7 +151,7 @@ When researching, designing, or making any technical decision, follow this chain
|
|
|
150
151
|
|
|
151
152
|
```
|
|
152
153
|
Step 1: Codebase → existing code, conventions, patterns already in use
|
|
153
|
-
Step 2: Project docs → README, docs/, inline comments, .specs/project/STATE.md (Decisions)
|
|
154
|
+
Step 2: Project docs (leads, not truth) → README, docs/, inline comments, .specs/project/STATE.md (Decisions) — verify against current source before relying
|
|
154
155
|
Step 3: Context7 MCP → resolve library ID, then query for current API/patterns
|
|
155
156
|
Step 4: Web search → official docs, reputable sources, community patterns
|
|
156
157
|
Step 5: Flag as uncertain → "I'm not certain about X — here's my reasoning, but verify"
|
|
@@ -52,7 +52,7 @@ Do not use this workflow for findings-only test coverage, assertion quality, fix
|
|
|
52
52
|
10. Use strict harness sensors:
|
|
53
53
|
- Never rely on AI subjective evaluation.
|
|
54
54
|
- If verification found a reusable signal (`ac_gap`, `surviving_mutant`, `spec_precision_gap`, `spec_deviation`, `gate_fail`), record it via `references/lessons.md`:
|
|
55
|
-
`
|
|
55
|
+
`bun skills/massa-ai/scripts/lessons.ts --root . add --feature "<slug>" --signal "<signal>" --source "<ref>" --text "<one terse lesson>"`
|
|
56
56
|
- Apply the Mandatory Verification Fix Gate from `references/verification-ladder.md`: run the report's Verification Suggestion or an equivalent deterministic command/artifact check for each selected finding or coherent group.
|
|
57
57
|
- Run the actual focused test command first, then broader relevant suites when feasible, such as `rtk yarn test`, `rtk npm test`, `rtk pytest`, or `rtk cargo test`.
|
|
58
58
|
- Continue only when the execution harness returns a clean exit code, or report the exact skipped-check reason.
|
|
@@ -1,590 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
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, and
|
|
8
|
-
rendering the human/agent-readable playbook. Bookkeeping by hand is exactly what
|
|
9
|
-
rots a lessons file, so it lives here, not in a prompt.
|
|
10
|
-
|
|
11
|
-
Canonical state: .specs/lessons.json (machine-owned - do NOT hand-edit)
|
|
12
|
-
Rendered view: .specs/LESSONS.md (regenerated on every write)
|
|
13
|
-
|
|
14
|
-
Pure standard library. No dependencies. Pass --root with the target workspace
|
|
15
|
-
root so the package-local script writes that workspace's .specs directory.
|
|
16
|
-
|
|
17
|
-
Commands:
|
|
18
|
-
add Record a grounded lesson from a verification signal.
|
|
19
|
-
list Print lessons (default: confirmed) for loading at Specify/Design.
|
|
20
|
-
penalize Mark a confirmed lesson as having failed when applied (-> quarantine).
|
|
21
|
-
prune Drop stale uncorroborated candidates (also runs automatically on add/list).
|
|
22
|
-
status Print counts (used by the self-check in validate.md).
|
|
23
|
-
init Create empty store + rendered file.
|
|
24
|
-
observe Ingest a JSON observation into the gitignored observations buffer.
|
|
25
|
-
export Export the lessons store as JSON (round-trips with import).
|
|
26
|
-
import Import lessons from JSON (merge by dedup key; best-effort massa-ai memory).
|
|
27
|
-
|
|
28
|
-
Exit codes: 0 ok, 2 usage/validation error (e.g. missing grounding).
|
|
29
|
-
"""
|
|
30
|
-
|
|
31
|
-
import argparse
|
|
32
|
-
import datetime as _dt
|
|
33
|
-
import json
|
|
34
|
-
import os
|
|
35
|
-
import re
|
|
36
|
-
import sys
|
|
37
|
-
import urllib.request
|
|
38
|
-
|
|
39
|
-
STORE_REL = os.path.join(".specs", "lessons.json")
|
|
40
|
-
RENDER_REL = os.path.join(".specs", "LESSONS.md")
|
|
41
|
-
OBS_REL = os.path.join(".specs", "observations.json")
|
|
42
|
-
|
|
43
|
-
SIGNALS = {
|
|
44
|
-
"ac_gap": "Acceptance criterion not covered / failed",
|
|
45
|
-
"surviving_mutant": "Discrimination sensor mutant survived (weak test)",
|
|
46
|
-
"spec_precision_gap": "Spec did not define a precise outcome",
|
|
47
|
-
"spec_deviation": "Implementation diverged from spec/design (SPEC_DEVIATION)",
|
|
48
|
-
"gate_fail": "Build-level gate check failed",
|
|
49
|
-
}
|
|
50
|
-
|
|
51
|
-
DEFAULTS = {"promote_threshold": 2, "window_days": 45, "quarantine_threshold": 2}
|
|
52
|
-
|
|
53
|
-
# massa-ai supported memory types (references/mcp-tools.md). `procedural` is a
|
|
54
|
-
# TAG, never a type. Lessons are procedural knowledge -> type `pattern`.
|
|
55
|
-
MASSA_AI_SUPPORTED_TYPES = ("critical", "conversation", "code", "decision", "pattern")
|
|
56
|
-
MASSA_AI_LESSON_TYPE = "pattern"
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
def _now():
|
|
60
|
-
return _dt.datetime.now(_dt.timezone.utc).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
def _parse_date(s):
|
|
64
|
-
try:
|
|
65
|
-
return _dt.datetime.strptime(s, "%Y-%m-%dT%H:%M:%SZ").replace(tzinfo=_dt.timezone.utc)
|
|
66
|
-
except Exception:
|
|
67
|
-
return _dt.datetime.now(_dt.timezone.utc)
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
def _store_path(root):
|
|
71
|
-
return os.path.join(root, STORE_REL)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def _render_path(root):
|
|
75
|
-
return os.path.join(root, RENDER_REL)
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
def _load(root):
|
|
79
|
-
path = _store_path(root)
|
|
80
|
-
if not os.path.exists(path):
|
|
81
|
-
return {
|
|
82
|
-
"schema": 1,
|
|
83
|
-
"promote_threshold": DEFAULTS["promote_threshold"],
|
|
84
|
-
"window_days": DEFAULTS["window_days"],
|
|
85
|
-
"quarantine_threshold": DEFAULTS["quarantine_threshold"],
|
|
86
|
-
"next_id": 1,
|
|
87
|
-
"lessons": [],
|
|
88
|
-
}
|
|
89
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
90
|
-
data = json.load(f)
|
|
91
|
-
for k, v in DEFAULTS.items():
|
|
92
|
-
data.setdefault(k, v)
|
|
93
|
-
data.setdefault("schema", 1)
|
|
94
|
-
data.setdefault("next_id", 1)
|
|
95
|
-
data.setdefault("lessons", [])
|
|
96
|
-
return data
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
def _save(root, data):
|
|
100
|
-
os.makedirs(os.path.join(root, ".specs"), exist_ok=True)
|
|
101
|
-
with open(_store_path(root), "w", encoding="utf-8") as f:
|
|
102
|
-
json.dump(data, f, indent=2, ensure_ascii=False)
|
|
103
|
-
f.write("\n")
|
|
104
|
-
_render(root, data)
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
def _confidence(lesson, data):
|
|
108
|
-
"""Deterministic 0-1 confidence from recurrence + signal + scope presence."""
|
|
109
|
-
rec_cap = min(lesson.get("recurrence", 1) / max(data["promote_threshold"], 1), 1.0)
|
|
110
|
-
sig_weight = 0.15
|
|
111
|
-
scope_weight = 0.10 if lesson.get("scope") else 0.0
|
|
112
|
-
return round(min(rec_cap * 0.75 + sig_weight + scope_weight, 1.0), 2)
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
def _obs_path(root):
|
|
116
|
-
return os.path.join(root, OBS_REL)
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
def _obs_load(root):
|
|
120
|
-
path = _obs_path(root)
|
|
121
|
-
if not os.path.exists(path):
|
|
122
|
-
return []
|
|
123
|
-
try:
|
|
124
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
125
|
-
data = json.load(f)
|
|
126
|
-
return data if isinstance(data, list) else []
|
|
127
|
-
except (ValueError, OSError):
|
|
128
|
-
return []
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
def _obs_append(root, item):
|
|
132
|
-
os.makedirs(os.path.join(root, ".specs"), exist_ok=True)
|
|
133
|
-
items = _obs_load(root)
|
|
134
|
-
items.append(item)
|
|
135
|
-
with open(_obs_path(root), "w", encoding="utf-8") as f:
|
|
136
|
-
json.dump(items, f, indent=2, ensure_ascii=False)
|
|
137
|
-
f.write("\n")
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
def _remember_best_effort(root, content, tags, project_id="", session_id=""):
|
|
141
|
-
"""Best-effort massa-ai memory write via REST (urllib, stdlib only).
|
|
142
|
-
|
|
143
|
-
massa-ai MCP is agent-side only; a CLI subprocess cannot call MCP. massa-ai exposes
|
|
144
|
-
REST at MASSA_AI_API_URL. Type is always `pattern` (lessons are procedural
|
|
145
|
-
knowledge); `procedural` is a tag, not a type. Returns True on success,
|
|
146
|
-
False (silent) when unavailable — the file store remains source of truth.
|
|
147
|
-
"""
|
|
148
|
-
api_url = os.environ.get("MASSA_AI_API_URL")
|
|
149
|
-
if not api_url:
|
|
150
|
-
return False
|
|
151
|
-
path = os.environ.get("MASSA_AI_MEMORY_PATH", "/api/v1/memory")
|
|
152
|
-
url = api_url.rstrip("/") + path
|
|
153
|
-
body = json.dumps({
|
|
154
|
-
"content": content, "type": MASSA_AI_LESSON_TYPE, "importance": 0.6,
|
|
155
|
-
"projectId": project_id, "sessionId": session_id, "tags": list(tags),
|
|
156
|
-
}).encode("utf-8")
|
|
157
|
-
req = urllib.request.Request(url, data=body, method="POST",
|
|
158
|
-
headers={"Content-Type": "application/json"})
|
|
159
|
-
key = os.environ.get("MASSA_AI_API_KEY")
|
|
160
|
-
if key:
|
|
161
|
-
req.add_header("x-api-key", key)
|
|
162
|
-
try:
|
|
163
|
-
with urllib.request.urlopen(req, timeout=1.5) as resp:
|
|
164
|
-
return 200 <= resp.status < 300
|
|
165
|
-
except Exception:
|
|
166
|
-
return False
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
def _lesson_tags(lesson):
|
|
170
|
-
"""massa-ai persistence tag contract for a lesson's massa-ai memory."""
|
|
171
|
-
return [
|
|
172
|
-
"project:%s" % lesson.get("project", ""),
|
|
173
|
-
"session:%s" % lesson.get("session", ""),
|
|
174
|
-
"workflow:%s" % (lesson.get("workflow", "") or "unset"),
|
|
175
|
-
"entity:%s" % (lesson.get("entity", "") or "unset"),
|
|
176
|
-
"memory:procedural",
|
|
177
|
-
]
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
def _norm(text):
|
|
181
|
-
"""Normalized dedup key: lowercase, strip punctuation, collapse whitespace.
|
|
182
|
-
Exact-after-normalization only - no semantic matching (stdlib-only limitation).
|
|
183
|
-
Phrase lessons tersely and canonically so recurrences actually merge."""
|
|
184
|
-
t = text.lower().strip()
|
|
185
|
-
t = re.sub(r"[^a-z0-9\s]", " ", t)
|
|
186
|
-
t = re.sub(r"\s+", " ", t).strip()
|
|
187
|
-
return t
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
def _key(signal, text):
|
|
191
|
-
return signal + "::" + _norm(text)
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
def _auto_prune(data):
|
|
195
|
-
"""Drop candidates that never recurred within the window. Mutates data."""
|
|
196
|
-
threshold = data["promote_threshold"]
|
|
197
|
-
window = data["window_days"]
|
|
198
|
-
now = _dt.datetime.now(_dt.timezone.utc)
|
|
199
|
-
kept = []
|
|
200
|
-
dropped = []
|
|
201
|
-
for l in data["lessons"]:
|
|
202
|
-
if l["status"] == "candidate" and l["recurrence"] < threshold:
|
|
203
|
-
age_days = (now - _parse_date(l.get("last_seen", l.get("created", _now())))).days
|
|
204
|
-
if age_days > window:
|
|
205
|
-
dropped.append(l["id"])
|
|
206
|
-
continue
|
|
207
|
-
kept.append(l)
|
|
208
|
-
data["lessons"] = kept
|
|
209
|
-
return dropped
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
def _find(data, signal, text):
|
|
213
|
-
k = _key(signal, text)
|
|
214
|
-
for l in data["lessons"]:
|
|
215
|
-
if l.get("key") == k:
|
|
216
|
-
return l
|
|
217
|
-
return None
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
def _render(root, data):
|
|
221
|
-
lines = []
|
|
222
|
-
lines.append("# LESSONS - auto-maintained by skills/massa-ai/scripts/lessons.py")
|
|
223
|
-
lines.append("")
|
|
224
|
-
lines.append("> Machine-owned. Do NOT hand-edit. Changes are overwritten on the next `lessons.py` write.")
|
|
225
|
-
lines.append("> Canonical state lives in `.specs/lessons.json`. Edit lessons only via the script.")
|
|
226
|
-
lines.append(f"> promote_threshold={data['promote_threshold']} distinct features | window_days={data['window_days']} | quarantine_threshold={data['quarantine_threshold']}")
|
|
227
|
-
lines.append("")
|
|
228
|
-
|
|
229
|
-
by_status = {"confirmed": [], "candidate": [], "quarantined": []}
|
|
230
|
-
for l in data["lessons"]:
|
|
231
|
-
by_status.get(l["status"], by_status["candidate"]).append(l)
|
|
232
|
-
|
|
233
|
-
def block(title, items, note):
|
|
234
|
-
out = [f"## {title}", ""]
|
|
235
|
-
if note:
|
|
236
|
-
out.append(note)
|
|
237
|
-
out.append("")
|
|
238
|
-
if not items:
|
|
239
|
-
out.append("_none_")
|
|
240
|
-
out.append("")
|
|
241
|
-
return out
|
|
242
|
-
for l in sorted(items, key=lambda x: x["id"]):
|
|
243
|
-
scope = f" | scope: `{l['scope']}`" if l.get("scope") else ""
|
|
244
|
-
conf = l.get("confidence", _confidence(l, data))
|
|
245
|
-
out.append(f"### {l['id']} - {l['text']}")
|
|
246
|
-
out.append(
|
|
247
|
-
f"- signal: `{l['signal']}` | recurrence: {l['recurrence']} feature(s){scope} | harmful: {l.get('harmful', 0)} | confidence: {conf}"
|
|
248
|
-
)
|
|
249
|
-
feats = ", ".join(l.get("features", [])) or "-"
|
|
250
|
-
out.append(f"- features: {feats}")
|
|
251
|
-
ctx = []
|
|
252
|
-
for k in ("project", "session", "workflow", "entity"):
|
|
253
|
-
if l.get(k):
|
|
254
|
-
ctx.append(f"{k}={l[k]}")
|
|
255
|
-
if ctx:
|
|
256
|
-
out.append(f"- context: {' '.join(ctx)}")
|
|
257
|
-
ev = l.get("evidence", [])
|
|
258
|
-
if ev:
|
|
259
|
-
out.append(f"- evidence: {ev[0]}" + (f" (+{len(ev) - 1} more)" if len(ev) > 1 else ""))
|
|
260
|
-
out.append(f"- last seen: {l.get('last_seen', '-')}")
|
|
261
|
-
out.append("")
|
|
262
|
-
return out
|
|
263
|
-
|
|
264
|
-
lines += block(
|
|
265
|
-
"Confirmed (load these at Specify/Design)",
|
|
266
|
-
by_status["confirmed"],
|
|
267
|
-
"Corroborated across multiple features. Safe to apply as guidance.",
|
|
268
|
-
)
|
|
269
|
-
lines += block(
|
|
270
|
-
"Candidates (under observation - do NOT load as guidance yet)",
|
|
271
|
-
by_status["candidate"],
|
|
272
|
-
"Seen once or not yet corroborated. Tracked, not trusted.",
|
|
273
|
-
)
|
|
274
|
-
lines += block(
|
|
275
|
-
"Quarantined (failed when applied - ignore)",
|
|
276
|
-
by_status["quarantined"],
|
|
277
|
-
"A confirmed lesson that recurred alongside failure. Kept for the maintainer to review.",
|
|
278
|
-
)
|
|
279
|
-
|
|
280
|
-
with open(_render_path(root), "w", encoding="utf-8") as f:
|
|
281
|
-
f.write("\n".join(lines).rstrip() + "\n")
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
# ----------------------------- commands -----------------------------
|
|
285
|
-
|
|
286
|
-
def cmd_init(root, args):
|
|
287
|
-
data = _load(root)
|
|
288
|
-
_save(root, data)
|
|
289
|
-
print(f"Initialized lessons store at {_store_path(root)} and {_render_path(root)}")
|
|
290
|
-
return 0
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
def cmd_add(root, args):
|
|
294
|
-
signal = args.signal
|
|
295
|
-
source = (args.source or "").strip()
|
|
296
|
-
text = (args.text or "").strip()
|
|
297
|
-
feature = (args.feature or "").strip()
|
|
298
|
-
|
|
299
|
-
# Grounding is enforced here, deterministically - not left to the prompt.
|
|
300
|
-
if signal not in SIGNALS:
|
|
301
|
-
print(f"ERROR: --signal must be one of {sorted(SIGNALS)}", file=sys.stderr)
|
|
302
|
-
return 2
|
|
303
|
-
if not feature:
|
|
304
|
-
print("ERROR: --feature is required (the feature the signal came from).", file=sys.stderr)
|
|
305
|
-
return 2
|
|
306
|
-
if not source:
|
|
307
|
-
print("ERROR: --source is required (file:line / AC id / mutant id / SPEC_DEVIATION ref).", file=sys.stderr)
|
|
308
|
-
print(" A lesson with no grounding in validation.md is an opinion, not a lesson. Refused.", file=sys.stderr)
|
|
309
|
-
return 2
|
|
310
|
-
if len(text) < 12:
|
|
311
|
-
print("ERROR: --text too short. State the actionable lesson in one terse sentence.", file=sys.stderr)
|
|
312
|
-
return 2
|
|
313
|
-
|
|
314
|
-
data = _load(root)
|
|
315
|
-
_auto_prune(data)
|
|
316
|
-
existing = _find(data, signal, text)
|
|
317
|
-
now = _now()
|
|
318
|
-
project = (getattr(args, "project", "") or "").strip()
|
|
319
|
-
session = (getattr(args, "session", "") or "").strip()
|
|
320
|
-
workflow = (getattr(args, "workflow", "") or "").strip()
|
|
321
|
-
entity = (getattr(args, "entity", "") or "").strip()
|
|
322
|
-
|
|
323
|
-
def _ctx(lesson):
|
|
324
|
-
if project:
|
|
325
|
-
lesson["project"] = project
|
|
326
|
-
if session:
|
|
327
|
-
lesson["session"] = session
|
|
328
|
-
if workflow:
|
|
329
|
-
lesson["workflow"] = workflow
|
|
330
|
-
if entity:
|
|
331
|
-
lesson["entity"] = entity
|
|
332
|
-
|
|
333
|
-
if existing:
|
|
334
|
-
if feature not in existing["features"]:
|
|
335
|
-
existing["features"].append(feature)
|
|
336
|
-
existing["recurrence"] = len(existing["features"])
|
|
337
|
-
existing["last_seen"] = now
|
|
338
|
-
_ctx(existing)
|
|
339
|
-
existing["confidence"] = _confidence(existing, data)
|
|
340
|
-
ev = source if not args.scope else f"{source} ({args.scope})"
|
|
341
|
-
if ev not in existing["evidence"]:
|
|
342
|
-
existing["evidence"].append(ev)
|
|
343
|
-
promoted = False
|
|
344
|
-
if existing["status"] == "candidate" and existing["recurrence"] >= data["promote_threshold"]:
|
|
345
|
-
existing["status"] = "confirmed"
|
|
346
|
-
promoted = True
|
|
347
|
-
_save(root, data)
|
|
348
|
-
_remember_best_effort(root, "%s [%s] %s" % (existing["id"], signal, text),
|
|
349
|
-
_lesson_tags(existing), project, session)
|
|
350
|
-
msg = f"UPDATED {existing['id']} (recurrence={existing['recurrence']}, status={existing['status']}, confidence={existing['confidence']})"
|
|
351
|
-
if promoted:
|
|
352
|
-
msg += " - PROMOTED to confirmed"
|
|
353
|
-
print(msg)
|
|
354
|
-
else:
|
|
355
|
-
lid = f"L-{data['next_id']:03d}"
|
|
356
|
-
data["next_id"] += 1
|
|
357
|
-
lesson = {
|
|
358
|
-
"id": lid,
|
|
359
|
-
"key": _key(signal, text),
|
|
360
|
-
"text": text,
|
|
361
|
-
"signal": signal,
|
|
362
|
-
"scope": (args.scope or "").strip(),
|
|
363
|
-
"status": "candidate",
|
|
364
|
-
"features": [feature],
|
|
365
|
-
"recurrence": 1,
|
|
366
|
-
"harmful": 0,
|
|
367
|
-
"evidence": [source if not args.scope else f"{source} ({args.scope})"],
|
|
368
|
-
"created": now,
|
|
369
|
-
"last_seen": now,
|
|
370
|
-
}
|
|
371
|
-
_ctx(lesson)
|
|
372
|
-
lesson["confidence"] = _confidence(lesson, data)
|
|
373
|
-
data["lessons"].append(lesson)
|
|
374
|
-
_save(root, data)
|
|
375
|
-
_remember_best_effort(root, "%s [%s] %s" % (lid, signal, text),
|
|
376
|
-
_lesson_tags(lesson), project, session)
|
|
377
|
-
print(f"ADDED {lid} (status=candidate, recurrence=1, confidence={lesson['confidence']})")
|
|
378
|
-
return 0
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
def cmd_penalize(root, args):
|
|
382
|
-
data = _load(root)
|
|
383
|
-
target = None
|
|
384
|
-
for l in data["lessons"]:
|
|
385
|
-
if l["id"].lower() == args.id.lower():
|
|
386
|
-
target = l
|
|
387
|
-
break
|
|
388
|
-
if not target:
|
|
389
|
-
print(f"ERROR: no lesson with id {args.id}", file=sys.stderr)
|
|
390
|
-
return 2
|
|
391
|
-
target["harmful"] = target.get("harmful", 0) + 1
|
|
392
|
-
target["last_seen"] = _now()
|
|
393
|
-
if target["harmful"] >= data["quarantine_threshold"]:
|
|
394
|
-
target["status"] = "quarantined"
|
|
395
|
-
_save(root, data)
|
|
396
|
-
print(f"PENALIZED {target['id']} (harmful={target['harmful']}, status={target['status']})")
|
|
397
|
-
return 0
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
def cmd_list(root, args):
|
|
401
|
-
data = _load(root)
|
|
402
|
-
if _auto_prune(data):
|
|
403
|
-
_save(root, data)
|
|
404
|
-
want = args.status
|
|
405
|
-
q = (args.query or "").lower().strip()
|
|
406
|
-
scope = (args.scope or "").lower().strip()
|
|
407
|
-
project = (getattr(args, "project", "") or "").lower().strip()
|
|
408
|
-
rows = []
|
|
409
|
-
for l in data["lessons"]:
|
|
410
|
-
if want != "all" and l["status"] != want:
|
|
411
|
-
continue
|
|
412
|
-
if q and q not in l["text"].lower():
|
|
413
|
-
continue
|
|
414
|
-
if scope and scope not in (l.get("scope", "").lower()):
|
|
415
|
-
continue
|
|
416
|
-
if project and project not in (l.get("project", "").lower()):
|
|
417
|
-
continue
|
|
418
|
-
rows.append(l)
|
|
419
|
-
if not rows:
|
|
420
|
-
flt = " ".join(f for f in (q, scope, project) if f)
|
|
421
|
-
print(f"(no {want} lessons" + (f" matching '{flt}'" if flt else "") + ")")
|
|
422
|
-
return 0
|
|
423
|
-
for l in sorted(rows, key=lambda x: x["id"]):
|
|
424
|
-
sc = f" [scope:{l['scope']}]" if l.get("scope") else ""
|
|
425
|
-
conf = l.get("confidence", _confidence(l, data))
|
|
426
|
-
print(f"{l['id']} ({l['status']}, x{l['recurrence']}, conf={conf}){sc}: {l['text']}")
|
|
427
|
-
return 0
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
def cmd_observe(root, args):
|
|
431
|
-
"""Ingest a JSON observation into the gitignored observations buffer.
|
|
432
|
-
|
|
433
|
-
Grounding is NOT enforced here; it is enforced when `add` consumes the
|
|
434
|
-
buffer. Observation fields: signal, text, source, feature, scope, project,
|
|
435
|
-
session, workflow, entity.
|
|
436
|
-
"""
|
|
437
|
-
raw = args.json if args.json else sys.stdin.read()
|
|
438
|
-
try:
|
|
439
|
-
item = json.loads(raw)
|
|
440
|
-
except (ValueError, TypeError) as exc:
|
|
441
|
-
print(f"ERROR: observation is not valid JSON: {exc}", file=sys.stderr)
|
|
442
|
-
return 2
|
|
443
|
-
if not isinstance(item, dict):
|
|
444
|
-
print("ERROR: observation must be a JSON object", file=sys.stderr)
|
|
445
|
-
return 2
|
|
446
|
-
item.setdefault("observed_at", _now())
|
|
447
|
-
_obs_append(root, item)
|
|
448
|
-
print(f"OBSERVED buffer=1 (total={len(_obs_load(root))})")
|
|
449
|
-
return 0
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
def cmd_export(root, args):
|
|
453
|
-
"""Export the lessons store as JSON (stdout or --out). Round-trips with import."""
|
|
454
|
-
data = _load(root)
|
|
455
|
-
text = json.dumps(data, indent=2, ensure_ascii=False) + "\n"
|
|
456
|
-
if args.out:
|
|
457
|
-
with open(args.out, "w", encoding="utf-8") as f:
|
|
458
|
-
f.write(text)
|
|
459
|
-
print(f"EXPORTED {len(data['lessons'])} lessons -> {args.out}")
|
|
460
|
-
else:
|
|
461
|
-
sys.stdout.write(text)
|
|
462
|
-
return 0
|
|
463
|
-
|
|
464
|
-
|
|
465
|
-
def cmd_import(root, args):
|
|
466
|
-
"""Import lessons from JSON (stdin or --in), merging by dedup key.
|
|
467
|
-
|
|
468
|
-
Re-emits massa-ai memory best-effort (type `pattern`, tag `memory:procedural`)
|
|
469
|
-
for each imported lesson so the file store and massa-ai memory stay consistent.
|
|
470
|
-
"""
|
|
471
|
-
raw = sys.stdin.read() if args.in_ is None else open(args.in_, "r", encoding="utf-8").read()
|
|
472
|
-
try:
|
|
473
|
-
incoming = json.loads(raw)
|
|
474
|
-
except (ValueError, TypeError) as exc:
|
|
475
|
-
print(f"ERROR: import payload is not valid JSON: {exc}", file=sys.stderr)
|
|
476
|
-
return 2
|
|
477
|
-
if not isinstance(incoming, dict) or not isinstance(incoming.get("lessons"), list):
|
|
478
|
-
print("ERROR: import payload must be a lessons store object with `lessons`", file=sys.stderr)
|
|
479
|
-
return 2
|
|
480
|
-
data = _load(root)
|
|
481
|
-
_auto_prune(data)
|
|
482
|
-
now = _now()
|
|
483
|
-
added = merged = 0
|
|
484
|
-
for l in incoming["lessons"]:
|
|
485
|
-
key = l.get("key") or _key(l.get("signal", ""), l.get("text", ""))
|
|
486
|
-
existing = next((x for x in data["lessons"] if x.get("key") == key), None)
|
|
487
|
-
if existing:
|
|
488
|
-
for f in l.get("features", []):
|
|
489
|
-
if f not in existing["features"]:
|
|
490
|
-
existing["features"].append(f)
|
|
491
|
-
existing["recurrence"] = len(existing["features"])
|
|
492
|
-
existing["last_seen"] = now
|
|
493
|
-
existing["confidence"] = _confidence(existing, data)
|
|
494
|
-
merged += 1
|
|
495
|
-
else:
|
|
496
|
-
lid = f"L-{data['next_id']:03d}"
|
|
497
|
-
data["next_id"] += 1
|
|
498
|
-
l.setdefault("id", lid)
|
|
499
|
-
l["id"] = lid
|
|
500
|
-
l["key"] = key
|
|
501
|
-
l.setdefault("status", "candidate")
|
|
502
|
-
l.setdefault("recurrence", len(l.get("features", [])) or 1)
|
|
503
|
-
l.setdefault("harmful", 0)
|
|
504
|
-
l.setdefault("created", now)
|
|
505
|
-
l["last_seen"] = now
|
|
506
|
-
l["confidence"] = _confidence(l, data)
|
|
507
|
-
data["lessons"].append(l)
|
|
508
|
-
added += 1
|
|
509
|
-
target = existing or l
|
|
510
|
-
_remember_best_effort(root, "%s [%s] %s" % (target.get("id"), target.get("signal", ""), target.get("text", "")),
|
|
511
|
-
_lesson_tags(target), target.get("project", ""), target.get("session", ""))
|
|
512
|
-
_save(root, data)
|
|
513
|
-
print(f"IMPORTED added={added} merged={merged} massa-ai=best-effort")
|
|
514
|
-
return 0
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
def cmd_prune(root, args):
|
|
518
|
-
data = _load(root)
|
|
519
|
-
dropped = _auto_prune(data)
|
|
520
|
-
_save(root, data)
|
|
521
|
-
print(f"Pruned {len(dropped)} stale candidate(s): {', '.join(dropped) if dropped else '-'}")
|
|
522
|
-
return 0
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
def cmd_status(root, args):
|
|
526
|
-
data = _load(root)
|
|
527
|
-
counts = {"confirmed": 0, "candidate": 0, "quarantined": 0}
|
|
528
|
-
for l in data["lessons"]:
|
|
529
|
-
counts[l["status"]] = counts.get(l["status"], 0) + 1
|
|
530
|
-
total = len(data["lessons"])
|
|
531
|
-
print(f"lessons: {total} total | confirmed={counts['confirmed']} candidate={counts['candidate']} quarantined={counts['quarantined']}")
|
|
532
|
-
return 0
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
def main(argv=None):
|
|
536
|
-
p = argparse.ArgumentParser(prog="lessons.py", description="Deterministic lessons bookkeeping for massa-ai spec-driven.")
|
|
537
|
-
p.add_argument("--root", default=".", help="Project root containing .specs/ (default: current dir)")
|
|
538
|
-
sub = p.add_subparsers(dest="cmd", required=True)
|
|
539
|
-
|
|
540
|
-
sp = sub.add_parser("init", help="Create empty store + rendered file")
|
|
541
|
-
sp.set_defaults(fn=cmd_init)
|
|
542
|
-
|
|
543
|
-
sp = sub.add_parser("add", help="Record a grounded lesson")
|
|
544
|
-
sp.add_argument("--feature", required=True)
|
|
545
|
-
sp.add_argument("--signal", required=True, choices=sorted(SIGNALS))
|
|
546
|
-
sp.add_argument("--source", required=True, help="file:line / AC id / mutant id / SPEC_DEVIATION ref")
|
|
547
|
-
sp.add_argument("--text", required=True, help="One terse, actionable sentence")
|
|
548
|
-
sp.add_argument("--scope", default="", help="Optional: path/layer/tag for retrieval filtering")
|
|
549
|
-
sp.add_argument("--project", default="", help="massa-ai projectId context")
|
|
550
|
-
sp.add_argument("--session", default="", help="massa-ai workflowSessionId context")
|
|
551
|
-
sp.add_argument("--workflow", default="", help="active massa-ai workflow type")
|
|
552
|
-
sp.add_argument("--entity", default="", help="active massa-ai entity")
|
|
553
|
-
sp.set_defaults(fn=cmd_add)
|
|
554
|
-
|
|
555
|
-
sp = sub.add_parser("penalize", help="Mark a confirmed lesson as failed-when-applied")
|
|
556
|
-
sp.add_argument("--id", required=True)
|
|
557
|
-
sp.set_defaults(fn=cmd_penalize)
|
|
558
|
-
|
|
559
|
-
sp = sub.add_parser("list", help="Print lessons for loading")
|
|
560
|
-
sp.add_argument("--status", default="confirmed", choices=["confirmed", "candidate", "quarantined", "all"])
|
|
561
|
-
sp.add_argument("--query", default="", help="Substring filter on lesson text")
|
|
562
|
-
sp.add_argument("--scope", default="", help="Substring filter on scope")
|
|
563
|
-
sp.add_argument("--project", default="", help="Substring filter on project")
|
|
564
|
-
sp.set_defaults(fn=cmd_list)
|
|
565
|
-
|
|
566
|
-
sp = sub.add_parser("observe", help="Ingest a JSON observation into the buffer")
|
|
567
|
-
sp.add_argument("--json", default="", help="Observation JSON (else read stdin)")
|
|
568
|
-
sp.set_defaults(fn=cmd_observe)
|
|
569
|
-
|
|
570
|
-
sp = sub.add_parser("export", help="Export lessons store as JSON")
|
|
571
|
-
sp.add_argument("--out", default="", help="Write to file (else stdout)")
|
|
572
|
-
sp.set_defaults(fn=cmd_export)
|
|
573
|
-
|
|
574
|
-
sp = sub.add_parser("import", help="Import lessons from JSON (merge by dedup key)")
|
|
575
|
-
sp.add_argument("--in", dest="in_", default=None, help="Read from file (else stdin)")
|
|
576
|
-
sp.set_defaults(fn=cmd_import)
|
|
577
|
-
|
|
578
|
-
sp = sub.add_parser("prune", help="Drop stale uncorroborated candidates")
|
|
579
|
-
sp.set_defaults(fn=cmd_prune)
|
|
580
|
-
|
|
581
|
-
sp = sub.add_parser("status", help="Print counts")
|
|
582
|
-
sp.set_defaults(fn=cmd_status)
|
|
583
|
-
|
|
584
|
-
args = p.parse_args(argv)
|
|
585
|
-
root = os.path.abspath(args.root)
|
|
586
|
-
return args.fn(root, args)
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
if __name__ == "__main__":
|
|
590
|
-
raise SystemExit(main())
|