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.
Files changed (103) hide show
  1. acf.py +36 -0
  2. ai_context_framework/__init__.py +7 -0
  3. ai_context_framework/cli.py +11 -0
  4. ai_context_framework/commands/__init__.py +2 -0
  5. ai_context_framework/commands/archive.py +138 -0
  6. ai_context_framework/commands/decisions.py +85 -0
  7. ai_context_framework/commands/doctor.py +75 -0
  8. ai_context_framework/commands/edit_link.py +298 -0
  9. ai_context_framework/commands/feedback.py +206 -0
  10. ai_context_framework/commands/human.py +114 -0
  11. ai_context_framework/commands/init_upgrade.py +97 -0
  12. ai_context_framework/commands/knowledge.py +297 -0
  13. ai_context_framework/commands/links.py +163 -0
  14. ai_context_framework/commands/log.py +396 -0
  15. ai_context_framework/commands/log_inventory.py +270 -0
  16. ai_context_framework/commands/new_context.py +432 -0
  17. ai_context_framework/commands/next_status.py +96 -0
  18. ai_context_framework/commands/plan_task.py +574 -0
  19. ai_context_framework/commands/review_audit_curate.py +198 -0
  20. ai_context_framework/commands/status_check.py +508 -0
  21. ai_context_framework/commands/versioning.py +190 -0
  22. ai_context_framework/commands/worklog.py +386 -0
  23. ai_context_framework/commands/workstream.py +1834 -0
  24. ai_context_framework/commands/workstream_reserve.py +603 -0
  25. ai_context_framework/commands/worktree.py +433 -0
  26. ai_context_framework/constants.py +19 -0
  27. ai_context_framework/context_budget.py +126 -0
  28. ai_context_framework/domains/__init__.py +2 -0
  29. ai_context_framework/domains/tasks.py +215 -0
  30. ai_context_framework/domains/upgrade_audit.py +889 -0
  31. ai_context_framework/front_matter.py +354 -0
  32. ai_context_framework/git_support.py +868 -0
  33. ai_context_framework/json_contract.py +556 -0
  34. ai_context_framework/locks.py +51 -0
  35. ai_context_framework/markdown.py +668 -0
  36. ai_context_framework/markers.py +186 -0
  37. ai_context_framework/models.py +107 -0
  38. ai_context_framework/observability.py +270 -0
  39. ai_context_framework/paths.py +115 -0
  40. ai_context_framework/runtime.py +1756 -0
  41. ai_context_framework/runtime_parts/__init__.py +1 -0
  42. ai_context_framework/runtime_parts/archive_workstream.py +1026 -0
  43. ai_context_framework/runtime_parts/check.py +849 -0
  44. ai_context_framework/runtime_parts/core.py +265 -0
  45. ai_context_framework/runtime_parts/doctor.py +1261 -0
  46. ai_context_framework/runtime_parts/knowledge_review.py +1176 -0
  47. ai_context_framework/runtime_parts/objects.py +1424 -0
  48. ai_context_framework/runtime_parts/plan_task.py +497 -0
  49. ai_context_framework/runtime_parts/upgrade.py +880 -0
  50. ai_context_framework/tables.py +136 -0
  51. ai_context_framework/templates.py +128 -0
  52. ai_context_framework/validators/__init__.py +2 -0
  53. ai_context_framework/validators/checks.py +84 -0
  54. ai_context_framework/validators/template_checks.py +38 -0
  55. ai_context_framework/version.py +3 -0
  56. ai_context_framework/worktree_artifacts.py +347 -0
  57. ai_context_framework/worktree_collision.py +571 -0
  58. ai_context_framework/worktree_merge_contracts.py +379 -0
  59. ai_context_framework/worktree_resilient_merge.py +985 -0
  60. ai_context_framework/worktree_retry.py +250 -0
  61. ai_context_framework/worktree_service.py +1195 -0
  62. ai_context_framework/worktree_status.py +383 -0
  63. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/AGENTS.md +197 -0
  64. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Context.md +114 -0
  65. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Current_Task.md +157 -0
  66. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Feedback_Inbox.md +43 -0
  67. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/active/Task_Plan.md +83 -0
  68. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/.gitkeep +1 -0
  69. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/Archive_Index.md +26 -0
  70. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/feedback/.gitkeep +1 -0
  71. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/plans/.gitkeep +1 -0
  72. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/archive/tasks/.gitkeep +1 -0
  73. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/decisions/ADR-0001-template.md +129 -0
  74. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/Human_Index.md +22 -0
  75. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/Human_Notes.md +20 -0
  76. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/reports/.gitkeep +1 -0
  77. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/human/weekly/.gitkeep +1 -0
  78. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Architecture.md +115 -0
  79. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Context_Curation_Prompt.md +240 -0
  80. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Decisions_Index.md +68 -0
  81. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Knowledge_Index.md +33 -0
  82. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Project_Brief.md +117 -0
  83. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Sources_Index.md +41 -0
  84. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/System_Manual.md +502 -0
  85. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/Tech_Context.md +87 -0
  86. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/knowledge/.gitkeep +1 -0
  87. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/reference/sources/.gitkeep +1 -0
  88. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Agent_Requested.md +5 -0
  89. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Always_Active.md +17 -0
  90. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Coding_Rules.md +22 -0
  91. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Manual_Only.md +10 -0
  92. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Project_Rules.md +11 -0
  93. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Review_Rules.md +24 -0
  94. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Rules_Index.md +27 -0
  95. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/rules/Writing_Rules.md +28 -0
  96. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/worklog/Worklog_Index.md +33 -0
  97. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/worklog/daily/YYYY-MM-DD.md +91 -0
  98. ai_context_framework-0.0.3.58.data/data/share/ai-context-framework/template/worklog/knowledge-drafts/.gitkeep +1 -0
  99. ai_context_framework-0.0.3.58.dist-info/METADATA +499 -0
  100. ai_context_framework-0.0.3.58.dist-info/RECORD +103 -0
  101. ai_context_framework-0.0.3.58.dist-info/WHEEL +5 -0
  102. ai_context_framework-0.0.3.58.dist-info/entry_points.txt +2 -0
  103. 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,7 @@
1
+ """AI Context Framework package.
2
+
3
+ The package CLI entrypoint is :mod:`ai_context_framework.cli`; top-level
4
+ ``acf.py`` remains as a compatibility shim.
5
+ """
6
+
7
+ __all__: tuple[str, ...] = ()
@@ -0,0 +1,11 @@
1
+ """Package CLI entrypoint."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from collections.abc import Sequence
6
+
7
+ from ai_context_framework import runtime
8
+
9
+
10
+ def main(argv: Sequence[str] | None = None) -> int:
11
+ return runtime.main(argv)
@@ -0,0 +1,2 @@
1
+ """Command handlers for ACF."""
2
+
@@ -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
+ )