@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.
Files changed (27) hide show
  1. package/docs/cli-interface.md +4 -2
  2. package/docs/workflow-governance-rules.json +15 -1
  3. package/docs/workflow-governance-rules.md +5 -3
  4. package/docs/workflow-router-rules.json +28 -5
  5. package/docs/workflow-router-rules.md +8 -3
  6. package/docs/workflow-stage-contracts.json +14 -5
  7. package/docs/workflow-stage-contracts.md +5 -4
  8. package/package.json +1 -1
  9. package/templates/workspace/.agents/skills/using-aiws/SKILL.md +16 -4
  10. package/templates/workspace/.agents/skills/ws-finish/SKILL.md +12 -4
  11. package/templates/workspace/.agents/skills/ws-intake/SKILL.md +87 -0
  12. package/templates/workspace/.agents/skills/ws-plan/SKILL.md +15 -9
  13. package/templates/workspace/.agents/skills/ws-plan-verify/SKILL.md +11 -2
  14. package/templates/workspace/.claude/commands/ws-finish.md +8 -4
  15. package/templates/workspace/.claude/commands/ws-intake.md +19 -0
  16. package/templates/workspace/.claude/skills/ws-finish/SKILL.md +12 -4
  17. package/templates/workspace/.claude/skills/ws-intake/SKILL.md +31 -0
  18. package/templates/workspace/.opencode/command/ws-finish.md +8 -4
  19. package/templates/workspace/.opencode/command/ws-intake.md +22 -0
  20. package/templates/workspace/.opencode/commands/ws-finish.md +8 -4
  21. package/templates/workspace/.opencode/commands/ws-intake.md +22 -0
  22. package/templates/workspace/.opencode/skills/ws-finish/SKILL.md +12 -4
  23. package/templates/workspace/.opencode/skills/ws-intake/SKILL.md +31 -0
  24. package/templates/workspace/AGENTS.md +7 -3
  25. package/templates/workspace/changes/README.md +2 -1
  26. package/templates/workspace/manifest.json +16 -0
  27. 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
- inferred = infer_change_id_from_branch(branch_arg)
965
- if not inferred:
966
- eprint(f"error: branch must be change/<change-id> (current: {branch_arg})")
967
- return 2
968
- if inferred != change_id:
969
- eprint(f"error: change-id does not match branch (branch={branch_arg}, change_id={change_id})")
970
- return 2
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")