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.
Files changed (133) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +245 -0
  3. package/README_Ko-KR.md +245 -0
  4. package/assets/lithermes-plugin/NOTICE.md +37 -0
  5. package/assets/lithermes-plugin/README.md +40 -0
  6. package/assets/lithermes-plugin/__init__.py +179 -0
  7. package/assets/lithermes-plugin/core.py +853 -0
  8. package/assets/lithermes-plugin/litgoal/__init__.py +10 -0
  9. package/assets/lithermes-plugin/litgoal/cli.py +133 -0
  10. package/assets/lithermes-plugin/litgoal/hook.py +48 -0
  11. package/assets/lithermes-plugin/litgoal/model.py +171 -0
  12. package/assets/lithermes-plugin/litgoal/runtime.py +273 -0
  13. package/assets/lithermes-plugin/litgoal/store.py +93 -0
  14. package/assets/lithermes-plugin/litgoal/tools.py +228 -0
  15. package/assets/lithermes-plugin/payload-version.json +471 -0
  16. package/assets/lithermes-plugin/plugin.yaml +9 -0
  17. package/assets/lithermes-plugin/skills/ai-slop-remover/SKILL.md +142 -0
  18. package/assets/lithermes-plugin/skills/comment-checker/SKILL.md +50 -0
  19. package/assets/lithermes-plugin/skills/debugging/SKILL.md +116 -0
  20. package/assets/lithermes-plugin/skills/debugging/references/methodology/00-setup.md +108 -0
  21. package/assets/lithermes-plugin/skills/debugging/references/methodology/02-investigate.md +121 -0
  22. package/assets/lithermes-plugin/skills/debugging/references/methodology/04-oracle-triple.md +136 -0
  23. package/assets/lithermes-plugin/skills/debugging/references/methodology/05-escalate.md +69 -0
  24. package/assets/lithermes-plugin/skills/debugging/references/methodology/06-fix.md +116 -0
  25. package/assets/lithermes-plugin/skills/debugging/references/methodology/08-qa.md +94 -0
  26. package/assets/lithermes-plugin/skills/debugging/references/methodology/09-cleanup.md +164 -0
  27. package/assets/lithermes-plugin/skills/debugging/references/methodology/partial-runtime-evidence.md +229 -0
  28. package/assets/lithermes-plugin/skills/debugging/references/runtimes/bundled-js-binary.md +415 -0
  29. package/assets/lithermes-plugin/skills/debugging/references/runtimes/go.md +252 -0
  30. package/assets/lithermes-plugin/skills/debugging/references/runtimes/native-binary.md +484 -0
  31. package/assets/lithermes-plugin/skills/debugging/references/runtimes/node.md +260 -0
  32. package/assets/lithermes-plugin/skills/debugging/references/runtimes/python.md +248 -0
  33. package/assets/lithermes-plugin/skills/debugging/references/runtimes/rust.md +234 -0
  34. package/assets/lithermes-plugin/skills/debugging/references/tools/ghidra.md +212 -0
  35. package/assets/lithermes-plugin/skills/debugging/references/tools/playwright-cli.md +194 -0
  36. package/assets/lithermes-plugin/skills/debugging/references/tools/pwndbg.md +263 -0
  37. package/assets/lithermes-plugin/skills/debugging/references/tools/pwntools.md +265 -0
  38. package/assets/lithermes-plugin/skills/frontend-ui-ux/SKILL.md +77 -0
  39. package/assets/lithermes-plugin/skills/lit-plan/SKILL.md +374 -0
  40. package/assets/lithermes-plugin/skills/litgoal/.gitkeep +0 -0
  41. package/assets/lithermes-plugin/skills/litgoal/SKILL.md +207 -0
  42. package/assets/lithermes-plugin/skills/litwork/SKILL.md +262 -0
  43. package/assets/lithermes-plugin/skills/lsp/SKILL.md +53 -0
  44. package/assets/lithermes-plugin/skills/programming/SKILL.md +463 -0
  45. package/assets/lithermes-plugin/skills/programming/references/go/README.md +90 -0
  46. package/assets/lithermes-plugin/skills/programming/references/go/backend-stack.md +641 -0
  47. package/assets/lithermes-plugin/skills/programming/references/go/bootstrap.md +328 -0
  48. package/assets/lithermes-plugin/skills/programming/references/go/bubbletea-v2.md +360 -0
  49. package/assets/lithermes-plugin/skills/programming/references/go/cobra-stack.md +468 -0
  50. package/assets/lithermes-plugin/skills/programming/references/go/concurrency.md +362 -0
  51. package/assets/lithermes-plugin/skills/programming/references/go/data-modeling.md +329 -0
  52. package/assets/lithermes-plugin/skills/programming/references/go/error-handling.md +359 -0
  53. package/assets/lithermes-plugin/skills/programming/references/go/golangci-strict.md +236 -0
  54. package/assets/lithermes-plugin/skills/programming/references/go/grpc-connect.md +375 -0
  55. package/assets/lithermes-plugin/skills/programming/references/go/libraries.md +337 -0
  56. package/assets/lithermes-plugin/skills/programming/references/go/one-liners.md +202 -0
  57. package/assets/lithermes-plugin/skills/programming/references/go/sqlc-pgx.md +471 -0
  58. package/assets/lithermes-plugin/skills/programming/references/go/testing.md +467 -0
  59. package/assets/lithermes-plugin/skills/programming/references/go/type-patterns.md +298 -0
  60. package/assets/lithermes-plugin/skills/programming/references/python/README.md +314 -0
  61. package/assets/lithermes-plugin/skills/programming/references/python/async-anyio.md +442 -0
  62. package/assets/lithermes-plugin/skills/programming/references/python/data-modeling.md +233 -0
  63. package/assets/lithermes-plugin/skills/programming/references/python/data-processing.md +133 -0
  64. package/assets/lithermes-plugin/skills/programming/references/python/error-handling.md +218 -0
  65. package/assets/lithermes-plugin/skills/programming/references/python/fastapi-stack.md +316 -0
  66. package/assets/lithermes-plugin/skills/programming/references/python/httpx2-optimization.md +360 -0
  67. package/assets/lithermes-plugin/skills/programming/references/python/libraries.md +307 -0
  68. package/assets/lithermes-plugin/skills/programming/references/python/one-liners.md +268 -0
  69. package/assets/lithermes-plugin/skills/programming/references/python/orjson-stack.md +378 -0
  70. package/assets/lithermes-plugin/skills/programming/references/python/pydantic-ai.md +285 -0
  71. package/assets/lithermes-plugin/skills/programming/references/python/pyproject-strict.md +232 -0
  72. package/assets/lithermes-plugin/skills/programming/references/python/textual-tui.md +201 -0
  73. package/assets/lithermes-plugin/skills/programming/references/python/type-patterns.md +176 -0
  74. package/assets/lithermes-plugin/skills/programming/references/rust/README.md +317 -0
  75. package/assets/lithermes-plugin/skills/programming/references/rust/async-tokio.md +299 -0
  76. package/assets/lithermes-plugin/skills/programming/references/rust/axum-stack.md +467 -0
  77. package/assets/lithermes-plugin/skills/programming/references/rust/cargo-strict.md +317 -0
  78. package/assets/lithermes-plugin/skills/programming/references/rust/clap-stack.md +409 -0
  79. package/assets/lithermes-plugin/skills/programming/references/rust/concurrency.md +375 -0
  80. package/assets/lithermes-plugin/skills/programming/references/rust/libraries.md +439 -0
  81. package/assets/lithermes-plugin/skills/programming/references/rust/one-liners.md +291 -0
  82. package/assets/lithermes-plugin/skills/programming/references/rust/proptest-insta.md +429 -0
  83. package/assets/lithermes-plugin/skills/programming/references/rust/type-state.md +354 -0
  84. package/assets/lithermes-plugin/skills/programming/references/rust/unsafe-discipline.md +250 -0
  85. package/assets/lithermes-plugin/skills/programming/references/rust/zero-cost-safety.md +527 -0
  86. package/assets/lithermes-plugin/skills/programming/references/rust-ub/README.md +289 -0
  87. package/assets/lithermes-plugin/skills/programming/references/rust-ub/miri-sanitizers-loom.md +411 -0
  88. package/assets/lithermes-plugin/skills/programming/references/rust-ub/ub-taxonomy.md +269 -0
  89. package/assets/lithermes-plugin/skills/programming/references/typescript/README.md +195 -0
  90. package/assets/lithermes-plugin/skills/programming/references/typescript/backend-hono.md +672 -0
  91. package/assets/lithermes-plugin/skills/programming/references/typescript/bootstrap.md +199 -0
  92. package/assets/lithermes-plugin/skills/programming/references/typescript/data-modeling.md +202 -0
  93. package/assets/lithermes-plugin/skills/programming/references/typescript/error-handling.md +169 -0
  94. package/assets/lithermes-plugin/skills/programming/references/typescript/tsconfig-strict.md +152 -0
  95. package/assets/lithermes-plugin/skills/programming/references/typescript/type-patterns.md +196 -0
  96. package/assets/lithermes-plugin/skills/programming/scripts/go/check-no-excuse-rules.sh +173 -0
  97. package/assets/lithermes-plugin/skills/programming/scripts/go/new-project.py +138 -0
  98. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/.editorconfig +13 -0
  99. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/.golangci.yml +95 -0
  100. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/AGENTS.md.tmpl +24 -0
  101. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/README.md.tmpl +12 -0
  102. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/Taskfile.yml +40 -0
  103. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/ci.yml +37 -0
  104. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/config.go +24 -0
  105. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/gitignore +15 -0
  106. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/main.go.tmpl +22 -0
  107. package/assets/lithermes-plugin/skills/programming/scripts/go/templates/run.go +15 -0
  108. package/assets/lithermes-plugin/skills/programming/scripts/python/check-no-excuse-rules.py +687 -0
  109. package/assets/lithermes-plugin/skills/programming/scripts/python/new-project.py +172 -0
  110. package/assets/lithermes-plugin/skills/programming/scripts/python/new-script.py +116 -0
  111. package/assets/lithermes-plugin/skills/programming/scripts/rust/check-no-excuse-rules.py +296 -0
  112. package/assets/lithermes-plugin/skills/programming/scripts/rust/check-no-excuse-rules.sh +158 -0
  113. package/assets/lithermes-plugin/skills/programming/scripts/rust/new-project.py +175 -0
  114. package/assets/lithermes-plugin/skills/programming/scripts/typescript/check-no-excuse-rules.ts +282 -0
  115. package/assets/lithermes-plugin/skills/programming/scripts/typescript/new-project.ts +177 -0
  116. package/assets/lithermes-plugin/skills/refactor/SKILL.md +770 -0
  117. package/assets/lithermes-plugin/skills/remove-ai-slops/SKILL.md +335 -0
  118. package/assets/lithermes-plugin/skills/review-work/SKILL.md +562 -0
  119. package/assets/lithermes-plugin/skills/rules/SKILL.md +41 -0
  120. package/assets/lithermes-plugin/skills/start-work/SKILL.md +332 -0
  121. package/bin/lithermes.js +8 -0
  122. package/cover.png +0 -0
  123. package/package.json +39 -0
  124. package/src/cli.js +129 -0
  125. package/src/lib/check.js +94 -0
  126. package/src/lib/config.js +170 -0
  127. package/src/lib/files.js +65 -0
  128. package/src/lib/hermesDiscovery.js +50 -0
  129. package/src/lib/hud.js +121 -0
  130. package/src/lib/install.js +159 -0
  131. package/src/lib/patch.js +153 -0
  132. package/src/lib/skins.js +113 -0
  133. 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}