its-magic 0.1.3-1 → 0.1.3-2
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/installer.ps1 +8 -0
- package/installer.py +8 -0
- package/installer.sh +1 -0
- package/package.json +1 -1
- package/template/.cursor/commands/auto.md +90 -0
- package/template/.cursor/commands/execute.md +26 -0
- package/template/.cursor/commands/refresh-context.md +32 -0
- package/template/.cursor/commands/sovereign-critic.md +104 -0
- package/template/.cursor/rules/sovereign-role-manifest.mdc.example +27 -0
- package/template/.cursor/scratchpad.md +141 -0
- package/template/.cursor/sovereign-role-manifest.yaml.example +53 -0
- package/template/decisions/DEC-0104.md +279 -0
- package/template/decisions/DEC-0105.md +231 -0
- package/template/decisions/DEC-0107.md +246 -0
- package/template/docs/engineering/architecture.md +78 -0
- package/template/docs/engineering/auto-orchestration-reference.md +7 -0
- package/template/docs/engineering/context/installer-owned-paths.manifest +8 -0
- package/template/docs/engineering/reason_codes.md +411 -0
- package/template/docs/engineering/runbook.md +922 -0
- package/template/docs/engineering/sovereign-memory/.gitkeep +0 -0
- package/template/docs/engineering/sovereign-memory/retrospectives/.gitkeep +0 -0
- package/template/handoffs/sovereign_decisions/.gitkeep +0 -0
- package/template/handoffs/sovereign_deferrals/.gitkeep +0 -0
- package/template/handoffs/sovereign_role_reviews.jsonl +0 -0
- package/template/scripts/__pycache__/decision_ledger_lib.cpython-312.pyc +0 -0
- package/template/scripts/check_intake_template_parity.py +119 -0
- package/template/scripts/decision_ledger_lib.py +732 -0
- package/template/scripts/ledger_validate.py +153 -0
- package/template/scripts/parallel_dev_arbiter.py +923 -0
- package/template/scripts/release_trigger_adapters.py +843 -0
- package/template/scripts/self_healing_deploy_lib.py +463 -0
- package/template/scripts/self_healing_deploy_validate.py +78 -0
- package/template/scripts/sovereign_convergence_lib.py +994 -0
- package/template/scripts/sovereign_convergence_validate.py +206 -0
- package/template/scripts/sovereign_critic_lib.py +629 -0
- package/template/scripts/sovereign_critic_validate.py +131 -0
- package/template/scripts/sovereign_loop_lib.py +828 -0
- package/template/scripts/sovereign_loop_validate.py +122 -0
- package/template/scripts/sovereign_memory_lib.py +869 -0
- package/template/scripts/sovereign_memory_validate.py +153 -0
- package/template/scripts/sovereign_role_manifest_lib.py +547 -0
- package/template/scripts/sovereign_role_manifest_validate.py +105 -0
- package/template/tests/us0108_contract_test.py +207 -0
- package/template/tests/us0109_contract_test.py +209 -0
- package/template/tests/us0109_us0110_compose_test.py +34 -0
- package/template/tests/us0111_contract_test.py +345 -0
|
@@ -0,0 +1,732 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AI Decision Ledger helper library (US-0103 / DEC-0103).
|
|
4
|
+
|
|
5
|
+
Provides:
|
|
6
|
+
- Append-only JSONL ledger operations (append / read / schema_check / summary_digest)
|
|
7
|
+
- Plan-fidelity deviation classifier per AUTO_PLAN_FIDELITY mode
|
|
8
|
+
- QA cross-check ledger_findings block builder
|
|
9
|
+
- Fail-closed reason codes for ledger + plan-fidelity families
|
|
10
|
+
|
|
11
|
+
Reason codes (DEC-0103 §8):
|
|
12
|
+
PLAN_FIDELITY_VIOLATION, PLAN_FIDELITY_OVERRIDE, PLAN_FIDELITY_SCOPE_GATE,
|
|
13
|
+
PLAN_FIDELITY_EXTENSION, PLAN_FIDELITY_REORDER,
|
|
14
|
+
LEDGER_FILE_MISSING, LEDGER_SCHEMA_INVALID, LEDGER_APPEND_FAILED,
|
|
15
|
+
LEDGER_CORRUPT, LEDGER_READ_BOUND, LEDGER_DISABLED
|
|
16
|
+
|
|
17
|
+
Default-off: AI_DECISION_LEDGER=0 → zero overhead (no reads, no writes).
|
|
18
|
+
"""
|
|
19
|
+
|
|
20
|
+
from __future__ import annotations
|
|
21
|
+
|
|
22
|
+
import json
|
|
23
|
+
import os
|
|
24
|
+
import sys
|
|
25
|
+
import uuid
|
|
26
|
+
from dataclasses import dataclass, field
|
|
27
|
+
from datetime import datetime, timezone
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from pathlib import Path
|
|
30
|
+
from typing import Dict, List, Optional, Tuple
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
# --- Scratchpad key contracts (DEC-0103 §1) -----------------------------------
|
|
34
|
+
|
|
35
|
+
AI_DECISION_LEDGER_KEY = "AI_DECISION_LEDGER"
|
|
36
|
+
AUTO_PLAN_FIDELITY_KEY = "AUTO_PLAN_FIDELITY"
|
|
37
|
+
|
|
38
|
+
AI_DECISION_LEDGER_VALUES = frozenset({"0", "1"})
|
|
39
|
+
AI_DECISION_LEDGER_DEFAULT = "0"
|
|
40
|
+
|
|
41
|
+
AUTO_PLAN_FIDELITY_VALUES = frozenset({"strict", "relaxed", "extended"})
|
|
42
|
+
AUTO_PLAN_FIDELITY_DEFAULT = "strict"
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
# --- Enums --------------------------------------------------------------------
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class PlanFidelity(str, Enum):
|
|
49
|
+
STRICT = "strict"
|
|
50
|
+
RELAXED = "relaxed"
|
|
51
|
+
EXTENDED = "extended"
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class RiskTier(str, Enum):
|
|
55
|
+
LOW = "low"
|
|
56
|
+
MEDIUM = "medium"
|
|
57
|
+
HIGH = "high"
|
|
58
|
+
CRITICAL = "critical"
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
class DecisionType(str, Enum):
|
|
62
|
+
PLAN_FIDELITY_VIOLATION = "PLAN_FIDELITY_VIOLATION"
|
|
63
|
+
PLAN_FIDELITY_OVERRIDE = "PLAN_FIDELITY_OVERRIDE"
|
|
64
|
+
PLAN_FIDELITY_SCOPE_GATE = "PLAN_FIDELITY_SCOPE_GATE"
|
|
65
|
+
PLAN_FIDELITY_EXTENSION = "PLAN_FIDELITY_EXTENSION"
|
|
66
|
+
PLAN_FIDELITY_REORDER = "PLAN_FIDELITY_REORDER"
|
|
67
|
+
LEDGER_DECISION = "LEDGER_DECISION"
|
|
68
|
+
LEDGER_DERIVATION = "LEDGER_DERIVATION"
|
|
69
|
+
LEDGER_PHASE_TRANSITION = "LEDGER_PHASE_TRANSITION"
|
|
70
|
+
LEDGER_DELEGATION = "LEDGER_DELEGATION"
|
|
71
|
+
|
|
72
|
+
|
|
73
|
+
class ReasonCode(str, Enum):
|
|
74
|
+
PLAN_FIDELITY_VIOLATION = "PLAN_FIDELITY_VIOLATION"
|
|
75
|
+
PLAN_FIDELITY_OVERRIDE = "PLAN_FIDELITY_OVERRIDE"
|
|
76
|
+
PLAN_FIDELITY_SCOPE_GATE = "PLAN_FIDELITY_SCOPE_GATE"
|
|
77
|
+
PLAN_FIDELITY_EXTENSION = "PLAN_FIDELITY_EXTENSION"
|
|
78
|
+
PLAN_FIDELITY_REORDER = "PLAN_FIDELITY_REORDER"
|
|
79
|
+
LEDGER_FILE_MISSING = "LEDGER_FILE_MISSING"
|
|
80
|
+
LEDGER_SCHEMA_INVALID = "LEDGER_SCHEMA_INVALID"
|
|
81
|
+
LEDGER_APPEND_FAILED = "LEDGER_APPEND_FAILED"
|
|
82
|
+
LEDGER_CORRUPT = "LEDGER_CORRUPT"
|
|
83
|
+
LEDGER_READ_BOUND = "LEDGER_READ_BOUND"
|
|
84
|
+
LEDGER_DISABLED = "LEDGER_DISABLED"
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
# --- Canonical phase ids (DEC-0086 / DEC-0087) --------------------------------
|
|
88
|
+
|
|
89
|
+
CANONICAL_PHASE_IDS = frozenset({
|
|
90
|
+
"ask", "refresh-context", "memory-audit", "status-reconcile", "pause",
|
|
91
|
+
"intake", "discovery", "research", "release", "plan-verify",
|
|
92
|
+
"architecture", "execute", "quick", "qa", "verify-work", "security-review",
|
|
93
|
+
"auto",
|
|
94
|
+
})
|
|
95
|
+
|
|
96
|
+
CANONICAL_ROLES = frozenset({
|
|
97
|
+
"po", "tech-lead", "dev", "qa", "security", "release", "curator",
|
|
98
|
+
})
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
# --- Deviation kind → decision_type mapping (DEC-0103 §3) --------------------
|
|
102
|
+
|
|
103
|
+
DEVIATION_KINDS = frozenset({
|
|
104
|
+
"drop_ac", "reorder_ac", "add_scope", "generic", "derivation",
|
|
105
|
+
"phase_transition", "delegation", "operator_override",
|
|
106
|
+
})
|
|
107
|
+
|
|
108
|
+
|
|
109
|
+
def _deviation_table(mode: PlanFidelity, deviation_kind: str) -> Tuple[DecisionType, ReasonCode, bool]:
|
|
110
|
+
"""
|
|
111
|
+
§3 decision table. Returns (decision_type, reason_code, blocking).
|
|
112
|
+
"""
|
|
113
|
+
if deviation_kind == "operator_override":
|
|
114
|
+
return (
|
|
115
|
+
DecisionType.PLAN_FIDELITY_OVERRIDE,
|
|
116
|
+
ReasonCode.PLAN_FIDELITY_OVERRIDE,
|
|
117
|
+
False,
|
|
118
|
+
)
|
|
119
|
+
|
|
120
|
+
if mode == PlanFidelity.STRICT:
|
|
121
|
+
if deviation_kind in ("drop_ac", "reorder_ac"):
|
|
122
|
+
return (
|
|
123
|
+
DecisionType.PLAN_FIDELITY_VIOLATION,
|
|
124
|
+
ReasonCode.PLAN_FIDELITY_VIOLATION,
|
|
125
|
+
True,
|
|
126
|
+
)
|
|
127
|
+
if deviation_kind == "add_scope":
|
|
128
|
+
return (
|
|
129
|
+
DecisionType.PLAN_FIDELITY_SCOPE_GATE,
|
|
130
|
+
ReasonCode.PLAN_FIDELITY_SCOPE_GATE,
|
|
131
|
+
True,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
if mode == PlanFidelity.RELAXED:
|
|
135
|
+
if deviation_kind in ("drop_ac", "reorder_ac"):
|
|
136
|
+
return (
|
|
137
|
+
DecisionType.PLAN_FIDELITY_REORDER,
|
|
138
|
+
ReasonCode.PLAN_FIDELITY_REORDER,
|
|
139
|
+
False,
|
|
140
|
+
)
|
|
141
|
+
if deviation_kind == "add_scope":
|
|
142
|
+
return (
|
|
143
|
+
DecisionType.PLAN_FIDELITY_SCOPE_GATE,
|
|
144
|
+
ReasonCode.PLAN_FIDELITY_SCOPE_GATE,
|
|
145
|
+
True,
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
if mode == PlanFidelity.EXTENDED:
|
|
149
|
+
if deviation_kind == "add_scope":
|
|
150
|
+
return (
|
|
151
|
+
DecisionType.PLAN_FIDELITY_EXTENSION,
|
|
152
|
+
ReasonCode.PLAN_FIDELITY_EXTENSION,
|
|
153
|
+
False,
|
|
154
|
+
)
|
|
155
|
+
if deviation_kind in ("drop_ac", "reorder_ac"):
|
|
156
|
+
return (
|
|
157
|
+
DecisionType.PLAN_FIDELITY_REORDER,
|
|
158
|
+
ReasonCode.PLAN_FIDELITY_REORDER,
|
|
159
|
+
False,
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
return (DecisionType.LEDGER_DECISION, ReasonCode.PLAN_FIDELITY_REORDER, False)
|
|
163
|
+
|
|
164
|
+
|
|
165
|
+
# Deviation kind → DecisionType for non-fidelity generic kinds:
|
|
166
|
+
|
|
167
|
+
_GENERIC_DECISION_TYPE = {
|
|
168
|
+
"generic": DecisionType.LEDGER_DECISION,
|
|
169
|
+
"derivation": DecisionType.LEDGER_DERIVATION,
|
|
170
|
+
"phase_transition": DecisionType.LEDGER_PHASE_TRANSITION,
|
|
171
|
+
"delegation": DecisionType.LEDGER_DELEGATION,
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
# --- Schema v1 (DEC-0103 §2) --------------------------------------------------
|
|
176
|
+
|
|
177
|
+
LEDGER_SCHEMA_FIELDS = (
|
|
178
|
+
"ts", "orchestrator_run_id", "phase_id", "role",
|
|
179
|
+
"decision_id", "decision_type", "from_artifact", "to_artifact",
|
|
180
|
+
"rationale", "plan_fidelity", "cross_model_reviewed", "risk_tier",
|
|
181
|
+
)
|
|
182
|
+
|
|
183
|
+
LEDGER_REQUIRED_FIELDS = frozenset(LEDGER_SCHEMA_FIELDS)
|
|
184
|
+
|
|
185
|
+
ARTIFACT_NONE_SENTINEL = "(none)"
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _is_iso8601_utc(value: str) -> bool:
|
|
189
|
+
if not isinstance(value, str):
|
|
190
|
+
return False
|
|
191
|
+
if not value.endswith("Z"):
|
|
192
|
+
return False
|
|
193
|
+
try:
|
|
194
|
+
datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
195
|
+
return True
|
|
196
|
+
except ValueError:
|
|
197
|
+
return False
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _is_uuidv4(value: str) -> bool:
|
|
201
|
+
if not isinstance(value, str):
|
|
202
|
+
return False
|
|
203
|
+
try:
|
|
204
|
+
u = uuid.UUID(value, version=4)
|
|
205
|
+
return str(u) == value.lower()
|
|
206
|
+
except ValueError:
|
|
207
|
+
return False
|
|
208
|
+
|
|
209
|
+
|
|
210
|
+
def schema_check(entry: dict) -> Tuple[bool, Optional[str]]:
|
|
211
|
+
"""Validate one JSONL entry against DEC-0103 schema v1 (12-field)."""
|
|
212
|
+
if not isinstance(entry, dict):
|
|
213
|
+
return False, "Entry must be a JSON object"
|
|
214
|
+
|
|
215
|
+
missing = [k for k in LEDGER_REQUIRED_FIELDS if k not in entry]
|
|
216
|
+
if missing:
|
|
217
|
+
return False, f"Missing required fields: {', '.join(missing)}"
|
|
218
|
+
|
|
219
|
+
extra = [k for k in entry.keys() if k not in LEDGER_REQUIRED_FIELDS]
|
|
220
|
+
if extra:
|
|
221
|
+
return False, f"Unknown fields: {', '.join(extra)}"
|
|
222
|
+
|
|
223
|
+
if not _is_iso8601_utc(entry["ts"]):
|
|
224
|
+
return False, f"Field 'ts' is not an ISO 8601 UTC timestamp: {entry['ts']!r}"
|
|
225
|
+
|
|
226
|
+
if not isinstance(entry["orchestrator_run_id"], str) or not entry["orchestrator_run_id"].strip():
|
|
227
|
+
return False, "Field 'orchestrator_run_id' must be non-empty string"
|
|
228
|
+
|
|
229
|
+
if entry["phase_id"] not in CANONICAL_PHASE_IDS:
|
|
230
|
+
return False, f"Field 'phase_id' unknown: {entry['phase_id']!r}"
|
|
231
|
+
|
|
232
|
+
if entry["role"] not in CANONICAL_ROLES:
|
|
233
|
+
return False, f"Field 'role' unknown: {entry['role']!r}"
|
|
234
|
+
|
|
235
|
+
if not _is_uuidv4(entry["decision_id"]):
|
|
236
|
+
return False, f"Field 'decision_id' is not a valid UUIDv4: {entry['decision_id']!r}"
|
|
237
|
+
|
|
238
|
+
try:
|
|
239
|
+
DecisionType(entry["decision_type"])
|
|
240
|
+
except ValueError:
|
|
241
|
+
return False, f"Field 'decision_type' unknown: {entry['decision_type']!r}"
|
|
242
|
+
|
|
243
|
+
for path_field in ("from_artifact", "to_artifact"):
|
|
244
|
+
value = entry[path_field]
|
|
245
|
+
if not isinstance(value, str):
|
|
246
|
+
return False, f"Field '{path_field}' must be a string"
|
|
247
|
+
|
|
248
|
+
if not isinstance(entry["rationale"], str) or not entry["rationale"].strip():
|
|
249
|
+
return False, "Field 'rationale' must be a non-empty string"
|
|
250
|
+
|
|
251
|
+
try:
|
|
252
|
+
PlanFidelity(entry["plan_fidelity"])
|
|
253
|
+
except ValueError:
|
|
254
|
+
return False, f"Field 'plan_fidelity' unknown: {entry['plan_fidelity']!r}"
|
|
255
|
+
|
|
256
|
+
if not isinstance(entry["cross_model_reviewed"], bool):
|
|
257
|
+
return False, "Field 'cross_model_reviewed' must be a boolean"
|
|
258
|
+
|
|
259
|
+
try:
|
|
260
|
+
RiskTier(entry["risk_tier"])
|
|
261
|
+
except ValueError:
|
|
262
|
+
return False, f"Field 'risk_tier' unknown: {entry['risk_tier']!r}"
|
|
263
|
+
|
|
264
|
+
return True, None
|
|
265
|
+
|
|
266
|
+
|
|
267
|
+
# --- Path + enabled checks ----------------------------------------------------
|
|
268
|
+
|
|
269
|
+
|
|
270
|
+
def is_ledger_enabled(scratchpad: Optional[Dict[str, str]] = None) -> bool:
|
|
271
|
+
"""AI_DECISION_LEDGER=1 check. Default off."""
|
|
272
|
+
if not scratchpad:
|
|
273
|
+
return False
|
|
274
|
+
value = (scratchpad.get(AI_DECISION_LEDGER_KEY) or AI_DECISION_LEDGER_DEFAULT).strip()
|
|
275
|
+
return value == "1"
|
|
276
|
+
|
|
277
|
+
|
|
278
|
+
def resolve_plan_fidelity(scratchpad: Optional[Dict[str, str]] = None) -> PlanFidelity:
|
|
279
|
+
"""AUTO_PLAN_FIDELITY value. Default strict."""
|
|
280
|
+
if not scratchpad:
|
|
281
|
+
return PlanFidelity.STRICT
|
|
282
|
+
value = (scratchpad.get(AUTO_PLAN_FIDELITY_KEY) or AUTO_PLAN_FIDELITY_DEFAULT).strip()
|
|
283
|
+
if value not in AUTO_PLAN_FIDELITY_VALUES:
|
|
284
|
+
return PlanFidelity.STRICT
|
|
285
|
+
return PlanFidelity(value)
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
def resolve_ledger_path(
|
|
289
|
+
orchestrator_run_id: str,
|
|
290
|
+
repo_root: Optional[Path] = None,
|
|
291
|
+
) -> Path:
|
|
292
|
+
"""Canonical ledger path per orchestrator run (DEC-0103 §2)."""
|
|
293
|
+
if not orchestrator_run_id or not orchestrator_run_id.strip():
|
|
294
|
+
raise ValueError("orchestrator_run_id must be a non-empty string")
|
|
295
|
+
base = Path(repo_root) if repo_root else Path.cwd()
|
|
296
|
+
return base / "handoffs" / "sovereign_decisions" / f"{orchestrator_run_id.strip()}.jsonl"
|
|
297
|
+
|
|
298
|
+
|
|
299
|
+
# --- Core operations ----------------------------------------------------------
|
|
300
|
+
|
|
301
|
+
|
|
302
|
+
def _fsync_file(path: Path) -> None:
|
|
303
|
+
"""fsync a file; best-effort on Windows where some handles reject it."""
|
|
304
|
+
try:
|
|
305
|
+
fd = os.open(str(path), os.O_RDONLY)
|
|
306
|
+
try:
|
|
307
|
+
os.fsync(fd)
|
|
308
|
+
except OSError:
|
|
309
|
+
pass
|
|
310
|
+
finally:
|
|
311
|
+
os.close(fd)
|
|
312
|
+
except OSError:
|
|
313
|
+
pass
|
|
314
|
+
|
|
315
|
+
|
|
316
|
+
@dataclass
|
|
317
|
+
class AppendResult:
|
|
318
|
+
success: bool
|
|
319
|
+
reason_code: Optional[ReasonCode] = None
|
|
320
|
+
reason_message: Optional[str] = None
|
|
321
|
+
decision_id: Optional[str] = None
|
|
322
|
+
|
|
323
|
+
|
|
324
|
+
def _ensure_parent_dir(path: Path) -> Tuple[bool, Optional[ReasonCode], Optional[str]]:
|
|
325
|
+
try:
|
|
326
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
327
|
+
return True, None, None
|
|
328
|
+
except OSError as exc:
|
|
329
|
+
return False, ReasonCode.LEDGER_APPEND_FAILED, f"Cannot create {path.parent}: {exc}"
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def append_entry(
|
|
333
|
+
ledger_path: Path,
|
|
334
|
+
entry: dict,
|
|
335
|
+
*,
|
|
336
|
+
scratchpad: Optional[Dict[str, str]] = None,
|
|
337
|
+
) -> AppendResult:
|
|
338
|
+
"""
|
|
339
|
+
Append one JSONL entry with fsync semantics.
|
|
340
|
+
|
|
341
|
+
Default-off: when AI_DECISION_LEDGER=0 (default), returns success with LEDGER_DISABLED
|
|
342
|
+
reason code (informational) and does NOT touch the filesystem.
|
|
343
|
+
"""
|
|
344
|
+
if not is_ledger_enabled(scratchpad):
|
|
345
|
+
return AppendResult(
|
|
346
|
+
success=True,
|
|
347
|
+
reason_code=ReasonCode.LEDGER_DISABLED,
|
|
348
|
+
reason_message="AI_DECISION_LEDGER=0 (default); zero overhead; no file written",
|
|
349
|
+
)
|
|
350
|
+
|
|
351
|
+
ok, err = schema_check(entry)
|
|
352
|
+
if not ok:
|
|
353
|
+
return AppendResult(
|
|
354
|
+
success=False,
|
|
355
|
+
reason_code=ReasonCode.LEDGER_SCHEMA_INVALID,
|
|
356
|
+
reason_message=err or "Schema validation failed",
|
|
357
|
+
decision_id=entry.get("decision_id"),
|
|
358
|
+
)
|
|
359
|
+
|
|
360
|
+
ok_dir, rc_dir, msg_dir = _ensure_parent_dir(ledger_path)
|
|
361
|
+
if not ok_dir:
|
|
362
|
+
return AppendResult(success=False, reason_code=rc_dir, reason_message=msg_dir)
|
|
363
|
+
|
|
364
|
+
try:
|
|
365
|
+
line = json.dumps(entry, separators=(",", ":"), sort_keys=False, ensure_ascii=False) + "\n"
|
|
366
|
+
with open(ledger_path, "a", encoding="utf-8") as fh:
|
|
367
|
+
fh.write(line)
|
|
368
|
+
fh.flush()
|
|
369
|
+
_fsync_file(ledger_path)
|
|
370
|
+
return AppendResult(success=True, decision_id=entry.get("decision_id"))
|
|
371
|
+
except OSError as exc:
|
|
372
|
+
return AppendResult(
|
|
373
|
+
success=False,
|
|
374
|
+
reason_code=ReasonCode.LEDGER_APPEND_FAILED,
|
|
375
|
+
reason_message=f"Append failed: {exc}",
|
|
376
|
+
decision_id=entry.get("decision_id"),
|
|
377
|
+
)
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def read_entries(
|
|
381
|
+
ledger_path: Path,
|
|
382
|
+
*,
|
|
383
|
+
last_n: Optional[int] = None,
|
|
384
|
+
strict: bool = True,
|
|
385
|
+
) -> Tuple[List[dict], Optional[ReasonCode], Optional[str]]:
|
|
386
|
+
"""
|
|
387
|
+
Read JSONL ledger entries. Returns (entries, reason_code, message).
|
|
388
|
+
|
|
389
|
+
`last_n` bounds the tail returned (default None = all when ledger present).
|
|
390
|
+
When `strict=True`, any schema-invalid line hard-stops and emits LEDGER_SCHEMA_INVALID.
|
|
391
|
+
When `strict=False`, invalid lines are skipped and reason code reflects LEDGER_READ_BOUND.
|
|
392
|
+
"""
|
|
393
|
+
if not ledger_path.exists():
|
|
394
|
+
return [], ReasonCode.LEDGER_FILE_MISSING, f"Ledger file not found: {ledger_path}"
|
|
395
|
+
|
|
396
|
+
try:
|
|
397
|
+
text = ledger_path.read_text(encoding="utf-8")
|
|
398
|
+
except UnicodeDecodeError as exc:
|
|
399
|
+
return [], ReasonCode.LEDGER_CORRUPT, f"UTF-8 decode failed: {exc}"
|
|
400
|
+
except OSError as exc:
|
|
401
|
+
return [], ReasonCode.LEDGER_CORRUPT, f"Read failed: {exc}"
|
|
402
|
+
|
|
403
|
+
raw_lines = [ln for ln in text.split("\n") if ln.strip()]
|
|
404
|
+
parsed: List[dict] = []
|
|
405
|
+
for idx, raw in enumerate(raw_lines, start=1):
|
|
406
|
+
try:
|
|
407
|
+
obj = json.loads(raw)
|
|
408
|
+
except json.JSONDecodeError as exc:
|
|
409
|
+
if strict:
|
|
410
|
+
return [], ReasonCode.LEDGER_CORRUPT, f"Line {idx}: JSON decode error: {exc}"
|
|
411
|
+
continue
|
|
412
|
+
ok, err = schema_check(obj)
|
|
413
|
+
if not ok:
|
|
414
|
+
if strict:
|
|
415
|
+
return [], ReasonCode.LEDGER_SCHEMA_INVALID, f"Line {idx}: {err}"
|
|
416
|
+
continue
|
|
417
|
+
parsed.append(obj)
|
|
418
|
+
|
|
419
|
+
if last_n is not None and last_n > 0:
|
|
420
|
+
parsed = parsed[-last_n:]
|
|
421
|
+
|
|
422
|
+
warn = None
|
|
423
|
+
if last_n is not None and len(raw_lines) > last_n:
|
|
424
|
+
warn = ReasonCode.LEDGER_READ_BOUND
|
|
425
|
+
|
|
426
|
+
return parsed, warn, f"Bounded to last {last_n} of {len(raw_lines)} lines" if warn else None
|
|
427
|
+
|
|
428
|
+
|
|
429
|
+
def classify_deviation(
|
|
430
|
+
mode: PlanFidelity,
|
|
431
|
+
deviation_kind: str,
|
|
432
|
+
) -> Tuple[DecisionType, ReasonCode, bool]:
|
|
433
|
+
"""
|
|
434
|
+
DEC-0103 §3 deviation classifier. Single source of truth.
|
|
435
|
+
|
|
436
|
+
Returns (decision_type, reason_code, blocking).
|
|
437
|
+
"""
|
|
438
|
+
if deviation_kind not in DEVIATION_KINDS:
|
|
439
|
+
return (
|
|
440
|
+
DecisionType.LEDGER_DECISION,
|
|
441
|
+
ReasonCode.PLAN_FIDELITY_VIOLATION,
|
|
442
|
+
False,
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
if deviation_kind in _GENERIC_DECISION_TYPE:
|
|
446
|
+
dt = _GENERIC_DECISION_TYPE[deviation_kind]
|
|
447
|
+
return (dt, ReasonCode.PLAN_FIDELITY_OVERRIDE, False)
|
|
448
|
+
|
|
449
|
+
return _deviation_table(mode, deviation_kind)
|
|
450
|
+
|
|
451
|
+
|
|
452
|
+
# --- Summary digest (DEC-0103 §6) --------------------------------------------
|
|
453
|
+
|
|
454
|
+
_DECISION_TYPE_ZERO_MAP = {dt.value: 0 for dt in DecisionType}
|
|
455
|
+
_RISK_TIER_ZERO_MAP = {rt.value: 0 for rt in RiskTier}
|
|
456
|
+
|
|
457
|
+
|
|
458
|
+
def summary_digest(entries: List[dict]) -> dict:
|
|
459
|
+
"""
|
|
460
|
+
Build bounded QA digest — DEC-0103 §6 shape.
|
|
461
|
+
"""
|
|
462
|
+
digest = {
|
|
463
|
+
"total_decisions": 0,
|
|
464
|
+
"by_type": dict(_DECISION_TYPE_ZERO_MAP),
|
|
465
|
+
"by_risk_tier": dict(_RISK_TIER_ZERO_MAP),
|
|
466
|
+
"violation_count": 0,
|
|
467
|
+
"override_count": 0,
|
|
468
|
+
"scope_gate_count": 0,
|
|
469
|
+
"extension_count": 0,
|
|
470
|
+
}
|
|
471
|
+
for entry in entries:
|
|
472
|
+
digest["total_decisions"] += 1
|
|
473
|
+
dt = entry.get("decision_type")
|
|
474
|
+
rt = entry.get("risk_tier")
|
|
475
|
+
if dt in digest["by_type"]:
|
|
476
|
+
digest["by_type"][dt] += 1
|
|
477
|
+
if rt in digest["by_risk_tier"]:
|
|
478
|
+
digest["by_risk_tier"][rt] += 1
|
|
479
|
+
if dt == DecisionType.PLAN_FIDELITY_VIOLATION.value:
|
|
480
|
+
digest["violation_count"] += 1
|
|
481
|
+
elif dt == DecisionType.PLAN_FIDELITY_OVERRIDE.value:
|
|
482
|
+
digest["override_count"] += 1
|
|
483
|
+
elif dt == DecisionType.PLAN_FIDELITY_SCOPE_GATE.value:
|
|
484
|
+
digest["scope_gate_count"] += 1
|
|
485
|
+
elif dt == DecisionType.PLAN_FIDELITY_EXTENSION.value:
|
|
486
|
+
digest["extension_count"] += 1
|
|
487
|
+
return digest
|
|
488
|
+
|
|
489
|
+
|
|
490
|
+
def build_qa_findings_block(
|
|
491
|
+
ledger_path: Path,
|
|
492
|
+
orchestrator_run_id: str,
|
|
493
|
+
*,
|
|
494
|
+
scratchpad: Optional[Dict[str, str]] = None,
|
|
495
|
+
) -> Tuple[dict, Optional[ReasonCode]]:
|
|
496
|
+
"""
|
|
497
|
+
Build `ledger_findings` block for QA emission (DEC-0103 §6).
|
|
498
|
+
Returns (block_dict, blocking_reason_code_or_None).
|
|
499
|
+
"""
|
|
500
|
+
if not is_ledger_enabled(scratchpad):
|
|
501
|
+
return {
|
|
502
|
+
"ledger_findings": [],
|
|
503
|
+
"ledger_summary_digest": summary_digest([]),
|
|
504
|
+
"ledger_source": str(ledger_path),
|
|
505
|
+
"ledger_orchestrator_run_id": orchestrator_run_id,
|
|
506
|
+
"ledger_status": "disabled",
|
|
507
|
+
}, ReasonCode.LEDGER_DISABLED
|
|
508
|
+
|
|
509
|
+
entries, reason, message = read_entries(ledger_path, last_n=100)
|
|
510
|
+
if reason == ReasonCode.LEDGER_FILE_MISSING:
|
|
511
|
+
return {
|
|
512
|
+
"ledger_findings": [],
|
|
513
|
+
"ledger_summary_digest": summary_digest([]),
|
|
514
|
+
"ledger_source": str(ledger_path),
|
|
515
|
+
"ledger_orchestrator_run_id": orchestrator_run_id,
|
|
516
|
+
"ledger_status": "file_missing",
|
|
517
|
+
"ledger_error": message or "Ledger file does not exist",
|
|
518
|
+
}, ReasonCode.LEDGER_FILE_MISSING
|
|
519
|
+
|
|
520
|
+
if reason in (ReasonCode.LEDGER_SCHEMA_INVALID, ReasonCode.LEDGER_CORRUPT):
|
|
521
|
+
return {
|
|
522
|
+
"ledger_findings": [],
|
|
523
|
+
"ledger_summary_digest": summary_digest([]),
|
|
524
|
+
"ledger_source": str(ledger_path),
|
|
525
|
+
"ledger_orchestrator_run_id": orchestrator_run_id,
|
|
526
|
+
"ledger_status": "schema_invalid",
|
|
527
|
+
"ledger_error": message or "Ledger lines failed schema_check",
|
|
528
|
+
}, reason
|
|
529
|
+
|
|
530
|
+
findings: List[dict] = []
|
|
531
|
+
for entry in entries:
|
|
532
|
+
rationale = (entry.get("rationale") or "").strip()
|
|
533
|
+
findings.append({
|
|
534
|
+
"decision_id": entry["decision_id"],
|
|
535
|
+
"decision_type": entry["decision_type"],
|
|
536
|
+
"phase_id": entry["phase_id"],
|
|
537
|
+
"rationale_summary": rationale[:200],
|
|
538
|
+
"risk_tier": entry["risk_tier"],
|
|
539
|
+
"plan_fidelity_mode": entry["plan_fidelity"],
|
|
540
|
+
"cross_model_reviewed": bool(entry.get("cross_model_reviewed", False)),
|
|
541
|
+
})
|
|
542
|
+
|
|
543
|
+
return {
|
|
544
|
+
"ledger_findings": findings,
|
|
545
|
+
"ledger_summary_digest": summary_digest(entries),
|
|
546
|
+
"ledger_source": str(ledger_path),
|
|
547
|
+
"ledger_orchestrator_run_id": orchestrator_run_id,
|
|
548
|
+
"ledger_status": "ok",
|
|
549
|
+
}, None
|
|
550
|
+
|
|
551
|
+
|
|
552
|
+
# --- Builder convenience ------------------------------------------------------
|
|
553
|
+
|
|
554
|
+
|
|
555
|
+
def build_new_entry(
|
|
556
|
+
*,
|
|
557
|
+
orchestrator_run_id: str,
|
|
558
|
+
phase_id: str,
|
|
559
|
+
role: str,
|
|
560
|
+
decision_type: str,
|
|
561
|
+
rationale: str,
|
|
562
|
+
plan_fidelity: str,
|
|
563
|
+
risk_tier: str = "medium",
|
|
564
|
+
from_artifact: str = ARTIFACT_NONE_SENTINEL,
|
|
565
|
+
to_artifact: str = ARTIFACT_NONE_SENTINEL,
|
|
566
|
+
cross_model_reviewed: bool = False,
|
|
567
|
+
decision_id: Optional[str] = None,
|
|
568
|
+
ts: Optional[str] = None,
|
|
569
|
+
) -> dict:
|
|
570
|
+
"""Build a valid ledger entry dict. Raises ValueError on invalid inputs."""
|
|
571
|
+
ts_value = ts or datetime.now(timezone.utc).strftime("%Y-%m-%dT%H:%M:%S.000Z")
|
|
572
|
+
did = decision_id or str(uuid.uuid4())
|
|
573
|
+
return {
|
|
574
|
+
"ts": ts_value,
|
|
575
|
+
"orchestrator_run_id": orchestrator_run_id,
|
|
576
|
+
"phase_id": phase_id,
|
|
577
|
+
"role": role,
|
|
578
|
+
"decision_id": did,
|
|
579
|
+
"decision_type": decision_type,
|
|
580
|
+
"from_artifact": from_artifact,
|
|
581
|
+
"to_artifact": to_artifact,
|
|
582
|
+
"rationale": rationale,
|
|
583
|
+
"plan_fidelity": plan_fidelity,
|
|
584
|
+
"cross_model_reviewed": bool(cross_model_reviewed),
|
|
585
|
+
"risk_tier": risk_tier,
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
|
|
589
|
+
# --- Self-test ----------------------------------------------------------------
|
|
590
|
+
|
|
591
|
+
|
|
592
|
+
def self_test() -> bool:
|
|
593
|
+
"""Run self-test to validate library contract (US-0103 / DEC-0103)."""
|
|
594
|
+
print("[SELF-TEST] Validating decision_ledger_lib contract...")
|
|
595
|
+
errors: List[str] = []
|
|
596
|
+
|
|
597
|
+
# Scratchpad literals
|
|
598
|
+
if AI_DECISION_LEDGER_VALUES != {"0", "1"}:
|
|
599
|
+
errors.append(f"AI_DECISION_LEDGER_VALUES mismatch: {AI_DECISION_LEDGER_VALUES}")
|
|
600
|
+
if AI_DECISION_LEDGER_DEFAULT != "0":
|
|
601
|
+
errors.append(f"AI_DECISION_LEDGER_DEFAULT mismatch: {AI_DECISION_LEDGER_DEFAULT}")
|
|
602
|
+
if AUTO_PLAN_FIDELITY_VALUES != {"strict", "relaxed", "extended"}:
|
|
603
|
+
errors.append(f"AUTO_PLAN_FIDELITY_VALUES mismatch: {AUTO_PLAN_FIDELITY_VALUES}")
|
|
604
|
+
if AUTO_PLAN_FIDELITY_DEFAULT != "strict":
|
|
605
|
+
errors.append(f"AUTO_PLAN_FIDELITY_DEFAULT mismatch: {AUTO_PLAN_FIDELITY_DEFAULT}")
|
|
606
|
+
|
|
607
|
+
# Enum cardinalities (DEC-0103 §8 — 5 + 6 = 11 reason codes; 3+4+9 = 16 decisions)
|
|
608
|
+
if len(ReasonCode) != 11:
|
|
609
|
+
errors.append(f"ReasonCode count: expected 11, got {len(ReasonCode)}")
|
|
610
|
+
plan_fidelity_codes = {c for c in ReasonCode if c.value.startswith("PLAN_FIDELITY_")}
|
|
611
|
+
ledger_codes = {c for c in ReasonCode if c.value.startswith("LEDGER_")}
|
|
612
|
+
if len(plan_fidelity_codes) != 5:
|
|
613
|
+
errors.append(f"PLAN_FIDELITY_* codes expected 5, got {len(plan_fidelity_codes)}")
|
|
614
|
+
if len(ledger_codes) != 6:
|
|
615
|
+
errors.append(f"LEDGER_* codes expected 6, got {len(ledger_codes)}")
|
|
616
|
+
|
|
617
|
+
# is_ledger_enabled / is_ledger_enabled default
|
|
618
|
+
if is_ledger_enabled(None) or is_ledger_enabled({}) or is_ledger_enabled({"AI_DECISION_LEDGER": "0"}):
|
|
619
|
+
errors.append("is_ledger_enabled should be False by default")
|
|
620
|
+
if not is_ledger_enabled({"AI_DECISION_LEDGER": "1"}):
|
|
621
|
+
errors.append("is_ledger_enabled should be True when key=1")
|
|
622
|
+
|
|
623
|
+
# resolve_plan_fidelity default path
|
|
624
|
+
if resolve_plan_fidelity(None) != PlanFidelity.STRICT:
|
|
625
|
+
errors.append("resolve_plan_fidelity default should be strict")
|
|
626
|
+
|
|
627
|
+
# schema_check + append round-trip
|
|
628
|
+
good = build_new_entry(
|
|
629
|
+
orchestrator_run_id="self-test-run-1",
|
|
630
|
+
phase_id="research",
|
|
631
|
+
role="tech-lead",
|
|
632
|
+
decision_type=DecisionType.LEDGER_DECISION.value,
|
|
633
|
+
rationale="Self-test rationale.",
|
|
634
|
+
plan_fidelity=PlanFidelity.STRICT.value,
|
|
635
|
+
risk_tier=RiskTier.LOW.value,
|
|
636
|
+
)
|
|
637
|
+
ok, err = schema_check(good)
|
|
638
|
+
if not ok:
|
|
639
|
+
errors.append(f"schema_check should accept valid entry: {err}")
|
|
640
|
+
|
|
641
|
+
bad = dict(good)
|
|
642
|
+
bad["decision_id"] = "not-a-uuid"
|
|
643
|
+
ok_b, err_b = schema_check(bad)
|
|
644
|
+
if ok_b:
|
|
645
|
+
errors.append("schema_check should reject invalid decision_id")
|
|
646
|
+
if err_b is None:
|
|
647
|
+
errors.append("schema_check error message must be non-None on failure")
|
|
648
|
+
|
|
649
|
+
bad2 = dict(good)
|
|
650
|
+
bad2["phase_id"] = "not-a-phase"
|
|
651
|
+
ok_b2, err_b2 = schema_check(bad2)
|
|
652
|
+
if ok_b2:
|
|
653
|
+
errors.append("schema_check should reject unknown phase_id")
|
|
654
|
+
|
|
655
|
+
# classify_deviation strict + drop_ac → blocking
|
|
656
|
+
dt, rc, blocking = classify_deviation(PlanFidelity.STRICT, "drop_ac")
|
|
657
|
+
if not blocking or dt != DecisionType.PLAN_FIDELITY_VIOLATION:
|
|
658
|
+
errors.append(f"strict+drop_ac should be blocking PLAN_FIDELITY_VIOLATION (got {dt}, {blocking})")
|
|
659
|
+
|
|
660
|
+
# relaxed+reorder_ac → non-blocking
|
|
661
|
+
dt, rc, blocking = classify_deviation(PlanFidelity.RELAXED, "reorder_ac")
|
|
662
|
+
if blocking or dt != DecisionType.PLAN_FIDELITY_REORDER:
|
|
663
|
+
errors.append(f"relaxed+reorder_ac should be non-blocking PLAN_FIDELITY_REORDER (got {dt}, {blocking})")
|
|
664
|
+
|
|
665
|
+
# extended+add_scope → non-blocking extension
|
|
666
|
+
dt, rc, blocking = classify_deviation(PlanFidelity.EXTENDED, "add_scope")
|
|
667
|
+
if blocking or dt != DecisionType.PLAN_FIDELITY_EXTENSION:
|
|
668
|
+
errors.append(f"extended+add_scope should be non-blocking PLAN_FIDELITY_EXTENSION (got {dt}, {blocking})")
|
|
669
|
+
|
|
670
|
+
# summary_digest shape
|
|
671
|
+
digest = summary_digest([good])
|
|
672
|
+
if digest["total_decisions"] != 1:
|
|
673
|
+
errors.append("summary_digest total_decisions != 1")
|
|
674
|
+
if "violation_count" not in digest or "by_type" not in digest or "by_risk_tier" not in digest:
|
|
675
|
+
errors.append("summary_digest shape missing required keys")
|
|
676
|
+
|
|
677
|
+
if errors:
|
|
678
|
+
print("[SELF_TEST_FAILED]")
|
|
679
|
+
for err in errors:
|
|
680
|
+
print(f" {err}", file=sys.stderr)
|
|
681
|
+
return False
|
|
682
|
+
|
|
683
|
+
print("[DECISION_LEDGER_SELF_TEST_OK]")
|
|
684
|
+
return True
|
|
685
|
+
|
|
686
|
+
|
|
687
|
+
# --- CLI ----------------------------------------------------------------------
|
|
688
|
+
|
|
689
|
+
|
|
690
|
+
if __name__ == "__main__":
|
|
691
|
+
import argparse
|
|
692
|
+
|
|
693
|
+
parser = argparse.ArgumentParser(description="Decision ledger library (US-0103 / DEC-0103)")
|
|
694
|
+
parser.add_argument("--self-test", action="store_true", help="Run self-test")
|
|
695
|
+
parser.add_argument("--append-json", help="JSON string of entry to append")
|
|
696
|
+
parser.add_argument("--ledger", type=Path, help="Ledger path for --append-json")
|
|
697
|
+
parser.add_argument("--orchestrator-run-id", help="Resolve path for --append-json")
|
|
698
|
+
parser.add_argument("--dump-digest", type=Path, help="Print summary_digest for ledger path")
|
|
699
|
+
parser.add_argument("--repo", type=Path, default=None, help="Repo root for ledger path resolution")
|
|
700
|
+
|
|
701
|
+
args = parser.parse_args()
|
|
702
|
+
|
|
703
|
+
if args.self_test:
|
|
704
|
+
sys.exit(0 if self_test() else 1)
|
|
705
|
+
|
|
706
|
+
if args.dump_digest:
|
|
707
|
+
entries, reason, message = read_entries(args.dump_digest, strict=True)
|
|
708
|
+
if reason in (ReasonCode.LEDGER_FILE_MISSING, ReasonCode.LEDGER_SCHEMA_INVALID, ReasonCode.LEDGER_CORRUPT):
|
|
709
|
+
print(f"[LEDGER_READ_FAIL] {reason.value}: {message}", file=sys.stderr)
|
|
710
|
+
sys.exit(1)
|
|
711
|
+
print(json.dumps(summary_digest(entries), indent=2, sort_keys=True))
|
|
712
|
+
sys.exit(0)
|
|
713
|
+
|
|
714
|
+
if args.append_json:
|
|
715
|
+
if not args.ledger and not args.orchestrator_run_id:
|
|
716
|
+
print("[USAGE] --ledger or --orchestrator-run-id required", file=sys.stderr)
|
|
717
|
+
sys.exit(2)
|
|
718
|
+
path = args.ledger or resolve_ledger_path(args.orchestrator_run_id, args.repo)
|
|
719
|
+
try:
|
|
720
|
+
entry = json.loads(args.append_json)
|
|
721
|
+
except json.JSONDecodeError as exc:
|
|
722
|
+
print(f"[USAGE] invalid --append-json: {exc}", file=sys.stderr)
|
|
723
|
+
sys.exit(2)
|
|
724
|
+
res = append_entry(path, entry)
|
|
725
|
+
if res.success:
|
|
726
|
+
print(f"[OK] decision_id={res.decision_id} reason={res.reason_code.value if res.reason_code else 'appended'}")
|
|
727
|
+
sys.exit(0)
|
|
728
|
+
print(f"[FAIL] {res.reason_code.value if res.reason_code else 'append_failed'}: {res.reason_message}", file=sys.stderr)
|
|
729
|
+
sys.exit(1)
|
|
730
|
+
|
|
731
|
+
parser.print_help()
|
|
732
|
+
sys.exit(0)
|