@pennixrv/trellis 0.7.0-beta.5 → 0.7.0-beta.50
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/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +47 -3
- package/dist/cli/index.js.map +1 -1
- package/dist/commands/backup.d.ts +25 -0
- package/dist/commands/backup.d.ts.map +1 -0
- package/dist/commands/backup.js +152 -0
- package/dist/commands/backup.js.map +1 -0
- package/dist/commands/channel/adapters/claude.d.ts.map +1 -1
- package/dist/commands/channel/adapters/claude.js +2 -0
- package/dist/commands/channel/adapters/claude.js.map +1 -1
- package/dist/commands/channel/adapters/codex.d.ts +5 -1
- package/dist/commands/channel/adapters/codex.d.ts.map +1 -1
- package/dist/commands/channel/adapters/codex.js +19 -9
- package/dist/commands/channel/adapters/codex.js.map +1 -1
- package/dist/commands/channel/adapters/index.d.ts +3 -0
- package/dist/commands/channel/adapters/index.d.ts.map +1 -1
- package/dist/commands/channel/adapters/index.js +3 -3
- package/dist/commands/channel/adapters/index.js.map +1 -1
- package/dist/commands/channel/adapters/types.d.ts +2 -0
- package/dist/commands/channel/adapters/types.d.ts.map +1 -1
- package/dist/commands/channel/guard.d.ts.map +1 -1
- package/dist/commands/channel/guard.js +38 -29
- package/dist/commands/channel/guard.js.map +1 -1
- package/dist/commands/channel/index.d.ts.map +1 -1
- package/dist/commands/channel/index.js +52 -16
- package/dist/commands/channel/index.js.map +1 -1
- package/dist/commands/channel/kill.d.ts.map +1 -1
- package/dist/commands/channel/kill.js +37 -15
- package/dist/commands/channel/kill.js.map +1 -1
- package/dist/commands/channel/messages.d.ts.map +1 -1
- package/dist/commands/channel/messages.js +3 -2
- package/dist/commands/channel/messages.js.map +1 -1
- package/dist/commands/channel/process-identity.d.ts +3 -0
- package/dist/commands/channel/process-identity.d.ts.map +1 -0
- package/dist/commands/channel/process-identity.js +24 -0
- package/dist/commands/channel/process-identity.js.map +1 -0
- package/dist/commands/channel/profiles.d.ts +26 -0
- package/dist/commands/channel/profiles.d.ts.map +1 -0
- package/dist/commands/channel/profiles.js +186 -0
- package/dist/commands/channel/profiles.js.map +1 -0
- package/dist/commands/channel/rm.d.ts +0 -1
- package/dist/commands/channel/rm.d.ts.map +1 -1
- package/dist/commands/channel/rm.js +203 -96
- package/dist/commands/channel/rm.js.map +1 -1
- package/dist/commands/channel/spawn.d.ts +3 -0
- package/dist/commands/channel/spawn.d.ts.map +1 -1
- package/dist/commands/channel/spawn.js +43 -15
- package/dist/commands/channel/spawn.js.map +1 -1
- package/dist/commands/channel/supervisor/idle.d.ts +2 -0
- package/dist/commands/channel/supervisor/idle.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/idle.js +15 -7
- package/dist/commands/channel/supervisor/idle.js.map +1 -1
- package/dist/commands/channel/supervisor/inbox.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/inbox.js +48 -33
- package/dist/commands/channel/supervisor/inbox.js.map +1 -1
- package/dist/commands/channel/supervisor/stdout.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/stdout.js +6 -3
- package/dist/commands/channel/supervisor/stdout.js.map +1 -1
- package/dist/commands/channel/supervisor/warning.d.ts.map +1 -1
- package/dist/commands/channel/supervisor/warning.js +2 -3
- package/dist/commands/channel/supervisor/warning.js.map +1 -1
- package/dist/commands/channel/supervisor.d.ts +12 -0
- package/dist/commands/channel/supervisor.d.ts.map +1 -1
- package/dist/commands/channel/supervisor.js +88 -16
- package/dist/commands/channel/supervisor.js.map +1 -1
- package/dist/commands/channel/wait.d.ts +3 -1
- package/dist/commands/channel/wait.d.ts.map +1 -1
- package/dist/commands/channel/wait.js +59 -14
- package/dist/commands/channel/wait.js.map +1 -1
- package/dist/commands/init.d.ts.map +1 -1
- package/dist/commands/init.js +23 -10
- package/dist/commands/init.js.map +1 -1
- package/dist/commands/update.d.ts.map +1 -1
- package/dist/commands/update.js +446 -333
- package/dist/commands/update.js.map +1 -1
- package/dist/commands/workflow.d.ts +1 -0
- package/dist/commands/workflow.d.ts.map +1 -1
- package/dist/commands/workflow.js +56 -1
- package/dist/commands/workflow.js.map +1 -1
- package/dist/configurators/codex.d.ts +6 -0
- package/dist/configurators/codex.d.ts.map +1 -1
- package/dist/configurators/codex.js +93 -0
- package/dist/configurators/codex.js.map +1 -1
- package/dist/configurators/shared.js +2 -2
- package/dist/configurators/shared.js.map +1 -1
- package/dist/constants/paths.d.ts +2 -0
- package/dist/constants/paths.d.ts.map +1 -1
- package/dist/constants/paths.js +2 -0
- package/dist/constants/paths.js.map +1 -1
- package/dist/migrations/manifests/0.7.0-beta.10.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.11.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.12.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.13.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.14.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.15.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.16.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.17.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.18.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.19.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.20.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.21.json +17 -0
- package/dist/migrations/manifests/0.7.0-beta.22.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.23.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.24.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.25.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.26.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.27.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.28.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.29.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.30.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.31.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.32.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.33.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.34.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.35.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.36.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.37.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.38.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.39.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.40.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.41.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.42.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.43.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.44.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.45.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.46.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.47.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.48.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.49.json +13 -0
- package/dist/migrations/manifests/0.7.0-beta.50.json +12 -0
- package/dist/migrations/manifests/0.7.0-beta.6.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.7.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.8.json +9 -0
- package/dist/migrations/manifests/0.7.0-beta.9.json +9 -0
- package/dist/templates/codex/hooks/session-start.py +21 -17
- package/dist/templates/common/bundled-skills/trellis-channel/SKILL.md +3 -1
- package/dist/templates/common/bundled-skills/trellis-channel/references/command-reference.md +24 -16
- package/dist/templates/common/bundled-skills/trellis-channel/references/forum.md +4 -12
- package/dist/templates/common/bundled-skills/trellis-channel/references/multi-target-dispatch.md +139 -0
- package/dist/templates/common/bundled-skills/trellis-channel/references/progress-debugging.md +9 -5
- package/dist/templates/common/bundled-skills/trellis-channel/references/subnode-work.md +84 -14
- package/dist/templates/common/bundled-skills/trellis-channel/references/workers.md +9 -11
- package/dist/templates/common/bundled-skills/trellis-channel/references/workflows.md +25 -14
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-skills-or-commands.md +13 -11
- package/dist/templates/common/bundled-skills/trellis-meta/references/customize-local/change-task-lifecycle.md +7 -2
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/task-system.md +6 -1
- package/dist/templates/common/bundled-skills/trellis-meta/references/local-architecture/workflow.md +6 -4
- package/dist/templates/common/bundled-skills/trellis-session-insight/SKILL.md +42 -3
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/cli-quick-reference.md +1 -1
- package/dist/templates/common/bundled-skills/trellis-session-insight/references/triggering-patterns.md +10 -2
- package/dist/templates/common/commands/continue.md +36 -8
- package/dist/templates/common/commands/finish-work.md +17 -2
- package/dist/templates/common/commands/start.md +8 -1
- package/dist/templates/common/skills/brainstorm.md +49 -22
- package/dist/templates/common/skills/break-loop.md +6 -10
- package/dist/templates/common/skills/check.md +34 -5
- package/dist/templates/copilot/hooks/session-start.py +19 -15
- package/dist/templates/copilot/prompts/brainstorm.prompt.md +27 -21
- package/dist/templates/markdown/spec/backend/database-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/backend/directory-structure.md.txt +20 -20
- package/dist/templates/markdown/spec/backend/error-handling.md.txt +22 -22
- package/dist/templates/markdown/spec/backend/index.md.txt +19 -19
- package/dist/templates/markdown/spec/backend/logging-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/backend/quality-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/component-guidelines.md.txt +25 -25
- package/dist/templates/markdown/spec/frontend/directory-structure.md.txt +20 -20
- package/dist/templates/markdown/spec/frontend/hook-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/index.md.txt +20 -20
- package/dist/templates/markdown/spec/frontend/quality-guidelines.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/state-management.md.txt +22 -22
- package/dist/templates/markdown/spec/frontend/type-safety.md.txt +22 -22
- package/dist/templates/markdown/spec/guides/code-reuse-thinking-guide.md.txt +80 -85
- package/dist/templates/markdown/spec/guides/cross-layer-thinking-guide.md.txt +164 -209
- package/dist/templates/markdown/spec/guides/cross-platform-thinking-guide.md.txt +161 -185
- package/dist/templates/markdown/spec/guides/index.md.txt +51 -51
- package/dist/templates/markdown/workspace-index.md +44 -44
- package/dist/templates/opencode/lib/session-utils.js +2 -2
- package/dist/templates/shared-hooks/inject-shell-session-context.py +3 -6
- package/dist/templates/shared-hooks/inject-workflow-state.py +3 -2
- package/dist/templates/shared-hooks/session-start.py +23 -18
- package/dist/templates/trellis/agents/subnode-profiles.json +13 -0
- package/dist/templates/trellis/agents/subnode.md +11 -5
- package/dist/templates/trellis/config.yaml +2 -0
- package/dist/templates/trellis/index.d.ts +2 -1
- package/dist/templates/trellis/index.d.ts.map +1 -1
- package/dist/templates/trellis/index.js +4 -2
- package/dist/templates/trellis/index.js.map +1 -1
- package/dist/templates/trellis/scripts/add_session.py +35 -32
- package/dist/templates/trellis/scripts/common/active_task.py +8 -8
- package/dist/templates/trellis/scripts/common/developer.py +18 -18
- package/dist/templates/trellis/scripts/common/ownership_record.py +13 -8
- package/dist/templates/trellis/scripts/common/safe_commit.py +38 -9
- package/dist/templates/trellis/scripts/common/task_planning.py +137 -0
- package/dist/templates/trellis/scripts/common/task_store.py +46 -14
- package/dist/templates/trellis/scripts/common/workflow_phase.py +4 -3
- package/dist/templates/trellis/scripts/subnode_artifact.py +470 -21
- package/dist/templates/trellis/scripts/task.py +175 -2
- package/dist/templates/trellis/workflow.md +407 -388
- package/dist/utils/managed-paths.d.ts +2 -0
- package/dist/utils/managed-paths.d.ts.map +1 -1
- package/dist/utils/managed-paths.js +24 -2
- package/dist/utils/managed-paths.js.map +1 -1
- package/dist/utils/template-fetcher.d.ts +1 -0
- package/dist/utils/template-fetcher.d.ts.map +1 -1
- package/dist/utils/template-fetcher.js +2 -1
- package/dist/utils/template-fetcher.js.map +1 -1
- package/dist/utils/uninstall-scrubbers.d.ts.map +1 -1
- package/dist/utils/uninstall-scrubbers.js +5 -0
- package/dist/utils/uninstall-scrubbers.js.map +1 -1
- package/dist/utils/workflow-provenance.d.ts +24 -0
- package/dist/utils/workflow-provenance.d.ts.map +1 -0
- package/dist/utils/workflow-provenance.js +71 -0
- package/dist/utils/workflow-provenance.js.map +1 -0
- package/dist/utils/workflow-resolver.d.ts +9 -2
- package/dist/utils/workflow-resolver.d.ts.map +1 -1
- package/dist/utils/workflow-resolver.js +33 -7
- package/dist/utils/workflow-resolver.js.map +1 -1
- package/package.json +2 -2
- package/dist/templates/trellis/agents/subnode.env +0 -4
|
@@ -97,8 +97,8 @@ MARKER_PREFIX = "<!-- trellis-session:"
|
|
|
97
97
|
# fingerprint mixed in the calendar date; see compute_record_fingerprint.
|
|
98
98
|
MARKER_VERSION = 2
|
|
99
99
|
LEGACY_MARKER_RE = re.compile(r"^<!-- trellis-session: fp=([0-9a-f]{16}) -->$")
|
|
100
|
-
ENTRY_DATE_RE = re.compile(r"^\*\*Date\*\*: (\d{4}-\d{2}-\d{2})\s*$")
|
|
101
|
-
SESSION_HEADING_RE = re.compile(r"^## Session (\d+)
|
|
100
|
+
ENTRY_DATE_RE = re.compile(r"^\*\*(?:Date|日期(Date))\*\*: (\d{4}-\d{2}-\d{2})\s*$")
|
|
101
|
+
SESSION_HEADING_RE = re.compile(r"^## (?:Session|会话(Session)) (\d+)(?::|:)", re.MULTILINE)
|
|
102
102
|
|
|
103
103
|
# Recording states, in the order the operation walks them.
|
|
104
104
|
STATE_ABSENT = "absent"
|
|
@@ -170,7 +170,7 @@ def _max_session_in_index(content: str) -> int:
|
|
|
170
170
|
|
|
171
171
|
|
|
172
172
|
def _max_session_in_journal(content: str) -> int:
|
|
173
|
-
"""Highest
|
|
173
|
+
"""Highest old or new session heading in a journal body (0 when none)."""
|
|
174
174
|
numbers = [int(m.group(1)) for m in SESSION_HEADING_RE.finditer(content)]
|
|
175
175
|
return max(numbers) if numbers else 0
|
|
176
176
|
|
|
@@ -195,7 +195,7 @@ def count_journal_files(dev_dir: Path, active_num: int) -> str:
|
|
|
195
195
|
for f in files:
|
|
196
196
|
filename = f.name
|
|
197
197
|
lines = len(f.read_text(encoding="utf-8").splitlines())
|
|
198
|
-
status = "Active" if filename == active_file else "Archived"
|
|
198
|
+
status = "活动(Active)" if filename == active_file else "归档(Archived)"
|
|
199
199
|
result_lines.append(f"| `{filename}` | ~{lines} | {status} |")
|
|
200
200
|
|
|
201
201
|
return "\n".join(result_lines)
|
|
@@ -301,10 +301,10 @@ def create_new_journal_file(
|
|
|
301
301
|
prev_num = num - 1
|
|
302
302
|
new_file = dev_dir / f"{FILE_JOURNAL_PREFIX}{num}.md"
|
|
303
303
|
|
|
304
|
-
content = f"""# Journal
|
|
304
|
+
content = f"""# 日志(Journal)- {developer}(第 {num} 部分)
|
|
305
305
|
|
|
306
|
-
>
|
|
307
|
-
> Started
|
|
306
|
+
> 续接自 `{FILE_JOURNAL_PREFIX}{prev_num}.md`(约 {max_lines} 行时归档)
|
|
307
|
+
> 开始日期(Started): {today}
|
|
308
308
|
|
|
309
309
|
---
|
|
310
310
|
|
|
@@ -615,7 +615,7 @@ def _max_session_across_refs(repo_root: Path, refs: list[str], dev_rel: str) ->
|
|
|
615
615
|
"-h",
|
|
616
616
|
"-E",
|
|
617
617
|
"-e",
|
|
618
|
-
r"^## Session [0-9]
|
|
618
|
+
r"^## (Session [0-9]+:|会话(Session) [0-9]+:)",
|
|
619
619
|
"-e",
|
|
620
620
|
r"Total Sessions",
|
|
621
621
|
]
|
|
@@ -629,7 +629,7 @@ def _max_session_across_refs(repo_root: Path, refs: list[str], dev_rel: str) ->
|
|
|
629
629
|
|
|
630
630
|
# `git grep` prefixes each hit with `<rev>:<path>:`, so the numbers are
|
|
631
631
|
# matched anywhere in the line rather than anchored.
|
|
632
|
-
numbers = [int(m.group(1)) for m in re.finditer(r"## Session (\d+)
|
|
632
|
+
numbers = [int(m.group(1)) for m in re.finditer(r"## (?:Session|会话(Session)) (\d+)(?::|:)", out)]
|
|
633
633
|
numbers += [
|
|
634
634
|
int(m.group(1)) for m in re.finditer(r"Total Sessions[^\d]{0,8}(\d+)", out)
|
|
635
635
|
]
|
|
@@ -843,7 +843,7 @@ def classify_record(
|
|
|
843
843
|
if session_num is None:
|
|
844
844
|
return "", None, None, (
|
|
845
845
|
f"{journal_file.name} carries this record's marker without a "
|
|
846
|
-
"
|
|
846
|
+
"session heading above it. The pending record is malformed; "
|
|
847
847
|
"repair it by hand before retrying."
|
|
848
848
|
)
|
|
849
849
|
|
|
@@ -884,9 +884,9 @@ def _render_bullet_section(header: str, items: list[str], bullet_prefix: str = "
|
|
|
884
884
|
def _render_main_changes(changes: list[str], extra_content: str | None) -> str:
|
|
885
885
|
"""Render the Main Changes section from --change bullets or freeform content."""
|
|
886
886
|
if changes:
|
|
887
|
-
return _render_bullet_section("Main Changes", changes)
|
|
887
|
+
return _render_bullet_section("主要变更(Main Changes)", changes)
|
|
888
888
|
if extra_content:
|
|
889
|
-
return f"\n\n### Main Changes
|
|
889
|
+
return f"\n\n### 主要变更(Main Changes)\n\n{extra_content}"
|
|
890
890
|
return ""
|
|
891
891
|
|
|
892
892
|
|
|
@@ -906,39 +906,39 @@ def generate_session_content(
|
|
|
906
906
|
) -> str:
|
|
907
907
|
"""Generate session content."""
|
|
908
908
|
if evidence:
|
|
909
|
-
commit_table = """| Hash | Message |
|
|
909
|
+
commit_table = """| 哈希(Hash) | 消息(Message) |
|
|
910
910
|
|------|---------|"""
|
|
911
911
|
for oid, subject in evidence:
|
|
912
912
|
commit_table += f"\n| `{oid}` | {escape_markdown_cell(subject)} |"
|
|
913
913
|
else:
|
|
914
|
-
commit_table = "
|
|
914
|
+
commit_table = "(无提交——规划会话)"
|
|
915
915
|
|
|
916
|
-
package_line = f"\n
|
|
917
|
-
branch_line = f"\n
|
|
916
|
+
package_line = f"\n**包(Package)**: {package}" if package else ""
|
|
917
|
+
branch_line = f"\n**分支(Branch)**: `{branch}`" if branch else ""
|
|
918
918
|
|
|
919
919
|
main_changes_section = _render_main_changes(changes or [], extra_content)
|
|
920
|
-
testing_section = _render_bullet_section("Testing", tests or [], bullet_prefix="- [OK] ")
|
|
921
|
-
next_steps_section = _render_bullet_section("Next Steps", next_steps or [])
|
|
920
|
+
testing_section = _render_bullet_section("测试(Testing)", tests or [], bullet_prefix="- [OK] ")
|
|
921
|
+
next_steps_section = _render_bullet_section("下一步(Next Steps)", next_steps or [])
|
|
922
922
|
|
|
923
923
|
return f"""
|
|
924
924
|
|
|
925
|
-
## Session {session_num}
|
|
925
|
+
## 会话(Session) {session_num}:{title}
|
|
926
926
|
{marker}
|
|
927
927
|
|
|
928
|
-
|
|
929
|
-
|
|
928
|
+
**日期(Date)**: {today}
|
|
929
|
+
**任务(Task)**: {title}{package_line}{branch_line}
|
|
930
930
|
|
|
931
|
-
### Summary
|
|
931
|
+
### 摘要(Summary)
|
|
932
932
|
|
|
933
933
|
{summary}{main_changes_section}
|
|
934
934
|
|
|
935
|
-
### Git Commits
|
|
935
|
+
### Git 提交(Git Commits)
|
|
936
936
|
|
|
937
937
|
{commit_table}{testing_section}
|
|
938
938
|
|
|
939
|
-
### Status
|
|
939
|
+
### 状态(Status)
|
|
940
940
|
|
|
941
|
-
[OK]
|
|
941
|
+
[OK] **已完成(Completed)**{next_steps_section}
|
|
942
942
|
"""
|
|
943
943
|
|
|
944
944
|
|
|
@@ -992,9 +992,9 @@ def update_index(
|
|
|
992
992
|
if "@@@auto:current-status" in line:
|
|
993
993
|
new_lines.append(line)
|
|
994
994
|
in_current_status = True
|
|
995
|
-
new_lines.append(f"-
|
|
996
|
-
new_lines.append(f"-
|
|
997
|
-
new_lines.append(f"-
|
|
995
|
+
new_lines.append(f"- **活动文件(Active File)**: `{active_file}`")
|
|
996
|
+
new_lines.append(f"- **会话总数(Total Sessions)**: {new_session}")
|
|
997
|
+
new_lines.append(f"- **最近活动(Last Active)**: {today}")
|
|
998
998
|
continue
|
|
999
999
|
|
|
1000
1000
|
if "@@@/auto:current-status" in line:
|
|
@@ -1005,7 +1005,7 @@ def update_index(
|
|
|
1005
1005
|
if "@@@auto:active-documents" in line:
|
|
1006
1006
|
new_lines.append(line)
|
|
1007
1007
|
in_active_documents = True
|
|
1008
|
-
new_lines.append("| File | Lines | Status |")
|
|
1008
|
+
new_lines.append("| 文件(File) | 行数(Lines) | 状态(Status) |")
|
|
1009
1009
|
new_lines.append("|------|-------|--------|")
|
|
1010
1010
|
new_lines.append(files_table)
|
|
1011
1011
|
continue
|
|
@@ -1038,13 +1038,16 @@ def update_index(
|
|
|
1038
1038
|
r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*Base Branch\s*\|\s*$",
|
|
1039
1039
|
line,
|
|
1040
1040
|
):
|
|
1041
|
-
new_lines.append("| # | Date | Title | Commits | Branch |")
|
|
1041
|
+
new_lines.append("| # | 日期(Date) | 标题(Title) | 提交(Commits) | 分支(Branch) |")
|
|
1042
1042
|
continue
|
|
1043
1043
|
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*Branch\s*\|\s*$", line):
|
|
1044
|
-
new_lines.append("| # | Date | Title | Commits | Branch |")
|
|
1044
|
+
new_lines.append("| # | 日期(Date) | 标题(Title) | 提交(Commits) | 分支(Branch) |")
|
|
1045
|
+
continue
|
|
1046
|
+
if re.match(r"^\|\s*#\s*\|\s*日期(Date)\s*\|\s*标题(Title)\s*\|\s*提交(Commits)\s*\|\s*分支(Branch)\s*\|\s*$", line):
|
|
1047
|
+
new_lines.append("| # | 日期(Date) | 标题(Title) | 提交(Commits) | 分支(Branch) |")
|
|
1045
1048
|
continue
|
|
1046
1049
|
if re.match(r"^\|\s*#\s*\|\s*Date\s*\|\s*Title\s*\|\s*Commits\s*\|\s*$", line):
|
|
1047
|
-
new_lines.append("| # | Date | Title | Commits | Branch |")
|
|
1050
|
+
new_lines.append("| # | 日期(Date) | 标题(Title) | 提交(Commits) | 分支(Branch) |")
|
|
1048
1051
|
continue
|
|
1049
1052
|
if re.match(r"^\|[-| ]+\|\s*$", line) and not header_written:
|
|
1050
1053
|
new_lines.append("|---|------|-------|---------|--------|")
|
|
@@ -33,7 +33,7 @@ DIR_SHELL_TICKETS = "shell-tickets"
|
|
|
33
33
|
# platform that works today.
|
|
34
34
|
DIR_LEGACY_CURSOR_SHELL_TICKETS = "cursor-shell"
|
|
35
35
|
SHELL_TICKET_TTL_SECONDS = 30
|
|
36
|
-
TASK_SESSION_COMMANDS = {"start", "current", "finish"}
|
|
36
|
+
TASK_SESSION_COMMANDS = {"start", "replan", "current", "finish"}
|
|
37
37
|
|
|
38
38
|
_SESSION_KEYS = ("session_id", "sessionId", "sessionID")
|
|
39
39
|
_CONVERSATION_KEYS = ("conversation_id", "conversationId", "conversationID")
|
|
@@ -425,7 +425,7 @@ def _pending_ticket_matches_args(ticket: dict[str, Any], repo_root: Path) -> boo
|
|
|
425
425
|
continue
|
|
426
426
|
if _string_value(subcommand.get("name")) != command_name:
|
|
427
427
|
continue
|
|
428
|
-
if command_name
|
|
428
|
+
if command_name not in {"start", "replan"}:
|
|
429
429
|
return True
|
|
430
430
|
task_ref = args[1] if len(args) > 1 else None
|
|
431
431
|
if _task_refs_match(_string_value(subcommand.get("task_ref")), task_ref, repo_root):
|
|
@@ -694,7 +694,7 @@ def resolve_active_task(
|
|
|
694
694
|
active = _active_from_ref(task_ref, repo_root, "session", context_key)
|
|
695
695
|
if active:
|
|
696
696
|
return active
|
|
697
|
-
unbound = _resolve_unbound_task(repo_root)
|
|
697
|
+
unbound = _resolve_unbound_task(repo_root, context_key)
|
|
698
698
|
if unbound is not None:
|
|
699
699
|
return unbound
|
|
700
700
|
return ActiveTask(None, "none", context_key)
|
|
@@ -736,8 +736,8 @@ def _resolve_single_session_fallback(repo_root: Path) -> ActiveTask | None:
|
|
|
736
736
|
return _active_from_ref(task_ref, repo_root, "session-fallback", fallback_key)
|
|
737
737
|
|
|
738
738
|
|
|
739
|
-
def _resolve_unbound_task(repo_root: Path) -> ActiveTask | None:
|
|
740
|
-
"""Expose
|
|
739
|
+
def _resolve_unbound_task(repo_root: Path, context_key: str | None = None) -> ActiveTask | None:
|
|
740
|
+
"""Expose read-only candidates without discarding a resolved identity."""
|
|
741
741
|
sessions_dir = _runtime_sessions_dir(repo_root)
|
|
742
742
|
if sessions_dir.is_dir():
|
|
743
743
|
session_files = sorted(sessions_dir.glob("*.json"))
|
|
@@ -764,8 +764,8 @@ def _resolve_unbound_task(repo_root: Path) -> ActiveTask | None:
|
|
|
764
764
|
for task in candidates
|
|
765
765
|
))
|
|
766
766
|
if len(task_paths) == 1:
|
|
767
|
-
return ActiveTask(task_paths[0], "unbound",
|
|
768
|
-
return ActiveTask(None, "unbound_ambiguous",
|
|
767
|
+
return ActiveTask(task_paths[0], "unbound", context_key)
|
|
768
|
+
return ActiveTask(None, "unbound_ambiguous", context_key, candidate_paths=task_paths)
|
|
769
769
|
|
|
770
770
|
|
|
771
771
|
def _utc_now() -> str:
|
|
@@ -834,7 +834,7 @@ def clear_active_task(
|
|
|
834
834
|
return ActiveTask(None, "none")
|
|
835
835
|
|
|
836
836
|
previous = resolve_active_task(repo_root, platform_input, platform)
|
|
837
|
-
if not previous.task_path or not previous.context_key:
|
|
837
|
+
if previous.source_type != "session" or not previous.task_path or not previous.context_key:
|
|
838
838
|
return previous
|
|
839
839
|
|
|
840
840
|
context_path = _context_path(repo_root, previous.context_key)
|
|
@@ -78,10 +78,10 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
|
|
78
78
|
journal_file = workspace_dir / f"{FILE_JOURNAL_PREFIX}1.md"
|
|
79
79
|
if not journal_file.exists():
|
|
80
80
|
today = datetime.now().strftime("%Y-%m-%d")
|
|
81
|
-
journal_content = f"""# Journal
|
|
81
|
+
journal_content = f"""# 日志(Journal)- {name}(第 1 部分)
|
|
82
82
|
|
|
83
|
-
> AI
|
|
84
|
-
> Started
|
|
83
|
+
> AI 开发会话日志
|
|
84
|
+
> 开始日期(Started): {today}
|
|
85
85
|
|
|
86
86
|
---
|
|
87
87
|
|
|
@@ -95,46 +95,46 @@ def init_developer(name: str, repo_root: Path | None = None) -> bool:
|
|
|
95
95
|
# Create index.md with markers for auto-update
|
|
96
96
|
index_file = workspace_dir / "index.md"
|
|
97
97
|
if not index_file.exists():
|
|
98
|
-
index_content = f"""# Workspace Index
|
|
98
|
+
index_content = f"""# 工作区索引(Workspace Index)- {name}
|
|
99
99
|
|
|
100
|
-
>
|
|
100
|
+
> AI 开发会话跟踪日志。
|
|
101
101
|
|
|
102
102
|
---
|
|
103
103
|
|
|
104
|
-
## Current Status
|
|
104
|
+
## 当前状态(Current Status)
|
|
105
105
|
|
|
106
106
|
<!-- @@@auto:current-status -->
|
|
107
|
-
-
|
|
108
|
-
-
|
|
109
|
-
-
|
|
107
|
+
- **活动文件(Active File)**: `journal-1.md`
|
|
108
|
+
- **会话总数(Total Sessions)**: 0
|
|
109
|
+
- **最近活动(Last Active)**: -
|
|
110
110
|
<!-- @@@/auto:current-status -->
|
|
111
111
|
|
|
112
112
|
---
|
|
113
113
|
|
|
114
|
-
## Active Documents
|
|
114
|
+
## 活动文档(Active Documents)
|
|
115
115
|
|
|
116
116
|
<!-- @@@auto:active-documents -->
|
|
117
|
-
| File | Lines | Status |
|
|
117
|
+
| 文件(File) | 行数(Lines) | 状态(Status) |
|
|
118
118
|
|------|-------|--------|
|
|
119
|
-
| `journal-1.md` | ~0 | Active |
|
|
119
|
+
| `journal-1.md` | ~0 | 活动(Active) |
|
|
120
120
|
<!-- @@@/auto:active-documents -->
|
|
121
121
|
|
|
122
122
|
---
|
|
123
123
|
|
|
124
|
-
## Session History
|
|
124
|
+
## 会话历史(Session History)
|
|
125
125
|
|
|
126
126
|
<!-- @@@auto:session-history -->
|
|
127
|
-
| # | Date | Title | Commits | Branch |
|
|
127
|
+
| # | 日期(Date) | 标题(Title) | 提交(Commits) | 分支(Branch) |
|
|
128
128
|
|---|------|-------|---------|--------|
|
|
129
129
|
<!-- @@@/auto:session-history -->
|
|
130
130
|
|
|
131
131
|
---
|
|
132
132
|
|
|
133
|
-
## Notes
|
|
133
|
+
## 备注(Notes)
|
|
134
134
|
|
|
135
|
-
-
|
|
136
|
-
-
|
|
137
|
-
-
|
|
135
|
+
- 会话会追加到日志文件
|
|
136
|
+
- 当前日志超过 2000 行时创建新的日志文件
|
|
137
|
+
- 使用 `add_session.py` 记录会话
|
|
138
138
|
"""
|
|
139
139
|
try:
|
|
140
140
|
index_file.write_text(index_content, encoding="utf-8")
|
|
@@ -10,7 +10,7 @@ from datetime import datetime, timezone
|
|
|
10
10
|
from pathlib import Path
|
|
11
11
|
from typing import Any
|
|
12
12
|
|
|
13
|
-
from .active_task import clear_active_task, clear_active_task_for_context, resolve_active_task, resolve_context_key, set_active_task
|
|
13
|
+
from .active_task import ActiveTask, clear_active_task, clear_active_task_for_context, resolve_active_task, resolve_context_key, set_active_task
|
|
14
14
|
from .continuation_record import (
|
|
15
15
|
ContinuationError,
|
|
16
16
|
SAFE_ID,
|
|
@@ -83,6 +83,9 @@ def _direct_context(root: Path, require_task: bool = False) -> tuple[str, Any]:
|
|
|
83
83
|
raise OwnershipError("session_fallback_untrusted")
|
|
84
84
|
if active.context_key != context_key:
|
|
85
85
|
raise OwnershipError("direct_session_identity_unstable")
|
|
86
|
+
if active.source_type in {"unbound", "unbound_ambiguous"}:
|
|
87
|
+
# Candidate paths are recovery context, never an owned session pointer.
|
|
88
|
+
active = ActiveTask(None, "none", context_key)
|
|
86
89
|
if require_task and not active.task_path:
|
|
87
90
|
raise OwnershipError("no_direct_current_task")
|
|
88
91
|
return context_key, active
|
|
@@ -273,14 +276,14 @@ def retire(root: Path, task_id: str, handoff_id: str, core_digest: str, expected
|
|
|
273
276
|
elif record["archive_observation"] != archive_observation:
|
|
274
277
|
raise OwnershipError("archive observation does not match retirement")
|
|
275
278
|
|
|
276
|
-
active =
|
|
279
|
+
_, active = _direct_context(root)
|
|
277
280
|
if active.context_key != actor:
|
|
278
281
|
raise OwnershipError("direct_session_identity_unstable")
|
|
279
282
|
if active.task_path == record["task"]["path"]:
|
|
280
283
|
clear_active_task(root)
|
|
281
284
|
elif active.task_path:
|
|
282
285
|
raise OwnershipError("source task pointer changed during retirement")
|
|
283
|
-
active =
|
|
286
|
+
_, active = _direct_context(root)
|
|
284
287
|
if active.context_key != actor or active.task_path:
|
|
285
288
|
raise OwnershipError("source task retirement could not be verified; recovery_required")
|
|
286
289
|
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
@@ -334,7 +337,7 @@ def claim(root: Path, task_id: str, task_path: str, handoff_id: str, core_digest
|
|
|
334
337
|
bound = set_active_task(record["task"]["path"], root)
|
|
335
338
|
if not bound:
|
|
336
339
|
raise OwnershipError("target task binding failed; recovery_required")
|
|
337
|
-
active =
|
|
340
|
+
_, active = _direct_context(root)
|
|
338
341
|
if active.context_key != actor or active.task_path != record["task"]["path"]:
|
|
339
342
|
raise OwnershipError("target task binding could not be verified; recovery_required")
|
|
340
343
|
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
@@ -361,7 +364,7 @@ def claim(root: Path, task_id: str, task_path: str, handoff_id: str, core_digest
|
|
|
361
364
|
bound = set_active_task(task["path"], root)
|
|
362
365
|
if not bound:
|
|
363
366
|
raise OwnershipError("target task binding failed; recovery_required")
|
|
364
|
-
active =
|
|
367
|
+
_, active = _direct_context(root)
|
|
365
368
|
if active.context_key != actor or active.task_path != task["path"]:
|
|
366
369
|
raise OwnershipError("target task binding could not be verified; recovery_required")
|
|
367
370
|
record["previous_event_digest"] = record["integrity"]["record_digest"]
|
|
@@ -416,17 +419,19 @@ def status(root: Path | None, task_id: str, handoff_id: str, core_digest: str) -
|
|
|
416
419
|
def assert_task_mutation_allowed(root: Path, task_path: Path) -> None:
|
|
417
420
|
"""Reject ordinary task pointer changes that would bypass ownership fencing."""
|
|
418
421
|
actor = resolve_context_key()
|
|
419
|
-
if not actor:
|
|
420
|
-
return
|
|
421
422
|
try:
|
|
422
423
|
task_id = _safe_task_id(task_path.name)
|
|
423
424
|
except ContinuationError:
|
|
424
425
|
return
|
|
425
426
|
relative = task_path.relative_to(root).as_posix()
|
|
426
427
|
records_dir = root / ".trellis" / ".runtime" / "handoff-ownership" / task_id
|
|
427
|
-
if
|
|
428
|
+
if records_dir.is_symlink():
|
|
429
|
+
raise OwnershipError("ownership record directory is unsafe")
|
|
430
|
+
if not records_dir.is_dir():
|
|
428
431
|
return
|
|
429
432
|
for record_path in sorted(records_dir.glob("*.json")):
|
|
433
|
+
if not actor:
|
|
434
|
+
raise OwnershipError("no_direct_session_identity: task has handoff ownership fencing")
|
|
430
435
|
if record_path.is_symlink():
|
|
431
436
|
raise OwnershipError("ownership record is unsafe")
|
|
432
437
|
record = _decode(record_path)
|
|
@@ -145,12 +145,13 @@ def safe_archive_paths_to_add(
|
|
|
145
145
|
repo_root: Path,
|
|
146
146
|
task_name: str | None = None,
|
|
147
147
|
modified_children: list[str] | None = None,
|
|
148
|
+
archive_dest: Path | None = None,
|
|
148
149
|
) -> list[str]:
|
|
149
150
|
"""Return paths to stage after `task.py archive`.
|
|
150
151
|
|
|
151
152
|
Scoped to ONLY the paths the archive operation actually touched:
|
|
152
153
|
|
|
153
|
-
- the archive
|
|
154
|
+
- the exact archive task directory (where the freshly-moved task lives)
|
|
154
155
|
- the source task directory (for source-side deletes; caller pairs
|
|
155
156
|
this with `git rm --cached` since `git add` won't stage deletes
|
|
156
157
|
for a path that no longer exists in the working tree)
|
|
@@ -174,14 +175,42 @@ def safe_archive_paths_to_add(
|
|
|
174
175
|
archive_dir = tasks_dir / DIR_ARCHIVE
|
|
175
176
|
|
|
176
177
|
if task_name is not None:
|
|
177
|
-
# Narrow scope — only
|
|
178
|
-
#
|
|
179
|
-
#
|
|
180
|
-
#
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
)
|
|
178
|
+
# Narrow scope — only the exact destination that the archive operation
|
|
179
|
+
# moved. The destination is passed by task_store, which already owns
|
|
180
|
+
# the move result. Validate it again so a caller cannot widen the
|
|
181
|
+
# auto-commit to the archive root or a sibling month/task directory.
|
|
182
|
+
candidate: Path | None = None
|
|
183
|
+
if archive_dest is not None:
|
|
184
|
+
candidate = archive_dest
|
|
185
|
+
if not candidate.is_absolute():
|
|
186
|
+
candidate = repo_root / candidate
|
|
187
|
+
elif archive_dir.is_dir():
|
|
188
|
+
# Compatibility for callers that only have a task name. An
|
|
189
|
+
# ambiguous historical layout fails closed instead of staging the
|
|
190
|
+
# whole archive tree.
|
|
191
|
+
matches = [
|
|
192
|
+
month_dir / task_name
|
|
193
|
+
for month_dir in sorted(archive_dir.iterdir())
|
|
194
|
+
if month_dir.is_dir() and (month_dir / task_name).is_dir()
|
|
195
|
+
]
|
|
196
|
+
if len(matches) == 1:
|
|
197
|
+
candidate = matches[0]
|
|
198
|
+
|
|
199
|
+
if candidate is not None and candidate.is_dir():
|
|
200
|
+
try:
|
|
201
|
+
candidate_resolved = candidate.resolve()
|
|
202
|
+
archive_resolved = archive_dir.resolve()
|
|
203
|
+
if (
|
|
204
|
+
candidate_resolved.name == task_name
|
|
205
|
+
and candidate_resolved.parent.parent == archive_resolved
|
|
206
|
+
):
|
|
207
|
+
relative = candidate_resolved.relative_to(repo_root.resolve())
|
|
208
|
+
paths.append(relative.as_posix())
|
|
209
|
+
except (OSError, RuntimeError, ValueError):
|
|
210
|
+
# An invalid or ambiguous destination must not widen scope.
|
|
211
|
+
pass
|
|
212
|
+
if not paths:
|
|
213
|
+
return []
|
|
185
214
|
for child_name in modified_children or []:
|
|
186
215
|
paths.append(f"{DIR_WORKFLOW}/{DIR_TASKS}/{child_name}")
|
|
187
216
|
return paths
|
|
@@ -0,0 +1,137 @@
|
|
|
1
|
+
"""Task-local material plan revisions; chat approval remains coordinator-owned."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime, timezone
|
|
6
|
+
from hashlib import sha256
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
class PlanningError(ValueError):
|
|
12
|
+
"""A planning contract needs correction before a task can start."""
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
def validate_meta_value(key: str, value: str) -> None:
|
|
16
|
+
if key == "planning" or key.startswith("planning."):
|
|
17
|
+
raise PlanningError("meta.planning is reserved; use task.py plan seal/approve")
|
|
18
|
+
choices = {
|
|
19
|
+
"execution_class": {"direct", "planned"},
|
|
20
|
+
"delivery_mode": {"change_bearing", "analysis_only"},
|
|
21
|
+
}
|
|
22
|
+
if key in choices and value not in choices[key]:
|
|
23
|
+
raise PlanningError(f"{key} must be one of: {', '.join(sorted(choices[key]))}")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def classification(data: dict[str, Any]) -> tuple[str, str]:
|
|
27
|
+
meta = data.get("meta", {})
|
|
28
|
+
if not isinstance(meta, dict):
|
|
29
|
+
raise PlanningError("task meta must be an object")
|
|
30
|
+
execution = meta.get("execution_class")
|
|
31
|
+
delivery = meta.get("delivery_mode")
|
|
32
|
+
if execution not in ("direct", "planned") or delivery not in ("change_bearing", "analysis_only"):
|
|
33
|
+
raise PlanningError("classify this task with execution_class=direct|planned and delivery_mode=change_bearing|analysis_only before start")
|
|
34
|
+
return execution, delivery
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def planning_record(data: dict[str, Any]) -> dict[str, Any]:
|
|
38
|
+
meta = data.get("meta", {})
|
|
39
|
+
if not isinstance(meta, dict):
|
|
40
|
+
raise PlanningError("task meta must be an object")
|
|
41
|
+
record = meta.get("planning", {})
|
|
42
|
+
if not isinstance(record, dict):
|
|
43
|
+
raise PlanningError("meta.planning must be an object")
|
|
44
|
+
revision = record.get("revision", 1)
|
|
45
|
+
if isinstance(revision, bool) or not isinstance(revision, int) or revision < 1:
|
|
46
|
+
raise PlanningError("planning revision must be a positive integer")
|
|
47
|
+
return {**record, "revision": revision}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
def invalidate_plan(data: dict[str, Any]) -> None:
|
|
51
|
+
record = planning_record(data)
|
|
52
|
+
data.setdefault("meta", {})["planning"] = {"revision": record["revision"] + 1}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def _plan_documents(task_dir: Path, execution: str, delivery: str) -> list[str]:
|
|
56
|
+
names = ["prd.md"]
|
|
57
|
+
if execution == "planned":
|
|
58
|
+
names.append("design.md")
|
|
59
|
+
if delivery == "change_bearing":
|
|
60
|
+
names.append("implement.md")
|
|
61
|
+
for name in names:
|
|
62
|
+
document = task_dir / name
|
|
63
|
+
if document.is_symlink() or not document.is_file():
|
|
64
|
+
raise PlanningError(f"plan requires a regular task document: {name}")
|
|
65
|
+
if document.stat().st_size > 256 * 1024 or not document.read_text(encoding="utf-8").strip():
|
|
66
|
+
raise PlanningError(f"plan document must be bounded, non-empty UTF-8: {name}")
|
|
67
|
+
return names
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def seal_plan(data: dict[str, Any], task_dir: Path) -> int:
|
|
71
|
+
execution, delivery = classification(data)
|
|
72
|
+
documents = _plan_documents(task_dir, execution, delivery)
|
|
73
|
+
record = planning_record(data)
|
|
74
|
+
# Resealing an already sealed plan declares a material new version. A replan
|
|
75
|
+
# already advanced the revision; its first seal keeps that revision.
|
|
76
|
+
revision = record["revision"] + (1 if record.get("seal") else 0)
|
|
77
|
+
data.setdefault("meta", {})["planning"] = {
|
|
78
|
+
"revision": revision,
|
|
79
|
+
"seal": {
|
|
80
|
+
"revision": revision,
|
|
81
|
+
"task_id": data.get("id") or task_dir.name,
|
|
82
|
+
"task_name": task_dir.name,
|
|
83
|
+
"documents": documents,
|
|
84
|
+
"document_digests": {name: sha256((task_dir / name).read_bytes()).hexdigest() for name in documents},
|
|
85
|
+
"execution_class": execution,
|
|
86
|
+
"delivery_mode": delivery,
|
|
87
|
+
"sealed_at": datetime.now(timezone.utc).isoformat(),
|
|
88
|
+
},
|
|
89
|
+
}
|
|
90
|
+
return revision
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
def _require_seal(data: dict[str, Any], task_dir: Path) -> dict[str, Any]:
|
|
94
|
+
execution, delivery = classification(data)
|
|
95
|
+
record = planning_record(data)
|
|
96
|
+
seal = record.get("seal")
|
|
97
|
+
if not isinstance(seal, dict) or type(seal.get("revision")) is not int or seal.get("revision") != record["revision"]:
|
|
98
|
+
raise PlanningError("current material plan is not sealed; use task.py plan seal")
|
|
99
|
+
if seal.get("task_id") != (data.get("id") or task_dir.name) or seal.get("task_name") != task_dir.name:
|
|
100
|
+
raise PlanningError("sealed plan belongs to another task")
|
|
101
|
+
if seal.get("execution_class") != execution or seal.get("delivery_mode") != delivery:
|
|
102
|
+
raise PlanningError("plan classification changed; reseal the material plan")
|
|
103
|
+
if seal.get("documents") != _plan_documents(task_dir, execution, delivery):
|
|
104
|
+
raise PlanningError("sealed plan documents do not match this task")
|
|
105
|
+
if seal.get("document_digests") != {name: sha256((task_dir / name).read_bytes()).hexdigest() for name in seal["documents"]}:
|
|
106
|
+
raise PlanningError("sealed plan content changed or lacks content digests; reseal the material plan")
|
|
107
|
+
return record
|
|
108
|
+
|
|
109
|
+
|
|
110
|
+
def approve_plan(data: dict[str, Any], task_dir: Path, revision: int, basis: str) -> None:
|
|
111
|
+
record = _require_seal(data, task_dir)
|
|
112
|
+
if revision != record["revision"]:
|
|
113
|
+
raise PlanningError(f"approval revision must match current revision {record['revision']}")
|
|
114
|
+
if not basis.strip() or len(basis) > 1024 or "\x00" in basis:
|
|
115
|
+
raise PlanningError("approval basis must be non-empty bounded text without credentials")
|
|
116
|
+
record.update({
|
|
117
|
+
"approved_revision": revision,
|
|
118
|
+
"approval_basis": basis.strip(),
|
|
119
|
+
"approved_at": datetime.now(timezone.utc).isoformat(),
|
|
120
|
+
})
|
|
121
|
+
data["meta"]["planning"] = record
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def require_start_approval(data: dict[str, Any], task_dir: Path) -> None:
|
|
125
|
+
if data.get("status") not in {"planning", "in_progress"}:
|
|
126
|
+
raise PlanningError("start requires an active planning or in_progress task")
|
|
127
|
+
# Existing running tasks retain their authorization; only crossing the
|
|
128
|
+
# planning boundary is gated. Selecting a task never crosses that boundary.
|
|
129
|
+
if data.get("status") == "in_progress":
|
|
130
|
+
return
|
|
131
|
+
execution, delivery = classification(data)
|
|
132
|
+
if delivery == "analysis_only":
|
|
133
|
+
raise PlanningError("analysis_only tasks remain in planning and cannot be started")
|
|
134
|
+
if execution == "planned" and delivery == "change_bearing":
|
|
135
|
+
record = _require_seal(data, task_dir)
|
|
136
|
+
if type(record.get("approved_revision")) is not int or record.get("approved_revision") != record["revision"] or not record.get("approval_basis"):
|
|
137
|
+
raise PlanningError("current sealed plan has no matching later implementation approval; use task.py plan approve only after the user's approval")
|