@deftai/directive-content 0.59.0 → 0.61.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/.githooks/pre-commit +10 -128
- package/.githooks/pre-push +8 -108
- package/Taskfile.yml +48 -58
- package/UPGRADING.md +19 -3
- package/docs/assets/directive-lifecycle-diagram.png +0 -0
- package/docs/directive-lifecycle.md +73 -0
- package/docs/getting-started.md +5 -1
- package/package.json +3 -3
- package/packs/skills/skills-pack-0.1.json +1 -1
- package/packs/strategies/strategies-pack-0.1.json +19 -19
- package/scm/github.md +37 -6
- package/skills/deft-directive-setup/SKILL.md +24 -15
- package/strategies/speckit.md +14 -14
- package/strategies/v0-20-contract.md +12 -1
- package/tasks/change.yml +16 -31
- package/tasks/ci.yml +8 -0
- package/tasks/commit.yml +12 -19
- package/tasks/core.yml +10 -0
- package/tasks/engine.yml +42 -0
- package/tasks/framework.yml +3 -0
- package/tasks/install.yml +20 -19
- package/tasks/migrate.yml +26 -15
- package/tasks/project.yml +26 -0
- package/tasks/toolchain.yml +15 -5
- package/tasks/vbrief.yml +4 -3
- package/tasks/verify.yml +12 -14
- package/templates/agents-entry.md +1 -1
- package/scripts/_agents_md.py +0 -494
- package/scripts/_cache_fetch.py +0 -635
- package/scripts/_cache_quota.py +0 -529
- package/scripts/_cache_refresh.py +0 -163
- package/scripts/_cache_validate.py +0 -209
- package/scripts/_content_root.py +0 -42
- package/scripts/_doctor_state.py +0 -277
- package/scripts/_event_detect.py +0 -305
- package/scripts/_events.py +0 -514
- package/scripts/_lifecycle_hygiene.py +0 -568
- package/scripts/_pathspec.py +0 -91
- package/scripts/_policy_show_cli.py +0 -266
- package/scripts/_precutover.py +0 -92
- package/scripts/_project_context.py +0 -224
- package/scripts/_project_definition_io.py +0 -164
- package/scripts/_relocate_snapshot.py +0 -209
- package/scripts/_relocate_states.py +0 -343
- package/scripts/_resolve_preflight_path.py +0 -152
- package/scripts/_safe_subprocess.py +0 -167
- package/scripts/_session_start_hook.py +0 -205
- package/scripts/_sor_gate_diff.py +0 -365
- package/scripts/_stdio_utf8.py +0 -59
- package/scripts/_triage_bootstrap_gitignore.py +0 -904
- package/scripts/_triage_classify_cli.py +0 -122
- package/scripts/_triage_queue_cli.py +0 -625
- package/scripts/_triage_scope_cli.py +0 -343
- package/scripts/_triage_scope_drift_cli.py +0 -121
- package/scripts/_triage_scope_ignores.py +0 -286
- package/scripts/_triage_scope_milestone.py +0 -432
- package/scripts/_triage_scope_mutations.py +0 -337
- package/scripts/_triage_scope_renderers.py +0 -207
- package/scripts/_triage_smoketest_stages.py +0 -674
- package/scripts/_triage_subscribe_cli.py +0 -140
- package/scripts/_triage_welcome_cli.py +0 -421
- package/scripts/_vbrief_build.py +0 -239
- package/scripts/_vbrief_fidelity.py +0 -479
- package/scripts/_vbrief_legacy.py +0 -589
- package/scripts/_vbrief_reconciliation.py +0 -883
- package/scripts/_vbrief_routing.py +0 -277
- package/scripts/_vbrief_safety.py +0 -778
- package/scripts/_vbrief_sources.py +0 -312
- package/scripts/_vbrief_speckit.py +0 -262
- package/scripts/_vbrief_story_quality.py +0 -353
- package/scripts/_vbrief_validation.py +0 -299
- package/scripts/build_dist.py +0 -412
- package/scripts/cache.py +0 -1078
- package/scripts/cache_scanner.py +0 -745
- package/scripts/candidates_log.py +0 -432
- package/scripts/capacity_backfill.py +0 -680
- package/scripts/capacity_show.py +0 -653
- package/scripts/ci_local.py +0 -689
- package/scripts/code_structure_validate.py +0 -765
- package/scripts/codebase_default_extractor.py +0 -495
- package/scripts/codebase_map.py +0 -304
- package/scripts/codebase_map_fresh.py +0 -104
- package/scripts/codebase_projection_registry.py +0 -94
- package/scripts/codebase_provider.py +0 -582
- package/scripts/doctor.py +0 -2552
- package/scripts/framework_commands.py +0 -505
- package/scripts/gh_rest.py +0 -882
- package/scripts/github_auth_modes.py +0 -437
- package/scripts/github_body.py +0 -292
- package/scripts/ip_risk.py +0 -531
- package/scripts/issue_emit.py +0 -670
- package/scripts/issue_ingest.py +0 -1064
- package/scripts/migrate_preflight.py +0 -418
- package/scripts/migrate_vbrief.py +0 -2677
- package/scripts/monitor_pr.py +0 -401
- package/scripts/pack_migrate_lessons.py +0 -336
- package/scripts/pack_migrate_patterns.py +0 -254
- package/scripts/pack_migrate_rules.py +0 -350
- package/scripts/pack_migrate_skills.py +0 -423
- package/scripts/pack_migrate_strategies.py +0 -311
- package/scripts/pack_migrate_swarm_spec.py +0 -250
- package/scripts/pack_render.py +0 -434
- package/scripts/packs_slice.py +0 -712
- package/scripts/platform_capabilities.py +0 -336
- package/scripts/policy.py +0 -2826
- package/scripts/policy_set.py +0 -324
- package/scripts/pr_check_closing_keywords.py +0 -524
- package/scripts/pr_check_protected_issues.py +0 -267
- package/scripts/pr_merge_readiness.py +0 -1004
- package/scripts/pr_wait_mergeable.py +0 -669
- package/scripts/prd_render.py +0 -159
- package/scripts/preflight_architecture_sor.py +0 -974
- package/scripts/preflight_branch.py +0 -289
- package/scripts/preflight_cache.py +0 -974
- package/scripts/preflight_gh.py +0 -721
- package/scripts/preflight_implementation.py +0 -272
- package/scripts/preflight_story_start.py +0 -838
- package/scripts/preflight_wip_cap.py +0 -149
- package/scripts/probe_session.py +0 -545
- package/scripts/project_render.py +0 -293
- package/scripts/quarantine_ext.py +0 -237
- package/scripts/reconcile_issues.py +0 -1442
- package/scripts/refresh-path.ps1 +0 -107
- package/scripts/release.py +0 -2030
- package/scripts/release_e2e.py +0 -1011
- package/scripts/release_publish.py +0 -486
- package/scripts/release_rollback.py +0 -980
- package/scripts/relocate.py +0 -1034
- package/scripts/resolve_changelog_unreleased.py +0 -667
- package/scripts/resolve_version.py +0 -490
- package/scripts/resume_conditions.py +0 -706
- package/scripts/ritual_sentinel.py +0 -609
- package/scripts/roadmap_render.py +0 -635
- package/scripts/rule_ownership_lint.py +0 -325
- package/scripts/scm.py +0 -591
- package/scripts/scope_audit_log.py +0 -387
- package/scripts/scope_decompose.py +0 -654
- package/scripts/scope_demote.py +0 -509
- package/scripts/scope_lifecycle.py +0 -1126
- package/scripts/scope_undo.py +0 -772
- package/scripts/session_start.py +0 -406
- package/scripts/setup_ghx.py +0 -339
- package/scripts/setup_windows.ps1 +0 -220
- package/scripts/slice_audit.py +0 -585
- package/scripts/slice_record.py +0 -530
- package/scripts/slice_record_existing.py +0 -692
- package/scripts/slug_normalize.py +0 -178
- package/scripts/spec_render.py +0 -477
- package/scripts/spec_validate.py +0 -238
- package/scripts/subagent_monitor.py +0 -658
- package/scripts/swarm_complete_cohort.py +0 -644
- package/scripts/swarm_launch.py +0 -1206
- package/scripts/swarm_readiness.py +0 -554
- package/scripts/swarm_verify_review_clean.py +0 -438
- package/scripts/swarm_worktrees.py +0 -497
- package/scripts/toolchain-check.py +0 -52
- package/scripts/triage_actions.py +0 -871
- package/scripts/triage_bootstrap.py +0 -1153
- package/scripts/triage_bulk.py +0 -630
- package/scripts/triage_classify.py +0 -932
- package/scripts/triage_help.py +0 -1685
- package/scripts/triage_queue.py +0 -1944
- package/scripts/triage_reconcile.py +0 -581
- package/scripts/triage_refresh.py +0 -643
- package/scripts/triage_scope.py +0 -999
- package/scripts/triage_scope_drift.py +0 -575
- package/scripts/triage_smoketest.py +0 -396
- package/scripts/triage_subscribe.py +0 -399
- package/scripts/triage_summary.py +0 -1011
- package/scripts/triage_welcome.py +0 -1178
- package/scripts/ts_check_lane.py +0 -86
- package/scripts/validate-links.py +0 -64
- package/scripts/validate_strategy_output.py +0 -212
- package/scripts/vbrief_activate.py +0 -228
- package/scripts/vbrief_migrate_conformance.py +0 -368
- package/scripts/vbrief_reconcile_graph.py +0 -306
- package/scripts/vbrief_reconcile_labels.py +0 -460
- package/scripts/vbrief_reconcile_umbrellas.py +0 -741
- package/scripts/vbrief_validate.py +0 -1144
- package/scripts/verify-stubs.py +0 -61
- package/scripts/verify_capacity.py +0 -160
- package/scripts/verify_encoding.py +0 -699
- package/scripts/verify_hooks_installed.py +0 -206
- package/scripts/verify_investigation.py +0 -360
- package/scripts/verify_judgment_gates.py +0 -827
- package/scripts/verify_no_task_runtime.py +0 -171
- package/scripts/verify_scm_boundary.py +0 -509
- package/scripts/verify_session_ritual.py +0 -389
- package/scripts/verify_tools.py +0 -426
- package/scripts/verify_vbrief_conformance.py +0 -478
|
@@ -1,387 +0,0 @@
|
|
|
1
|
-
"""scope_audit_log.py -- append-only audit log for scope lifecycle decisions.
|
|
2
|
-
|
|
3
|
-
Public surface:
|
|
4
|
-
append(entry: dict, *, log_path: Path | None = None) -> str
|
|
5
|
-
read_all(*, log_path: Path | None = None) -> list[dict]
|
|
6
|
-
find_by_path(vbrief_path: str, *, log_path: Path | None = None) -> list[dict]
|
|
7
|
-
latest_for_path(vbrief_path: str, action: str | None = None,
|
|
8
|
-
*, log_path: Path | None = None) -> dict | None
|
|
9
|
-
new_decision_id() -> str
|
|
10
|
-
canonical_log_path(project_root: Path) -> Path
|
|
11
|
-
|
|
12
|
-
Storage:
|
|
13
|
-
``<project_root>/vbrief/.eval/scope-lifecycle.jsonl`` -- one JSON object
|
|
14
|
-
per line, UTF-8. Parent directory is created on first append. The file is
|
|
15
|
-
operator-private and is gitignored alongside ``candidates.jsonl`` /
|
|
16
|
-
``summary-history.jsonl`` (#1144). The ``vbrief/.eval/*.jsonl
|
|
17
|
-
merge=union`` rule in ``.gitattributes`` covers single-operator rebases.
|
|
18
|
-
|
|
19
|
-
Concurrency:
|
|
20
|
-
Mirrors ``candidates_log.py`` (#845 Story 2):
|
|
21
|
-
|
|
22
|
-
- Cross-process safety: an advisory lock is held on a sidecar
|
|
23
|
-
``scope-lifecycle.jsonl.lock`` file via ``msvcrt.locking`` on Windows
|
|
24
|
-
and ``fcntl.flock`` on POSIX while the writer appends a single line.
|
|
25
|
-
- In-process thread safety: a module-level ``threading.Lock`` serialises
|
|
26
|
-
appends from threads in the same Python process.
|
|
27
|
-
|
|
28
|
-
Entry shape (operator-facing -- separate from ``candidates.jsonl``, which is
|
|
29
|
-
the FROZEN triage schema):
|
|
30
|
-
|
|
31
|
-
{
|
|
32
|
-
"decision_id": "<uuid4>",
|
|
33
|
-
"timestamp": "2026-05-17T21:05:00Z",
|
|
34
|
-
"action": "demote",
|
|
35
|
-
"vbrief_path": "vbrief/proposed/2026-05-17-1121-d1-scope-demote.vbrief.json",
|
|
36
|
-
"from_status": "pending",
|
|
37
|
-
"to_status": "proposed",
|
|
38
|
-
"actor": "operator",
|
|
39
|
-
"demote_meta": {
|
|
40
|
-
"was_promoted": true,
|
|
41
|
-
"original_promotion_decision_id": null,
|
|
42
|
-
"days_in_pending": 12,
|
|
43
|
-
"demote_reason": "operator-requested",
|
|
44
|
-
"demoted_from": "pending"
|
|
45
|
-
}
|
|
46
|
-
}
|
|
47
|
-
|
|
48
|
-
The ``action`` vocabulary starts as ``{"demote"}``; future scope-lifecycle
|
|
49
|
-
audit emitters MAY widen it (e.g. ``"promote"``) so this writer keeps the
|
|
50
|
-
field free-form. ``demote_meta`` is the only action-specific block this
|
|
51
|
-
module recognises; readers MUST tolerate entries that lack it (forward-compat
|
|
52
|
-
for future actions).
|
|
53
|
-
"""
|
|
54
|
-
|
|
55
|
-
from __future__ import annotations
|
|
56
|
-
|
|
57
|
-
import json
|
|
58
|
-
import logging
|
|
59
|
-
import os
|
|
60
|
-
import re
|
|
61
|
-
import sys
|
|
62
|
-
import threading
|
|
63
|
-
import time
|
|
64
|
-
import uuid
|
|
65
|
-
from collections.abc import Iterator
|
|
66
|
-
from contextlib import contextmanager, suppress
|
|
67
|
-
from datetime import UTC, datetime
|
|
68
|
-
from pathlib import Path
|
|
69
|
-
from typing import Any
|
|
70
|
-
|
|
71
|
-
LOG = logging.getLogger(__name__)
|
|
72
|
-
|
|
73
|
-
_AUDIT_LOG_RELPATH = Path("vbrief") / ".eval" / "scope-lifecycle.jsonl"
|
|
74
|
-
|
|
75
|
-
_UUID_RE = re.compile(
|
|
76
|
-
r"^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$"
|
|
77
|
-
)
|
|
78
|
-
# Match candidates_log: UTC-only timestamps with literal Z suffix so a
|
|
79
|
-
# downstream lexicographic sort is chronologically correct.
|
|
80
|
-
_ISO8601_RE = re.compile(
|
|
81
|
-
r"^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?Z$"
|
|
82
|
-
)
|
|
83
|
-
|
|
84
|
-
_REQUIRED_FIELDS: tuple[str, ...] = (
|
|
85
|
-
"decision_id",
|
|
86
|
-
"timestamp",
|
|
87
|
-
"action",
|
|
88
|
-
"vbrief_path",
|
|
89
|
-
"from_status",
|
|
90
|
-
"to_status",
|
|
91
|
-
"actor",
|
|
92
|
-
)
|
|
93
|
-
_DEMOTE_META_REQUIRED: tuple[str, ...] = (
|
|
94
|
-
"was_promoted",
|
|
95
|
-
"original_promotion_decision_id",
|
|
96
|
-
"days_in_pending",
|
|
97
|
-
"demote_reason",
|
|
98
|
-
"demoted_from",
|
|
99
|
-
)
|
|
100
|
-
|
|
101
|
-
_thread_lock = threading.Lock()
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
class ScopeAuditLogError(ValueError):
|
|
105
|
-
"""Raised when an entry passed to :func:`append` fails validation."""
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
# ---------------------------------------------------------------------------
|
|
109
|
-
# Public helpers
|
|
110
|
-
# ---------------------------------------------------------------------------
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def new_decision_id() -> str:
|
|
114
|
-
"""Return a fresh UUID4 string for use as ``decision_id``."""
|
|
115
|
-
return str(uuid.uuid4())
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
def utc_now_iso() -> str:
|
|
119
|
-
"""Return the current UTC time as an ISO-8601 string with literal Z."""
|
|
120
|
-
return datetime.now(UTC).strftime("%Y-%m-%dT%H:%M:%SZ")
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
def canonical_log_path(project_root: Path) -> Path:
|
|
124
|
-
"""Resolve the canonical audit log path under *project_root*."""
|
|
125
|
-
return project_root / _AUDIT_LOG_RELPATH
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
# ---------------------------------------------------------------------------
|
|
129
|
-
# Validation
|
|
130
|
-
# ---------------------------------------------------------------------------
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _validate_entry(entry: Any) -> None:
|
|
134
|
-
if not isinstance(entry, dict):
|
|
135
|
-
raise ScopeAuditLogError(
|
|
136
|
-
f"entry must be a dict, got {type(entry).__name__}"
|
|
137
|
-
)
|
|
138
|
-
|
|
139
|
-
missing = [f for f in _REQUIRED_FIELDS if f not in entry]
|
|
140
|
-
if missing:
|
|
141
|
-
raise ScopeAuditLogError(
|
|
142
|
-
f"entry missing required field(s): {missing}"
|
|
143
|
-
)
|
|
144
|
-
|
|
145
|
-
decision_id = entry["decision_id"]
|
|
146
|
-
if not isinstance(decision_id, str) or not _UUID_RE.match(decision_id):
|
|
147
|
-
raise ScopeAuditLogError(
|
|
148
|
-
f"decision_id must be a UUID string, got {decision_id!r}"
|
|
149
|
-
)
|
|
150
|
-
|
|
151
|
-
timestamp = entry["timestamp"]
|
|
152
|
-
if not isinstance(timestamp, str) or not _ISO8601_RE.match(timestamp):
|
|
153
|
-
raise ScopeAuditLogError(
|
|
154
|
-
f"timestamp must be ISO-8601 UTC with Z suffix, got {timestamp!r}"
|
|
155
|
-
)
|
|
156
|
-
|
|
157
|
-
for field in ("action", "vbrief_path", "from_status", "to_status", "actor"):
|
|
158
|
-
value = entry[field]
|
|
159
|
-
if not isinstance(value, str) or not value:
|
|
160
|
-
raise ScopeAuditLogError(
|
|
161
|
-
f"{field} must be a non-empty string, got {value!r}"
|
|
162
|
-
)
|
|
163
|
-
|
|
164
|
-
# demote_meta is required only for action == "demote" (forward-compat
|
|
165
|
-
# for future scope-lifecycle audit emitters).
|
|
166
|
-
if entry["action"] == "demote":
|
|
167
|
-
meta = entry.get("demote_meta")
|
|
168
|
-
if not isinstance(meta, dict):
|
|
169
|
-
raise ScopeAuditLogError(
|
|
170
|
-
f"action='demote' requires a 'demote_meta' object, got {meta!r}"
|
|
171
|
-
)
|
|
172
|
-
_validate_demote_meta(meta)
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
def _validate_demote_meta(meta: dict) -> None:
|
|
176
|
-
missing = [f for f in _DEMOTE_META_REQUIRED if f not in meta]
|
|
177
|
-
if missing:
|
|
178
|
-
raise ScopeAuditLogError(
|
|
179
|
-
f"demote_meta missing required field(s): {missing}"
|
|
180
|
-
)
|
|
181
|
-
|
|
182
|
-
was_promoted = meta["was_promoted"]
|
|
183
|
-
if not isinstance(was_promoted, bool):
|
|
184
|
-
raise ScopeAuditLogError(
|
|
185
|
-
f"demote_meta.was_promoted must be bool, got {was_promoted!r}"
|
|
186
|
-
)
|
|
187
|
-
|
|
188
|
-
opdid = meta["original_promotion_decision_id"]
|
|
189
|
-
if opdid is not None and (
|
|
190
|
-
not isinstance(opdid, str) or not _UUID_RE.match(opdid)
|
|
191
|
-
):
|
|
192
|
-
raise ScopeAuditLogError(
|
|
193
|
-
f"demote_meta.original_promotion_decision_id must be a UUID string"
|
|
194
|
-
f" or null, got {opdid!r}"
|
|
195
|
-
)
|
|
196
|
-
|
|
197
|
-
days = meta["days_in_pending"]
|
|
198
|
-
# bool is a subclass of int -- explicitly reject it.
|
|
199
|
-
if (
|
|
200
|
-
not isinstance(days, int)
|
|
201
|
-
or isinstance(days, bool)
|
|
202
|
-
or days < 0
|
|
203
|
-
):
|
|
204
|
-
raise ScopeAuditLogError(
|
|
205
|
-
f"demote_meta.days_in_pending must be a non-negative int, got {days!r}"
|
|
206
|
-
)
|
|
207
|
-
|
|
208
|
-
reason = meta["demote_reason"]
|
|
209
|
-
if not isinstance(reason, str) or not reason:
|
|
210
|
-
raise ScopeAuditLogError(
|
|
211
|
-
f"demote_meta.demote_reason must be a non-empty string, got {reason!r}"
|
|
212
|
-
)
|
|
213
|
-
|
|
214
|
-
demoted_from = meta["demoted_from"]
|
|
215
|
-
if not isinstance(demoted_from, str) or not demoted_from:
|
|
216
|
-
raise ScopeAuditLogError(
|
|
217
|
-
f"demote_meta.demoted_from must be a non-empty string, got {demoted_from!r}"
|
|
218
|
-
)
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
# ---------------------------------------------------------------------------
|
|
222
|
-
# Locking
|
|
223
|
-
# ---------------------------------------------------------------------------
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
@contextmanager
|
|
227
|
-
def _append_lock(log_path: Path) -> Iterator[None]:
|
|
228
|
-
"""Serialise appenders across threads AND processes.
|
|
229
|
-
|
|
230
|
-
Mirrors ``candidates_log._append_lock``. Sidecar lock file keeps the
|
|
231
|
-
advisory lock orthogonal to the data file so a torn lock-file write
|
|
232
|
-
never corrupts the audit trail.
|
|
233
|
-
"""
|
|
234
|
-
lock_path = log_path.parent / (log_path.name + ".lock")
|
|
235
|
-
lock_path.parent.mkdir(parents=True, exist_ok=True)
|
|
236
|
-
with _thread_lock:
|
|
237
|
-
try:
|
|
238
|
-
with open(lock_path, "a+b") as fh:
|
|
239
|
-
if not lock_path.stat().st_size:
|
|
240
|
-
fh.write(b"\0")
|
|
241
|
-
fh.flush()
|
|
242
|
-
fh.seek(0)
|
|
243
|
-
if sys.platform == "win32":
|
|
244
|
-
import msvcrt
|
|
245
|
-
|
|
246
|
-
acquired = False
|
|
247
|
-
deadline = time.monotonic() + 30.0
|
|
248
|
-
while True:
|
|
249
|
-
try:
|
|
250
|
-
msvcrt.locking(fh.fileno(), msvcrt.LK_NBLCK, 1)
|
|
251
|
-
acquired = True
|
|
252
|
-
break
|
|
253
|
-
except OSError:
|
|
254
|
-
if time.monotonic() > deadline:
|
|
255
|
-
raise
|
|
256
|
-
time.sleep(0.02)
|
|
257
|
-
try:
|
|
258
|
-
yield
|
|
259
|
-
finally:
|
|
260
|
-
if acquired:
|
|
261
|
-
fh.seek(0)
|
|
262
|
-
with suppress(OSError):
|
|
263
|
-
msvcrt.locking(fh.fileno(), msvcrt.LK_UNLCK, 1)
|
|
264
|
-
else:
|
|
265
|
-
import fcntl
|
|
266
|
-
|
|
267
|
-
fcntl.flock(fh.fileno(), fcntl.LOCK_EX)
|
|
268
|
-
try:
|
|
269
|
-
yield
|
|
270
|
-
finally:
|
|
271
|
-
fcntl.flock(fh.fileno(), fcntl.LOCK_UN)
|
|
272
|
-
finally:
|
|
273
|
-
# Remove the sidecar ``<log>.lock`` so a clean append never leaves
|
|
274
|
-
# an untracked lock file behind (#1311 discipline). The handle is
|
|
275
|
-
# closed by the `with open(...)` block above BEFORE this unlink
|
|
276
|
-
# (Windows refuses to delete an open file); held under
|
|
277
|
-
# ``_thread_lock`` so the unlink cannot race an in-process
|
|
278
|
-
# re-acquire. Best-effort across processes.
|
|
279
|
-
with suppress(OSError):
|
|
280
|
-
lock_path.unlink()
|
|
281
|
-
|
|
282
|
-
|
|
283
|
-
# ---------------------------------------------------------------------------
|
|
284
|
-
# I/O
|
|
285
|
-
# ---------------------------------------------------------------------------
|
|
286
|
-
|
|
287
|
-
|
|
288
|
-
def append(entry: dict, *, log_path: Path | str | None = None) -> str:
|
|
289
|
-
"""Validate *entry* and atomically append it to the audit log.
|
|
290
|
-
|
|
291
|
-
Args:
|
|
292
|
-
entry: dict matching the schema documented in the module docstring.
|
|
293
|
-
log_path: optional override of the log file path. Production callers
|
|
294
|
-
MUST leave this as None and let the caller pass the canonical
|
|
295
|
-
path resolved via :func:`canonical_log_path` (this signature
|
|
296
|
-
keeps the test hook explicit).
|
|
297
|
-
|
|
298
|
-
Returns:
|
|
299
|
-
The ``decision_id`` from *entry*.
|
|
300
|
-
"""
|
|
301
|
-
if log_path is None:
|
|
302
|
-
raise ScopeAuditLogError(
|
|
303
|
-
"append() requires log_path; pass canonical_log_path(project_root)"
|
|
304
|
-
)
|
|
305
|
-
_validate_entry(entry)
|
|
306
|
-
log_file = Path(log_path)
|
|
307
|
-
log_file.parent.mkdir(parents=True, exist_ok=True)
|
|
308
|
-
line = json.dumps(entry, sort_keys=True, ensure_ascii=False)
|
|
309
|
-
with _append_lock(log_file), open(
|
|
310
|
-
log_file, "a", encoding="utf-8", newline=""
|
|
311
|
-
) as fh:
|
|
312
|
-
fh.write(line + "\n")
|
|
313
|
-
fh.flush()
|
|
314
|
-
os.fsync(fh.fileno())
|
|
315
|
-
return str(entry["decision_id"])
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
def read_all(*, log_path: Path | str | None = None) -> list[dict]:
|
|
319
|
-
"""Return every well-formed entry in insertion order. Tolerant of
|
|
320
|
-
malformed lines (logs a warning, skips them).
|
|
321
|
-
"""
|
|
322
|
-
if log_path is None:
|
|
323
|
-
raise ScopeAuditLogError(
|
|
324
|
-
"read_all() requires log_path; pass canonical_log_path(project_root)"
|
|
325
|
-
)
|
|
326
|
-
log_file = Path(log_path)
|
|
327
|
-
if not log_file.exists():
|
|
328
|
-
return []
|
|
329
|
-
out: list[dict] = []
|
|
330
|
-
with open(log_file, encoding="utf-8") as fh:
|
|
331
|
-
for lineno, raw in enumerate(fh, start=1):
|
|
332
|
-
stripped = raw.strip()
|
|
333
|
-
if not stripped:
|
|
334
|
-
continue
|
|
335
|
-
try:
|
|
336
|
-
obj = json.loads(stripped)
|
|
337
|
-
except json.JSONDecodeError as exc:
|
|
338
|
-
LOG.warning(
|
|
339
|
-
"scope-lifecycle.jsonl: skipping malformed line %d: %s",
|
|
340
|
-
lineno,
|
|
341
|
-
exc,
|
|
342
|
-
)
|
|
343
|
-
continue
|
|
344
|
-
if not isinstance(obj, dict):
|
|
345
|
-
LOG.warning(
|
|
346
|
-
"scope-lifecycle.jsonl: skipping non-object on line %d",
|
|
347
|
-
lineno,
|
|
348
|
-
)
|
|
349
|
-
continue
|
|
350
|
-
out.append(obj)
|
|
351
|
-
return out
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
def find_by_path(
|
|
355
|
-
vbrief_path: str, *, log_path: Path | str | None = None
|
|
356
|
-
) -> list[dict]:
|
|
357
|
-
"""Return every entry matching ``vbrief_path`` (string equality).
|
|
358
|
-
|
|
359
|
-
Path normalisation:
|
|
360
|
-
Callers SHOULD pass the canonical form used at write time
|
|
361
|
-
(forward-slash project-root-relative). This helper does NOT
|
|
362
|
-
re-normalise -- byte-equal match is the contract.
|
|
363
|
-
"""
|
|
364
|
-
return [
|
|
365
|
-
e for e in read_all(log_path=log_path) if e.get("vbrief_path") == vbrief_path
|
|
366
|
-
]
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
def latest_for_path(
|
|
370
|
-
vbrief_path: str,
|
|
371
|
-
action: str | None = None,
|
|
372
|
-
*,
|
|
373
|
-
log_path: Path | str | None = None,
|
|
374
|
-
) -> dict | None:
|
|
375
|
-
"""Return the most recent entry for ``vbrief_path``, optionally filtered
|
|
376
|
-
by ``action`` (e.g. ``"promote"`` to find the prior promotion).
|
|
377
|
-
|
|
378
|
-
Sort key is the entry's ``timestamp``. ISO-8601 UTC strings sort
|
|
379
|
-
lexicographically in chronological order.
|
|
380
|
-
"""
|
|
381
|
-
rows = find_by_path(vbrief_path, log_path=log_path)
|
|
382
|
-
if action is not None:
|
|
383
|
-
rows = [r for r in rows if r.get("action") == action]
|
|
384
|
-
if not rows:
|
|
385
|
-
return None
|
|
386
|
-
rows.sort(key=lambda r: r.get("timestamp", ""))
|
|
387
|
-
return rows[-1]
|