ai-context-framework 0.0.3.58__py3-none-any.whl
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.
- acf.py +36 -0
- ai_context_framework/__init__.py +7 -0
- ai_context_framework/cli.py +11 -0
- ai_context_framework/commands/__init__.py +2 -0
- ai_context_framework/commands/archive.py +138 -0
- ai_context_framework/commands/decisions.py +85 -0
- ai_context_framework/commands/doctor.py +75 -0
- ai_context_framework/commands/edit_link.py +298 -0
- ai_context_framework/commands/feedback.py +206 -0
- ai_context_framework/commands/human.py +114 -0
- ai_context_framework/commands/init_upgrade.py +97 -0
- ai_context_framework/commands/knowledge.py +297 -0
- ai_context_framework/commands/links.py +163 -0
- ai_context_framework/commands/log.py +396 -0
- ai_context_framework/commands/log_inventory.py +270 -0
- ai_context_framework/commands/new_context.py +432 -0
- ai_context_framework/commands/next_status.py +96 -0
- ai_context_framework/commands/plan_task.py +574 -0
- ai_context_framework/commands/review_audit_curate.py +198 -0
- ai_context_framework/commands/status_check.py +508 -0
- ai_context_framework/commands/versioning.py +190 -0
- ai_context_framework/commands/worklog.py +386 -0
- ai_context_framework/commands/workstream.py +1834 -0
- ai_context_framework/commands/workstream_reserve.py +603 -0
- ai_context_framework/commands/worktree.py +433 -0
- ai_context_framework/constants.py +19 -0
- ai_context_framework/context_budget.py +126 -0
- ai_context_framework/domains/__init__.py +2 -0
- ai_context_framework/domains/tasks.py +215 -0
- ai_context_framework/domains/upgrade_audit.py +889 -0
- ai_context_framework/front_matter.py +354 -0
- ai_context_framework/git_support.py +868 -0
- ai_context_framework/json_contract.py +556 -0
- ai_context_framework/locks.py +51 -0
- ai_context_framework/markdown.py +668 -0
- ai_context_framework/markers.py +186 -0
- ai_context_framework/models.py +107 -0
- ai_context_framework/observability.py +270 -0
- ai_context_framework/paths.py +115 -0
- ai_context_framework/runtime.py +1756 -0
- ai_context_framework/runtime_parts/__init__.py +1 -0
- ai_context_framework/runtime_parts/archive_workstream.py +1026 -0
- ai_context_framework/runtime_parts/check.py +849 -0
- ai_context_framework/runtime_parts/core.py +265 -0
- ai_context_framework/runtime_parts/doctor.py +1261 -0
- ai_context_framework/runtime_parts/knowledge_review.py +1176 -0
- ai_context_framework/runtime_parts/objects.py +1424 -0
- ai_context_framework/runtime_parts/plan_task.py +497 -0
- ai_context_framework/runtime_parts/upgrade.py +880 -0
- ai_context_framework/tables.py +136 -0
- ai_context_framework/templates.py +128 -0
- ai_context_framework/validators/__init__.py +2 -0
- ai_context_framework/validators/checks.py +84 -0
- ai_context_framework/validators/template_checks.py +38 -0
- ai_context_framework/version.py +3 -0
- ai_context_framework/worktree_artifacts.py +347 -0
- ai_context_framework/worktree_collision.py +571 -0
- ai_context_framework/worktree_merge_contracts.py +379 -0
- ai_context_framework/worktree_resilient_merge.py +985 -0
- ai_context_framework/worktree_retry.py +250 -0
- ai_context_framework/worktree_service.py +1195 -0
- ai_context_framework/worktree_status.py +383 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/AGENTS.md +197 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Context.md +114 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Current_Task.md +157 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Feedback_Inbox.md +43 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Task_Plan.md +83 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/Archive_Index.md +26 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/feedback/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/plans/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/tasks/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/decisions/ADR-0001-template.md +129 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/Human_Index.md +22 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/Human_Notes.md +20 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/reports/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/weekly/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Architecture.md +115 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Context_Curation_Prompt.md +240 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Decisions_Index.md +68 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Knowledge_Index.md +33 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Project_Brief.md +117 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Sources_Index.md +41 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/System_Manual.md +502 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Tech_Context.md +87 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/knowledge/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/sources/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Agent_Requested.md +5 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Always_Active.md +17 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Coding_Rules.md +22 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Manual_Only.md +10 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Project_Rules.md +11 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Review_Rules.md +24 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Rules_Index.md +27 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Writing_Rules.md +28 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/worklog/Worklog_Index.md +33 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/worklog/daily/YYYY-MM-DD.md +91 -0
- ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/worklog/knowledge-drafts/.gitkeep +1 -0
- ai_context_framework-0.0.3.58.dist-info/METADATA +499 -0
- ai_context_framework-0.0.3.58.dist-info/RECORD +103 -0
- ai_context_framework-0.0.3.58.dist-info/WHEEL +5 -0
- ai_context_framework-0.0.3.58.dist-info/entry_points.txt +2 -0
- ai_context_framework-0.0.3.58.dist-info/top_level.txt +2 -0
acf.py
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""Compatibility shim for the ACF CLI.
|
|
3
|
+
|
|
4
|
+
The package runtime lives under :mod:`ai_context_framework`. This top-level
|
|
5
|
+
module remains importable for legacy scripts and for ``python acf.py``.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
from collections.abc import Sequence
|
|
11
|
+
from typing import Any
|
|
12
|
+
|
|
13
|
+
from ai_context_framework import runtime as _runtime
|
|
14
|
+
from ai_context_framework.version import VERSION
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
ROOT = _runtime.ROOT
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def main(argv: Sequence[str] | None = None) -> int:
|
|
21
|
+
_runtime.ROOT = ROOT
|
|
22
|
+
_runtime._sync_runtime_part_globals()
|
|
23
|
+
return _runtime.main(argv)
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def __getattr__(name: str) -> Any:
|
|
27
|
+
_runtime.ROOT = ROOT
|
|
28
|
+
_runtime._sync_runtime_part_globals()
|
|
29
|
+
try:
|
|
30
|
+
return getattr(_runtime, name)
|
|
31
|
+
except AttributeError as exc:
|
|
32
|
+
raise AttributeError(f"module {__name__!r} has no attribute {name!r}") from exc
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
if __name__ == "__main__":
|
|
36
|
+
raise SystemExit(main())
|
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
"""Command handlers for archive workflows."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from ai_context_framework.json_contract import dry_run_enabled, emit_write_result, json_enabled, print_json, set_result_payload
|
|
11
|
+
from ai_context_framework.markers import (
|
|
12
|
+
ARCHIVE_INDEX_MARKER_END,
|
|
13
|
+
ARCHIVE_INDEX_MARKER_START,
|
|
14
|
+
insert_generated_marker_block_after_heading,
|
|
15
|
+
replace_generated_marker_block,
|
|
16
|
+
)
|
|
17
|
+
from ai_context_framework.models import CheckResult
|
|
18
|
+
from ai_context_framework.paths import require_context_root
|
|
19
|
+
from ai_context_framework.tables import split_table_line
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
MaybeCheckAfter = Callable[[argparse.Namespace, Path, str | None], CheckResult | None]
|
|
23
|
+
|
|
24
|
+
|
|
25
|
+
@dataclass(frozen=True)
|
|
26
|
+
class ArchiveDependencies:
|
|
27
|
+
maybe_check_after: MaybeCheckAfter
|
|
28
|
+
archive_index_path: Callable[..., Any]
|
|
29
|
+
read_text: Callable[..., Any]
|
|
30
|
+
render_archive_index: Callable[..., Any]
|
|
31
|
+
collect_archive_sync_rows: Callable[..., Any]
|
|
32
|
+
skipped_missing_generated_archive_details: Callable[..., Any]
|
|
33
|
+
render_archive_index_table: Callable[..., Any]
|
|
34
|
+
archive_file: Callable[..., Any]
|
|
35
|
+
current_task_path: Callable[..., Any]
|
|
36
|
+
task_plan_path: Callable[..., Any]
|
|
37
|
+
find_archive_table: Callable[..., Any]
|
|
38
|
+
archive_table_header: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def archive_sync_command(args: argparse.Namespace, *, deps: ArchiveDependencies) -> int:
|
|
42
|
+
root = require_context_root(args.path)
|
|
43
|
+
index_path = deps.archive_index_path(root)
|
|
44
|
+
original = deps.read_text(index_path) if index_path.exists() else deps.render_archive_index()
|
|
45
|
+
rows, skipped, warnings = deps.collect_archive_sync_rows(root)
|
|
46
|
+
skipped.extend(deps.skipped_missing_generated_archive_details(root, original))
|
|
47
|
+
generated_table = deps.render_archive_index_table(rows)
|
|
48
|
+
|
|
49
|
+
if ARCHIVE_INDEX_MARKER_START in original or ARCHIVE_INDEX_MARKER_END in original:
|
|
50
|
+
updated, changed = replace_generated_marker_block(
|
|
51
|
+
original,
|
|
52
|
+
ARCHIVE_INDEX_MARKER_START,
|
|
53
|
+
ARCHIVE_INDEX_MARKER_END,
|
|
54
|
+
generated_table,
|
|
55
|
+
)
|
|
56
|
+
initialized_marker = False
|
|
57
|
+
elif args.init_marker:
|
|
58
|
+
updated, changed = insert_generated_marker_block_after_heading(
|
|
59
|
+
original,
|
|
60
|
+
"## 归档条目",
|
|
61
|
+
ARCHIVE_INDEX_MARKER_START,
|
|
62
|
+
ARCHIVE_INDEX_MARKER_END,
|
|
63
|
+
generated_table,
|
|
64
|
+
replace_empty_table_header=deps.archive_table_header,
|
|
65
|
+
)
|
|
66
|
+
initialized_marker = True
|
|
67
|
+
else:
|
|
68
|
+
raise SystemExit("generated_marker_missing: archive/Archive_Index.md is missing ACF:ARCHIVE:INDEX-GENERATED markers")
|
|
69
|
+
|
|
70
|
+
changed_files = [index_path] if changed or not index_path.exists() else []
|
|
71
|
+
if changed_files and not dry_run_enabled(args):
|
|
72
|
+
index_path.parent.mkdir(parents=True, exist_ok=True)
|
|
73
|
+
index_path.write_text(updated, encoding="utf-8")
|
|
74
|
+
check_result = deps.maybe_check_after(args, root, None)
|
|
75
|
+
action = "would sync" if dry_run_enabled(args) else "synced"
|
|
76
|
+
extra_payload: dict[str, object] = {
|
|
77
|
+
"generated_count": len(rows),
|
|
78
|
+
"skipped_items": skipped,
|
|
79
|
+
"initialized_marker": initialized_marker,
|
|
80
|
+
}
|
|
81
|
+
if dry_run_enabled(args):
|
|
82
|
+
extra_payload["planned_block"] = generated_table
|
|
83
|
+
return emit_write_result(
|
|
84
|
+
args,
|
|
85
|
+
"archive sync",
|
|
86
|
+
f"{action} Archive index generated block",
|
|
87
|
+
changed_files,
|
|
88
|
+
check_result,
|
|
89
|
+
extra_payload=extra_payload,
|
|
90
|
+
warnings=warnings,
|
|
91
|
+
)
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def archive_current_task_command(args: argparse.Namespace, *, deps: ArchiveDependencies) -> int:
|
|
95
|
+
root = require_context_root(args.path)
|
|
96
|
+
dry_run = dry_run_enabled(args)
|
|
97
|
+
changed = deps.archive_file(root, deps.current_task_path(root), "Task", args.reason, args.force, dry_run)
|
|
98
|
+
check_result = deps.maybe_check_after(args, root, None)
|
|
99
|
+
action = "would archive" if dry_run else "archived"
|
|
100
|
+
return emit_write_result(args, "archive current-task", f"{action} current task", changed, check_result)
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
def archive_task_plan_command(args: argparse.Namespace, *, deps: ArchiveDependencies) -> int:
|
|
104
|
+
root = require_context_root(args.path)
|
|
105
|
+
dry_run = dry_run_enabled(args)
|
|
106
|
+
changed = deps.archive_file(root, deps.task_plan_path(root), "Plan", args.reason, args.force, dry_run)
|
|
107
|
+
check_result = deps.maybe_check_after(args, root, None)
|
|
108
|
+
action = "would archive" if dry_run else "archived"
|
|
109
|
+
return emit_write_result(args, "archive task-plan", f"{action} task plan", changed, check_result)
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def archive_list_command(args: argparse.Namespace, *, deps: ArchiveDependencies) -> int:
|
|
113
|
+
root = require_context_root(args.path)
|
|
114
|
+
index_path = deps.archive_index_path(root)
|
|
115
|
+
rows: list[dict[str, str]] = []
|
|
116
|
+
if index_path.exists():
|
|
117
|
+
lines = deps.read_text(index_path).splitlines()
|
|
118
|
+
table, legacy = deps.find_archive_table(lines)
|
|
119
|
+
keys = ["日期", "类型", "标题", "原因", "详情"] if legacy else ["日期", "类型", "ID", "原路径", "归档路径", "状态", "原因"]
|
|
120
|
+
for line in lines[table.body_start : table.body_end]:
|
|
121
|
+
cells = split_table_line(line)
|
|
122
|
+
if len(cells) >= len(keys) and cells[0] != "暂无":
|
|
123
|
+
rows.append(dict(zip(keys, cells)))
|
|
124
|
+
payload: dict[str, object] = {
|
|
125
|
+
"command": "archive list",
|
|
126
|
+
"ok": True,
|
|
127
|
+
"context": str(root),
|
|
128
|
+
"archives": rows,
|
|
129
|
+
"error_code": None,
|
|
130
|
+
"next_actions": [],
|
|
131
|
+
}
|
|
132
|
+
set_result_payload(args, payload)
|
|
133
|
+
if json_enabled(args):
|
|
134
|
+
print_json(payload)
|
|
135
|
+
else:
|
|
136
|
+
for row in rows:
|
|
137
|
+
print(f"{row.get('日期')} {row.get('类型')} {row.get('ID') or row.get('标题')} {row.get('归档路径') or row.get('详情')}")
|
|
138
|
+
return 0
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"""Command handlers for decision index maintenance."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from ai_context_framework.json_contract import dry_run_enabled, emit_write_result
|
|
11
|
+
from ai_context_framework.markers import (
|
|
12
|
+
DECISIONS_INDEX_MARKER_END,
|
|
13
|
+
DECISIONS_INDEX_MARKER_START,
|
|
14
|
+
insert_generated_marker_block_after_heading,
|
|
15
|
+
replace_generated_marker_block,
|
|
16
|
+
)
|
|
17
|
+
from ai_context_framework.models import CheckResult
|
|
18
|
+
from ai_context_framework.paths import require_context_root
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
MaybeCheckAfter = Callable[[argparse.Namespace, Path, str | None], CheckResult | None]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@dataclass(frozen=True)
|
|
25
|
+
class DecisionsDependencies:
|
|
26
|
+
maybe_check_after: MaybeCheckAfter
|
|
27
|
+
decisions_index_path: Callable[..., Any]
|
|
28
|
+
read_text: Callable[..., Any]
|
|
29
|
+
collect_decision_sync_rows: Callable[..., Any]
|
|
30
|
+
skipped_missing_generated_decision_details: Callable[..., Any]
|
|
31
|
+
render_decisions_index_table: Callable[..., Any]
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def decisions_sync_command(args: argparse.Namespace, *, deps: DecisionsDependencies) -> int:
|
|
35
|
+
root = require_context_root(args.path)
|
|
36
|
+
index_path = deps.decisions_index_path(root)
|
|
37
|
+
if not index_path.exists():
|
|
38
|
+
raise SystemExit(f"sync_source_invalid: decisions index does not exist: {index_path}")
|
|
39
|
+
original = deps.read_text(index_path)
|
|
40
|
+
rows, skipped, warnings = deps.collect_decision_sync_rows(root)
|
|
41
|
+
skipped.extend(deps.skipped_missing_generated_decision_details(root, original))
|
|
42
|
+
generated_table = deps.render_decisions_index_table(rows)
|
|
43
|
+
|
|
44
|
+
if DECISIONS_INDEX_MARKER_START in original or DECISIONS_INDEX_MARKER_END in original:
|
|
45
|
+
updated, changed = replace_generated_marker_block(
|
|
46
|
+
original,
|
|
47
|
+
DECISIONS_INDEX_MARKER_START,
|
|
48
|
+
DECISIONS_INDEX_MARKER_END,
|
|
49
|
+
generated_table,
|
|
50
|
+
)
|
|
51
|
+
initialized_marker = False
|
|
52
|
+
elif args.init_marker:
|
|
53
|
+
updated, changed = insert_generated_marker_block_after_heading(
|
|
54
|
+
original,
|
|
55
|
+
"## 当前有效决策",
|
|
56
|
+
DECISIONS_INDEX_MARKER_START,
|
|
57
|
+
DECISIONS_INDEX_MARKER_END,
|
|
58
|
+
generated_table,
|
|
59
|
+
replace_empty_table_header="| ID | 标题 | 状态 | 摘要 | 详情 |",
|
|
60
|
+
)
|
|
61
|
+
initialized_marker = True
|
|
62
|
+
else:
|
|
63
|
+
raise SystemExit("generated_marker_missing: reference/Decisions_Index.md is missing ACF:DECISIONS:INDEX-GENERATED markers")
|
|
64
|
+
|
|
65
|
+
changed_files = [index_path] if changed else []
|
|
66
|
+
if changed_files and not dry_run_enabled(args):
|
|
67
|
+
index_path.write_text(updated, encoding="utf-8")
|
|
68
|
+
check_result = deps.maybe_check_after(args, root, None)
|
|
69
|
+
action = "would sync" if dry_run_enabled(args) else "synced"
|
|
70
|
+
extra_payload: dict[str, object] = {
|
|
71
|
+
"generated_count": len(rows),
|
|
72
|
+
"skipped_items": skipped,
|
|
73
|
+
"initialized_marker": initialized_marker,
|
|
74
|
+
}
|
|
75
|
+
if dry_run_enabled(args):
|
|
76
|
+
extra_payload["planned_block"] = generated_table
|
|
77
|
+
return emit_write_result(
|
|
78
|
+
args,
|
|
79
|
+
"decisions sync",
|
|
80
|
+
f"{action} Decisions index generated block",
|
|
81
|
+
changed_files,
|
|
82
|
+
check_result,
|
|
83
|
+
extra_payload=extra_payload,
|
|
84
|
+
warnings=warnings,
|
|
85
|
+
)
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
"""Command handler for doctor diagnostics."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable
|
|
9
|
+
|
|
10
|
+
from ai_context_framework.constants import JSON_SCHEMA_VERSION
|
|
11
|
+
from ai_context_framework.json_contract import json_enabled, print_json, set_result_payload
|
|
12
|
+
from ai_context_framework.paths import require_context_root
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DoctorDependencies:
|
|
17
|
+
doctor_project_payload: Callable[..., Any]
|
|
18
|
+
doctor_single_context_payload: Callable[..., Any]
|
|
19
|
+
doctor_summary: Callable[..., Any]
|
|
20
|
+
doctor_projects_next_actions: Callable[..., Any]
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def doctor_command(args: argparse.Namespace, *, deps: DoctorDependencies) -> int:
|
|
24
|
+
if getattr(args, "projects", None):
|
|
25
|
+
if getattr(args, "fix", "none") != "none":
|
|
26
|
+
raise SystemExit(
|
|
27
|
+
"doctor_projects_fix_unsupported: --projects is read-only; "
|
|
28
|
+
"run single-project doctor with --fix for write repairs"
|
|
29
|
+
)
|
|
30
|
+
if getattr(args, "report", False) or getattr(args, "draft_semantic", False):
|
|
31
|
+
raise SystemExit(
|
|
32
|
+
"doctor_projects_write_unsupported: --projects is read-only; "
|
|
33
|
+
"run single-project doctor with --report or --draft-semantic"
|
|
34
|
+
)
|
|
35
|
+
project_payloads = [deps.doctor_project_payload(args, project) for project in args.projects]
|
|
36
|
+
ok = all(bool(item.get("ok")) for item in project_payloads)
|
|
37
|
+
findings = [
|
|
38
|
+
finding
|
|
39
|
+
for item in project_payloads
|
|
40
|
+
for finding in item.get("findings", [])
|
|
41
|
+
if isinstance(finding, dict)
|
|
42
|
+
]
|
|
43
|
+
changed_files = [
|
|
44
|
+
changed_file
|
|
45
|
+
for item in project_payloads
|
|
46
|
+
for changed_file in item.get("changed_files", [])
|
|
47
|
+
if isinstance(changed_file, str)
|
|
48
|
+
]
|
|
49
|
+
payload: dict[str, object] = {
|
|
50
|
+
"schema_version": JSON_SCHEMA_VERSION,
|
|
51
|
+
"command": "doctor",
|
|
52
|
+
"ok": ok,
|
|
53
|
+
"projects": project_payloads,
|
|
54
|
+
"summary": deps.doctor_summary(findings),
|
|
55
|
+
"changed_files": sorted(dict.fromkeys(changed_files)),
|
|
56
|
+
"error_code": None if ok else "doctor_failed",
|
|
57
|
+
"message": "" if ok else "one or more projects failed",
|
|
58
|
+
"next_actions": deps.doctor_projects_next_actions(project_payloads, findings, getattr(args, "fix", "none"), ok),
|
|
59
|
+
}
|
|
60
|
+
else:
|
|
61
|
+
payload = deps.doctor_single_context_payload(args, require_context_root(args.path))
|
|
62
|
+
set_result_payload(args, payload)
|
|
63
|
+
if json_enabled(args):
|
|
64
|
+
print_json(payload)
|
|
65
|
+
else:
|
|
66
|
+
if "projects" in payload:
|
|
67
|
+
for item in payload["projects"]:
|
|
68
|
+
if isinstance(item, dict):
|
|
69
|
+
print(f"{item.get('context')}: {item.get('summary', {}).get('findings_total', 0)} finding(s)")
|
|
70
|
+
else:
|
|
71
|
+
print(f"doctor findings: {payload['summary']['findings_total']}")
|
|
72
|
+
for finding in payload.get("findings", []):
|
|
73
|
+
if isinstance(finding, dict):
|
|
74
|
+
print(f"{finding.get('severity')}: {finding.get('code')} - {finding.get('message')}")
|
|
75
|
+
return 0 if payload.get("ok") else 1
|
|
@@ -0,0 +1,298 @@
|
|
|
1
|
+
"""Edit, linkify, and link command handlers."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import sys
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from pathlib import Path
|
|
9
|
+
|
|
10
|
+
from ai_context_framework.json_contract import (
|
|
11
|
+
dry_run_enabled,
|
|
12
|
+
emit_write_result,
|
|
13
|
+
json_enabled,
|
|
14
|
+
print_json,
|
|
15
|
+
set_result_payload,
|
|
16
|
+
)
|
|
17
|
+
from ai_context_framework.markdown import (
|
|
18
|
+
append_section_text,
|
|
19
|
+
find_section,
|
|
20
|
+
linkify_markdown_text,
|
|
21
|
+
markdown_heading_anchor_for,
|
|
22
|
+
markdown_link_target_for,
|
|
23
|
+
render_markdown_link,
|
|
24
|
+
replace_section_text,
|
|
25
|
+
section_body,
|
|
26
|
+
)
|
|
27
|
+
from ai_context_framework.models import CheckResult
|
|
28
|
+
from ai_context_framework.observability import atomic_write_text
|
|
29
|
+
from ai_context_framework.paths import is_relative_to, require_context_root
|
|
30
|
+
from ai_context_framework.tables import parse_cell_updates, upsert_table_row
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
LINKIFY_DEFAULT_DIRS = ("active", "reference", "rules", "decisions")
|
|
34
|
+
LINKIFY_DEFAULT_FILES = ("worklog/Worklog_Index.md", "archive/Archive_Index.md")
|
|
35
|
+
CheckAfter = Callable[[argparse.Namespace, Path], CheckResult | None]
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def resolve_context_markdown_file(root: Path, target: Path) -> Path:
|
|
39
|
+
root = root.resolve()
|
|
40
|
+
if target.is_absolute():
|
|
41
|
+
resolved = target.resolve()
|
|
42
|
+
else:
|
|
43
|
+
root_candidate = (root / target).resolve()
|
|
44
|
+
if not is_relative_to(root_candidate, root):
|
|
45
|
+
raise SystemExit(f"target file is outside context root: {target}")
|
|
46
|
+
|
|
47
|
+
cwd_candidate = (Path.cwd() / target).resolve()
|
|
48
|
+
if cwd_candidate.exists() and is_relative_to(cwd_candidate, root):
|
|
49
|
+
resolved = cwd_candidate
|
|
50
|
+
else:
|
|
51
|
+
resolved = root_candidate
|
|
52
|
+
|
|
53
|
+
if not is_relative_to(resolved, root):
|
|
54
|
+
raise SystemExit(f"target file is outside context root: {target}")
|
|
55
|
+
if resolved.suffix.lower() != ".md":
|
|
56
|
+
raise SystemExit(f"target file must be Markdown (.md): {resolved}")
|
|
57
|
+
if not resolved.exists():
|
|
58
|
+
raise SystemExit(f"target file does not exist: {resolved}")
|
|
59
|
+
if not resolved.is_file():
|
|
60
|
+
raise SystemExit(f"target path is not a file: {resolved}")
|
|
61
|
+
return resolved
|
|
62
|
+
|
|
63
|
+
|
|
64
|
+
def resolve_context_file(root: Path, target: Path, *, must_exist: bool = True) -> Path:
|
|
65
|
+
root = root.resolve()
|
|
66
|
+
if target.is_absolute():
|
|
67
|
+
resolved = target.resolve()
|
|
68
|
+
else:
|
|
69
|
+
root_candidate = (root / target).resolve()
|
|
70
|
+
if not is_relative_to(root_candidate, root):
|
|
71
|
+
raise SystemExit(f"target file is outside context root: {target}")
|
|
72
|
+
|
|
73
|
+
cwd_candidate = (Path.cwd() / target).resolve()
|
|
74
|
+
if cwd_candidate.exists() and is_relative_to(cwd_candidate, root):
|
|
75
|
+
resolved = cwd_candidate
|
|
76
|
+
else:
|
|
77
|
+
resolved = root_candidate
|
|
78
|
+
|
|
79
|
+
if not is_relative_to(resolved, root):
|
|
80
|
+
raise SystemExit(f"target file is outside context root: {target}")
|
|
81
|
+
if must_exist and not resolved.exists():
|
|
82
|
+
raise SystemExit(f"target file does not exist: {resolved}")
|
|
83
|
+
if must_exist and not resolved.is_file():
|
|
84
|
+
raise SystemExit(f"target path is not a file: {resolved}")
|
|
85
|
+
return resolved
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def read_edit_input(args: argparse.Namespace) -> str:
|
|
89
|
+
text = getattr(args, "text", None)
|
|
90
|
+
input_path = getattr(args, "input", None)
|
|
91
|
+
if text is not None and input_path is not None:
|
|
92
|
+
raise SystemExit("use either --text or --input, not both")
|
|
93
|
+
if text is not None:
|
|
94
|
+
return text
|
|
95
|
+
if input_path is not None:
|
|
96
|
+
resolved = input_path.resolve()
|
|
97
|
+
if not resolved.is_file():
|
|
98
|
+
raise SystemExit(f"edit input file does not exist: {resolved}")
|
|
99
|
+
return resolved.read_text(encoding="utf-8")
|
|
100
|
+
if not sys.stdin.isatty():
|
|
101
|
+
return sys.stdin.read()
|
|
102
|
+
raise SystemExit("edit command requires --text, --input, or stdin")
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
def linkify_candidate_files(root: Path, args: argparse.Namespace) -> list[Path]:
|
|
106
|
+
files: set[Path] = set()
|
|
107
|
+
for dirname in LINKIFY_DEFAULT_DIRS:
|
|
108
|
+
directory = root / dirname
|
|
109
|
+
if directory.exists():
|
|
110
|
+
files.update(path for path in directory.rglob("*.md") if path.is_file())
|
|
111
|
+
for rel in LINKIFY_DEFAULT_FILES:
|
|
112
|
+
file_path = root / rel
|
|
113
|
+
if file_path.is_file():
|
|
114
|
+
files.add(file_path)
|
|
115
|
+
if getattr(args, "include_worklog_daily", False):
|
|
116
|
+
daily = root / "worklog" / "daily"
|
|
117
|
+
if daily.exists():
|
|
118
|
+
files.update(path for path in daily.rglob("*.md") if path.is_file())
|
|
119
|
+
if getattr(args, "include_archive", False):
|
|
120
|
+
archive = root / "archive"
|
|
121
|
+
if archive.exists():
|
|
122
|
+
files.update(path for path in archive.rglob("*.md") if path.is_file())
|
|
123
|
+
return sorted(files)
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def linkify_command(args: argparse.Namespace, *, maybe_check_after: CheckAfter) -> int:
|
|
127
|
+
if getattr(args, "format", "markdown") != "markdown":
|
|
128
|
+
raise SystemExit("linkify currently supports only --format markdown")
|
|
129
|
+
root = require_context_root(args.path)
|
|
130
|
+
dry_run = dry_run_enabled(args)
|
|
131
|
+
changed_files: list[Path] = []
|
|
132
|
+
updated_entries: list[dict[str, object]] = []
|
|
133
|
+
skipped_entries: list[dict[str, str]] = []
|
|
134
|
+
for md_file in linkify_candidate_files(root, args):
|
|
135
|
+
original = md_file.read_text(encoding="utf-8")
|
|
136
|
+
updated, replacements, skipped = linkify_markdown_text(
|
|
137
|
+
root,
|
|
138
|
+
md_file,
|
|
139
|
+
original,
|
|
140
|
+
allow_missing=bool(getattr(args, "allow_missing", False)),
|
|
141
|
+
)
|
|
142
|
+
rel_file = md_file.relative_to(root).as_posix()
|
|
143
|
+
skipped_entries.extend({"file": rel_file, **entry} for entry in skipped)
|
|
144
|
+
if updated == original:
|
|
145
|
+
continue
|
|
146
|
+
changed_files.append(md_file)
|
|
147
|
+
updated_entries.append({"path": rel_file, "replacements": replacements})
|
|
148
|
+
if not dry_run:
|
|
149
|
+
atomic_write_text(md_file, updated)
|
|
150
|
+
|
|
151
|
+
check_result = maybe_check_after(args, root)
|
|
152
|
+
action = "would linkify" if dry_run else "linkified"
|
|
153
|
+
return emit_write_result(
|
|
154
|
+
args,
|
|
155
|
+
"linkify",
|
|
156
|
+
f"{action} {len(changed_files)} file(s)",
|
|
157
|
+
changed_files,
|
|
158
|
+
check_result,
|
|
159
|
+
extra_payload={
|
|
160
|
+
"format": args.format,
|
|
161
|
+
"updated": updated_entries,
|
|
162
|
+
"skipped": skipped_entries,
|
|
163
|
+
},
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
def link_add_command(args: argparse.Namespace, *, maybe_check_after: CheckAfter) -> int:
|
|
168
|
+
root = require_context_root(args.path)
|
|
169
|
+
dry_run = dry_run_enabled(args)
|
|
170
|
+
target_file = resolve_context_markdown_file(root, args.file)
|
|
171
|
+
link_target_file = resolve_context_file(root, args.target)
|
|
172
|
+
target_fragment = ""
|
|
173
|
+
if args.target_heading:
|
|
174
|
+
if link_target_file.suffix.lower() != ".md":
|
|
175
|
+
raise SystemExit("--target-heading requires a Markdown target file")
|
|
176
|
+
anchor = markdown_heading_anchor_for(link_target_file.read_text(encoding="utf-8"), args.target_heading)
|
|
177
|
+
if anchor is None:
|
|
178
|
+
raise SystemExit(f"target heading was not found: {args.target_heading}")
|
|
179
|
+
target_fragment = anchor
|
|
180
|
+
link_href = markdown_link_target_for(target_file, link_target_file, target_fragment)
|
|
181
|
+
context_target_display = link_target_file.relative_to(root).as_posix()
|
|
182
|
+
default_text = f"{context_target_display}#{target_fragment}" if target_fragment else context_target_display
|
|
183
|
+
link_text = args.text or default_text
|
|
184
|
+
bullet = f"- {render_markdown_link(link_text, link_href)}"
|
|
185
|
+
|
|
186
|
+
original = target_file.read_text(encoding="utf-8")
|
|
187
|
+
original_lines = original.splitlines()
|
|
188
|
+
body = section_body(original_lines, find_section(original_lines, args.heading))
|
|
189
|
+
duplicate_markers = {f"]({link_href})", f"](<{link_href}>)"}
|
|
190
|
+
if not args.force and any(marker in body for marker in duplicate_markers):
|
|
191
|
+
raise SystemExit(f"link already exists in section: {link_href}")
|
|
192
|
+
updated = append_section_text(original, args.heading, bullet)
|
|
193
|
+
changed_files = [target_file] if updated != original else []
|
|
194
|
+
if changed_files and not dry_run:
|
|
195
|
+
atomic_write_text(target_file, updated)
|
|
196
|
+
|
|
197
|
+
check_result = maybe_check_after(args, root)
|
|
198
|
+
action = "would add" if dry_run else "added"
|
|
199
|
+
return emit_write_result(
|
|
200
|
+
args,
|
|
201
|
+
"link add",
|
|
202
|
+
f"{action} link to {target_file}",
|
|
203
|
+
changed_files,
|
|
204
|
+
check_result,
|
|
205
|
+
extra_payload={
|
|
206
|
+
"file": target_file.relative_to(root).as_posix(),
|
|
207
|
+
"heading": args.heading,
|
|
208
|
+
"link": bullet,
|
|
209
|
+
"target": context_target_display,
|
|
210
|
+
"target_heading": args.target_heading,
|
|
211
|
+
},
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def edit_section_get_command(args: argparse.Namespace) -> int:
|
|
216
|
+
root = require_context_root(args.context)
|
|
217
|
+
target = resolve_context_markdown_file(root, args.file)
|
|
218
|
+
lines = target.read_text(encoding="utf-8").splitlines()
|
|
219
|
+
section = find_section(lines, args.heading)
|
|
220
|
+
body = section_body(lines, section)
|
|
221
|
+
payload: dict[str, object] = {
|
|
222
|
+
"command": "edit section get",
|
|
223
|
+
"ok": True,
|
|
224
|
+
"context": str(root),
|
|
225
|
+
"file": str(target),
|
|
226
|
+
"heading": args.heading,
|
|
227
|
+
"body": body,
|
|
228
|
+
"heading_line": section.heading_index + 1,
|
|
229
|
+
"body_start_line": section.body_start + 1,
|
|
230
|
+
"body_end_line": section.body_end,
|
|
231
|
+
}
|
|
232
|
+
set_result_payload(args, payload)
|
|
233
|
+
|
|
234
|
+
if json_enabled(args):
|
|
235
|
+
print_json(payload)
|
|
236
|
+
else:
|
|
237
|
+
print(body)
|
|
238
|
+
return 0
|
|
239
|
+
|
|
240
|
+
|
|
241
|
+
def edit_section_replace_command(args: argparse.Namespace, *, maybe_check_after: CheckAfter) -> int:
|
|
242
|
+
root = require_context_root(args.context)
|
|
243
|
+
dry_run = dry_run_enabled(args)
|
|
244
|
+
target = resolve_context_markdown_file(root, args.file)
|
|
245
|
+
updated = replace_section_text(target.read_text(encoding="utf-8"), args.heading, read_edit_input(args))
|
|
246
|
+
if not dry_run:
|
|
247
|
+
target.write_text(updated, encoding="utf-8")
|
|
248
|
+
check_result = maybe_check_after(args, root)
|
|
249
|
+
action = "would replace" if dry_run else "replaced"
|
|
250
|
+
return emit_write_result(
|
|
251
|
+
args,
|
|
252
|
+
"edit section replace",
|
|
253
|
+
f"{action} section {args.heading} in {target}",
|
|
254
|
+
[target],
|
|
255
|
+
check_result,
|
|
256
|
+
)
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
def edit_section_append_command(args: argparse.Namespace, *, maybe_check_after: CheckAfter) -> int:
|
|
260
|
+
root = require_context_root(args.context)
|
|
261
|
+
dry_run = dry_run_enabled(args)
|
|
262
|
+
target = resolve_context_markdown_file(root, args.file)
|
|
263
|
+
updated = append_section_text(target.read_text(encoding="utf-8"), args.heading, read_edit_input(args))
|
|
264
|
+
if not dry_run:
|
|
265
|
+
target.write_text(updated, encoding="utf-8")
|
|
266
|
+
check_result = maybe_check_after(args, root)
|
|
267
|
+
action = "would append" if dry_run else "appended"
|
|
268
|
+
return emit_write_result(
|
|
269
|
+
args,
|
|
270
|
+
"edit section append",
|
|
271
|
+
f"{action} to section {args.heading} in {target}",
|
|
272
|
+
[target],
|
|
273
|
+
check_result,
|
|
274
|
+
)
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
def edit_table_upsert_command(args: argparse.Namespace, *, maybe_check_after: CheckAfter) -> int:
|
|
278
|
+
root = require_context_root(args.context)
|
|
279
|
+
dry_run = dry_run_enabled(args)
|
|
280
|
+
target = resolve_context_markdown_file(root, args.file)
|
|
281
|
+
updated = upsert_table_row(
|
|
282
|
+
target.read_text(encoding="utf-8"),
|
|
283
|
+
args.header,
|
|
284
|
+
args.key_column,
|
|
285
|
+
args.key,
|
|
286
|
+
parse_cell_updates(args.cell),
|
|
287
|
+
)
|
|
288
|
+
if not dry_run:
|
|
289
|
+
target.write_text(updated, encoding="utf-8")
|
|
290
|
+
check_result = maybe_check_after(args, root)
|
|
291
|
+
action = "would upsert" if dry_run else "upserted"
|
|
292
|
+
return emit_write_result(
|
|
293
|
+
args,
|
|
294
|
+
"edit table upsert",
|
|
295
|
+
f"{action} table row {args.key} in {target}",
|
|
296
|
+
[target],
|
|
297
|
+
check_result,
|
|
298
|
+
)
|