@aipper/aiws-spec 0.0.26 → 0.0.28
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/docs/cli-interface.md +4 -2
- package/docs/workflow-governance-rules.json +15 -1
- package/docs/workflow-governance-rules.md +5 -3
- package/docs/workflow-router-rules.json +28 -5
- package/docs/workflow-router-rules.md +8 -3
- package/docs/workflow-stage-contracts.json +14 -5
- package/docs/workflow-stage-contracts.md +5 -4
- package/package.json +1 -1
- package/templates/workspace/.agents/skills/using-aiws/SKILL.md +16 -4
- package/templates/workspace/.agents/skills/ws-finish/SKILL.md +12 -4
- package/templates/workspace/.agents/skills/ws-intake/SKILL.md +87 -0
- package/templates/workspace/.agents/skills/ws-plan/SKILL.md +15 -9
- package/templates/workspace/.agents/skills/ws-plan-verify/SKILL.md +11 -2
- package/templates/workspace/.claude/commands/ws-finish.md +8 -4
- package/templates/workspace/.claude/commands/ws-intake.md +19 -0
- package/templates/workspace/.claude/skills/ws-finish/SKILL.md +12 -4
- package/templates/workspace/.claude/skills/ws-intake/SKILL.md +31 -0
- package/templates/workspace/.opencode/command/ws-finish.md +8 -4
- package/templates/workspace/.opencode/command/ws-intake.md +22 -0
- package/templates/workspace/.opencode/commands/ws-finish.md +8 -4
- package/templates/workspace/.opencode/commands/ws-intake.md +22 -0
- package/templates/workspace/.opencode/skills/ws-finish/SKILL.md +12 -4
- package/templates/workspace/.opencode/skills/ws-intake/SKILL.md +31 -0
- package/templates/workspace/AGENTS.md +7 -3
- package/templates/workspace/changes/README.md +2 -1
- package/templates/workspace/manifest.json +16 -0
- package/templates/workspace/tools/ws_change_check.py +224 -7
|
@@ -16,6 +16,13 @@ from typing import Any, Dict, List, Optional, Tuple
|
|
|
16
16
|
|
|
17
17
|
CHANGE_BRANCH_RE = re.compile(r"^(change|changes|ws|ws-change)/([a-z0-9]+(?:-[a-z0-9]+)*)$")
|
|
18
18
|
CHANGE_ID_RE = re.compile(r"^[a-z0-9]+(?:-[a-z0-9]+)*$")
|
|
19
|
+
PIN_BRANCH_RE = re.compile(r"^aiws/pin/.+$")
|
|
20
|
+
FINISH_DONE_COMPLETED_CLEANUPS = {
|
|
21
|
+
"removed",
|
|
22
|
+
"pruned-missing",
|
|
23
|
+
"skipped:no_separate_change_worktree",
|
|
24
|
+
"skipped:current_worktree",
|
|
25
|
+
}
|
|
19
26
|
|
|
20
27
|
|
|
21
28
|
def eprint(msg: str) -> None:
|
|
@@ -93,6 +100,53 @@ def parse_submodule_targets(path: Path) -> Tuple[Dict[str, Tuple[str, str]], Lis
|
|
|
93
100
|
return parsed, perr
|
|
94
101
|
|
|
95
102
|
|
|
103
|
+
def git_text(root: Path, args: List[str]) -> Tuple[int, str, str]:
|
|
104
|
+
try:
|
|
105
|
+
res = subprocess.run(
|
|
106
|
+
["git", "-C", str(root), *args],
|
|
107
|
+
check=False,
|
|
108
|
+
stdout=subprocess.PIPE,
|
|
109
|
+
stderr=subprocess.PIPE,
|
|
110
|
+
text=True,
|
|
111
|
+
)
|
|
112
|
+
return (res.returncode, (res.stdout or "").strip(), (res.stderr or "").strip())
|
|
113
|
+
except Exception as exc:
|
|
114
|
+
return (1, "", str(exc))
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
def resolve_change_gitlink_commit(root: Path, change_id: str, sub_path: str) -> str:
|
|
118
|
+
for spec in (f"change/{change_id}:{sub_path}", f"HEAD:{sub_path}"):
|
|
119
|
+
code, out, _ = git_text(root, ["rev-parse", spec])
|
|
120
|
+
if code == 0 and out:
|
|
121
|
+
return out
|
|
122
|
+
return ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
def git_ref_exists(root: Path, ref: str) -> bool:
|
|
126
|
+
code, _, _ = git_text(root, ["show-ref", "--verify", "--quiet", ref])
|
|
127
|
+
return code == 0
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
def git_revision_exists(root: Path, rev: str) -> bool:
|
|
131
|
+
code, _, _ = git_text(root, ["cat-file", "-e", f"{rev}^{{commit}}"])
|
|
132
|
+
return code == 0
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
def git_is_ancestor(root: Path, older: str, newer: str) -> bool:
|
|
136
|
+
code, _, _ = git_text(root, ["merge-base", "--is-ancestor", older, newer])
|
|
137
|
+
return code == 0
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
def resolve_branch_ref(repo_root: Path, remote: str, branch: str) -> str:
|
|
141
|
+
remote_ref = f"refs/remotes/{remote}/{branch}"
|
|
142
|
+
if git_ref_exists(repo_root, remote_ref):
|
|
143
|
+
return remote_ref
|
|
144
|
+
local_ref = f"refs/heads/{branch}"
|
|
145
|
+
if git_ref_exists(repo_root, local_ref):
|
|
146
|
+
return local_ref
|
|
147
|
+
return ""
|
|
148
|
+
|
|
149
|
+
|
|
96
150
|
def sha256(path: Path) -> str:
|
|
97
151
|
h = hashlib.sha256()
|
|
98
152
|
with path.open("rb") as f:
|
|
@@ -128,6 +182,17 @@ def current_branch(root: Path) -> Optional[str]:
|
|
|
128
182
|
return None
|
|
129
183
|
|
|
130
184
|
|
|
185
|
+
def is_submodule_repo(root: Path) -> bool:
|
|
186
|
+
try:
|
|
187
|
+
parent = subprocess.check_output(
|
|
188
|
+
["git", "-C", str(root), "rev-parse", "--show-superproject-working-tree"],
|
|
189
|
+
text=True,
|
|
190
|
+
).strip()
|
|
191
|
+
return bool(parent)
|
|
192
|
+
except Exception:
|
|
193
|
+
return False
|
|
194
|
+
|
|
195
|
+
|
|
131
196
|
def infer_change_id_from_branch(branch: Optional[str]) -> Optional[str]:
|
|
132
197
|
if not branch:
|
|
133
198
|
return None
|
|
@@ -137,6 +202,103 @@ def infer_change_id_from_branch(branch: Optional[str]) -> Optional[str]:
|
|
|
137
202
|
return m.group(2)
|
|
138
203
|
|
|
139
204
|
|
|
205
|
+
def parse_archived_change_dir_name(entry_name: str) -> Optional[Tuple[str, str, str]]:
|
|
206
|
+
m = re.match(r"^(\d{4}-\d{2}-\d{2})-(.+?)(?:-(\d{8}-\d{6}Z))?$", entry_name or "")
|
|
207
|
+
if not m:
|
|
208
|
+
return None
|
|
209
|
+
change_id = (m.group(2) or "").strip()
|
|
210
|
+
if not CHANGE_ID_RE.match(change_id):
|
|
211
|
+
return None
|
|
212
|
+
return (m.group(1) or "", change_id, m.group(3) or "")
|
|
213
|
+
|
|
214
|
+
|
|
215
|
+
def find_archived_change(root: Path, change_id: str) -> Optional[Path]:
|
|
216
|
+
archive_root = root / "changes" / "archive"
|
|
217
|
+
if not archive_root.exists():
|
|
218
|
+
return None
|
|
219
|
+
matches: List[Path] = []
|
|
220
|
+
for entry in archive_root.iterdir():
|
|
221
|
+
if not entry.is_dir():
|
|
222
|
+
continue
|
|
223
|
+
parsed = parse_archived_change_dir_name(entry.name)
|
|
224
|
+
if parsed and parsed[1] == change_id:
|
|
225
|
+
matches.append(entry)
|
|
226
|
+
if not matches:
|
|
227
|
+
return None
|
|
228
|
+
return sorted(matches, key=lambda item: item.name)[-1]
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
def classify_finish_lifecycle_event(ev: Dict[str, Any]) -> Optional[Tuple[str, str]]:
|
|
232
|
+
event_type = str((ev or {}).get("type") or "").strip()
|
|
233
|
+
if not event_type:
|
|
234
|
+
return None
|
|
235
|
+
if event_type == "finish_local":
|
|
236
|
+
return ("local", "")
|
|
237
|
+
if event_type == "finish_failed":
|
|
238
|
+
return ("failed", str((ev or {}).get("payload", {}).get("error") or ""))
|
|
239
|
+
if event_type == "finish_cleanup_pending":
|
|
240
|
+
payload = (ev or {}).get("payload") or {}
|
|
241
|
+
return ("cleanup_pending", str(payload.get("reason") or payload.get("cleanup") or ""))
|
|
242
|
+
if event_type == "finish":
|
|
243
|
+
return ("done", "")
|
|
244
|
+
if event_type != "finish_done":
|
|
245
|
+
return None
|
|
246
|
+
payload = (ev or {}).get("payload")
|
|
247
|
+
if not isinstance(payload, dict):
|
|
248
|
+
payload = {}
|
|
249
|
+
push_present = "push" in payload
|
|
250
|
+
push_completed = payload.get("push") is True if push_present else None
|
|
251
|
+
cleanup = str(payload.get("cleanup") or "").strip()
|
|
252
|
+
if push_completed is False or cleanup == "not_requested":
|
|
253
|
+
return ("local", "")
|
|
254
|
+
if not cleanup or cleanup in FINISH_DONE_COMPLETED_CLEANUPS:
|
|
255
|
+
return ("done", "")
|
|
256
|
+
reason = cleanup[len("skipped:") :] if cleanup.startswith("skipped:") else cleanup
|
|
257
|
+
return ("cleanup_pending", reason)
|
|
258
|
+
|
|
259
|
+
|
|
260
|
+
def summarize_finish_state(change_dir: Path) -> str:
|
|
261
|
+
metrics_path = change_dir / "metrics.json"
|
|
262
|
+
if not metrics_path.exists():
|
|
263
|
+
return ""
|
|
264
|
+
try:
|
|
265
|
+
metrics = json.loads(read_text(metrics_path))
|
|
266
|
+
except Exception:
|
|
267
|
+
return ""
|
|
268
|
+
events = metrics.get("events")
|
|
269
|
+
if not isinstance(events, list):
|
|
270
|
+
return ""
|
|
271
|
+
finish_state = ""
|
|
272
|
+
for ev in events:
|
|
273
|
+
event_type = str((ev or {}).get("type") or "").strip()
|
|
274
|
+
if not event_type:
|
|
275
|
+
continue
|
|
276
|
+
if finish_state == "done" and event_type not in ("finish_done", "finish"):
|
|
277
|
+
continue
|
|
278
|
+
classified = classify_finish_lifecycle_event(ev)
|
|
279
|
+
if not classified:
|
|
280
|
+
continue
|
|
281
|
+
finish_state = classified[0]
|
|
282
|
+
return finish_state
|
|
283
|
+
|
|
284
|
+
|
|
285
|
+
def terminated_change_message(root: Path, change_id: str) -> Optional[str]:
|
|
286
|
+
change_dir = root / "changes" / change_id
|
|
287
|
+
if change_dir.exists() and summarize_finish_state(change_dir) == "done":
|
|
288
|
+
return (
|
|
289
|
+
f"change/{change_id} already reached finish, but archive/push closeout is still pending; "
|
|
290
|
+
f"rerun `aiws change finish {change_id} --push` instead of continuing development on this branch"
|
|
291
|
+
)
|
|
292
|
+
archived = find_archived_change(root, change_id)
|
|
293
|
+
if archived:
|
|
294
|
+
handoff = archived / "handoff.md"
|
|
295
|
+
details = f"change/{change_id} is already archived at {archived.relative_to(root)}"
|
|
296
|
+
if handoff.exists():
|
|
297
|
+
details += f" (handoff: {handoff.relative_to(root)})"
|
|
298
|
+
return details + "; create a new follow-up change instead of reusing this branch"
|
|
299
|
+
return None
|
|
300
|
+
|
|
301
|
+
|
|
140
302
|
def has_truth_files(root: Path) -> Tuple[bool, List[str]]:
|
|
141
303
|
required = ["AI_PROJECT.md", "AI_WORKSPACE.md", "REQUIREMENTS.md"]
|
|
142
304
|
missing = [f for f in required if not (root / f).exists()]
|
|
@@ -487,6 +649,45 @@ def validate_change(
|
|
|
487
649
|
else:
|
|
488
650
|
warnings.append(msg)
|
|
489
651
|
|
|
652
|
+
if subs:
|
|
653
|
+
targets_path = change_dir / "submodules.targets"
|
|
654
|
+
if targets_path.exists() and targets_path.stat().st_size > 0:
|
|
655
|
+
targets, _ = parse_submodule_targets(targets_path)
|
|
656
|
+
base_branch = str(meta.get("base_branch") or "").strip() or "main"
|
|
657
|
+
for _, sub_path in subs:
|
|
658
|
+
target = targets.get(sub_path)
|
|
659
|
+
if not target:
|
|
660
|
+
continue
|
|
661
|
+
target_branch = target[0] if target[0] != "." else base_branch
|
|
662
|
+
remote = target[1] or "origin"
|
|
663
|
+
gitlink_sha = resolve_change_gitlink_commit(root, change_id, sub_path)
|
|
664
|
+
if not gitlink_sha:
|
|
665
|
+
warnings.append(f"unable to resolve gitlink commit for submodule path: {sub_path}")
|
|
666
|
+
continue
|
|
667
|
+
sub_root = root / sub_path
|
|
668
|
+
if not sub_root.exists():
|
|
669
|
+
warnings.append(f"submodule path not initialized locally (skip target consistency check): {sub_path}")
|
|
670
|
+
continue
|
|
671
|
+
if not git_revision_exists(sub_root, gitlink_sha):
|
|
672
|
+
warnings.append(
|
|
673
|
+
f"{sub_path}: gitlink commit {gitlink_sha} is not available in local submodule clone; run `git submodule update --init --recursive`"
|
|
674
|
+
)
|
|
675
|
+
continue
|
|
676
|
+
branch_ref = resolve_branch_ref(sub_root, remote, target_branch)
|
|
677
|
+
if not branch_ref:
|
|
678
|
+
warnings.append(
|
|
679
|
+
f"{sub_path}: cannot verify target branch history locally (missing {remote}/{target_branch} and local branch {target_branch})"
|
|
680
|
+
)
|
|
681
|
+
continue
|
|
682
|
+
if git_is_ancestor(sub_root, gitlink_sha, branch_ref):
|
|
683
|
+
continue
|
|
684
|
+
errors.append(
|
|
685
|
+
f"{sub_path}: gitlink commit {gitlink_sha} is not contained in declared target branch {remote}/{target_branch}"
|
|
686
|
+
)
|
|
687
|
+
errors.append(
|
|
688
|
+
f"hint: fix `.gitmodules` / `changes/{change_id}/submodules.targets`, or move the submodule gitlink onto a commit reachable from {remote}/{target_branch}"
|
|
689
|
+
)
|
|
690
|
+
|
|
490
691
|
def placeholder_scan(rel: str, text: str) -> None:
|
|
491
692
|
if "{{CHANGE_ID}}" in text or "{{TITLE}}" in text or "{{CREATED_AT}}" in text:
|
|
492
693
|
errors.append(f"unrendered template placeholders in {rel}")
|
|
@@ -942,6 +1143,8 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
942
1143
|
|
|
943
1144
|
branch_arg = (args.branch or "").strip()
|
|
944
1145
|
branch = branch_arg or current_branch(root)
|
|
1146
|
+
inferred_from_branch = False
|
|
1147
|
+
submodule_repo = is_submodule_repo(root)
|
|
945
1148
|
if not change_id:
|
|
946
1149
|
# Detached HEAD during rebase/merge; do not block unless CI passes --branch.
|
|
947
1150
|
if not branch:
|
|
@@ -950,26 +1153,40 @@ def main(argv: Optional[List[str]] = None) -> int:
|
|
|
950
1153
|
allow = {b.strip() for b in (args.allow_branches or "").split(",") if b.strip()}
|
|
951
1154
|
if branch in allow:
|
|
952
1155
|
return 0
|
|
1156
|
+
if submodule_repo and PIN_BRANCH_RE.match(branch):
|
|
1157
|
+
return 0
|
|
953
1158
|
inferred = infer_change_id_from_branch(branch)
|
|
954
1159
|
if not inferred:
|
|
955
1160
|
eprint(f"error: branch must be change/<change-id> (current: {branch})")
|
|
956
1161
|
eprint("hint: switch/create: git switch -c change/<change-id>")
|
|
957
1162
|
return 2
|
|
958
1163
|
change_id = inferred
|
|
1164
|
+
inferred_from_branch = True
|
|
959
1165
|
else:
|
|
960
1166
|
# If CI provides --branch, cross-check branch naming even when --change-id is provided.
|
|
961
1167
|
if branch_arg:
|
|
962
1168
|
allow = {b.strip() for b in (args.allow_branches or "").split(",") if b.strip()}
|
|
963
1169
|
if branch_arg not in allow:
|
|
964
|
-
|
|
965
|
-
|
|
966
|
-
|
|
967
|
-
|
|
968
|
-
|
|
969
|
-
|
|
970
|
-
|
|
1170
|
+
if submodule_repo and PIN_BRANCH_RE.match(branch_arg):
|
|
1171
|
+
branch_arg = ""
|
|
1172
|
+
else:
|
|
1173
|
+
inferred = infer_change_id_from_branch(branch_arg)
|
|
1174
|
+
if not inferred:
|
|
1175
|
+
eprint(f"error: branch must be change/<change-id> (current: {branch_arg})")
|
|
1176
|
+
return 2
|
|
1177
|
+
if inferred != change_id:
|
|
1178
|
+
eprint(f"error: change-id does not match branch (branch={branch_arg}, change_id={change_id})")
|
|
1179
|
+
return 2
|
|
971
1180
|
|
|
972
1181
|
change_dir = root / "changes" / change_id
|
|
1182
|
+
terminated = terminated_change_message(root, change_id) if (inferred_from_branch or bool(branch_arg)) else None
|
|
1183
|
+
if terminated:
|
|
1184
|
+
eprint(f"error: {terminated}")
|
|
1185
|
+
eprint(
|
|
1186
|
+
f"hint: stop using change/{change_id} for new work; rerun `aiws change finish {change_id} --push` "
|
|
1187
|
+
"or archive manually only for local recovery"
|
|
1188
|
+
)
|
|
1189
|
+
return 2
|
|
973
1190
|
if not change_dir.exists():
|
|
974
1191
|
eprint(f"error: missing change dir: {change_dir}")
|
|
975
1192
|
eprint(f"hint: create: aiws change new {change_id} --no-design")
|