lithermes-ai 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +245 -0
- package/README_Ko-KR.md +245 -0
- package/assets/lithermes-plugin/NOTICE.md +37 -0
- package/assets/lithermes-plugin/README.md +40 -0
- package/assets/lithermes-plugin/__init__.py +179 -0
- package/assets/lithermes-plugin/core.py +853 -0
- package/assets/lithermes-plugin/litgoal/__init__.py +10 -0
- package/assets/lithermes-plugin/litgoal/cli.py +133 -0
- package/assets/lithermes-plugin/litgoal/hook.py +48 -0
- package/assets/lithermes-plugin/litgoal/model.py +171 -0
- package/assets/lithermes-plugin/litgoal/runtime.py +273 -0
- package/assets/lithermes-plugin/litgoal/store.py +93 -0
- package/assets/lithermes-plugin/litgoal/tools.py +228 -0
- package/assets/lithermes-plugin/payload-version.json +471 -0
- package/assets/lithermes-plugin/plugin.yaml +9 -0
- package/assets/lithermes-plugin/skills/ai-slop-remover/SKILL.md +142 -0
- package/assets/lithermes-plugin/skills/comment-checker/SKILL.md +50 -0
- package/assets/lithermes-plugin/skills/debugging/SKILL.md +116 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +108 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +121 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/04-oracle-triple.md +136 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/05-escalate.md +69 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/06-fix.md +116 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/08-qa.md +94 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +164 -0
- package/assets/lithermes-plugin/skills/debugging/references/methodology/partial-runtime-evidence.md +229 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/go.md +252 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +484 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/node.md +260 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/python.md +248 -0
- package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +234 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +212 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/playwright-cli.md +194 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/pwndbg.md +263 -0
- package/assets/lithermes-plugin/skills/debugging/references/tools/pwntools.md +265 -0
- package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +77 -0
- package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +374 -0
- package/assets/lithermes-plugin/skills/litgoal/.gitkeep +0 -0
- package/assets/lithermes-plugin/skills/litgoal/SKILL.md +207 -0
- package/assets/lithermes-plugin/skills/litwork/SKILL.md +262 -0
- package/assets/lithermes-plugin/skills/lsp/SKILL.md +53 -0
- package/assets/lithermes-plugin/skills/programming/SKILL.md +463 -0
- package/assets/lithermes-plugin/skills/programming/references/go/README.md +90 -0
- package/assets/lithermes-plugin/skills/programming/references/go/backend-stack.md +641 -0
- package/assets/lithermes-plugin/skills/programming/references/go/bootstrap.md +328 -0
- package/assets/lithermes-plugin/skills/programming/references/go/bubbletea-v2.md +360 -0
- package/assets/lithermes-plugin/skills/programming/references/go/cobra-stack.md +468 -0
- package/assets/lithermes-plugin/skills/programming/references/go/concurrency.md +362 -0
- package/assets/lithermes-plugin/skills/programming/references/go/data-modeling.md +329 -0
- package/assets/lithermes-plugin/skills/programming/references/go/error-handling.md +359 -0
- package/assets/lithermes-plugin/skills/programming/references/go/golangci-strict.md +236 -0
- package/assets/lithermes-plugin/skills/programming/references/go/grpc-connect.md +375 -0
- package/assets/lithermes-plugin/skills/programming/references/go/libraries.md +337 -0
- package/assets/lithermes-plugin/skills/programming/references/go/one-liners.md +202 -0
- package/assets/lithermes-plugin/skills/programming/references/go/sqlc-pgx.md +471 -0
- package/assets/lithermes-plugin/skills/programming/references/go/testing.md +467 -0
- package/assets/lithermes-plugin/skills/programming/references/go/type-patterns.md +298 -0
- package/assets/lithermes-plugin/skills/programming/references/python/README.md +314 -0
- package/assets/lithermes-plugin/skills/programming/references/python/async-anyio.md +442 -0
- package/assets/lithermes-plugin/skills/programming/references/python/data-modeling.md +233 -0
- package/assets/lithermes-plugin/skills/programming/references/python/data-processing.md +133 -0
- package/assets/lithermes-plugin/skills/programming/references/python/error-handling.md +218 -0
- package/assets/lithermes-plugin/skills/programming/references/python/fastapi-stack.md +316 -0
- package/assets/lithermes-plugin/skills/programming/references/python/httpx2-optimization.md +360 -0
- package/assets/lithermes-plugin/skills/programming/references/python/libraries.md +307 -0
- package/assets/lithermes-plugin/skills/programming/references/python/one-liners.md +268 -0
- package/assets/lithermes-plugin/skills/programming/references/python/orjson-stack.md +378 -0
- package/assets/lithermes-plugin/skills/programming/references/python/pydantic-ai.md +285 -0
- package/assets/lithermes-plugin/skills/programming/references/python/pyproject-strict.md +232 -0
- package/assets/lithermes-plugin/skills/programming/references/python/textual-tui.md +201 -0
- package/assets/lithermes-plugin/skills/programming/references/python/type-patterns.md +176 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/README.md +317 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/async-tokio.md +299 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/axum-stack.md +467 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/cargo-strict.md +317 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/clap-stack.md +409 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/concurrency.md +375 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/libraries.md +439 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/one-liners.md +291 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/proptest-insta.md +429 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/type-state.md +354 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/unsafe-discipline.md +250 -0
- package/assets/lithermes-plugin/skills/programming/references/rust/zero-cost-safety.md +527 -0
- package/assets/lithermes-plugin/skills/programming/references/rust-ub/README.md +289 -0
- package/assets/lithermes-plugin/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
- package/assets/lithermes-plugin/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
- package/assets/lithermes-plugin/skills/programming/references/typescript/README.md +195 -0
- package/assets/lithermes-plugin/skills/programming/references/typescript/backend-hono.md +672 -0
- package/assets/lithermes-plugin/skills/programming/references/typescript/bootstrap.md +199 -0
- package/assets/lithermes-plugin/skills/programming/references/typescript/data-modeling.md +202 -0
- package/assets/lithermes-plugin/skills/programming/references/typescript/error-handling.md +169 -0
- package/assets/lithermes-plugin/skills/programming/references/typescript/tsconfig-strict.md +152 -0
- package/assets/lithermes-plugin/skills/programming/references/typescript/type-patterns.md +196 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/new-project.py +138 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/.editorconfig +13 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/.golangci.yml +95 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/ci.yml +37 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/config.go +24 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/gitignore +15 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
- package/assets/lithermes-plugin/skills/programming/scripts/go/templates/run.go +15 -0
- package/assets/lithermes-plugin/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
- package/assets/lithermes-plugin/skills/programming/scripts/python/new-project.py +172 -0
- package/assets/lithermes-plugin/skills/programming/scripts/python/new-script.py +116 -0
- package/assets/lithermes-plugin/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
- package/assets/lithermes-plugin/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
- package/assets/lithermes-plugin/skills/programming/scripts/rust/new-project.py +175 -0
- package/assets/lithermes-plugin/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
- package/assets/lithermes-plugin/skills/programming/scripts/typescript/new-project.ts +177 -0
- package/assets/lithermes-plugin/skills/refactor/SKILL.md +770 -0
- package/assets/lithermes-plugin/skills/remove-ai-slops/SKILL.md +335 -0
- package/assets/lithermes-plugin/skills/review-work/SKILL.md +562 -0
- package/assets/lithermes-plugin/skills/rules/SKILL.md +41 -0
- package/assets/lithermes-plugin/skills/start-work/SKILL.md +332 -0
- package/bin/lithermes.js +8 -0
- package/cover.png +0 -0
- package/package.json +39 -0
- package/src/cli.js +129 -0
- package/src/lib/check.js +94 -0
- package/src/lib/config.js +170 -0
- package/src/lib/files.js +65 -0
- package/src/lib/hermesDiscovery.js +50 -0
- package/src/lib/hud.js +121 -0
- package/src/lib/install.js +159 -0
- package/src/lib/patch.js +153 -0
- package/src/lib/skins.js +113 -0
- package/src/lib/spinner.js +104 -0
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
"""LitHermes litgoal durable runtime.
|
|
2
|
+
|
|
3
|
+
Layers durable artifacts (criteria, evidence ledger, checkpoints, steering
|
|
4
|
+
history, quality gate, review blockers) on top of Hermes' native goal/subgoal
|
|
5
|
+
system. State persists under <workspace>/.hermes/lithermes/litgoal/.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from . import model, store, runtime, tools, cli, hook # noqa: F401
|
|
@@ -0,0 +1,133 @@
|
|
|
1
|
+
"""`hermes lithermes goal ...` CLI surface for the litgoal durable runtime.
|
|
2
|
+
|
|
3
|
+
Registered via ctx.register_cli_command("goal", help, setup_fn, handler_fn).
|
|
4
|
+
setup_fn receives an argparse subparser; handler_fn(args) dispatches.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import json
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
from . import runtime, store
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def _ws(args) -> Path:
|
|
16
|
+
val = getattr(args, "workspace", None)
|
|
17
|
+
return Path(val).expanduser().resolve() if val else Path.cwd()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def setup(parser) -> None:
|
|
21
|
+
parser.add_argument("--workspace", help="workspace root (default: cwd)")
|
|
22
|
+
sub = parser.add_subparsers(dest="goal_cmd")
|
|
23
|
+
|
|
24
|
+
p_set = sub.add_parser("set", help="create the active goal")
|
|
25
|
+
p_set.add_argument("--objective", required=True)
|
|
26
|
+
p_set.add_argument("--title", default="")
|
|
27
|
+
p_set.add_argument("--criterion", action="append", default=[],
|
|
28
|
+
help="scenario|qa_channel|test_ref (repeatable)")
|
|
29
|
+
|
|
30
|
+
sub.add_parser("status", help="show the active goal + quality gate")
|
|
31
|
+
sub.add_parser("show", help="dump raw goals.json")
|
|
32
|
+
|
|
33
|
+
p_crit = sub.add_parser("criterion", help="add a success criterion")
|
|
34
|
+
p_crit.add_argument("--scenario", required=True)
|
|
35
|
+
p_crit.add_argument("--qa-channel", default="")
|
|
36
|
+
p_crit.add_argument("--test-ref", default="")
|
|
37
|
+
|
|
38
|
+
p_ev = sub.add_parser("evidence", help="attach evidence to a criterion")
|
|
39
|
+
p_ev.add_argument("criterion_id")
|
|
40
|
+
p_ev.add_argument("--kind", required=True, choices=["red", "green", "scenario", "cleanup", "note"])
|
|
41
|
+
p_ev.add_argument("--ref", required=True)
|
|
42
|
+
p_ev.add_argument("--detail", default="")
|
|
43
|
+
|
|
44
|
+
p_st = sub.add_parser("criterion-status", help="set a criterion status")
|
|
45
|
+
p_st.add_argument("criterion_id")
|
|
46
|
+
p_st.add_argument("status", choices=["pending", "in_progress", "blocked", "pass", "fail"])
|
|
47
|
+
|
|
48
|
+
p_steer = sub.add_parser("steer", help="record a steering directive")
|
|
49
|
+
p_steer.add_argument("directive")
|
|
50
|
+
|
|
51
|
+
p_cp = sub.add_parser("checkpoint", help="record a checkpoint")
|
|
52
|
+
p_cp.add_argument("summary")
|
|
53
|
+
p_cp.add_argument("--active-criterion", default="")
|
|
54
|
+
|
|
55
|
+
p_bl = sub.add_parser("blocker", help="add a review blocker")
|
|
56
|
+
p_bl.add_argument("detail")
|
|
57
|
+
p_rb = sub.add_parser("resolve-blocker", help="resolve a review blocker")
|
|
58
|
+
p_rb.add_argument("blocker_id")
|
|
59
|
+
|
|
60
|
+
sub.add_parser("complete", help="attempt to complete the goal (gated)")
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
def handle(args) -> int:
|
|
64
|
+
ws = _ws(args)
|
|
65
|
+
cmd = getattr(args, "goal_cmd", None)
|
|
66
|
+
if cmd == "set":
|
|
67
|
+
criteria = []
|
|
68
|
+
for raw in args.criterion:
|
|
69
|
+
parts = (raw.split("|") + ["", "", ""])[:3]
|
|
70
|
+
criteria.append({"scenario": parts[0], "qa_channel": parts[1], "test_ref": parts[2]})
|
|
71
|
+
runtime.create_goal(ws, args.objective, title=args.title, criteria=criteria)
|
|
72
|
+
return _print_status(ws)
|
|
73
|
+
if cmd in (None, "status"):
|
|
74
|
+
return _print_status(ws)
|
|
75
|
+
if cmd == "show":
|
|
76
|
+
print(store.goals_path(ws).read_text(encoding="utf-8") if store.goals_path(ws).exists() else "{}")
|
|
77
|
+
return 0
|
|
78
|
+
if cmd == "criterion":
|
|
79
|
+
crit = runtime.add_criterion(ws, args.scenario, qa_channel=args.qa_channel, test_ref=args.test_ref)
|
|
80
|
+
print(f"added {crit.id}")
|
|
81
|
+
return _print_status(ws)
|
|
82
|
+
if cmd == "evidence":
|
|
83
|
+
runtime.add_evidence(ws, args.criterion_id, args.kind, args.ref, args.detail)
|
|
84
|
+
return _print_status(ws)
|
|
85
|
+
if cmd == "criterion-status":
|
|
86
|
+
runtime.set_criterion_status(ws, args.criterion_id, args.status)
|
|
87
|
+
return _print_status(ws)
|
|
88
|
+
if cmd == "steer":
|
|
89
|
+
runtime.record_steering(ws, args.directive)
|
|
90
|
+
return _print_status(ws)
|
|
91
|
+
if cmd == "checkpoint":
|
|
92
|
+
runtime.record_checkpoint(ws, args.summary, active_criterion=args.active_criterion)
|
|
93
|
+
return _print_status(ws)
|
|
94
|
+
if cmd == "blocker":
|
|
95
|
+
b = runtime.add_review_blocker(ws, args.detail)
|
|
96
|
+
print(f"blocker {b.id}")
|
|
97
|
+
return _print_status(ws)
|
|
98
|
+
if cmd == "resolve-blocker":
|
|
99
|
+
runtime.resolve_review_blocker(ws, args.blocker_id)
|
|
100
|
+
return _print_status(ws)
|
|
101
|
+
if cmd == "complete":
|
|
102
|
+
result = runtime.complete_goal(ws)
|
|
103
|
+
print(json.dumps(result, ensure_ascii=False, indent=2))
|
|
104
|
+
return 0 if result.get("completed") else 1
|
|
105
|
+
return _print_status(ws)
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
def _print_status(ws: Path) -> int:
|
|
109
|
+
goal = runtime.get_active(ws)
|
|
110
|
+
if goal is None:
|
|
111
|
+
print("no active litgoal")
|
|
112
|
+
return 0
|
|
113
|
+
gate = runtime.quality_gate(ws)
|
|
114
|
+
print(f"goal {goal.id} [{goal.status}]: {goal.objective}")
|
|
115
|
+
for c in goal.criteria:
|
|
116
|
+
kinds = ",".join(sorted({e.kind for e in c.evidence})) or "-"
|
|
117
|
+
print(f" {c.id} [{c.status}] {c.scenario} channel={c.qa_channel or '-'} evidence={kinds}")
|
|
118
|
+
for b in goal.review_blockers:
|
|
119
|
+
flag = "resolved" if b.resolved else "OPEN"
|
|
120
|
+
print(f" blocker {b.id} [{flag}] {b.detail}")
|
|
121
|
+
print(f"quality gate: {'PASS' if gate['passed'] else 'CLOSED'}")
|
|
122
|
+
for r in gate["reasons"]:
|
|
123
|
+
print(f" - {r}")
|
|
124
|
+
return 0
|
|
125
|
+
|
|
126
|
+
|
|
127
|
+
def main(argv: list[str] | None = None) -> int:
|
|
128
|
+
import argparse
|
|
129
|
+
|
|
130
|
+
parser = argparse.ArgumentParser(prog="hermes lithermes goal")
|
|
131
|
+
setup(parser)
|
|
132
|
+
args = parser.parse_args(argv)
|
|
133
|
+
return handle(args)
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
"""pre_llm_call snapshot injection for the active litgoal.
|
|
2
|
+
|
|
3
|
+
Returns a compact context block reminding the model of the live objective, the
|
|
4
|
+
next unmet criteria, unresolved review blockers, and that completion is gated on
|
|
5
|
+
proven evidence. Returns None when no active goal exists.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from . import runtime
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def snapshot_context(**kwargs: Any) -> str | None:
|
|
17
|
+
workspace = Path.cwd()
|
|
18
|
+
try:
|
|
19
|
+
goal = runtime.get_active(workspace)
|
|
20
|
+
except Exception:
|
|
21
|
+
return None
|
|
22
|
+
if goal is None or goal.status == "complete":
|
|
23
|
+
return None
|
|
24
|
+
|
|
25
|
+
gate = runtime.quality_gate(workspace)
|
|
26
|
+
open_crit = [c for c in goal.criteria if c.status != "pass"]
|
|
27
|
+
lines = [
|
|
28
|
+
"<lithermes-litgoal-snapshot>",
|
|
29
|
+
f"Active goal {goal.id}: {goal.objective}",
|
|
30
|
+
]
|
|
31
|
+
if goal.steering:
|
|
32
|
+
lines.append(f"Latest steering: {goal.steering[-1].directive}")
|
|
33
|
+
if not goal.criteria:
|
|
34
|
+
lines.append("No success criteria yet — define them (goal_add_criterion) before claiming progress.")
|
|
35
|
+
for crit in open_crit:
|
|
36
|
+
kinds = sorted({e.kind for e in crit.evidence})
|
|
37
|
+
missing = [k for k in ("green", "scenario") if k not in kinds]
|
|
38
|
+
miss = f" missing: {', '.join(missing)}" if missing else ""
|
|
39
|
+
lines.append(f"- {crit.id} [{crit.status}] {crit.scenario} (channel {crit.qa_channel or '?'}).{miss}")
|
|
40
|
+
unresolved = [b for b in goal.review_blockers if not b.resolved]
|
|
41
|
+
for blocker in unresolved:
|
|
42
|
+
lines.append(f"- BLOCKER {blocker.id}: {blocker.detail}")
|
|
43
|
+
if gate["passed"]:
|
|
44
|
+
lines.append("Quality gate: PASS — you may call goal_complete.")
|
|
45
|
+
else:
|
|
46
|
+
lines.append("Quality gate: CLOSED — goal_complete is refused until every criterion has green + scenario evidence and blockers are resolved.")
|
|
47
|
+
lines.append("</lithermes-litgoal-snapshot>")
|
|
48
|
+
return "\n".join(lines)
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
"""Durable litgoal data model.
|
|
2
|
+
|
|
3
|
+
Plain dataclasses with explicit (de)serialization so state survives across turns
|
|
4
|
+
and sessions as human-auditable JSON.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
STATE_VERSION = 1
|
|
13
|
+
|
|
14
|
+
CRITERION_STATUSES = ("pending", "in_progress", "blocked", "pass", "fail")
|
|
15
|
+
GOAL_STATUSES = ("active", "blocked", "complete")
|
|
16
|
+
EVIDENCE_KINDS = ("red", "green", "scenario", "cleanup", "note")
|
|
17
|
+
# Structured steering kinds. Steering can redirect or extend the goal; it can
|
|
18
|
+
# NEVER weaken the completion gate (see runtime._weakening_reason).
|
|
19
|
+
STEERING_KINDS = ("redirect", "add_criterion", "narrow_scope", "reprioritize", "annotate")
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
@dataclass
|
|
23
|
+
class Evidence:
|
|
24
|
+
kind: str
|
|
25
|
+
ref: str
|
|
26
|
+
detail: str = ""
|
|
27
|
+
at: str = ""
|
|
28
|
+
|
|
29
|
+
@staticmethod
|
|
30
|
+
def from_dict(d: dict[str, Any]) -> "Evidence":
|
|
31
|
+
return Evidence(
|
|
32
|
+
kind=str(d.get("kind", "note")),
|
|
33
|
+
ref=str(d.get("ref", "")),
|
|
34
|
+
detail=str(d.get("detail", "")),
|
|
35
|
+
at=str(d.get("at", "")),
|
|
36
|
+
)
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass
|
|
40
|
+
class Criterion:
|
|
41
|
+
id: str
|
|
42
|
+
scenario: str
|
|
43
|
+
qa_channel: str = ""
|
|
44
|
+
test_ref: str = ""
|
|
45
|
+
status: str = "pending"
|
|
46
|
+
evidence: list[Evidence] = field(default_factory=list)
|
|
47
|
+
|
|
48
|
+
@staticmethod
|
|
49
|
+
def from_dict(d: dict[str, Any]) -> "Criterion":
|
|
50
|
+
return Criterion(
|
|
51
|
+
id=str(d.get("id", "")),
|
|
52
|
+
scenario=str(d.get("scenario", "")),
|
|
53
|
+
qa_channel=str(d.get("qa_channel", "")),
|
|
54
|
+
test_ref=str(d.get("test_ref", "")),
|
|
55
|
+
status=str(d.get("status", "pending")),
|
|
56
|
+
evidence=[Evidence.from_dict(e) for e in d.get("evidence", []) or []],
|
|
57
|
+
)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@dataclass
|
|
61
|
+
class Checkpoint:
|
|
62
|
+
id: str
|
|
63
|
+
at: str
|
|
64
|
+
summary: str
|
|
65
|
+
active_criterion: str = ""
|
|
66
|
+
|
|
67
|
+
@staticmethod
|
|
68
|
+
def from_dict(d: dict[str, Any]) -> "Checkpoint":
|
|
69
|
+
return Checkpoint(
|
|
70
|
+
id=str(d.get("id", "")),
|
|
71
|
+
at=str(d.get("at", "")),
|
|
72
|
+
summary=str(d.get("summary", "")),
|
|
73
|
+
active_criterion=str(d.get("active_criterion", "")),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
@dataclass
|
|
78
|
+
class Steering:
|
|
79
|
+
id: str
|
|
80
|
+
at: str
|
|
81
|
+
directive: str
|
|
82
|
+
kind: str = "redirect"
|
|
83
|
+
|
|
84
|
+
@staticmethod
|
|
85
|
+
def from_dict(d: dict[str, Any]) -> "Steering":
|
|
86
|
+
return Steering(
|
|
87
|
+
id=str(d.get("id", "")),
|
|
88
|
+
at=str(d.get("at", "")),
|
|
89
|
+
directive=str(d.get("directive", "")),
|
|
90
|
+
kind=str(d.get("kind", "redirect")),
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass
|
|
95
|
+
class ReviewBlocker:
|
|
96
|
+
id: str
|
|
97
|
+
detail: str
|
|
98
|
+
resolved: bool = False
|
|
99
|
+
|
|
100
|
+
@staticmethod
|
|
101
|
+
def from_dict(d: dict[str, Any]) -> "ReviewBlocker":
|
|
102
|
+
return ReviewBlocker(
|
|
103
|
+
id=str(d.get("id", "")),
|
|
104
|
+
detail=str(d.get("detail", "")),
|
|
105
|
+
resolved=bool(d.get("resolved", False)),
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
@dataclass
|
|
110
|
+
class Goal:
|
|
111
|
+
id: str
|
|
112
|
+
objective: str
|
|
113
|
+
title: str = ""
|
|
114
|
+
status: str = "active"
|
|
115
|
+
criteria: list[Criterion] = field(default_factory=list)
|
|
116
|
+
checkpoints: list[Checkpoint] = field(default_factory=list)
|
|
117
|
+
steering: list[Steering] = field(default_factory=list)
|
|
118
|
+
review_blockers: list[ReviewBlocker] = field(default_factory=list)
|
|
119
|
+
|
|
120
|
+
@staticmethod
|
|
121
|
+
def from_dict(d: dict[str, Any]) -> "Goal":
|
|
122
|
+
return Goal(
|
|
123
|
+
id=str(d.get("id", "")),
|
|
124
|
+
objective=str(d.get("objective", "")),
|
|
125
|
+
title=str(d.get("title", "")),
|
|
126
|
+
status=str(d.get("status", "active")),
|
|
127
|
+
criteria=[Criterion.from_dict(c) for c in d.get("criteria", []) or []],
|
|
128
|
+
checkpoints=[Checkpoint.from_dict(c) for c in d.get("checkpoints", []) or []],
|
|
129
|
+
steering=[Steering.from_dict(s) for s in d.get("steering", []) or []],
|
|
130
|
+
review_blockers=[ReviewBlocker.from_dict(b) for b in d.get("review_blockers", []) or []],
|
|
131
|
+
)
|
|
132
|
+
|
|
133
|
+
|
|
134
|
+
@dataclass
|
|
135
|
+
class LitgoalState:
|
|
136
|
+
version: int = STATE_VERSION
|
|
137
|
+
created_at: str = ""
|
|
138
|
+
updated_at: str = ""
|
|
139
|
+
active_goal_id: str = ""
|
|
140
|
+
goals: list[Goal] = field(default_factory=list)
|
|
141
|
+
|
|
142
|
+
@staticmethod
|
|
143
|
+
def from_dict(d: dict[str, Any]) -> "LitgoalState":
|
|
144
|
+
return LitgoalState(
|
|
145
|
+
version=int(d.get("version", STATE_VERSION)),
|
|
146
|
+
created_at=str(d.get("created_at", "")),
|
|
147
|
+
updated_at=str(d.get("updated_at", "")),
|
|
148
|
+
active_goal_id=str(d.get("active_goal_id", "")),
|
|
149
|
+
goals=[Goal.from_dict(g) for g in d.get("goals", []) or []],
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
def active_goal(self) -> Goal | None:
|
|
153
|
+
for g in self.goals:
|
|
154
|
+
if g.id == self.active_goal_id:
|
|
155
|
+
return g
|
|
156
|
+
return None
|
|
157
|
+
|
|
158
|
+
def goal_by_id(self, goal_id: str) -> Goal | None:
|
|
159
|
+
for g in self.goals:
|
|
160
|
+
if g.id == goal_id:
|
|
161
|
+
return g
|
|
162
|
+
return None
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
def to_dict(obj: Any) -> Any:
|
|
166
|
+
"""Recursive dataclass→dict that preserves field order and nested lists."""
|
|
167
|
+
from dataclasses import asdict, is_dataclass
|
|
168
|
+
|
|
169
|
+
if is_dataclass(obj):
|
|
170
|
+
return asdict(obj)
|
|
171
|
+
return obj
|
|
@@ -0,0 +1,273 @@
|
|
|
1
|
+
"""Litgoal lifecycle operations on top of the durable store.
|
|
2
|
+
|
|
3
|
+
Every mutation: load -> mutate -> save -> append a typed ledger event. The
|
|
4
|
+
quality gate is the completion contract — a goal cannot complete until every
|
|
5
|
+
criterion is proven (status=pass + RED/GREEN + manual-QA scenario evidence) and
|
|
6
|
+
no review blocker is unresolved.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import re
|
|
12
|
+
from datetime import datetime, timezone
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
from typing import Any
|
|
15
|
+
|
|
16
|
+
from . import model, store
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _utc_now() -> str:
|
|
20
|
+
return datetime.now(timezone.utc).isoformat()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _next_id(prefix: str, existing: list[str]) -> str:
|
|
24
|
+
n = 1
|
|
25
|
+
used = set(existing)
|
|
26
|
+
while f"{prefix}{n:03d}" in used:
|
|
27
|
+
n += 1
|
|
28
|
+
return f"{prefix}{n:03d}"
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
# -- goal lifecycle ---------------------------------------------------------
|
|
32
|
+
|
|
33
|
+
def create_goal(
|
|
34
|
+
workspace: Path,
|
|
35
|
+
objective: str,
|
|
36
|
+
*,
|
|
37
|
+
title: str = "",
|
|
38
|
+
criteria: list[dict[str, Any]] | None = None,
|
|
39
|
+
) -> model.Goal:
|
|
40
|
+
objective = (objective or "").strip()
|
|
41
|
+
if not objective:
|
|
42
|
+
raise ValueError("objective must be non-empty")
|
|
43
|
+
state = store.load_or_create(workspace)
|
|
44
|
+
goal_id = _next_id("G", [g.id for g in state.goals])
|
|
45
|
+
goal = model.Goal(id=goal_id, objective=objective, title=title.strip())
|
|
46
|
+
for spec in criteria or []:
|
|
47
|
+
goal.criteria.append(
|
|
48
|
+
model.Criterion(
|
|
49
|
+
id=_next_id("C", [c.id for c in goal.criteria]),
|
|
50
|
+
scenario=str(spec.get("scenario", "")).strip(),
|
|
51
|
+
qa_channel=str(spec.get("qa_channel", "")).strip(),
|
|
52
|
+
test_ref=str(spec.get("test_ref", "")).strip(),
|
|
53
|
+
)
|
|
54
|
+
)
|
|
55
|
+
state.goals.append(goal)
|
|
56
|
+
state.active_goal_id = goal_id
|
|
57
|
+
store.save(workspace, state)
|
|
58
|
+
store.append_ledger(workspace, {"kind": "goal_created", "goal_id": goal_id, "objective": objective})
|
|
59
|
+
return goal
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
def get_active(workspace: Path) -> model.Goal | None:
|
|
63
|
+
return store.load_or_create(workspace).active_goal()
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def _require_active(state: model.LitgoalState) -> model.Goal:
|
|
67
|
+
goal = state.active_goal()
|
|
68
|
+
if goal is None:
|
|
69
|
+
raise ValueError("no active litgoal — create one first")
|
|
70
|
+
return goal
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
# -- criteria + evidence ----------------------------------------------------
|
|
74
|
+
|
|
75
|
+
def add_criterion(
|
|
76
|
+
workspace: Path,
|
|
77
|
+
scenario: str,
|
|
78
|
+
*,
|
|
79
|
+
qa_channel: str = "",
|
|
80
|
+
test_ref: str = "",
|
|
81
|
+
) -> model.Criterion:
|
|
82
|
+
state = store.load_or_create(workspace)
|
|
83
|
+
goal = _require_active(state)
|
|
84
|
+
crit = model.Criterion(
|
|
85
|
+
id=_next_id("C", [c.id for c in goal.criteria]),
|
|
86
|
+
scenario=scenario.strip(),
|
|
87
|
+
qa_channel=qa_channel.strip(),
|
|
88
|
+
test_ref=test_ref.strip(),
|
|
89
|
+
)
|
|
90
|
+
goal.criteria.append(crit)
|
|
91
|
+
store.save(workspace, state)
|
|
92
|
+
store.append_ledger(workspace, {"kind": "criterion_added", "goal_id": goal.id, "criterion_id": crit.id})
|
|
93
|
+
return crit
|
|
94
|
+
|
|
95
|
+
|
|
96
|
+
def set_criterion_status(workspace: Path, criterion_id: str, status: str) -> None:
|
|
97
|
+
if status not in model.CRITERION_STATUSES:
|
|
98
|
+
raise ValueError(f"invalid criterion status '{status}' (valid: {model.CRITERION_STATUSES})")
|
|
99
|
+
state = store.load_or_create(workspace)
|
|
100
|
+
goal = _require_active(state)
|
|
101
|
+
for crit in goal.criteria:
|
|
102
|
+
if crit.id == criterion_id:
|
|
103
|
+
crit.status = status
|
|
104
|
+
store.save(workspace, state)
|
|
105
|
+
store.append_ledger(
|
|
106
|
+
workspace,
|
|
107
|
+
{"kind": "criterion_status", "criterion_id": criterion_id, "status": status},
|
|
108
|
+
)
|
|
109
|
+
return
|
|
110
|
+
raise ValueError(f"criterion '{criterion_id}' not found")
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def add_evidence(
|
|
114
|
+
workspace: Path,
|
|
115
|
+
criterion_id: str,
|
|
116
|
+
kind: str,
|
|
117
|
+
ref: str,
|
|
118
|
+
detail: str = "",
|
|
119
|
+
) -> model.Evidence:
|
|
120
|
+
if kind not in model.EVIDENCE_KINDS:
|
|
121
|
+
raise ValueError(f"invalid evidence kind '{kind}' (valid: {model.EVIDENCE_KINDS})")
|
|
122
|
+
state = store.load_or_create(workspace)
|
|
123
|
+
goal = _require_active(state)
|
|
124
|
+
for crit in goal.criteria:
|
|
125
|
+
if crit.id == criterion_id:
|
|
126
|
+
ev = model.Evidence(kind=kind, ref=ref, detail=detail, at=_utc_now())
|
|
127
|
+
crit.evidence.append(ev)
|
|
128
|
+
store.save(workspace, state)
|
|
129
|
+
store.append_ledger(
|
|
130
|
+
workspace,
|
|
131
|
+
{"kind": "evidence_added", "criterion_id": criterion_id, "evidence_kind": kind, "ref": ref},
|
|
132
|
+
)
|
|
133
|
+
return ev
|
|
134
|
+
raise ValueError(f"criterion '{criterion_id}' not found")
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
# -- checkpoints + steering -------------------------------------------------
|
|
138
|
+
|
|
139
|
+
def record_checkpoint(workspace: Path, summary: str, *, active_criterion: str = "") -> model.Checkpoint:
|
|
140
|
+
state = store.load_or_create(workspace)
|
|
141
|
+
goal = _require_active(state)
|
|
142
|
+
cp = model.Checkpoint(
|
|
143
|
+
id=_next_id("K", [c.id for c in goal.checkpoints]),
|
|
144
|
+
at=_utc_now(),
|
|
145
|
+
summary=summary.strip(),
|
|
146
|
+
active_criterion=active_criterion.strip(),
|
|
147
|
+
)
|
|
148
|
+
goal.checkpoints.append(cp)
|
|
149
|
+
store.save(workspace, state)
|
|
150
|
+
store.append_ledger(workspace, {"kind": "checkpoint", "goal_id": goal.id, "checkpoint_id": cp.id})
|
|
151
|
+
return cp
|
|
152
|
+
|
|
153
|
+
|
|
154
|
+
# Directives that try to weaken the completion contract are refused: steering can
|
|
155
|
+
# redirect or extend the work, never lower the evidence bar or force completion.
|
|
156
|
+
_STEERING_WEAKENS = [
|
|
157
|
+
re.compile(pattern, re.IGNORECASE)
|
|
158
|
+
for pattern in (
|
|
159
|
+
r"\bskip(p?ing)?\s+(the\s+|all\s+)?(unit\s+|integration\s+)?tests?\b",
|
|
160
|
+
r"\bbypass(ing)?\s+(the\s+)?(quality\s+)?(gate|tests?|qa|review|criteri)",
|
|
161
|
+
r"\bdisabl(e|ing)\s+(the\s+)?(tests?|gate|check|qa)",
|
|
162
|
+
r"\bwithout\s+(running\s+|a\s+|any\s+)?(tests?|qa|review|evidence)",
|
|
163
|
+
r"\bauto[-\s]?complet",
|
|
164
|
+
r"\b(force|mark)\s+\w*\s*complet",
|
|
165
|
+
r"\bignore\s+(the\s+)?(criteri|tests?|gate|evidence|qa)",
|
|
166
|
+
r"\b(lower|relax|loosen)\s+(the\s+)?(bar|gate|criteri|standard)",
|
|
167
|
+
r"\bskip\s+(the\s+)?(qa|review|gate|verification|evidence)",
|
|
168
|
+
)
|
|
169
|
+
]
|
|
170
|
+
|
|
171
|
+
|
|
172
|
+
def _weakening_reason(directive: str) -> str | None:
|
|
173
|
+
for pattern in _STEERING_WEAKENS:
|
|
174
|
+
if pattern.search(directive):
|
|
175
|
+
return f"directive matches a completion-weakening pattern ({pattern.pattern})"
|
|
176
|
+
return None
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def record_steering(workspace: Path, directive: str, *, kind: str = "redirect") -> model.Steering:
|
|
180
|
+
if kind not in model.STEERING_KINDS:
|
|
181
|
+
raise ValueError(f"invalid steering kind '{kind}' (valid: {model.STEERING_KINDS})")
|
|
182
|
+
reason = _weakening_reason(directive)
|
|
183
|
+
if reason is not None:
|
|
184
|
+
raise ValueError(
|
|
185
|
+
f"steering refused: {reason}. Steering can redirect or extend the goal, "
|
|
186
|
+
"but never weaken the completion gate (skip tests/QA/review or auto-complete)."
|
|
187
|
+
)
|
|
188
|
+
state = store.load_or_create(workspace)
|
|
189
|
+
goal = _require_active(state)
|
|
190
|
+
st = model.Steering(
|
|
191
|
+
id=_next_id("S", [s.id for s in goal.steering]),
|
|
192
|
+
at=_utc_now(),
|
|
193
|
+
directive=directive.strip(),
|
|
194
|
+
kind=kind,
|
|
195
|
+
)
|
|
196
|
+
goal.steering.append(st)
|
|
197
|
+
store.save(workspace, state)
|
|
198
|
+
store.append_ledger(
|
|
199
|
+
workspace,
|
|
200
|
+
{"kind": "steer", "goal_id": goal.id, "steering_id": st.id, "steer_kind": kind},
|
|
201
|
+
)
|
|
202
|
+
return st
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
# -- review blockers --------------------------------------------------------
|
|
206
|
+
|
|
207
|
+
def add_review_blocker(workspace: Path, detail: str) -> model.ReviewBlocker:
|
|
208
|
+
state = store.load_or_create(workspace)
|
|
209
|
+
goal = _require_active(state)
|
|
210
|
+
blocker = model.ReviewBlocker(
|
|
211
|
+
id=_next_id("B", [b.id for b in goal.review_blockers]),
|
|
212
|
+
detail=detail.strip(),
|
|
213
|
+
)
|
|
214
|
+
goal.review_blockers.append(blocker)
|
|
215
|
+
store.save(workspace, state)
|
|
216
|
+
store.append_ledger(workspace, {"kind": "review_blocker", "goal_id": goal.id, "blocker_id": blocker.id})
|
|
217
|
+
return blocker
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def resolve_review_blocker(workspace: Path, blocker_id: str) -> None:
|
|
221
|
+
state = store.load_or_create(workspace)
|
|
222
|
+
goal = _require_active(state)
|
|
223
|
+
for blocker in goal.review_blockers:
|
|
224
|
+
if blocker.id == blocker_id:
|
|
225
|
+
blocker.resolved = True
|
|
226
|
+
store.save(workspace, state)
|
|
227
|
+
store.append_ledger(
|
|
228
|
+
workspace, {"kind": "review_blocker_resolved", "blocker_id": blocker_id}
|
|
229
|
+
)
|
|
230
|
+
return
|
|
231
|
+
raise ValueError(f"review blocker '{blocker_id}' not found")
|
|
232
|
+
|
|
233
|
+
|
|
234
|
+
# -- quality gate + completion ---------------------------------------------
|
|
235
|
+
|
|
236
|
+
def _evaluate_gate(goal: model.Goal) -> dict[str, Any]:
|
|
237
|
+
reasons: list[str] = []
|
|
238
|
+
if not goal.criteria:
|
|
239
|
+
reasons.append("no success criteria defined")
|
|
240
|
+
for crit in goal.criteria:
|
|
241
|
+
kinds = {e.kind for e in crit.evidence}
|
|
242
|
+
if crit.status != "pass":
|
|
243
|
+
reasons.append(f"{crit.id} status is '{crit.status}' (need 'pass')")
|
|
244
|
+
if "green" not in kinds:
|
|
245
|
+
reasons.append(f"{crit.id} missing RED->GREEN proof (green evidence)")
|
|
246
|
+
if "scenario" not in kinds:
|
|
247
|
+
reasons.append(f"{crit.id} missing manual-QA scenario evidence")
|
|
248
|
+
for blocker in goal.review_blockers:
|
|
249
|
+
if not blocker.resolved:
|
|
250
|
+
reasons.append(f"unresolved review blocker {blocker.id}: {blocker.detail}")
|
|
251
|
+
return {"passed": len(reasons) == 0, "reasons": reasons, "goal_id": goal.id}
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def quality_gate(workspace: Path, goal_id: str | None = None) -> dict[str, Any]:
|
|
255
|
+
state = store.load_or_create(workspace)
|
|
256
|
+
goal = state.goal_by_id(goal_id) if goal_id else state.active_goal()
|
|
257
|
+
if goal is None:
|
|
258
|
+
return {"passed": False, "reasons": ["no active goal"]}
|
|
259
|
+
return _evaluate_gate(goal)
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def complete_goal(workspace: Path) -> dict[str, Any]:
|
|
263
|
+
# Single load: evaluate the gate and mutate the same state to avoid any
|
|
264
|
+
# read-decide-read race between the gate check and the completion write.
|
|
265
|
+
state = store.load_or_create(workspace)
|
|
266
|
+
goal = _require_active(state)
|
|
267
|
+
gate = _evaluate_gate(goal)
|
|
268
|
+
if not gate["passed"]:
|
|
269
|
+
return {"completed": False, "reasons": gate["reasons"]}
|
|
270
|
+
goal.status = "complete"
|
|
271
|
+
store.save(workspace, state)
|
|
272
|
+
store.append_ledger(workspace, {"kind": "goal_completed", "goal_id": goal.id})
|
|
273
|
+
return {"completed": True, "goal_id": goal.id}
|