@michaelxwb/code-flow 0.8.1 → 1.0.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/README.md +73 -152
- package/bin/code-flow.js +12 -0
- package/docs/README.md +19 -0
- package/docs/USAGE.md +156 -1417
- package/docs/v1-design.md +1858 -0
- package/docs/v1-implementation.md +186 -0
- package/lib/adapters.js +126 -0
- package/lib/cli.js +258 -0
- package/lib/config.js +85 -0
- package/lib/doctor.js +283 -0
- package/lib/frontmatter.js +42 -0
- package/lib/fs.js +141 -0
- package/lib/glob.js +23 -0
- package/lib/hook.js +99 -0
- package/lib/init.js +132 -0
- package/lib/learn.js +170 -0
- package/lib/locks.js +52 -0
- package/lib/markdown.js +13 -0
- package/lib/process.js +116 -0
- package/lib/render.js +115 -0
- package/lib/repo.js +48 -0
- package/lib/runs.js +155 -0
- package/lib/session.js +40 -0
- package/lib/spec.js +80 -0
- package/lib/task.js +258 -0
- package/lib/taskdoc.js +308 -0
- package/lib/toml.js +140 -0
- package/lib/verify.js +168 -0
- package/lib/worktree.js +930 -0
- package/package.json +18 -21
- package/templates/adapters/opencode.mjs +65 -0
- package/templates/config.json +39 -0
- package/templates/instructions.md +13 -0
- package/templates/launcher/bridge.cjs +295 -0
- package/templates/launcher/hook.cmd +15 -0
- package/templates/launcher/hook.ps1 +10 -0
- package/templates/launcher/hook.sh +20 -0
- package/templates/skills/cf-check.md +19 -0
- package/templates/skills/cf-init.md +16 -0
- package/templates/skills/cf-spec.md +20 -0
- package/templates/skills/cf-task-align.md +19 -0
- package/templates/skills/cf-task-archive.md +20 -0
- package/templates/skills/cf-task-plan.md +31 -0
- package/templates/skills/cf-task-prd.md +14 -0
- package/templates/skills/cf-task-start.md +41 -0
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-frontend.md +12 -12
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-full.md +17 -19
- package/{src/core/code-flow/specs/shared/design → templates/workflow}/design-lite.md +12 -12
- package/{src/core/code-flow/specs/shared/prd-template.md → templates/workflow/prd.md} +1 -1
- package/templates/workflow/project-guidelines.md +48 -0
- package/templates/workflow/scenarios.md +67 -0
- package/templates/workflow/task.md +56 -0
- package/docs/acceptance-contract.md +0 -217
- package/docs/ci/acceptance-evidence.md +0 -77
- package/docs/migrations/runtime-0.7.0.md +0 -41
- package/docs/migrations/runtime-0.8.0.md +0 -154
- package/docs/migrations/runtime-0.9.0.md +0 -40
- package/docs/migrations/spec-workflow-0.6.0.md +0 -42
- package/docs/spec-injection-hardening.md +0 -54
- package/docs/spec-injection-performance.json +0 -73
- package/docs/spec-optimization.md +0 -12
- package/src/adapters/claude/CLAUDE.md +0 -45
- package/src/adapters/claude/commands/cf-init.md +0 -262
- package/src/adapters/claude/commands/cf-learn.md +0 -291
- package/src/adapters/claude/commands/cf-spec.md +0 -87
- package/src/adapters/claude/commands/cf-stats.md +0 -96
- package/src/adapters/claude/commands/cf-sync.md +0 -39
- package/src/adapters/claude/commands/cf-task/align.md +0 -331
- package/src/adapters/claude/commands/cf-task/archive.md +0 -155
- package/src/adapters/claude/commands/cf-task/block.md +0 -49
- package/src/adapters/claude/commands/cf-task/graph.md +0 -92
- package/src/adapters/claude/commands/cf-task/note.md +0 -248
- package/src/adapters/claude/commands/cf-task/plan.md +0 -477
- package/src/adapters/claude/commands/cf-task/prd.md +0 -173
- package/src/adapters/claude/commands/cf-task/start.md +0 -317
- package/src/adapters/claude/commands/cf-task/status.md +0 -71
- package/src/adapters/claude/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/claude/commands/cf-validate.md +0 -100
- package/src/adapters/claude/settings.local.json +0 -50
- package/src/adapters/codex/AGENTS.md +0 -45
- package/src/adapters/codex/config.toml +0 -3
- package/src/adapters/codex/hooks.json +0 -54
- package/src/adapters/codex/skills/cf-init/SKILL.md +0 -273
- package/src/adapters/codex/skills/cf-learn/SKILL.md +0 -296
- package/src/adapters/codex/skills/cf-spec/SKILL.md +0 -88
- package/src/adapters/codex/skills/cf-stats/SKILL.md +0 -97
- package/src/adapters/codex/skills/cf-sync/SKILL.md +0 -45
- package/src/adapters/codex/skills/cf-task-align/SKILL.md +0 -332
- package/src/adapters/codex/skills/cf-task-archive/SKILL.md +0 -156
- package/src/adapters/codex/skills/cf-task-block/SKILL.md +0 -50
- package/src/adapters/codex/skills/cf-task-graph/SKILL.md +0 -93
- package/src/adapters/codex/skills/cf-task-note/SKILL.md +0 -245
- package/src/adapters/codex/skills/cf-task-plan/SKILL.md +0 -478
- package/src/adapters/codex/skills/cf-task-prd/SKILL.md +0 -174
- package/src/adapters/codex/skills/cf-task-start/SKILL.md +0 -318
- package/src/adapters/codex/skills/cf-task-status/SKILL.md +0 -72
- package/src/adapters/codex/skills/cf-task-verify-e2e/SKILL.md +0 -145
- package/src/adapters/codex/skills/cf-validate/SKILL.md +0 -102
- package/src/adapters/commands.json +0 -115
- package/src/adapters/costrict/CLAUDE.md +0 -45
- package/src/adapters/costrict/commands/cf-init.md +0 -262
- package/src/adapters/costrict/commands/cf-learn.md +0 -291
- package/src/adapters/costrict/commands/cf-spec.md +0 -87
- package/src/adapters/costrict/commands/cf-stats.md +0 -96
- package/src/adapters/costrict/commands/cf-sync.md +0 -39
- package/src/adapters/costrict/commands/cf-task/align.md +0 -331
- package/src/adapters/costrict/commands/cf-task/archive.md +0 -155
- package/src/adapters/costrict/commands/cf-task/block.md +0 -49
- package/src/adapters/costrict/commands/cf-task/graph.md +0 -92
- package/src/adapters/costrict/commands/cf-task/note.md +0 -248
- package/src/adapters/costrict/commands/cf-task/plan.md +0 -477
- package/src/adapters/costrict/commands/cf-task/prd.md +0 -173
- package/src/adapters/costrict/commands/cf-task/start.md +0 -317
- package/src/adapters/costrict/commands/cf-task/status.md +0 -71
- package/src/adapters/costrict/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/costrict/commands/cf-validate.md +0 -100
- package/src/adapters/costrict/settings.local.json +0 -50
- package/src/adapters/install-plan.json +0 -100
- package/src/adapters/opencode/AGENTS.md +0 -45
- package/src/adapters/opencode/commands/cf-init.md +0 -281
- package/src/adapters/opencode/commands/cf-learn.md +0 -295
- package/src/adapters/opencode/commands/cf-spec.md +0 -91
- package/src/adapters/opencode/commands/cf-stats.md +0 -100
- package/src/adapters/opencode/commands/cf-sync.md +0 -44
- package/src/adapters/opencode/commands/cf-task/align.md +0 -331
- package/src/adapters/opencode/commands/cf-task/archive.md +0 -160
- package/src/adapters/opencode/commands/cf-task/block.md +0 -54
- package/src/adapters/opencode/commands/cf-task/graph.md +0 -97
- package/src/adapters/opencode/commands/cf-task/note.md +0 -253
- package/src/adapters/opencode/commands/cf-task/plan.md +0 -477
- package/src/adapters/opencode/commands/cf-task/prd.md +0 -178
- package/src/adapters/opencode/commands/cf-task/start.md +0 -317
- package/src/adapters/opencode/commands/cf-task/status.md +0 -76
- package/src/adapters/opencode/commands/cf-task/verify-e2e.md +0 -148
- package/src/adapters/opencode/commands/cf-validate.md +0 -105
- package/src/adapters/opencode/opencode.json +0 -3
- package/src/adapters/opencode/plugins/code-flow/index.js +0 -162
- package/src/adapters/opencode/plugins/code-flow/package.json +0 -6
- package/src/adapters/platform-bindings.json +0 -370
- package/src/cli.js +0 -1076
- package/src/core/code-flow/config.yml +0 -161
- package/src/core/code-flow/runtime-commands.json +0 -259
- package/src/core/code-flow/scripts/cf_acceptance_manifest.py +0 -10
- package/src/core/code-flow/scripts/cf_acceptance_runner.py +0 -10
- package/src/core/code-flow/scripts/cf_audit.py +0 -10
- package/src/core/code-flow/scripts/cf_codex_hook.py +0 -10
- package/src/core/code-flow/scripts/cf_design_lint.py +0 -10
- package/src/core/code-flow/scripts/cf_evidence_import.py +0 -10
- package/src/core/code-flow/scripts/cf_feedback.py +0 -10
- package/src/core/code-flow/scripts/cf_opencode_event.py +0 -10
- package/src/core/code-flow/scripts/cf_post_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_pre_tool_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_scenarios.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_context.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_gate.py +0 -10
- package/src/core/code-flow/scripts/cf_spec_session.py +0 -10
- package/src/core/code-flow/scripts/cf_stats.py +0 -10
- package/src/core/code-flow/scripts/cf_stop_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_sync.py +0 -10
- package/src/core/code-flow/scripts/cf_task_index.py +0 -10
- package/src/core/code-flow/scripts/cf_task_parallel.py +0 -10
- package/src/core/code-flow/scripts/cf_task_workflow.py +0 -10
- package/src/core/code-flow/scripts/cf_user_prompt_hook.py +0 -11
- package/src/core/code-flow/scripts/cf_validation.py +0 -10
- package/src/core/code-flow/scripts/codeflow/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_evidence.py +0 -347
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_manifest_core.py +0 -660
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_plan.py +0 -202
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_registration.py +0 -60
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_risk.py +0 -73
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_schema.py +0 -282
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_acceptance_selection.py +0 -266
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_inputs.py +0 -172
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_check_registration.py +0 -326
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_checks.py +0 -333
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_plan_quality.py +0 -197
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_quality.py +0 -174
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_scenario_source.py +0 -300
- package/src/core/code-flow/scripts/codeflow/acceptance/cf_traceability.py +0 -502
- package/src/core/code-flow/scripts/codeflow/cli/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_manifest.py +0 -347
- package/src/core/code-flow/scripts/codeflow/cli/cf_acceptance_runner.py +0 -1490
- package/src/core/code-flow/scripts/codeflow/cli/cf_audit.py +0 -533
- package/src/core/code-flow/scripts/codeflow/cli/cf_design_lint.py +0 -172
- package/src/core/code-flow/scripts/codeflow/cli/cf_evidence_import.py +0 -607
- package/src/core/code-flow/scripts/codeflow/cli/cf_feedback.py +0 -53
- package/src/core/code-flow/scripts/codeflow/cli/cf_scenarios.py +0 -58
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_context.py +0 -1175
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_gate.py +0 -60
- package/src/core/code-flow/scripts/codeflow/cli/cf_spec_session.py +0 -46
- package/src/core/code-flow/scripts/codeflow/cli/cf_stats.py +0 -540
- package/src/core/code-flow/scripts/codeflow/cli/cf_sync.py +0 -144
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_index.py +0 -177
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_parallel.py +0 -1142
- package/src/core/code-flow/scripts/codeflow/cli/cf_task_workflow.py +0 -742
- package/src/core/code-flow/scripts/codeflow/cli/cf_validation.py +0 -31
- package/src/core/code-flow/scripts/codeflow/core/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/core/cf_context_cache.py +0 -67
- package/src/core/code-flow/scripts/codeflow/core/cf_core.py +0 -609
- package/src/core/code-flow/scripts/codeflow/core/cf_exec_base.py +0 -325
- package/src/core/code-flow/scripts/codeflow/core/cf_fs.py +0 -57
- package/src/core/code-flow/scripts/codeflow/core/cf_git.py +0 -92
- package/src/core/code-flow/scripts/codeflow/core/cf_log.py +0 -160
- package/src/core/code-flow/scripts/codeflow/core/cf_markdown.py +0 -77
- package/src/core/code-flow/scripts/codeflow/core/cf_native_paths.py +0 -26
- package/src/core/code-flow/scripts/codeflow/core/cf_patch.py +0 -33
- package/src/core/code-flow/scripts/codeflow/core/cf_runtime_install.py +0 -32
- package/src/core/code-flow/scripts/codeflow/core/cf_session_state.py +0 -37
- package/src/core/code-flow/scripts/codeflow/core/cf_stop_guard.py +0 -53
- package/src/core/code-flow/scripts/codeflow/core/cf_task_state.py +0 -9
- package/src/core/code-flow/scripts/codeflow/core/cf_validators.py +0 -408
- package/src/core/code-flow/scripts/codeflow/core/cf_worktree_env.py +0 -253
- package/src/core/code-flow/scripts/codeflow/evidence/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/evidence/cf_evidence_store.py +0 -527
- package/src/core/code-flow/scripts/codeflow/hooks/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/hooks/cf_codex_hook.py +0 -95
- package/src/core/code-flow/scripts/codeflow/hooks/cf_opencode_event.py +0 -98
- package/src/core/code-flow/scripts/codeflow/hooks/cf_post_hook.py +0 -30
- package/src/core/code-flow/scripts/codeflow/hooks/cf_pre_tool_hook.py +0 -41
- package/src/core/code-flow/scripts/codeflow/hooks/cf_stop_hook.py +0 -292
- package/src/core/code-flow/scripts/codeflow/hooks/cf_user_prompt_hook.py +0 -161
- package/src/core/code-flow/scripts/codeflow/spec/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/spec/cf_active_task.py +0 -412
- package/src/core/code-flow/scripts/codeflow/spec/cf_context_refresh.py +0 -301
- package/src/core/code-flow/scripts/codeflow/spec/cf_edit_service.py +0 -149
- package/src/core/code-flow/scripts/codeflow/spec/cf_injection.py +0 -111
- package/src/core/code-flow/scripts/codeflow/spec/cf_rule_readiness.py +0 -114
- package/src/core/code-flow/scripts/codeflow/spec/cf_scan.py +0 -367
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_budget.py +0 -55
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_context_model.py +0 -477
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_impact.py +0 -119
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_metadata.py +0 -387
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_migrate.py +0 -529
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize.py +0 -193
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_optimize_apply.py +0 -187
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_resolver.py +0 -369
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_router.py +0 -304
- package/src/core/code-flow/scripts/codeflow/spec/cf_spec_verify.py +0 -687
- package/src/core/code-flow/scripts/codeflow/spec/cf_stage_gate.py +0 -330
- package/src/core/code-flow/scripts/codeflow/spec/cf_task_projection.py +0 -83
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_service.py +0 -755
- package/src/core/code-flow/scripts/codeflow/spec/cf_workflow_transaction.py +0 -110
- package/src/core/code-flow/scripts/codeflow/task/__init__.py +0 -0
- package/src/core/code-flow/scripts/codeflow/task/cf_requirement_verification.py +0 -867
- package/src/core/code-flow/scripts/codeflow/task/cf_task_doc.py +0 -178
- package/src/core/code-flow/scripts/codeflow/task/cf_task_merge.py +0 -635
- package/src/core/code-flow/scripts/codeflow/task/cf_task_plan.py +0 -251
- package/src/core/code-flow/scripts/codeflow/task/cf_task_runtime.py +0 -570
- package/src/core/code-flow/scripts/codeflow/task/cf_validation_service.py +0 -73
- package/src/core/code-flow/scripts/runtime-resources.json +0 -29
- package/src/core/code-flow/specs/backend/_map.md +0 -56
- package/src/core/code-flow/specs/backend/code-quality-performance.md +0 -59
- package/src/core/code-flow/specs/backend/database.md +0 -51
- package/src/core/code-flow/specs/backend/directory-structure.md +0 -54
- package/src/core/code-flow/specs/backend/logging.md +0 -50
- package/src/core/code-flow/specs/backend/platform-rules.md +0 -55
- package/src/core/code-flow/specs/frontend/_map.md +0 -52
- package/src/core/code-flow/specs/frontend/component-specs.md +0 -68
- package/src/core/code-flow/specs/frontend/directory-structure.md +0 -53
- package/src/core/code-flow/specs/frontend/quality-standards.md +0 -58
- package/src/core/code-flow/specs/shared/_map.md +0 -57
- package/src/core/code-flow/specs/shared/scenarios-template.md +0 -55
- package/src/core/code-flow/tasks/.gitkeep +0 -0
- package/src/core/code-flow/validation.yml +0 -59
- package/src/migrate/runtime.js +0 -213
- package/src/migrate/spec-workflow.js +0 -253
- package/src/runtime.js +0 -219
|
@@ -1,325 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Shared execution substrate: deadline-aware argv subprocess runner.
|
|
3
|
-
|
|
4
|
-
All check executors (acceptance scenarios, Spec verifiers, session
|
|
5
|
-
validators) funnel through here so one entry deadline constrains the whole
|
|
6
|
-
chain, commands never pass through a shell, and unrun work is reported as
|
|
7
|
-
`incomplete` instead of silently skipped or falsely passed.
|
|
8
|
-
"""
|
|
9
|
-
|
|
10
|
-
from __future__ import annotations
|
|
11
|
-
|
|
12
|
-
import hashlib
|
|
13
|
-
import os
|
|
14
|
-
import re
|
|
15
|
-
import signal
|
|
16
|
-
import shlex
|
|
17
|
-
import json
|
|
18
|
-
import subprocess
|
|
19
|
-
import sys
|
|
20
|
-
import time
|
|
21
|
-
from contextlib import contextmanager
|
|
22
|
-
from contextvars import ContextVar
|
|
23
|
-
from pathlib import Path
|
|
24
|
-
from typing import Callable, Iterator, Mapping, Optional, Sequence
|
|
25
|
-
|
|
26
|
-
_EXECUTIONS: ContextVar[Optional[dict[str, dict[str, object]]]] = ContextVar("cf_executions", default=None)
|
|
27
|
-
_REPORTS: ContextVar[Optional[list[dict[str, object]]]] = ContextVar("cf_reports", default=None)
|
|
28
|
-
_SECRET_OPTION = re.compile(r"(?i)^--?(?:api[-_]?key|access[-_]?token|token|password|passwd|secret|credential)$")
|
|
29
|
-
_SECRET_ASSIGNMENT = re.compile(r"(?i)(--?(?:api[-_]?key|access[-_]?token|token|password|passwd|secret|credential)=)([^\s]+)")
|
|
30
|
-
_RUNTIME_ENV = frozenset(("CF_RUNTIME_COMMAND", "CF_RUNTIME_ACTION", "CF_CLI_VERSION"))
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
def redact_sensitive_text(value: str) -> str:
|
|
34
|
-
"""Hide inherited sensitive environment values from diagnostics."""
|
|
35
|
-
names = ("SECRET", "TOKEN", "PASSWORD", "PASSWD", "CREDENTIAL", "API_KEY", "PRIVATE_KEY")
|
|
36
|
-
secrets = {item for key, item in os.environ.items()
|
|
37
|
-
if item and any(name in key.upper() for name in names)}
|
|
38
|
-
for secret in sorted(secrets, key=len, reverse=True):
|
|
39
|
-
value = value.replace(secret, "[REDACTED]")
|
|
40
|
-
return _SECRET_ASSIGNMENT.sub(r"\1[REDACTED]", value)
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
def _redact_sequence(values: Sequence[object]) -> list[object]:
|
|
44
|
-
result: list[object] = []
|
|
45
|
-
hide_next = False
|
|
46
|
-
for item in values:
|
|
47
|
-
if hide_next:
|
|
48
|
-
result.append("[REDACTED]")
|
|
49
|
-
hide_next = False
|
|
50
|
-
continue
|
|
51
|
-
result.append(redact_sensitive_payload(item))
|
|
52
|
-
hide_next = isinstance(item, str) and bool(_SECRET_OPTION.fullmatch(item))
|
|
53
|
-
return result
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
def redact_sensitive_payload(value: object) -> object:
|
|
57
|
-
"""Return a JSON-compatible copy with sensitive strings redacted."""
|
|
58
|
-
if isinstance(value, str):
|
|
59
|
-
return redact_sensitive_text(value)
|
|
60
|
-
if isinstance(value, Mapping):
|
|
61
|
-
return {key: redact_sensitive_payload(item) for key, item in value.items()}
|
|
62
|
-
if isinstance(value, (list, tuple)):
|
|
63
|
-
return _redact_sequence(value)
|
|
64
|
-
return value
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
@contextmanager
|
|
68
|
-
def report_session() -> Iterator[list[dict[str, object]]]:
|
|
69
|
-
reports: list[dict[str, object]] = []
|
|
70
|
-
token = _REPORTS.set(reports)
|
|
71
|
-
try:
|
|
72
|
-
yield reports
|
|
73
|
-
finally:
|
|
74
|
-
_REPORTS.reset(token)
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
def record_report(report: Mapping[str, object]) -> None:
|
|
78
|
-
reports = _REPORTS.get()
|
|
79
|
-
if reports is not None:
|
|
80
|
-
reports.append(dict(report))
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
def report_requested() -> bool:
|
|
84
|
-
return _REPORTS.get() is not None
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
@contextmanager
|
|
88
|
-
def execution_session() -> Iterator[None]:
|
|
89
|
-
"""Share results only inside one validation operation, never across turns."""
|
|
90
|
-
if _EXECUTIONS.get() is not None:
|
|
91
|
-
yield
|
|
92
|
-
return
|
|
93
|
-
token = _EXECUTIONS.set({})
|
|
94
|
-
try:
|
|
95
|
-
yield
|
|
96
|
-
finally:
|
|
97
|
-
_EXECUTIONS.reset(token)
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
def invalidate_executions() -> None:
|
|
101
|
-
cache = _EXECUTIONS.get()
|
|
102
|
-
if cache is not None:
|
|
103
|
-
cache.clear()
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
def command_environment(env: Optional[Mapping[str, str]] = None) -> dict[str, str]:
|
|
107
|
-
"""Do not pass CLI dispatch metadata into user checks."""
|
|
108
|
-
environment = {key: value for key, value in os.environ.items() if key not in _RUNTIME_ENV}
|
|
109
|
-
environment.update({str(key): str(value) for key, value in (env or {}).items()})
|
|
110
|
-
return environment
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
def execution_key(argv: Sequence[str], cwd: str, timeout: float,
|
|
114
|
-
env: Optional[Mapping[str, str]] = None) -> str:
|
|
115
|
-
"""Identity of a command under the current process environment."""
|
|
116
|
-
environment = command_environment(env)
|
|
117
|
-
payload: list[object] = [list(argv), str(Path(cwd).resolve()), float(timeout), sorted(environment.items())]
|
|
118
|
-
return hashlib.sha256(json.dumps(payload, ensure_ascii=False).encode()).hexdigest()
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
def execution_environment_hash(env: Optional[Mapping[str, str]] = None) -> str:
|
|
122
|
-
"""Bind evidence to inherited and explicit environment without exposing values."""
|
|
123
|
-
environment = command_environment(env)
|
|
124
|
-
payload = json.dumps(sorted(environment.items()), ensure_ascii=False)
|
|
125
|
-
return hashlib.sha256(payload.encode()).hexdigest()
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def remaining_seconds(deadline: Optional[float]) -> Optional[float]:
|
|
129
|
-
"""Seconds left until the absolute monotonic deadline (None = unbounded)."""
|
|
130
|
-
if deadline is None:
|
|
131
|
-
return None
|
|
132
|
-
return deadline - time.monotonic()
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
def build_argv(template: str, files: Sequence[str]) -> list[str]:
|
|
136
|
-
"""Expand a validator command template into argv without a shell.
|
|
137
|
-
|
|
138
|
-
`{files}` must be a standalone token and expands to one argv entry per
|
|
139
|
-
file (spaces/metachars safe). Embedded usage is a config error — refusing
|
|
140
|
-
is safer than reintroducing shell splitting.
|
|
141
|
-
"""
|
|
142
|
-
tokens = shlex.split(template or "")
|
|
143
|
-
if not tokens:
|
|
144
|
-
return []
|
|
145
|
-
argv: list[str] = []
|
|
146
|
-
for token in tokens:
|
|
147
|
-
if token == "{files}":
|
|
148
|
-
argv.extend(files)
|
|
149
|
-
elif "{files}" in token:
|
|
150
|
-
raise ValueError(f"validator command must keep {{files}} as a standalone token: {template!r}")
|
|
151
|
-
else:
|
|
152
|
-
argv.append(token)
|
|
153
|
-
return argv
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
def run_command(
|
|
157
|
-
argv: Sequence[str],
|
|
158
|
-
cwd: str,
|
|
159
|
-
timeout: float,
|
|
160
|
-
deadline: Optional[float] = None,
|
|
161
|
-
env: Optional[Mapping[str, str]] = None,
|
|
162
|
-
identity_context: Optional[Mapping[str, object]] = None,
|
|
163
|
-
) -> dict[str, object]:
|
|
164
|
-
"""Run argv synchronously under timeout+deadline. Never uses a shell."""
|
|
165
|
-
key = execution_key(argv, cwd, timeout, env)
|
|
166
|
-
if identity_context:
|
|
167
|
-
payload = json.dumps(identity_context, sort_keys=True, ensure_ascii=False, default=str)
|
|
168
|
-
key = hashlib.sha256(f"{key}:{payload}".encode()).hexdigest()
|
|
169
|
-
cache = _EXECUTIONS.get()
|
|
170
|
-
fingerprint = ""
|
|
171
|
-
if cache is not None:
|
|
172
|
-
root = _fingerprint_git(cwd, "rev-parse", "--show-toplevel")
|
|
173
|
-
if root is not None and root.returncode == 0:
|
|
174
|
-
fingerprint = worktree_fingerprint(root.stdout.strip())
|
|
175
|
-
key = hashlib.sha256(f"{key}:{fingerprint}".encode()).hexdigest() if fingerprint else ""
|
|
176
|
-
if cache is not None and key and key in cache:
|
|
177
|
-
return {**cache[key], "execution_ms": 0.0, "execution_reused": True}
|
|
178
|
-
started = time.monotonic()
|
|
179
|
-
result = _execute(argv, cwd, timeout, deadline, env)
|
|
180
|
-
result["execution_ms"] = ((time.monotonic() - started) * 1000
|
|
181
|
-
if result.get("execution_started", True) else 0.0)
|
|
182
|
-
result["execution_reused"] = False
|
|
183
|
-
if cache is not None and key and result["status"] == "ok" and result["returncode"] == 0:
|
|
184
|
-
cache[key] = result
|
|
185
|
-
return result
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
def hash_worktree_paths(project_root: str, paths: Sequence[str]) -> Optional[list]:
|
|
189
|
-
"""Batch-hash worktree file contents (single `git hash-object` process)."""
|
|
190
|
-
if not paths:
|
|
191
|
-
return []
|
|
192
|
-
try:
|
|
193
|
-
completed = subprocess.run(
|
|
194
|
-
("git", "-C", project_root, "hash-object", "--stdin-paths"),
|
|
195
|
-
input="\n".join(paths) + "\n", text=True, capture_output=True, timeout=5,
|
|
196
|
-
)
|
|
197
|
-
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
198
|
-
sys.stderr.write(f"worktree hashing failed: {exc}\n")
|
|
199
|
-
return None
|
|
200
|
-
if completed.returncode != 0:
|
|
201
|
-
return None
|
|
202
|
-
hashes = completed.stdout.splitlines()
|
|
203
|
-
if len(hashes) != len(paths):
|
|
204
|
-
return None
|
|
205
|
-
return [(path, sha.strip()) for path, sha in zip(paths, hashes)]
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
def _fingerprint_git(root: str, *arguments: str) -> Optional[subprocess.CompletedProcess]:
|
|
209
|
-
try:
|
|
210
|
-
return subprocess.run(
|
|
211
|
-
("git", "-C", root, *arguments), text=True, encoding="utf-8",
|
|
212
|
-
capture_output=True, check=False, timeout=5,
|
|
213
|
-
)
|
|
214
|
-
except (OSError, subprocess.TimeoutExpired) as exc:
|
|
215
|
-
sys.stderr.write(f"worktree fingerprint failed: {exc}\n")
|
|
216
|
-
return None
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
def _runtime_path(path: str) -> bool:
|
|
220
|
-
return path.startswith(".code-flow/") or Path(path).name == ".acceptance-manifest.json"
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
def _fingerprint_changes(status: str, contents: dict[str, str], include_untracked: bool,
|
|
224
|
-
untracked_filter: Optional[Callable[[str], bool]]) -> Optional[list[str]]:
|
|
225
|
-
changed: list[str] = []
|
|
226
|
-
for line in status.splitlines():
|
|
227
|
-
code, path = line[:2], line[3:].strip()
|
|
228
|
-
if " -> " in path:
|
|
229
|
-
path = path.split(" -> ", 1)[1].strip()
|
|
230
|
-
if not path or _runtime_path(path):
|
|
231
|
-
continue
|
|
232
|
-
if path.startswith('"'):
|
|
233
|
-
return None # Quoted/escaped paths cannot be identified reliably.
|
|
234
|
-
if code.strip() == "??":
|
|
235
|
-
if include_untracked and (untracked_filter is None or untracked_filter(path)):
|
|
236
|
-
changed.append(path)
|
|
237
|
-
elif "D" in code:
|
|
238
|
-
contents.pop(path, None)
|
|
239
|
-
else:
|
|
240
|
-
changed.append(path)
|
|
241
|
-
return changed
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
def worktree_fingerprint(project_root: str, untracked_filter: Optional[Callable[[str], bool]] = None,
|
|
245
|
-
include_untracked: bool = True) -> str:
|
|
246
|
-
"""Hash real content including new files, excluding workflow runtime state.
|
|
247
|
-
|
|
248
|
-
Staging/committing unchanged bytes does not change identity. Ignored files
|
|
249
|
-
never participate; test outputs must be Git-ignored. Explicit check inputs
|
|
250
|
-
are hashed separately. Return an empty identity when it cannot be proven.
|
|
251
|
-
"""
|
|
252
|
-
tree = _fingerprint_git(project_root, "ls-tree", "-r", "HEAD")
|
|
253
|
-
status = _fingerprint_git(project_root, "status", "--porcelain", "--untracked-files=all")
|
|
254
|
-
if tree is None or status is None or tree.returncode != 0 or status.returncode != 0:
|
|
255
|
-
return ""
|
|
256
|
-
contents: dict[str, str] = {}
|
|
257
|
-
for line in tree.stdout.splitlines():
|
|
258
|
-
meta, _, path = line.partition("\t")
|
|
259
|
-
fields = meta.split()
|
|
260
|
-
if path and len(fields) >= 3 and not _runtime_path(path):
|
|
261
|
-
contents[path] = fields[2]
|
|
262
|
-
changed = _fingerprint_changes(status.stdout, contents, include_untracked, untracked_filter)
|
|
263
|
-
if changed is None:
|
|
264
|
-
return ""
|
|
265
|
-
hashed = hash_worktree_paths(project_root, changed)
|
|
266
|
-
if hashed is None:
|
|
267
|
-
return ""
|
|
268
|
-
for path, sha in hashed:
|
|
269
|
-
contents[path] = sha
|
|
270
|
-
digest = hashlib.sha256()
|
|
271
|
-
for path in sorted(contents):
|
|
272
|
-
digest.update(path.encode())
|
|
273
|
-
digest.update(b"\0")
|
|
274
|
-
digest.update(contents[path].encode())
|
|
275
|
-
digest.update(b"\n")
|
|
276
|
-
return digest.hexdigest()
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
def _execute(argv: Sequence[str], cwd: str, timeout: float, deadline: Optional[float],
|
|
280
|
-
env: Optional[Mapping[str, str]] = None) -> dict[str, object]:
|
|
281
|
-
remaining = remaining_seconds(deadline)
|
|
282
|
-
if remaining is not None and remaining <= 0:
|
|
283
|
-
return {"status": "deadline_exceeded", "returncode": None, "stdout": "", "stderr": "",
|
|
284
|
-
"execution_started": False}
|
|
285
|
-
# When the entry deadline is tighter than the command timeout, expiry is
|
|
286
|
-
# unexecuted work (`incomplete`), never a product failure.
|
|
287
|
-
deadline_bounded = remaining is not None and remaining <= timeout
|
|
288
|
-
effective = min(timeout, remaining) if remaining is not None else timeout
|
|
289
|
-
child_env = command_environment(env)
|
|
290
|
-
try:
|
|
291
|
-
proc = subprocess.Popen(
|
|
292
|
-
list(argv), cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, text=True,
|
|
293
|
-
env=child_env, start_new_session=os.name != "nt",
|
|
294
|
-
)
|
|
295
|
-
out, err = proc.communicate(timeout=max(effective, 0.01))
|
|
296
|
-
except subprocess.TimeoutExpired as exc:
|
|
297
|
-
return _timeout_result(proc, exc, deadline_bounded)
|
|
298
|
-
except OSError as exc:
|
|
299
|
-
return {"status": "spawn_error", "returncode": None, "stdout": "", "stderr": str(exc),
|
|
300
|
-
"execution_started": False}
|
|
301
|
-
return {"status": "ok", "returncode": proc.returncode, "stdout": out or "", "stderr": err or ""}
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
def _timeout_result(proc: subprocess.Popen, expired: subprocess.TimeoutExpired,
|
|
305
|
-
deadline_bounded: bool) -> dict[str, object]:
|
|
306
|
-
if os.name != "nt":
|
|
307
|
-
try:
|
|
308
|
-
os.killpg(proc.pid, signal.SIGKILL)
|
|
309
|
-
except ProcessLookupError:
|
|
310
|
-
pass # The process group already exited between timeout and kill.
|
|
311
|
-
else:
|
|
312
|
-
subprocess.run(("taskkill", "/F", "/T", "/PID", str(proc.pid)),
|
|
313
|
-
capture_output=True, timeout=2, check=False)
|
|
314
|
-
proc.kill()
|
|
315
|
-
try:
|
|
316
|
-
out, err = proc.communicate(timeout=1)
|
|
317
|
-
except subprocess.TimeoutExpired:
|
|
318
|
-
out, err = expired.stdout, expired.stderr
|
|
319
|
-
for pipe in (proc.stdout, proc.stderr):
|
|
320
|
-
if pipe is not None:
|
|
321
|
-
pipe.close()
|
|
322
|
-
def tail(value: object) -> str:
|
|
323
|
-
return (value.decode(errors="replace") if isinstance(value, bytes) else str(value or ""))[-4000:]
|
|
324
|
-
return {"status": "deadline_exceeded" if deadline_bounded else "timeout", "returncode": None,
|
|
325
|
-
"stdout": tail(out), "stderr": tail(err)}
|
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""L0 filesystem primitives: text reads and atomic replacement writes."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import os
|
|
7
|
-
import tempfile
|
|
8
|
-
from pathlib import Path
|
|
9
|
-
from typing import Optional, Union
|
|
10
|
-
|
|
11
|
-
PathLike = Union[str, os.PathLike]
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
def read_text(path: PathLike) -> Optional[str]:
|
|
15
|
-
"""Read UTF-8 text; a missing file is None, other IO errors propagate."""
|
|
16
|
-
try:
|
|
17
|
-
return Path(path).read_text(encoding="utf-8")
|
|
18
|
-
except FileNotFoundError:
|
|
19
|
-
return None
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
def write_text(path: PathLike, text: str) -> None:
|
|
23
|
-
"""Write UTF-8 text in place (non-atomic convenience writer)."""
|
|
24
|
-
Path(path).write_text(text, encoding="utf-8")
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
def atomic_write_text(path: PathLike, text: str) -> None:
|
|
28
|
-
"""Replace path with exact UTF-8 text via a temp file and os.replace.
|
|
29
|
-
|
|
30
|
-
On any failure the target keeps its previous content and the temp file is
|
|
31
|
-
removed before the original exception propagates. Disable native newline
|
|
32
|
-
translation so transaction snapshots also round-trip unchanged on Windows.
|
|
33
|
-
"""
|
|
34
|
-
target = Path(path)
|
|
35
|
-
temporary = ""
|
|
36
|
-
try:
|
|
37
|
-
with tempfile.NamedTemporaryFile(
|
|
38
|
-
"w",
|
|
39
|
-
encoding="utf-8",
|
|
40
|
-
newline="",
|
|
41
|
-
dir=str(target.parent),
|
|
42
|
-
prefix=f".{target.name}.",
|
|
43
|
-
suffix=".tmp",
|
|
44
|
-
delete=False,
|
|
45
|
-
) as handle:
|
|
46
|
-
temporary = handle.name
|
|
47
|
-
handle.write(text)
|
|
48
|
-
handle.flush()
|
|
49
|
-
os.fsync(handle.fileno())
|
|
50
|
-
os.replace(temporary, target)
|
|
51
|
-
except Exception:
|
|
52
|
-
if temporary:
|
|
53
|
-
try:
|
|
54
|
-
os.unlink(temporary)
|
|
55
|
-
except OSError:
|
|
56
|
-
pass # best effort: the original failure is what callers need
|
|
57
|
-
raise
|
|
@@ -1,92 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""L0 Git primitives: one implementation of the git CLI for every consumer."""
|
|
3
|
-
|
|
4
|
-
from __future__ import annotations
|
|
5
|
-
|
|
6
|
-
import subprocess
|
|
7
|
-
from typing import Mapping, Optional, Sequence
|
|
8
|
-
|
|
9
|
-
_STATUS_ARGV = ("status", "--porcelain=v1", "-z", "--untracked-files=all")
|
|
10
|
-
_STATUS_NO_UNTRACKED_ARGV = ("status", "--porcelain=v1", "-z", "--untracked-files=no")
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
class GitError(RuntimeError):
|
|
14
|
-
"""Stable git failure carrying a machine code and the failing argv."""
|
|
15
|
-
|
|
16
|
-
def __init__(self, code: str, message: str, *, argv: Sequence[str] = (),
|
|
17
|
-
returncode: Optional[int] = None) -> None:
|
|
18
|
-
super().__init__(message)
|
|
19
|
-
self.code = code
|
|
20
|
-
self.message = message
|
|
21
|
-
self.argv = tuple(argv)
|
|
22
|
-
self.returncode = returncode
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
def run_git(root: str, argv: Sequence[str], *, timeout: float = 30) -> str:
|
|
26
|
-
"""Run git in root and return stdout; any failure raises GitError."""
|
|
27
|
-
try:
|
|
28
|
-
result = subprocess.run(
|
|
29
|
-
("git", *argv), cwd=root, text=True, encoding="utf-8",
|
|
30
|
-
capture_output=True, check=False, timeout=timeout,
|
|
31
|
-
)
|
|
32
|
-
except OSError as exc:
|
|
33
|
-
raise GitError("git_unavailable", str(exc), argv=argv) from exc
|
|
34
|
-
except subprocess.TimeoutExpired as exc:
|
|
35
|
-
raise GitError("git_timeout", f"git timed out after {timeout}s", argv=argv) from exc
|
|
36
|
-
if result.returncode != 0:
|
|
37
|
-
message = result.stderr.strip() or "Git command failed"
|
|
38
|
-
raise GitError("git_error", message, argv=argv, returncode=result.returncode)
|
|
39
|
-
return result.stdout
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
def _status_name(code: str) -> str:
|
|
43
|
-
if code == "??":
|
|
44
|
-
return "untracked"
|
|
45
|
-
if "D" in code:
|
|
46
|
-
return "deleted"
|
|
47
|
-
if "A" in code:
|
|
48
|
-
return "added"
|
|
49
|
-
if "M" in code:
|
|
50
|
-
return "modified"
|
|
51
|
-
return "changed"
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def changed_entries(root: str, *, include_untracked: bool = True) -> Mapping[str, str]:
|
|
55
|
-
"""Working-tree changes keyed by relative path; renames also name the old path."""
|
|
56
|
-
argv = _STATUS_ARGV if include_untracked else _STATUS_NO_UNTRACKED_ARGV
|
|
57
|
-
records = run_git(root, argv).split("\0")
|
|
58
|
-
changes: dict[str, str] = {}
|
|
59
|
-
index = 0
|
|
60
|
-
while index < len(records) and records[index]:
|
|
61
|
-
record = records[index]
|
|
62
|
-
if len(record) < 4:
|
|
63
|
-
raise GitError("invalid_git_status", record, argv=argv)
|
|
64
|
-
code, path = record[:2], record[3:]
|
|
65
|
-
index += 1
|
|
66
|
-
if "R" in code or "C" in code:
|
|
67
|
-
# porcelain -z rename/copy emits two records: "XY <new>" then "<old>"
|
|
68
|
-
if index >= len(records) or not records[index]:
|
|
69
|
-
raise GitError("invalid_git_status", f"{code} {path}", argv=argv)
|
|
70
|
-
old_path = records[index]
|
|
71
|
-
index += 1
|
|
72
|
-
changes[path] = _status_name(code)
|
|
73
|
-
changes[old_path] = "deleted"
|
|
74
|
-
continue
|
|
75
|
-
changes[path] = _status_name(code)
|
|
76
|
-
return changes
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
def changed_files(root: str, *, include_untracked: bool = True) -> tuple[str, ...]:
|
|
80
|
-
"""Relative paths with uncommitted changes (working tree and index)."""
|
|
81
|
-
return tuple(changed_entries(root, include_untracked=include_untracked))
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
def tracked_files(root: str) -> tuple[str, ...]:
|
|
85
|
-
"""Tracked relative paths; non-git projects read as an empty set."""
|
|
86
|
-
try:
|
|
87
|
-
output = run_git(root, ("ls-files",))
|
|
88
|
-
except GitError:
|
|
89
|
-
return ()
|
|
90
|
-
return tuple(
|
|
91
|
-
line.replace("\\", "/").strip() for line in output.splitlines() if line.strip()
|
|
92
|
-
)
|
|
@@ -1,160 +0,0 @@
|
|
|
1
|
-
#!/usr/bin/env python3
|
|
2
|
-
"""Session activity log: JSONL event stream for the quality-loop feature.
|
|
3
|
-
|
|
4
|
-
Design contract (spec-quality-loop.design.md §3.3):
|
|
5
|
-
- One event per line: {"v", "ts", "sid", "event", "data"}.
|
|
6
|
-
- append is O(1) single-write; failures are swallowed (stderr only) so the
|
|
7
|
-
hook protocol is never broken by logging (RULE-01 / E-03).
|
|
8
|
-
- Reads are line-tolerant: corrupt lines are skipped, never raised (RULE-05).
|
|
9
|
-
- Rotation: when the live file reaches MAX_BYTES it is archived to
|
|
10
|
-
sessions/YYYY-MM/session-log-<ts>.jsonl and a fresh file starts (B-02).
|
|
11
|
-
"""
|
|
12
|
-
import json
|
|
13
|
-
import os
|
|
14
|
-
import sys
|
|
15
|
-
from datetime import datetime, timedelta
|
|
16
|
-
|
|
17
|
-
LOG_NAME = ".session-log.jsonl"
|
|
18
|
-
SESSIONS_DIR = "sessions"
|
|
19
|
-
MAX_BYTES = 5 * 1024 * 1024
|
|
20
|
-
SCHEMA_VERSION = 1
|
|
21
|
-
|
|
22
|
-
KNOWN_EVENTS = (
|
|
23
|
-
"inject",
|
|
24
|
-
"edit",
|
|
25
|
-
"violation",
|
|
26
|
-
"correction",
|
|
27
|
-
"false_positive",
|
|
28
|
-
"degrade",
|
|
29
|
-
"stop_check",
|
|
30
|
-
"spec_candidate",
|
|
31
|
-
"spec_bound",
|
|
32
|
-
"spec_applied",
|
|
33
|
-
"spec_read",
|
|
34
|
-
"spec_drift",
|
|
35
|
-
"spec_gate",
|
|
36
|
-
"late_violation",
|
|
37
|
-
"metrics_degraded",
|
|
38
|
-
)
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
def warn(msg: str) -> None:
|
|
42
|
-
"""Write one diagnostic line to stderr; stdout stays reserved for protocol JSON."""
|
|
43
|
-
sys.stderr.write(f"[cf-log] {msg}\n")
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
def log_path(project_root: str) -> str:
|
|
47
|
-
return os.path.join(project_root, ".code-flow", LOG_NAME)
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
def _archive_dir(project_root: str, month: str) -> str:
|
|
51
|
-
return os.path.join(project_root, ".code-flow", SESSIONS_DIR, month)
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
def _rotate_if_needed(project_root: str, path: str) -> None:
|
|
55
|
-
try:
|
|
56
|
-
if os.path.getsize(path) < MAX_BYTES:
|
|
57
|
-
return
|
|
58
|
-
except OSError:
|
|
59
|
-
return
|
|
60
|
-
now = datetime.now()
|
|
61
|
-
month_dir = _archive_dir(project_root, now.strftime("%Y-%m"))
|
|
62
|
-
os.makedirs(month_dir, exist_ok=True)
|
|
63
|
-
stamp = now.strftime("%Y%m%dT%H%M%S")
|
|
64
|
-
target = os.path.join(month_dir, f"session-log-{stamp}.jsonl")
|
|
65
|
-
# Second-resolution names collide when a log rotates twice quickly; never
|
|
66
|
-
# overwrite an existing archive.
|
|
67
|
-
counter = 1
|
|
68
|
-
while os.path.exists(target):
|
|
69
|
-
counter += 1
|
|
70
|
-
target = os.path.join(month_dir, f"session-log-{stamp}-{counter}.jsonl")
|
|
71
|
-
os.replace(path, target)
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
def append_event(project_root: str, event: str, data: dict, sid: str) -> bool:
|
|
75
|
-
"""Append one event line. Never raises; returns False on failure (E-03)."""
|
|
76
|
-
try:
|
|
77
|
-
path = log_path(project_root)
|
|
78
|
-
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
79
|
-
_rotate_if_needed(project_root, path)
|
|
80
|
-
line = json.dumps(
|
|
81
|
-
{
|
|
82
|
-
"v": SCHEMA_VERSION,
|
|
83
|
-
"ts": datetime.now().isoformat(timespec="seconds"),
|
|
84
|
-
"sid": sid,
|
|
85
|
-
"event": event,
|
|
86
|
-
"data": data or {},
|
|
87
|
-
},
|
|
88
|
-
ensure_ascii=False,
|
|
89
|
-
)
|
|
90
|
-
with open(path, "a", encoding="utf-8") as f:
|
|
91
|
-
f.write(line + "\n")
|
|
92
|
-
return True
|
|
93
|
-
except Exception as exc:
|
|
94
|
-
warn(f"append_event failed: {exc}")
|
|
95
|
-
return False
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
def degrade(project_root: str, component: str, error: object, sid: str = "") -> None:
|
|
99
|
-
"""Record a degrade event (FEAT-02). Never raises — degrading the
|
|
100
|
-
degrade-logger itself must stay invisible (RULE-01)."""
|
|
101
|
-
append_event(
|
|
102
|
-
project_root,
|
|
103
|
-
"degrade",
|
|
104
|
-
{"component": component, "error": str(error)[:200]},
|
|
105
|
-
sid,
|
|
106
|
-
)
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
def _candidate_files(project_root: str, days: int) -> list:
|
|
110
|
-
"""Live log + archived months overlapping the [now-days, now] window."""
|
|
111
|
-
paths = []
|
|
112
|
-
base = os.path.join(project_root, ".code-flow", SESSIONS_DIR)
|
|
113
|
-
if os.path.isdir(base):
|
|
114
|
-
cutoff = datetime.now() - timedelta(days=days)
|
|
115
|
-
for month in sorted(os.listdir(base)):
|
|
116
|
-
try:
|
|
117
|
-
month_start = datetime.strptime(month, "%Y-%m")
|
|
118
|
-
except ValueError:
|
|
119
|
-
continue
|
|
120
|
-
# keep a month if any part of it can fall inside the window
|
|
121
|
-
if month_start >= cutoff.replace(day=1, hour=0, minute=0, second=0, microsecond=0):
|
|
122
|
-
month_dir = os.path.join(base, month)
|
|
123
|
-
paths.extend(
|
|
124
|
-
os.path.join(month_dir, name)
|
|
125
|
-
for name in sorted(os.listdir(month_dir))
|
|
126
|
-
if name.endswith(".jsonl")
|
|
127
|
-
)
|
|
128
|
-
live = log_path(project_root)
|
|
129
|
-
if os.path.exists(live):
|
|
130
|
-
paths.append(live)
|
|
131
|
-
return paths
|
|
132
|
-
|
|
133
|
-
|
|
134
|
-
def read_events(project_root: str, days: int = 30, events: tuple = ()) -> list:
|
|
135
|
-
"""Read events within the window, oldest first. Corrupt lines skipped."""
|
|
136
|
-
cutoff = (datetime.now() - timedelta(days=days)).isoformat(timespec="seconds")
|
|
137
|
-
wanted = set(events) if events else None
|
|
138
|
-
out = []
|
|
139
|
-
for path in _candidate_files(project_root, days):
|
|
140
|
-
try:
|
|
141
|
-
with open(path, "r", encoding="utf-8") as f:
|
|
142
|
-
for raw in f:
|
|
143
|
-
raw = raw.strip()
|
|
144
|
-
if not raw:
|
|
145
|
-
continue
|
|
146
|
-
try:
|
|
147
|
-
item = json.loads(raw)
|
|
148
|
-
except Exception:
|
|
149
|
-
continue
|
|
150
|
-
if not isinstance(item, dict) or "event" not in item:
|
|
151
|
-
continue
|
|
152
|
-
if wanted is not None and item["event"] not in wanted:
|
|
153
|
-
continue
|
|
154
|
-
if str(item.get("ts", "")) < cutoff:
|
|
155
|
-
continue
|
|
156
|
-
out.append(item)
|
|
157
|
-
except Exception as exc:
|
|
158
|
-
warn(f"read_events skipped {path}: {exc}")
|
|
159
|
-
continue
|
|
160
|
-
return out
|