@sakanano-moe/agenthelm 6.6.0 → 6.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +68 -0
- package/README.zh-CN.md +28 -0
- package/package.json +2 -2
- package/runtime/index.json +15 -15
- package/runtime/plugins/agent-pack/.codex-plugin/plugin.json +11 -4
- package/runtime/plugins/agent-pack/plugin.json +1 -1
- package/runtime/plugins/agent-pack/references/capability-config.md +29 -5
- package/runtime/plugins/agent-pack/runtime/agent_pack_claude.py +448 -0
- package/runtime/plugins/agent-pack/runtime/agent_pack_config.py +178 -22
- package/runtime/plugins/agent-pack/runtime/agenthelm.py +237 -6
- package/runtime/plugins/agent-pack/runtime/agenthelm_runtime.py +19 -0
- package/runtime/plugins/agent-pack/runtime/mcp_server.py +30 -8
- package/runtime/plugins/agent-pack/skills/agent-pack/SKILL.md +2 -2
- package/runtime/plugins/agents-init/.codex-plugin/plugin.json +5 -5
- package/runtime/plugins/agents-init/content.json +15 -0
- package/runtime/plugins/agents-init/plugin.json +2 -2
- package/runtime/plugins/agents-init/skills/agents-init/SKILL.md +14 -1
- package/runtime/plugins/agents-init/skills/agents-init/project.schema.json +18 -3
- package/runtime/plugins/agents-init/skills/agents-init/scripts/init_project.py +43 -4
- package/runtime/plugins/agents-init/skills/agents-init/scripts/scaffold.py +933 -0
- package/runtime/plugins/agents-init/skills/agents-init/scripts/test_scaffold.py +551 -0
- package/runtime/plugins/agents-init/skills/agents-init/skeleton.json +64 -0
- package/runtime/plugins/agents-init/skills/agents-init/skill.json +3 -3
- package/runtime/plugins/opinion-manager/.codex-plugin/plugin.json +5 -5
- package/runtime/plugins/opinion-manager/plugin.json +1 -1
- package/runtime/plugins/opinion-manager/skills/opinion-manager/SKILL.md +1 -1
- package/runtime/plugins/opinion-manager/skills/opinion-manager/agents/openai.yaml +2 -2
- package/runtime/plugins/project-orchestrator/.codex-plugin/plugin.json +10 -4
- package/runtime/plugins/project-orchestrator/plugin.json +1 -1
- package/runtime/plugins/project-orchestrator/runtime/project_cli.py +69 -9
- package/runtime/plugins/project-orchestrator/runtime/project_maintain.py +380 -27
- package/runtime/plugins/project-orchestrator/runtime/project_menu.py +18 -0
- package/runtime/plugins/project-orchestrator/runtime/project_real.py +7 -1
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/SKILL.md +1 -1
- package/runtime/plugins/project-orchestrator/skills/project-orchestrator/agents/openai.yaml +2 -2
|
@@ -0,0 +1,551 @@
|
|
|
1
|
+
"""脚手架一致性:期望骨架清单、受检 Profile 字段、内容锁与用户决定。
|
|
2
|
+
|
|
3
|
+
所有项目都在临时目录里造;“旧版项目”用当前初始化器生成后删文件、改 Profile、改内容锁来模拟。
|
|
4
|
+
"""
|
|
5
|
+
from __future__ import annotations
|
|
6
|
+
|
|
7
|
+
import hashlib
|
|
8
|
+
import json
|
|
9
|
+
import subprocess
|
|
10
|
+
import sys
|
|
11
|
+
import tempfile
|
|
12
|
+
import unittest
|
|
13
|
+
from pathlib import Path
|
|
14
|
+
|
|
15
|
+
SCRIPT = Path(__file__).with_name("init_project.py")
|
|
16
|
+
sys.path.insert(0, str(SCRIPT.parent))
|
|
17
|
+
import init_project # noqa: E402
|
|
18
|
+
import scaffold # noqa: E402
|
|
19
|
+
|
|
20
|
+
scaffold.bind(init_project)
|
|
21
|
+
PROFILE = init_project.PROFILE_PATH
|
|
22
|
+
LOCK = init_project.CONTENT_LOCK_PATH
|
|
23
|
+
DECISIONS = scaffold.DECISIONS_PATH
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
def sha(path: Path) -> str:
|
|
27
|
+
return hashlib.sha256(path.read_bytes()).hexdigest()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def snapshot(root: Path) -> dict[str, str]:
|
|
31
|
+
return {p.relative_to(root).as_posix(): sha(p) for p in sorted(root.rglob("*")) if p.is_file()}
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ScaffoldCase(unittest.TestCase):
|
|
35
|
+
def setUp(self) -> None:
|
|
36
|
+
self._temporary = tempfile.TemporaryDirectory()
|
|
37
|
+
self.addCleanup(self._temporary.cleanup)
|
|
38
|
+
base = Path(self._temporary.name).resolve()
|
|
39
|
+
self.root = base / "sample-project"
|
|
40
|
+
self.root.mkdir()
|
|
41
|
+
self.state = base / "state"
|
|
42
|
+
self.recovery = base / "recovery"
|
|
43
|
+
|
|
44
|
+
def init(self, *extra: str, name: str = "Sample Project") -> None:
|
|
45
|
+
completed = subprocess.run(
|
|
46
|
+
[sys.executable, str(SCRIPT), "--project", str(self.root), "--name", name, "--project-type", "notes",
|
|
47
|
+
"--vcs", "none", "--stack", "markdown", "--runtime", "local", "--agent-cli", "claude",
|
|
48
|
+
"--output", "json", "--apply", *extra],
|
|
49
|
+
capture_output=True, text=True, check=False)
|
|
50
|
+
self.assertEqual(completed.returncode, 0, completed.stdout + completed.stderr)
|
|
51
|
+
|
|
52
|
+
def run_mode(self, mode: str, *extra: str) -> tuple[int, dict]:
|
|
53
|
+
completed = subprocess.run(
|
|
54
|
+
[sys.executable, str(SCRIPT), "--project", str(self.root), "--mode", mode, "--output", "json",
|
|
55
|
+
"--state-dir", str(self.state), *extra], capture_output=True, text=True, check=False)
|
|
56
|
+
return completed.returncode, json.loads(completed.stdout)
|
|
57
|
+
|
|
58
|
+
def check(self, **kwargs) -> dict:
|
|
59
|
+
return scaffold.check(self.root, state_dir=self.state, **kwargs)
|
|
60
|
+
|
|
61
|
+
def ids(self, report: dict) -> set[str]:
|
|
62
|
+
return {item["id"] for item in report["findings"]}
|
|
63
|
+
|
|
64
|
+
def old_project(self) -> None:
|
|
65
|
+
"""A project as an older initializer left it: no launcher, no lock, four scaffold files missing."""
|
|
66
|
+
self.init("--no-entry")
|
|
67
|
+
for relative in (".agents/plugin.json", ".agents/mcp.json", "docs/refs/README.md"):
|
|
68
|
+
(self.root / relative).unlink()
|
|
69
|
+
(self.root / "docs/drafts/.gitkeep").unlink()
|
|
70
|
+
(self.root / "docs/drafts").rmdir()
|
|
71
|
+
lock = self.root / LOCK
|
|
72
|
+
if lock.exists():
|
|
73
|
+
lock.unlink()
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class ManifestTests(ScaffoldCase):
|
|
77
|
+
def test_manifest_matches_what_the_initializer_renders(self) -> None:
|
|
78
|
+
for extra in ((), ("--no-entry",), ("--work-mode", "legacy", "--no-entry")):
|
|
79
|
+
with self.subTest(extra=extra):
|
|
80
|
+
self.setUp()
|
|
81
|
+
self.init(*extra)
|
|
82
|
+
ctx = scaffold.Context(self.root)
|
|
83
|
+
expected = {path.as_posix() for path in ctx.rendered} - {PROFILE.as_posix()}
|
|
84
|
+
self.assertEqual({entry["path"] for entry in ctx.entries()}, expected)
|
|
85
|
+
|
|
86
|
+
def test_lock_compared_entries_are_exactly_the_tracked_templates(self) -> None:
|
|
87
|
+
self.init()
|
|
88
|
+
ctx = scaffold.Context(self.root)
|
|
89
|
+
locked = {entry["path"] for entry in ctx.entries() if entry["compare"] == "lock"}
|
|
90
|
+
self.assertEqual(locked, ctx.tracked)
|
|
91
|
+
|
|
92
|
+
def test_every_profile_rule_points_at_a_field_the_initializer_writes_or_is_manual(self) -> None:
|
|
93
|
+
self.init()
|
|
94
|
+
ctx = scaffold.Context(self.root)
|
|
95
|
+
for rule in ctx.manifest["profile"]:
|
|
96
|
+
present, _ = scaffold._get(ctx.fresh, rule["path"])
|
|
97
|
+
self.assertTrue(present or rule["mode"] == "manual", rule["path"])
|
|
98
|
+
|
|
99
|
+
def test_fresh_project_is_consistent(self) -> None:
|
|
100
|
+
self.init()
|
|
101
|
+
report = self.check()
|
|
102
|
+
self.assertEqual(report["findings"], [], report["findings"])
|
|
103
|
+
self.assertTrue(report["ok"])
|
|
104
|
+
|
|
105
|
+
def test_opinion_and_audit_fields_are_not_compared(self) -> None:
|
|
106
|
+
self.init()
|
|
107
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
108
|
+
profile["opinion"]["strict_mode"] = "strict"
|
|
109
|
+
profile["initializer_version"] = "0.1.0"
|
|
110
|
+
profile["runtime"]["source_ref"] = "v1.0.0"
|
|
111
|
+
(self.root / PROFILE).write_text(json.dumps(profile))
|
|
112
|
+
(self.root / "OPINION.md").write_text("own rules\n")
|
|
113
|
+
report = self.check()
|
|
114
|
+
self.assertEqual(report["findings"], [])
|
|
115
|
+
self.assertNotIn("OPINION.md", {item["path"] for item in report["skeleton"] if item["state"] != "ignored"})
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class SkeletonTests(ScaffoldCase):
|
|
119
|
+
def test_missing_files_are_listed_and_fixed_one_by_one(self) -> None:
|
|
120
|
+
self.old_project()
|
|
121
|
+
report = self.check()
|
|
122
|
+
self.assertEqual(self.ids(report), {"skel:.agents/plugin.json", "skel:.agents/mcp.json",
|
|
123
|
+
"skel:docs/refs/README.md", "skel:docs/drafts/.gitkeep"})
|
|
124
|
+
before = snapshot(self.root)
|
|
125
|
+
preview = scaffold.add_skeleton(self.root, [".agents/mcp.json"], state_dir=self.state)
|
|
126
|
+
self.assertFalse(preview["applied"])
|
|
127
|
+
self.assertEqual(snapshot(self.root), before)
|
|
128
|
+
scaffold.add_skeleton(self.root, [".agents/mcp.json"], apply=True, state_dir=self.state)
|
|
129
|
+
self.assertEqual(len(self.ids(self.check())), 3)
|
|
130
|
+
scaffold.add_skeleton(self.root, None, apply=True, state_dir=self.state)
|
|
131
|
+
self.assertEqual(self.check()["findings"], [])
|
|
132
|
+
self.assertEqual(json.loads((self.root / ".agents/mcp.json").read_text())["mcpServers"], {})
|
|
133
|
+
|
|
134
|
+
def test_existing_files_are_never_touched(self) -> None:
|
|
135
|
+
self.init("--no-entry")
|
|
136
|
+
(self.root / "AGENTS.md").write_text("my own contract\n")
|
|
137
|
+
(self.root / ".agents/plugin.json").write_text('{"name": "mine"}')
|
|
138
|
+
result = scaffold.add_skeleton(self.root, ["AGENTS.md", ".agents/plugin.json"], apply=True, state_dir=self.state)
|
|
139
|
+
self.assertEqual(result["create"], [])
|
|
140
|
+
self.assertEqual((self.root / "AGENTS.md").read_text(), "my own contract\n")
|
|
141
|
+
self.assertEqual((self.root / ".agents/plugin.json").read_text(), '{"name": "mine"}')
|
|
142
|
+
|
|
143
|
+
def test_unknown_path_is_refused(self) -> None:
|
|
144
|
+
self.old_project()
|
|
145
|
+
with self.assertRaises(init_project.InitError):
|
|
146
|
+
scaffold.add_skeleton(self.root, ["notes/random.md"], state_dir=self.state)
|
|
147
|
+
|
|
148
|
+
def test_check_never_writes(self) -> None:
|
|
149
|
+
self.old_project()
|
|
150
|
+
before = snapshot(self.root)
|
|
151
|
+
self.check()
|
|
152
|
+
self.run_mode("check")
|
|
153
|
+
self.assertEqual(snapshot(self.root), before)
|
|
154
|
+
self.assertFalse(self.state.exists())
|
|
155
|
+
|
|
156
|
+
def test_a_directory_that_has_other_content_counts_as_present(self) -> None:
|
|
157
|
+
self.init("--no-entry")
|
|
158
|
+
(self.root / "docs/drafts/.gitkeep").unlink()
|
|
159
|
+
(self.root / "docs/drafts/idea.md").write_text("x\n")
|
|
160
|
+
self.assertEqual(self.check()["findings"], [])
|
|
161
|
+
|
|
162
|
+
def test_custom_roots_in_the_profile_are_honoured(self) -> None:
|
|
163
|
+
self.init("--no-entry")
|
|
164
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
165
|
+
profile["work"]["case"]["root"] = "work-items"
|
|
166
|
+
profile["memory"]["root"] = "notes-memory"
|
|
167
|
+
(self.root / PROFILE).write_text(json.dumps(profile))
|
|
168
|
+
self.assertEqual(self.ids(self.check()), {"skel:work-items/.gitkeep", "skel:notes-memory/.gitkeep"})
|
|
169
|
+
|
|
170
|
+
def test_a_non_ascii_project_name_does_not_break_the_check(self) -> None:
|
|
171
|
+
self.init("--no-entry", "--slug", "sample-notes", name="样例笔记")
|
|
172
|
+
(self.root / ".agents/plugin.json").unlink()
|
|
173
|
+
self.assertEqual(self.ids(self.check()), {"skel:.agents/plugin.json"})
|
|
174
|
+
|
|
175
|
+
def test_cli_modes_and_exit_codes(self) -> None:
|
|
176
|
+
self.old_project()
|
|
177
|
+
code, report = self.run_mode("check")
|
|
178
|
+
self.assertEqual(code, 1)
|
|
179
|
+
self.assertEqual(len(report["findings"]), 4)
|
|
180
|
+
code, preview = self.run_mode("add-skeleton")
|
|
181
|
+
self.assertEqual((code, preview["applied"]), (0, False))
|
|
182
|
+
code, applied = self.run_mode("add-skeleton", "--apply")
|
|
183
|
+
self.assertEqual((code, applied["applied"]), (0, True))
|
|
184
|
+
self.assertEqual(self.run_mode("check")[0], 0)
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
class DecisionTests(ScaffoldCase):
|
|
188
|
+
def test_skip_is_remembered_by_another_machine_and_listed(self) -> None:
|
|
189
|
+
self.old_project()
|
|
190
|
+
identifier = "skel:docs/drafts/.gitkeep"
|
|
191
|
+
preview = scaffold.decide(self.root, identifier, "skip", reason="no drafts here", state_dir=self.state)
|
|
192
|
+
self.assertFalse(preview["applied"])
|
|
193
|
+
self.assertFalse((self.root / DECISIONS).exists())
|
|
194
|
+
scaffold.decide(self.root, identifier, "skip", reason="no drafts here", apply=True, state_dir=self.state)
|
|
195
|
+
other_machine = Path(self._temporary.name) / "other-state"
|
|
196
|
+
report = scaffold.check(self.root, state_dir=other_machine)
|
|
197
|
+
self.assertNotIn(identifier, self.ids(report))
|
|
198
|
+
skipped = {item["id"]: item for item in report["skipped"]}
|
|
199
|
+
self.assertEqual(skipped[identifier]["decision"]["reason"], "no drafts here")
|
|
200
|
+
self.assertFalse(other_machine.exists())
|
|
201
|
+
|
|
202
|
+
def test_changed_expectation_asks_again_and_reprompt_forces_it(self) -> None:
|
|
203
|
+
self.old_project()
|
|
204
|
+
identifier = "skel:docs/refs/README.md"
|
|
205
|
+
scaffold.decide(self.root, identifier, "skip", apply=True, state_dir=self.state)
|
|
206
|
+
self.assertNotIn(identifier, self.ids(self.check()))
|
|
207
|
+
self.assertIn(identifier, self.ids(self.check(reprompt=True)))
|
|
208
|
+
scaffold.decide(self.root, identifier, "skip", fingerprint="template-as-it-was", apply=True, state_dir=self.state)
|
|
209
|
+
again = {item["id"]: item for item in self.check()["findings"]}
|
|
210
|
+
self.assertIn("changed_since_decision", again[identifier])
|
|
211
|
+
|
|
212
|
+
def test_later_does_not_hide_the_finding(self) -> None:
|
|
213
|
+
self.old_project()
|
|
214
|
+
scaffold.decide(self.root, "skel:.agents/mcp.json", "later", apply=True, state_dir=self.state)
|
|
215
|
+
found = {item["id"]: item for item in self.check()["findings"]}
|
|
216
|
+
self.assertIn("deferred", found["skel:.agents/mcp.json"])
|
|
217
|
+
|
|
218
|
+
def test_unskip_restores_the_finding(self) -> None:
|
|
219
|
+
self.old_project()
|
|
220
|
+
scaffold.decide(self.root, "skel:.agents/mcp.json", "skip", apply=True, state_dir=self.state)
|
|
221
|
+
scaffold.undecide(self.root, "skel:.agents/mcp.json", apply=True, state_dir=self.state)
|
|
222
|
+
self.assertIn("skel:.agents/mcp.json", self.ids(self.check()))
|
|
223
|
+
|
|
224
|
+
def test_unknown_finding_cannot_be_skipped(self) -> None:
|
|
225
|
+
self.init("--no-entry")
|
|
226
|
+
with self.assertRaises(init_project.InitError):
|
|
227
|
+
scaffold.decide(self.root, "skel:nothing", "skip", state_dir=self.state)
|
|
228
|
+
|
|
229
|
+
def test_machine_decisions_stay_out_of_the_project(self) -> None:
|
|
230
|
+
self.init("--no-entry")
|
|
231
|
+
before = snapshot(self.root)
|
|
232
|
+
scaffold.decide(self.root, "local:runtime-missing", "skip", fingerprint="runtime-missing", reason="dev box",
|
|
233
|
+
apply=True, state_dir=self.state)
|
|
234
|
+
self.assertEqual(snapshot(self.root), before)
|
|
235
|
+
decisions = scaffold.load_decisions(self.root, self.state)
|
|
236
|
+
findings = [{"id": "local:runtime-missing", "scope": "machine", "fingerprint": "runtime-missing"}]
|
|
237
|
+
active, skipped = scaffold.partition(findings, decisions)
|
|
238
|
+
self.assertEqual((active, len(skipped)), ([], 1))
|
|
239
|
+
|
|
240
|
+
def test_a_decision_file_changed_elsewhere_is_not_overwritten(self) -> None:
|
|
241
|
+
self.old_project()
|
|
242
|
+
scaffold.decide(self.root, "skel:.agents/mcp.json", "skip", apply=True, state_dir=self.state)
|
|
243
|
+
path = self.root / DECISIONS
|
|
244
|
+
table, current, _ = scaffold.read_decisions(path)
|
|
245
|
+
path.write_text(path.read_text() + "\n") # another machine's sync landed in between
|
|
246
|
+
with self.assertRaises(init_project.InitError) as caught:
|
|
247
|
+
scaffold._store(path, table, current, self.root, "project")
|
|
248
|
+
self.assertEqual(caught.exception.code, "conflict")
|
|
249
|
+
|
|
250
|
+
def test_a_broken_decision_file_is_reported_not_fatal(self) -> None:
|
|
251
|
+
self.old_project()
|
|
252
|
+
(self.root / DECISIONS).write_text("{not json")
|
|
253
|
+
report = self.check()
|
|
254
|
+
self.assertTrue(report["decisions"]["errors"])
|
|
255
|
+
self.assertEqual(len(report["findings"]), 4)
|
|
256
|
+
|
|
257
|
+
def test_sync_conflict_copies_are_reported(self) -> None:
|
|
258
|
+
self.init("--no-entry")
|
|
259
|
+
directory = (self.root / DECISIONS).parent
|
|
260
|
+
(directory / "check-decisions 2.json").write_text("{}")
|
|
261
|
+
self.assertEqual(self.check()["decisions"]["conflict_copies"], ["check-decisions 2.json"])
|
|
262
|
+
|
|
263
|
+
def test_decide_cli(self) -> None:
|
|
264
|
+
self.old_project()
|
|
265
|
+
code, result = self.run_mode("decide", "--target", "skel:.agents/mcp.json", "--decision", "skip",
|
|
266
|
+
"--reason", "not needed", "--apply")
|
|
267
|
+
self.assertEqual((code, result["applied"]), (0, True))
|
|
268
|
+
self.assertNotIn("skel:.agents/mcp.json", self.ids(self.check()))
|
|
269
|
+
code, result = self.run_mode("decide", "--target", "skel:.agents/mcp.json", "--decision", "unskip", "--apply")
|
|
270
|
+
self.assertEqual((code, result["applied"]), (0, True))
|
|
271
|
+
self.assertIn("skel:.agents/mcp.json", self.ids(self.check()))
|
|
272
|
+
|
|
273
|
+
|
|
274
|
+
class ProfileTests(ScaffoldCase):
|
|
275
|
+
def drop(self, *paths: str) -> None:
|
|
276
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
277
|
+
for dotted in paths:
|
|
278
|
+
parts = dotted.split(".")
|
|
279
|
+
node = profile
|
|
280
|
+
for part in parts[:-1]:
|
|
281
|
+
node = node[part]
|
|
282
|
+
node.pop(parts[-1])
|
|
283
|
+
(self.root / PROFILE).write_text(json.dumps(profile, indent=2))
|
|
284
|
+
|
|
285
|
+
def test_missing_fields_are_found_and_fixed_without_touching_the_rest(self) -> None:
|
|
286
|
+
self.init("--no-entry")
|
|
287
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
288
|
+
profile["work"]["case"]["directory_naming"] = {"style": "date-topic"}
|
|
289
|
+
profile["custom"] = {"anything": 1}
|
|
290
|
+
(self.root / PROFILE).write_text(json.dumps(profile, indent=2))
|
|
291
|
+
self.drop("runtime.commit_policy", "privacy")
|
|
292
|
+
report = self.check()
|
|
293
|
+
self.assertEqual(self.ids(report), {"profile:runtime.commit_policy", "profile:privacy.forbidden_default_reads",
|
|
294
|
+
"profile:privacy.generated_outputs"})
|
|
295
|
+
preview = scaffold.fix_profile(self.root, ["runtime.commit_policy"], state_dir=self.state)
|
|
296
|
+
self.assertIn('+ "commit_policy": "explicit"', preview["diff"])
|
|
297
|
+
self.assertEqual(preview["fields"], ["runtime.commit_policy"])
|
|
298
|
+
with self.assertRaises(init_project.InitError): # replacing the Profile needs a backup location
|
|
299
|
+
scaffold.fix_profile(self.root, ["runtime.commit_policy"], apply=True, state_dir=self.state)
|
|
300
|
+
applied = scaffold.fix_profile(self.root, ["runtime.commit_policy"], apply=True, recovery_dir=self.recovery,
|
|
301
|
+
state_dir=self.state)
|
|
302
|
+
self.assertTrue(applied["applied"])
|
|
303
|
+
updated = json.loads((self.root / PROFILE).read_text())
|
|
304
|
+
self.assertEqual(updated["runtime"]["commit_policy"], "explicit")
|
|
305
|
+
self.assertNotIn("privacy", updated) # not selected, not touched
|
|
306
|
+
self.assertEqual(updated["custom"], {"anything": 1})
|
|
307
|
+
self.assertEqual(updated["work"]["case"]["directory_naming"], {"style": "date-topic"})
|
|
308
|
+
self.assertTrue((self.recovery / PROFILE).is_file())
|
|
309
|
+
|
|
310
|
+
def test_project_own_fields_are_listed_but_never_flagged(self) -> None:
|
|
311
|
+
self.init("--no-entry")
|
|
312
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
313
|
+
profile["work"]["case"]["something_of_mine"] = True
|
|
314
|
+
profile["team_notes"] = {"a": 1}
|
|
315
|
+
(self.root / PROFILE).write_text(json.dumps(profile))
|
|
316
|
+
report = self.check()
|
|
317
|
+
self.assertEqual(report["findings"], [])
|
|
318
|
+
own = {item["path"] for item in report["extras"] if item["kind"] == "project-field"}
|
|
319
|
+
self.assertTrue({"team_notes", "work.case.something_of_mine"} <= own)
|
|
320
|
+
|
|
321
|
+
def test_wrong_identity_value_is_flagged_and_manual_fields_have_no_fix(self) -> None:
|
|
322
|
+
self.init("--no-entry")
|
|
323
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
324
|
+
profile["runtime"]["capability_manager"] = "something-else"
|
|
325
|
+
profile.pop("name")
|
|
326
|
+
(self.root / PROFILE).write_text(json.dumps(profile))
|
|
327
|
+
found = {item["id"]: item for item in self.check()["findings"]}
|
|
328
|
+
self.assertEqual(found["profile:runtime.capability_manager"]["fix"], "profile")
|
|
329
|
+
self.assertIsNone(found["profile:name"]["fix"])
|
|
330
|
+
|
|
331
|
+
def test_profile_fix_cli_preview_writes_nothing(self) -> None:
|
|
332
|
+
self.init("--no-entry")
|
|
333
|
+
self.drop("memory.root")
|
|
334
|
+
before = snapshot(self.root)
|
|
335
|
+
code, result = self.run_mode("profile")
|
|
336
|
+
self.assertEqual((code, result["applied"], result["fields"]), (0, False, ["memory.root"]))
|
|
337
|
+
self.assertEqual(snapshot(self.root), before)
|
|
338
|
+
|
|
339
|
+
|
|
340
|
+
class LockTests(ScaffoldCase):
|
|
341
|
+
def lock_with_unknown_version(self, files: dict[str, str]) -> None:
|
|
342
|
+
path = self.root / LOCK
|
|
343
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
344
|
+
path.write_text(json.dumps({"schema_version": 1, "packs": {
|
|
345
|
+
"agents-init/project-contract": {"plugin": "agents-init", "version": "0.0.0", "files": files}}}))
|
|
346
|
+
|
|
347
|
+
def entry_version(self) -> str:
|
|
348
|
+
return json.loads((self.root / LOCK).read_text())["packs"]["agents-init/project-contract"]["version"]
|
|
349
|
+
|
|
350
|
+
def test_entry_mode_writes_the_real_version_not_zero(self) -> None:
|
|
351
|
+
self.old_project()
|
|
352
|
+
completed = subprocess.run(
|
|
353
|
+
[sys.executable, str(SCRIPT), "--project", str(self.root), "--mode", "entry", "--entry-name", "menu",
|
|
354
|
+
"--apply", "--output", "json"], capture_output=True, text=True, check=False)
|
|
355
|
+
self.assertEqual(completed.returncode, 0, completed.stdout)
|
|
356
|
+
self.assertEqual(self.entry_version(), init_project.contract_pack()["version"])
|
|
357
|
+
|
|
358
|
+
def test_existing_zero_version_is_recognised_and_repaired(self) -> None:
|
|
359
|
+
self.init("--no-entry")
|
|
360
|
+
ctx = scaffold.Context(self.root)
|
|
361
|
+
launcher = init_project.launcher_text()
|
|
362
|
+
(self.root / "menu").write_text(launcher)
|
|
363
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
364
|
+
profile["entry"] = {"api": 1, "launcher": "menu"}
|
|
365
|
+
(self.root / PROFILE).write_text(json.dumps(profile))
|
|
366
|
+
self.lock_with_unknown_version({"menu": hashlib.sha256(launcher.encode()).hexdigest()})
|
|
367
|
+
found = {item["id"]: item for item in self.check()["findings"]}
|
|
368
|
+
lock_finding = found["lock:agents-init/project-contract"]
|
|
369
|
+
self.assertEqual(lock_finding["fix"], "repair-lock")
|
|
370
|
+
before = (self.root / LOCK).read_text()
|
|
371
|
+
preview = scaffold.repair_lock(self.root)
|
|
372
|
+
self.assertEqual((self.root / LOCK).read_text(), before)
|
|
373
|
+
self.assertTrue(any("0.0.0" in change for change in preview["changes"]))
|
|
374
|
+
scaffold.repair_lock(self.root, apply=True)
|
|
375
|
+
self.assertEqual(self.entry_version(), ctx.pack["version"])
|
|
376
|
+
self.assertNotIn("lock:agents-init/project-contract", self.ids(self.check()))
|
|
377
|
+
|
|
378
|
+
def test_zero_version_with_unrecognised_baselines_is_not_guessed(self) -> None:
|
|
379
|
+
self.init("--no-entry")
|
|
380
|
+
self.lock_with_unknown_version({"AGENTS.md": "0" * 64})
|
|
381
|
+
found = {item["id"]: item for item in self.check()["findings"]}
|
|
382
|
+
self.assertEqual(found["lock:agents-init/project-contract"]["fix"], "update-content")
|
|
383
|
+
result = scaffold.repair_lock(self.root, apply=True)
|
|
384
|
+
self.assertTrue(result["blocked"])
|
|
385
|
+
self.assertEqual(self.entry_version(), "0.0.0")
|
|
386
|
+
|
|
387
|
+
def test_template_file_without_a_baseline_that_differs_is_reported(self) -> None:
|
|
388
|
+
self.init("--no-entry")
|
|
389
|
+
(self.root / LOCK).unlink()
|
|
390
|
+
(self.root / "AGENTS.md").write_text("older contract\n")
|
|
391
|
+
found = {item["id"]: item for item in self.check()["findings"]}
|
|
392
|
+
self.assertEqual(found["content:AGENTS.md"]["fix"], "update-content")
|
|
393
|
+
|
|
394
|
+
def test_keep_is_remembered_until_the_template_really_changes(self) -> None:
|
|
395
|
+
self.init("--no-entry")
|
|
396
|
+
(self.root / LOCK).unlink()
|
|
397
|
+
(self.root / "AGENTS.md").write_text("my contract\n")
|
|
398
|
+
|
|
399
|
+
def refresh(*extra: str) -> tuple[int, dict]:
|
|
400
|
+
completed = subprocess.run(
|
|
401
|
+
[sys.executable, str(SCRIPT), "--project", str(self.root), "--mode", "refresh", "--output", "json",
|
|
402
|
+
*extra], capture_output=True, text=True, check=False)
|
|
403
|
+
return completed.returncode, json.loads(completed.stdout)
|
|
404
|
+
|
|
405
|
+
code, result = refresh()
|
|
406
|
+
self.assertEqual(code, 1) # blocked: needs a choice
|
|
407
|
+
code, result = refresh("--keep", "AGENTS.md", "--apply")
|
|
408
|
+
self.assertEqual((code, result["applied"]), (0, True))
|
|
409
|
+
self.assertEqual((self.root / "AGENTS.md").read_text(), "my contract\n")
|
|
410
|
+
table, _, _ = scaffold.read_decisions(self.root / DECISIONS)
|
|
411
|
+
self.assertEqual(table["content:AGENTS.md"]["decision"], "keep")
|
|
412
|
+
# Same template later: no new choice required.
|
|
413
|
+
self.assertEqual(refresh()[0], 0)
|
|
414
|
+
# The template changes for this file: asked again.
|
|
415
|
+
table["content:AGENTS.md"]["fingerprint"] = "an-older-render"
|
|
416
|
+
(self.root / DECISIONS).write_text(json.dumps({"schema_version": 1, "decisions": table}))
|
|
417
|
+
self.assertEqual(refresh()[0], 1)
|
|
418
|
+
|
|
419
|
+
|
|
420
|
+
class CaseNamingTests(ScaffoldCase):
|
|
421
|
+
def make_cases(self, *names: str) -> None:
|
|
422
|
+
for name in names:
|
|
423
|
+
directory = self.root / "cases" / name
|
|
424
|
+
directory.mkdir(parents=True)
|
|
425
|
+
(directory / "CASE.md").write_text("# case\n", encoding="utf-8")
|
|
426
|
+
|
|
427
|
+
def declare(self, naming) -> None:
|
|
428
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
429
|
+
profile["work"]["case"]["directory_naming"] = naming
|
|
430
|
+
(self.root / PROFILE).write_text(json.dumps(profile))
|
|
431
|
+
|
|
432
|
+
def test_default_rule_flags_other_conventions_once_in_aggregate(self) -> None:
|
|
433
|
+
self.init("--no-entry")
|
|
434
|
+
self.make_cases("CASE-20260901-001-vocab", "20260902-grammar", "20260903-listening")
|
|
435
|
+
found = {item["id"]: item for item in self.check()["findings"]}
|
|
436
|
+
self.assertIn("2 个 Case 目录", found["case-naming"]["title"])
|
|
437
|
+
self.assertEqual(found["case-naming"]["fix"], "case-naming")
|
|
438
|
+
|
|
439
|
+
def test_a_declared_convention_is_checked_instead_of_the_default(self) -> None:
|
|
440
|
+
self.init("--no-entry")
|
|
441
|
+
self.make_cases("20260902-grammar", "20260903-listening")
|
|
442
|
+
self.declare({"style": "date-topic"})
|
|
443
|
+
self.assertEqual(self.check()["findings"], [])
|
|
444
|
+
self.make_cases("grammar-notes")
|
|
445
|
+
self.assertIn("case-naming", self.ids(self.check()))
|
|
446
|
+
self.declare({"style": "custom", "pattern": r"[a-z0-9-]+"})
|
|
447
|
+
self.assertEqual(self.check()["findings"], [])
|
|
448
|
+
|
|
449
|
+
def test_a_broken_declaration_is_reported_without_a_fix(self) -> None:
|
|
450
|
+
self.init("--no-entry")
|
|
451
|
+
for naming in ({"style": "weekly"}, {"style": "custom"}, {"pattern": "("}, "date-topic"):
|
|
452
|
+
with self.subTest(naming=naming):
|
|
453
|
+
self.declare(naming)
|
|
454
|
+
found = {item["id"]: item for item in self.check()["findings"]}
|
|
455
|
+
self.assertIsNone(found["profile:work.case.directory_naming"]["fix"])
|
|
456
|
+
|
|
457
|
+
def test_directories_without_a_case_file_and_hidden_ones_are_not_cases(self) -> None:
|
|
458
|
+
self.init("--no-entry")
|
|
459
|
+
(self.root / "cases/archive").mkdir(parents=True, exist_ok=True)
|
|
460
|
+
(self.root / "cases/_templates").mkdir()
|
|
461
|
+
self.assertEqual(self.check()["findings"], [])
|
|
462
|
+
|
|
463
|
+
def test_plan_lists_renames_and_references_and_moves_nothing(self) -> None:
|
|
464
|
+
self.init("--no-entry")
|
|
465
|
+
self.make_cases("20260902-grammar", "weekly-review")
|
|
466
|
+
(self.root / "docs/refs/overview.md").write_text("see cases/20260902-grammar/CASE.md\n")
|
|
467
|
+
before = snapshot(self.root)
|
|
468
|
+
code, plan = self.run_mode("case-naming")
|
|
469
|
+
self.assertEqual(code, 0)
|
|
470
|
+
self.assertEqual(snapshot(self.root), before)
|
|
471
|
+
renames = {item["from"]: item for item in plan["renames"]}
|
|
472
|
+
grammar = renames["cases/20260902-grammar"]
|
|
473
|
+
self.assertEqual(grammar["to"], "cases/CASE-20260902-001-grammar")
|
|
474
|
+
self.assertIn("docs/refs/overview.md", grammar["references"])
|
|
475
|
+
self.assertIsNone(renames["cases/weekly-review"]["to"])
|
|
476
|
+
self.assertFalse(plan["moves_directories"])
|
|
477
|
+
self.assertTrue((self.root / "cases/20260902-grammar").is_dir())
|
|
478
|
+
|
|
479
|
+
def test_the_schema_declares_the_naming_field(self) -> None:
|
|
480
|
+
schema = json.loads((SCRIPT.parent.parent / "project.schema.json").read_text())
|
|
481
|
+
case = schema["properties"]["work"]["properties"]["case"]["properties"]
|
|
482
|
+
self.assertIn("directory_naming", case)
|
|
483
|
+
self.assertIn("command", case)
|
|
484
|
+
runtime = schema["properties"]["runtime"]["properties"]
|
|
485
|
+
for field in ("source_ref", "plugin_version", "source_commit"):
|
|
486
|
+
self.assertEqual(runtime[field]["x-agenthelm-scope"], "audit")
|
|
487
|
+
|
|
488
|
+
|
|
489
|
+
class FingerprintTests(ScaffoldCase):
|
|
490
|
+
def copy_of_project(self) -> Path:
|
|
491
|
+
import shutil
|
|
492
|
+
other = Path(self._temporary.name) / "synced-copy"
|
|
493
|
+
shutil.copytree(self.root, other)
|
|
494
|
+
return other
|
|
495
|
+
|
|
496
|
+
def print_of(self, root: Path, state: Path | None = None) -> dict:
|
|
497
|
+
return scaffold.fingerprint(scaffold.check(root, state_dir=state or self.state))
|
|
498
|
+
|
|
499
|
+
def test_same_scaffold_gives_the_same_fingerprint_on_two_machines(self) -> None:
|
|
500
|
+
self.init("--no-entry")
|
|
501
|
+
other = self.copy_of_project()
|
|
502
|
+
first = self.print_of(self.root)
|
|
503
|
+
second = self.print_of(other, Path(self._temporary.name) / "other-state")
|
|
504
|
+
self.assertEqual(first["fingerprint"], second["fingerprint"])
|
|
505
|
+
self.assertEqual(scaffold.explain(first["detail"], second["detail"]), [])
|
|
506
|
+
|
|
507
|
+
def test_a_missing_skeleton_file_changes_it_and_explain_names_the_file(self) -> None:
|
|
508
|
+
self.init("--no-entry")
|
|
509
|
+
other = self.copy_of_project()
|
|
510
|
+
(other / ".agents/mcp.json").unlink()
|
|
511
|
+
first, second = self.print_of(self.root), self.print_of(other)
|
|
512
|
+
self.assertNotEqual(first["fingerprint"], second["fingerprint"])
|
|
513
|
+
lines = scaffold.explain(first["detail"], second["detail"])
|
|
514
|
+
self.assertEqual(len(lines), 1)
|
|
515
|
+
self.assertIn("skeleton:.agents/mcp.json", lines[0])
|
|
516
|
+
self.assertIn("ok", lines[0])
|
|
517
|
+
self.assertIn("missing", lines[0])
|
|
518
|
+
|
|
519
|
+
def test_opinion_own_fields_audit_fields_and_skipped_items_do_not_count(self) -> None:
|
|
520
|
+
self.old_project()
|
|
521
|
+
base = self.print_of(self.root)["fingerprint"]
|
|
522
|
+
profile = json.loads((self.root / PROFILE).read_text())
|
|
523
|
+
profile["opinion"]["strict_mode"] = "strict"
|
|
524
|
+
profile["runtime"]["source_ref"] = "v0.0.1"
|
|
525
|
+
profile["my_section"] = {"x": 1}
|
|
526
|
+
(self.root / PROFILE).write_text(json.dumps(profile))
|
|
527
|
+
(self.root / "OPINION.md").write_text("different opinion\n")
|
|
528
|
+
(self.root / "notes.md").write_text("an extra file\n")
|
|
529
|
+
self.assertEqual(self.print_of(self.root)["fingerprint"], base)
|
|
530
|
+
scaffold.decide(self.root, "skel:docs/drafts/.gitkeep", "skip", apply=True, state_dir=self.state)
|
|
531
|
+
skipped = self.print_of(self.root)
|
|
532
|
+
self.assertNotEqual(skipped["fingerprint"], base)
|
|
533
|
+
self.assertNotIn("skeleton:docs/drafts/.gitkeep", skipped["detail"])
|
|
534
|
+
scaffold.add_skeleton(self.root, ["docs/drafts/.gitkeep"], apply=True, state_dir=self.state)
|
|
535
|
+
self.assertEqual(self.print_of(self.root)["detail"].get("skeleton:docs/drafts/.gitkeep"), "ok") # once it exists the skip no longer matters
|
|
536
|
+
|
|
537
|
+
def test_machines_expecting_different_manifest_versions_are_told_so(self) -> None:
|
|
538
|
+
mine = {"expected:agents-init/project-skeleton": "1.1.0", "skeleton:AGENTS.md": "ok"}
|
|
539
|
+
theirs = {"expected:agents-init/project-skeleton": "1.0.0", "skeleton:AGENTS.md": "ok"}
|
|
540
|
+
(line,) = scaffold.explain(mine, theirs)
|
|
541
|
+
self.assertIn("期望清单版本不同", line)
|
|
542
|
+
|
|
543
|
+
def test_cli_check_reports_a_fingerprint(self) -> None:
|
|
544
|
+
self.init("--no-entry")
|
|
545
|
+
code, report = self.run_mode("check")
|
|
546
|
+
self.assertEqual(code, 0)
|
|
547
|
+
self.assertTrue(report["fingerprint"]["fingerprint"].startswith("sha256:"))
|
|
548
|
+
|
|
549
|
+
|
|
550
|
+
if __name__ == "__main__":
|
|
551
|
+
unittest.main()
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
{
|
|
2
|
+
"schema_version": 1,
|
|
3
|
+
"description": "agents-init 发布的期望骨架清单:项目应有哪些脚手架文件、Profile 字段,以及各自怎样比较。",
|
|
4
|
+
"files": [
|
|
5
|
+
{"path": "AGENTS.md", "required": true, "compare": "lock"},
|
|
6
|
+
{"path": "AGENT.RULES.md", "required": true, "compare": "lock"},
|
|
7
|
+
{"path": "CLAUDE.md", "required": true, "compare": "lock"},
|
|
8
|
+
{"path": "AGENT.md", "required": true, "compare": "lock"},
|
|
9
|
+
{"path": "OPINION.md", "required": false, "compare": "opinion"},
|
|
10
|
+
{"path": ".agents/plugin.json", "required": true, "compare": "exists"},
|
|
11
|
+
{"path": ".agents/mcp.json", "required": true, "compare": "exists"},
|
|
12
|
+
{"path": ".agents/skills/.gitkeep", "required": true, "compare": "dir"},
|
|
13
|
+
{"path": ".agent-doc/plan.md", "required": true, "compare": "exists"},
|
|
14
|
+
{"path": ".agent-doc/progress.md", "required": true, "compare": "exists"},
|
|
15
|
+
{"path": ".agent-doc/chat-summary.md", "required": true, "compare": "exists"},
|
|
16
|
+
{"path": "{memory_root}/.gitkeep", "required": true, "compare": "dir"},
|
|
17
|
+
{"path": "docs/refs/README.md", "required": true, "compare": "lock"},
|
|
18
|
+
{"path": "docs/drafts/.gitkeep", "required": true, "compare": "dir"},
|
|
19
|
+
{"path": "{case_root}/.gitkeep", "required": true, "compare": "dir", "when": "case-workspace"},
|
|
20
|
+
{"path": "{launcher}", "required": true, "compare": "lock", "when": "entry"}
|
|
21
|
+
],
|
|
22
|
+
"profile": [
|
|
23
|
+
{"path": "schema_version", "type": "string", "mode": "equal"},
|
|
24
|
+
{"path": "name", "type": "string", "mode": "manual"},
|
|
25
|
+
{"path": "runtime.skill", "type": "string", "mode": "equal"},
|
|
26
|
+
{"path": "runtime.plugin", "type": "string", "mode": "equal"},
|
|
27
|
+
{"path": "runtime.capability_manager", "type": "string", "mode": "equal"},
|
|
28
|
+
{"path": "runtime.distribution", "type": "string", "mode": "default", "enum": ["marketplace", "bundled", "external"]},
|
|
29
|
+
{"path": "runtime.required", "type": "boolean", "mode": "default"},
|
|
30
|
+
{"path": "runtime.commit_policy", "type": "string", "mode": "default"},
|
|
31
|
+
{"path": "runtime.push_policy", "type": "string", "mode": "default"},
|
|
32
|
+
{"path": "opinion.provider", "type": "string", "mode": "default"},
|
|
33
|
+
{"path": "opinion.project_overlay", "type": "string", "mode": "default"},
|
|
34
|
+
{"path": "memory.provider", "type": "string", "mode": "default"},
|
|
35
|
+
{"path": "memory.root", "type": "string", "mode": "default"},
|
|
36
|
+
{"path": "capabilities.plugin_roots", "type": "array", "mode": "default"},
|
|
37
|
+
{"path": "capabilities.skill_roots", "type": "array", "mode": "default"},
|
|
38
|
+
{"path": "capabilities.mcp_sources", "type": "array", "mode": "default"},
|
|
39
|
+
{"path": "capabilities.destination_skill_root", "type": "string", "mode": "default"},
|
|
40
|
+
{"path": "capabilities.destination_mcp", "type": "string", "mode": "default"},
|
|
41
|
+
{"path": "work.mode", "type": "string", "mode": "manual"},
|
|
42
|
+
{"path": "work.case.root", "type": "string", "mode": "default", "when": "case-workspace"},
|
|
43
|
+
{"path": "work.case.artifact_mode", "type": "string", "mode": "default", "when": "case-workspace"},
|
|
44
|
+
{"path": "privacy.forbidden_default_reads", "type": "array", "mode": "default"},
|
|
45
|
+
{"path": "privacy.generated_outputs", "type": "array", "mode": "default"}
|
|
46
|
+
],
|
|
47
|
+
"audit_fields": [
|
|
48
|
+
"$schema",
|
|
49
|
+
"initializer_version",
|
|
50
|
+
"runtime.source_repository",
|
|
51
|
+
"runtime.source_ref",
|
|
52
|
+
"runtime.source_commit",
|
|
53
|
+
"runtime.plugin_version",
|
|
54
|
+
"runtime.capability_manager_version",
|
|
55
|
+
"opinion.tool_version"
|
|
56
|
+
],
|
|
57
|
+
"opinion_fields": [
|
|
58
|
+
"opinion.strict_mode",
|
|
59
|
+
"opinion.loading_mode"
|
|
60
|
+
],
|
|
61
|
+
"legacy_paths": [
|
|
62
|
+
".agents/moe.sakanano.project-runtime/project.json"
|
|
63
|
+
]
|
|
64
|
+
}
|
|
@@ -1,9 +1,9 @@
|
|
|
1
1
|
{
|
|
2
2
|
"id": "agents-init",
|
|
3
|
-
"name": "agents-init v6 / Project Profile
|
|
4
|
-
"description": "
|
|
3
|
+
"name": "agents-init v6 / Project Profile initialization and migration",
|
|
4
|
+
"description": "Create or migrate a general project skeleton and Project Profile; the bundled opinion-manager manages user-confirmed Agent behavior rules.",
|
|
5
5
|
"schema_version": "1.0",
|
|
6
|
-
"version": "6.
|
|
6
|
+
"version": "6.7.0",
|
|
7
7
|
"status": "stable",
|
|
8
8
|
"language": "zh-CN",
|
|
9
9
|
"license": "MIT",
|