@ikon85/agent-workflow-kit 0.37.0 → 0.39.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/.agents/skills/kit-update/SKILL.md +33 -1
- package/.agents/skills/orchestrate-wave/SKILL.md +5 -5
- package/.agents/skills/setup-workflow/SKILL.md +42 -4
- package/.agents/skills/setup-workflow/board-sync.md +6 -2
- package/.agents/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.agents/skills/setup-workflow/worktree-lifecycle.md +47 -1
- package/.agents/skills/wrapup/SKILL.md +46 -0
- package/.claude/hooks/drift-guard.py +212 -21
- package/.claude/skills/kit-update/SKILL.md +33 -1
- package/.claude/skills/orchestrate-wave/SKILL.md +5 -5
- package/.claude/skills/setup-workflow/SKILL.md +42 -4
- package/.claude/skills/setup-workflow/board-sync.md +6 -2
- package/.claude/skills/setup-workflow/workflow-advisories.md +34 -0
- package/.claude/skills/setup-workflow/worktree-lifecycle.md +47 -1
- package/.claude/skills/wrapup/SKILL.md +46 -0
- package/README.md +73 -0
- package/agent-workflow-kit.package.json +49 -25
- package/docs/adr/0007-session-teardown-requires-provenance-bound-ownership.md +88 -0
- package/docs/agents/workflow-capabilities.json +11 -1
- package/package.json +1 -1
- package/scripts/board_bootstrap.py +367 -0
- package/scripts/kit-update-pr.mjs +20 -7
- package/scripts/kit-update-pr.test.mjs +29 -0
- package/scripts/profile_globs.py +347 -0
- package/scripts/test_board_bootstrap.py +348 -0
- package/scripts/test_drift_guard_diagnostics.py +295 -0
- package/scripts/test_orchestrate_wave_contract.py +9 -0
- package/scripts/test_profile_globs.py +280 -0
- package/scripts/test_skill_setup_workflow_seeds.py +87 -0
- package/scripts/test_worktree_wrapup_contract.py +1592 -3
- package/scripts/workflow-advisories/core.py +29 -4
- package/scripts/worktree-lifecycle/README.md +139 -0
- package/scripts/worktree-lifecycle/capabilities.json +9 -1
- package/scripts/worktree-lifecycle/cleanup.py +22 -51
- package/scripts/worktree-lifecycle/core.py +1206 -5
- package/scripts/worktree-lifecycle/profile.py +38 -3
- package/scripts/worktree-lifecycle/session.py +1857 -0
- package/scripts/worktree-lifecycle/setup.py +15 -0
- package/scripts/wrapup-land.py +461 -14
- package/src/cli.mjs +32 -1
- package/src/commands/update.mjs +30 -21
- package/src/consumer-migrations.json +19 -0
- package/src/lib/bundle.mjs +11 -0
- package/src/lib/consumerMigrations.mjs +161 -0
|
@@ -13,8 +13,8 @@ NOT a board-wide scan, NOT a "grill-exit" event (no clean tool hook for that).
|
|
|
13
13
|
Accepted gap: Bash redirect / tee / cp into `.handoff/` is not covered — the
|
|
14
14
|
threat model is "skill/agent forgot", not an adversary; handoff writes via Write.
|
|
15
15
|
|
|
16
|
-
Mechanism: self-filters to `.handoff/*.md`; extracts the issue (
|
|
17
|
-
first, filename
|
|
16
|
+
Mechanism: self-filters to `.handoff/*.md`; extracts the issue (an own-repository
|
|
17
|
+
content anchor first, then the filename); delegates graph coherence to
|
|
18
18
|
scripts/execute-ready-check.py (`--mode handoff`) and census state/fingerprint
|
|
19
19
|
evaluation to `scripts/census/index.mjs`. Deny = exit 2 + stderr (house pattern:
|
|
20
20
|
enforce-worktree.py, block-secrets.py). A deliberate
|
|
@@ -39,8 +39,18 @@ from _hook_utils import log
|
|
|
39
39
|
HOOK_NAME = "drift-guard"
|
|
40
40
|
HANDLED_TOOLS = {"Write", "Edit", "MultiEdit"}
|
|
41
41
|
HANDOFF_PATH_RE = re.compile(r"/\.handoff/[^/]*\.md$")
|
|
42
|
-
ISSUE_ANCHOR_RE = re.compile(r"/issues/(\d+)") #
|
|
43
|
-
FILENAME_ISSUE_RE = re.compile(r"(\d+)\.md$") #
|
|
42
|
+
ISSUE_ANCHOR_RE = re.compile(r"/issues/(\d+)") # any repo: [#n](…/issues/n)
|
|
43
|
+
FILENAME_ISSUE_RE = re.compile(r"(\d+)\.md$") # skill-controlled: <date>-<n>.md
|
|
44
|
+
# Remote URL → host/owner/repo. Covers scp-style (`git@host:owner/repo.git`),
|
|
45
|
+
# https and ssh:// forms; deeper namespaces and local paths deliberately do not
|
|
46
|
+
# match — an unparsable remote falls back to the filename anchor.
|
|
47
|
+
REMOTE_URL_RE = re.compile(
|
|
48
|
+
r"^(?:[a-z][a-z0-9+.-]*://)?(?:[^@/\s]+@)?(?P<host>[^/:\s]+)[/:]"
|
|
49
|
+
r"(?P<owner>[^/\s]+)/(?P<repo>[^/\s]+?)(?:\.git)?/?$",
|
|
50
|
+
re.IGNORECASE,
|
|
51
|
+
)
|
|
52
|
+
# Bounded diagnostics: a large drift reports its head plus a remainder counter.
|
|
53
|
+
CENSUS_DELTA_LIMIT = 10
|
|
44
54
|
# Override must be issue-scoped, rev-scoped, reasoned, by-user (Codex R1 — not the cheap `known`).
|
|
45
55
|
GUARD_ACK_RE = re.compile(r"<!--\s*guard-ack:\s*#?\d+\s+r\d+\s+reason:.+\bby-user\s*-->",
|
|
46
56
|
re.IGNORECASE | re.DOTALL)
|
|
@@ -168,6 +178,42 @@ const hasOpen = uniqueReasons.some((reason) => reason.startsWith('proof:'))
|
|
|
168
178
|
.some(({ status }) => status === CENSUS_VERDICTS.open);
|
|
169
179
|
if (hasOpen) uniqueReasons.push('open');
|
|
170
180
|
const delta = active === null ? null : diffCensus(active, fresh);
|
|
181
|
+
// The guard already knows WHAT moved; report it instead of forcing a blocked
|
|
182
|
+
// consumer to rebuild the diff from the kit internals.
|
|
183
|
+
const pathHashes = (entries) => new Map(
|
|
184
|
+
(Array.isArray(entries) ? entries : []).map(({ path, hash }) => [path, hash]),
|
|
185
|
+
);
|
|
186
|
+
const diffEntries = (before, after) => {
|
|
187
|
+
const previous = pathHashes(before);
|
|
188
|
+
const next = pathHashes(after);
|
|
189
|
+
return {
|
|
190
|
+
added: [...next.keys()].filter((path) => !previous.has(path)).sort(),
|
|
191
|
+
changed: [...next.keys()]
|
|
192
|
+
.filter((path) => previous.has(path) && previous.get(path) !== next.get(path)).sort(),
|
|
193
|
+
removed: [...previous.keys()].filter((path) => !next.has(path)).sort(),
|
|
194
|
+
};
|
|
195
|
+
};
|
|
196
|
+
const familyIndex = (families) => new Map([
|
|
197
|
+
...(families?.surfaces ?? []), ...(families?.behaviors ?? []),
|
|
198
|
+
].map(({ name, status, type }) => [`${type}:${name}`, status]));
|
|
199
|
+
const familyDelta = (before, after) => {
|
|
200
|
+
const previous = familyIndex(before);
|
|
201
|
+
const next = familyIndex(after);
|
|
202
|
+
return {
|
|
203
|
+
added: [...next.keys()].filter((key) => !previous.has(key)).sort(),
|
|
204
|
+
removed: [...previous.keys()].filter((key) => !next.has(key)).sort(),
|
|
205
|
+
statusChanged: [...next.keys()]
|
|
206
|
+
.filter((key) => previous.has(key) && previous.get(key) !== next.get(key)).sort()
|
|
207
|
+
.map((key) => `${key}: ${previous.get(key)} → ${next.get(key)}`),
|
|
208
|
+
};
|
|
209
|
+
};
|
|
210
|
+
const deltaReport = active === null ? null : {
|
|
211
|
+
denominator: {
|
|
212
|
+
added: delta.added, changed: delta.changed, removed: delta.removed,
|
|
213
|
+
},
|
|
214
|
+
evidence: diffEntries(active.evidence, fresh.evidence),
|
|
215
|
+
families: { ...familyDelta(active.families, fresh.families), open: delta.open },
|
|
216
|
+
};
|
|
171
217
|
const denominatorUnchanged = delta !== null
|
|
172
218
|
&& Object.values(delta).every((paths) => paths.length === 0);
|
|
173
219
|
const familiesUnchanged = active !== null
|
|
@@ -184,6 +230,7 @@ const state = resolveCensusState({
|
|
|
184
230
|
writeResult(JSON.stringify({
|
|
185
231
|
builderVersion: CENSUS_BUILDER_VERSION,
|
|
186
232
|
changeBinding: fresh.fingerprints.topology,
|
|
233
|
+
delta: deltaReport,
|
|
187
234
|
fresh: {
|
|
188
235
|
...fresh,
|
|
189
236
|
profileReport: {
|
|
@@ -367,18 +414,71 @@ def extract_content(payload: dict) -> str:
|
|
|
367
414
|
return ""
|
|
368
415
|
|
|
369
416
|
|
|
370
|
-
def
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
417
|
+
def own_repository_slugs(repo_root: Path) -> list[str]:
|
|
418
|
+
"""Return the `host/owner/repo` slugs this repository's remotes point at."""
|
|
419
|
+
try:
|
|
420
|
+
completed = subprocess.run(
|
|
421
|
+
["git", "-C", str(repo_root), "config", "--get-regexp", r"^remote\..*\.url$"],
|
|
422
|
+
capture_output=True,
|
|
423
|
+
text=True,
|
|
424
|
+
timeout=5,
|
|
425
|
+
)
|
|
426
|
+
except (OSError, subprocess.SubprocessError):
|
|
427
|
+
return []
|
|
428
|
+
if completed.returncode != 0:
|
|
429
|
+
return []
|
|
430
|
+
slugs = []
|
|
431
|
+
for line in completed.stdout.splitlines():
|
|
432
|
+
parts = line.split(None, 1)
|
|
433
|
+
if len(parts) != 2:
|
|
434
|
+
continue
|
|
435
|
+
match = REMOTE_URL_RE.match(parts[1].strip())
|
|
436
|
+
if match is None:
|
|
437
|
+
continue
|
|
438
|
+
slug = f"{match.group('host')}/{match.group('owner')}/{match.group('repo')}"
|
|
439
|
+
if slug not in slugs:
|
|
440
|
+
slugs.append(slug)
|
|
441
|
+
return slugs
|
|
442
|
+
|
|
443
|
+
|
|
444
|
+
def own_issue_pattern(repo_root):
|
|
445
|
+
"""Match only issue links of the repository that owns the handoff.
|
|
446
|
+
|
|
447
|
+
A handoff routinely links the upstream issues a session produced. Without
|
|
448
|
+
this restriction the first `/issues/<n>` in the prose wins, so a foreign
|
|
449
|
+
link hijacks the anchor and the guard reports an unrelated issue number.
|
|
450
|
+
"""
|
|
451
|
+
if repo_root is None:
|
|
452
|
+
return None
|
|
453
|
+
slugs = own_repository_slugs(repo_root)
|
|
454
|
+
if not slugs:
|
|
455
|
+
return None
|
|
456
|
+
alternatives = "|".join(re.escape(slug) for slug in slugs)
|
|
457
|
+
return re.compile(rf"(?<![A-Za-z0-9.-])(?:{alternatives})/issues/(\d+)", re.IGNORECASE)
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def extract_issue(payload: dict, content: str, repo_root=None):
|
|
461
|
+
# Own-repository content anchor first, then the skill-controlled filename.
|
|
462
|
+
# The unrestricted content anchor only remains for a repository whose remote
|
|
463
|
+
# is unparsable — there the filename is the only trustworthy signal, and a
|
|
464
|
+
# handoff without one keeps today's behaviour. Deliberately NO branch
|
|
465
|
+
# fallback: a meta/tooling handoff carries no issue (handoff skill supports
|
|
466
|
+
# this) and must fail-open; a branch-based guess would mis-attribute the
|
|
467
|
+
# branch's issue and false-block. None here → should_block() allows (not the
|
|
468
|
+
# stale handoff we guard).
|
|
469
|
+
own = own_issue_pattern(repo_root)
|
|
470
|
+
if own is not None:
|
|
471
|
+
m = own.search(content or "")
|
|
472
|
+
if m:
|
|
473
|
+
return int(m.group(1))
|
|
378
474
|
fp = (payload.get("tool_input") or {}).get("file_path", "")
|
|
379
475
|
m = FILENAME_ISSUE_RE.search(fp)
|
|
380
476
|
if m:
|
|
381
477
|
return int(m.group(1))
|
|
478
|
+
if own is None:
|
|
479
|
+
m = ISSUE_ANCHOR_RE.search(content or "")
|
|
480
|
+
if m:
|
|
481
|
+
return int(m.group(1))
|
|
382
482
|
return None
|
|
383
483
|
|
|
384
484
|
|
|
@@ -534,15 +634,89 @@ def evaluate_census(repo_root: Path, proof_timeout_ms=CENSUS_PROOF_TIMEOUT_MS) -
|
|
|
534
634
|
"override_applied": override_applied,
|
|
535
635
|
"change_binding": result["changeBinding"],
|
|
536
636
|
"mechanical_false_positive": result["mechanicalFalsePositive"],
|
|
637
|
+
"delta": result.get("delta"),
|
|
537
638
|
}
|
|
538
639
|
|
|
539
640
|
|
|
540
|
-
def
|
|
641
|
+
def _cap_entries(entries, limit: int):
|
|
642
|
+
if not isinstance(entries, list) or len(entries) <= limit:
|
|
643
|
+
return entries
|
|
644
|
+
return [*entries[:limit], f"…and {len(entries) - limit} more"]
|
|
645
|
+
|
|
646
|
+
|
|
647
|
+
def cap_delta(delta, limit: int = CENSUS_DELTA_LIMIT):
|
|
648
|
+
"""Keep a diagnostic delta readable — head plus an honest remainder count."""
|
|
649
|
+
if not isinstance(delta, dict):
|
|
650
|
+
return delta
|
|
651
|
+
capped = {}
|
|
652
|
+
for group, entries in delta.items():
|
|
653
|
+
if isinstance(entries, dict):
|
|
654
|
+
capped[group] = {key: _cap_entries(value, limit) for key, value in entries.items()}
|
|
655
|
+
else:
|
|
656
|
+
capped[group] = _cap_entries(entries, limit)
|
|
657
|
+
return capped
|
|
658
|
+
|
|
659
|
+
|
|
660
|
+
def worktree_identity(path):
|
|
661
|
+
"""Return (checkout root, shared git dir) or None when git cannot answer."""
|
|
662
|
+
try:
|
|
663
|
+
completed = subprocess.run(
|
|
664
|
+
["git", "-C", str(path), "rev-parse", "--show-toplevel", "--git-common-dir"],
|
|
665
|
+
capture_output=True,
|
|
666
|
+
text=True,
|
|
667
|
+
timeout=5,
|
|
668
|
+
)
|
|
669
|
+
except (OSError, subprocess.SubprocessError):
|
|
670
|
+
return None
|
|
671
|
+
if completed.returncode != 0:
|
|
672
|
+
return None
|
|
673
|
+
lines = completed.stdout.splitlines()
|
|
674
|
+
if len(lines) != 2 or not lines[0] or not lines[1]:
|
|
675
|
+
return None
|
|
676
|
+
# `--git-common-dir` may answer relative to the directory git ran in.
|
|
677
|
+
toplevel = Path(os.path.realpath(lines[0]))
|
|
678
|
+
common = Path(os.path.realpath(Path(path) / lines[1]))
|
|
679
|
+
return toplevel, common
|
|
680
|
+
|
|
681
|
+
|
|
682
|
+
def checkout_diagnostic_lines(census_root, cwd=None) -> list[str]:
|
|
683
|
+
"""Name the checkout the census verdict describes.
|
|
684
|
+
|
|
685
|
+
The census root comes from the handoff TARGET path, not from the session's
|
|
686
|
+
working directory — handoff documents live in the main checkout so they
|
|
687
|
+
survive worktree cleanup. A census describes the tree it was scanned in, so
|
|
688
|
+
a refresh in another worktree legitimately does not count here; without this
|
|
689
|
+
diagnosis the block reads as simply wrong to a session working elsewhere.
|
|
690
|
+
"""
|
|
691
|
+
if census_root is None:
|
|
692
|
+
return []
|
|
693
|
+
lines = [
|
|
694
|
+
f" · evaluated checkout: {census_root}",
|
|
695
|
+
" (derived from the handoff target path — a census refresh in another",
|
|
696
|
+
" worktree does not count for this checkout)",
|
|
697
|
+
]
|
|
698
|
+
try:
|
|
699
|
+
# A diagnostic must never cost the block: an unusable working directory
|
|
700
|
+
# only drops the extra sentence.
|
|
701
|
+
here = worktree_identity(Path.cwd() if cwd is None else Path(cwd))
|
|
702
|
+
there = worktree_identity(census_root)
|
|
703
|
+
except OSError:
|
|
704
|
+
return lines
|
|
705
|
+
if here and there and here[1] == there[1] and here[0] != there[0]:
|
|
706
|
+
lines += [
|
|
707
|
+
" · your working directory is a different worktree of the same repository:",
|
|
708
|
+
f" {here[0]} — refresh the evaluated checkout above, not this one",
|
|
709
|
+
]
|
|
710
|
+
return lines
|
|
711
|
+
|
|
712
|
+
|
|
713
|
+
def build_census_block_message(issue: int, result: dict, census_root=None, cwd=None) -> str:
|
|
541
714
|
reasons = ", ".join(result.get("reasons", [])) or "activated census is stale"
|
|
542
715
|
lines = [
|
|
543
716
|
f"CENSUS — Build-Handoff für #{issue} BLOCKED ({result.get('state', 'refresh_required')}):",
|
|
544
717
|
"",
|
|
545
718
|
f" · {reasons}",
|
|
719
|
+
*checkout_diagnostic_lines(census_root, cwd),
|
|
546
720
|
" · run `$census-update` and activate a verified current census",
|
|
547
721
|
]
|
|
548
722
|
if result.get("overrides"):
|
|
@@ -557,24 +731,38 @@ def should_block(payload: dict):
|
|
|
557
731
|
if not is_handoff_write(payload):
|
|
558
732
|
return False, ""
|
|
559
733
|
content = extract_content(payload)
|
|
560
|
-
issue
|
|
734
|
+
# The owning repository decides which issue links may anchor the handoff,
|
|
735
|
+
# so it is resolved before the anchor is extracted.
|
|
736
|
+
census_root = None
|
|
737
|
+
root_error = None
|
|
738
|
+
try:
|
|
739
|
+
census_root = resolve_handoff_repo_root(payload)
|
|
740
|
+
except Exception as error:
|
|
741
|
+
root_error = error
|
|
742
|
+
issue = extract_issue(payload, content, census_root)
|
|
561
743
|
if issue is None:
|
|
562
744
|
log(HOOK_NAME, "no identifiable issue target → fail-open allow")
|
|
563
745
|
return False, ""
|
|
564
746
|
intent = _infer_intent(content)
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
except Exception as error:
|
|
569
|
-
census = {
|
|
747
|
+
|
|
748
|
+
def unavailable(error):
|
|
749
|
+
return {
|
|
570
750
|
"state": "failed",
|
|
571
751
|
"block_handoff": intent == "build",
|
|
572
752
|
"reasons": [f"target repository unavailable ({error})"],
|
|
573
753
|
"overrides": [],
|
|
574
754
|
}
|
|
755
|
+
|
|
756
|
+
if census_root is None:
|
|
757
|
+
census = unavailable(root_error)
|
|
758
|
+
else:
|
|
759
|
+
try:
|
|
760
|
+
census = evaluate_census(census_root)
|
|
761
|
+
except Exception as error:
|
|
762
|
+
census = unavailable(error)
|
|
575
763
|
log(HOOK_NAME, f"census state={census['state']} reasons={census.get('reasons', [])}")
|
|
576
764
|
if intent == "build" and census.get("block_handoff"):
|
|
577
|
-
return True, build_census_block_message(issue, census)
|
|
765
|
+
return True, build_census_block_message(issue, census, census_root)
|
|
578
766
|
if GUARD_ACK_RE.search(content):
|
|
579
767
|
log(HOOK_NAME, "guard-ack override present → allow graph gate only")
|
|
580
768
|
return False, ""
|
|
@@ -608,7 +796,8 @@ def build_block_message(issue: int, intent: str, result: dict) -> str:
|
|
|
608
796
|
|
|
609
797
|
|
|
610
798
|
def main() -> int:
|
|
611
|
-
|
|
799
|
+
arguments = sys.argv[1:]
|
|
800
|
+
if arguments[:1] == ["--census-status"] and arguments[1:] in ([], ["--verbose"]):
|
|
612
801
|
try:
|
|
613
802
|
root = resolve_census_root_from_cwd(Path.cwd())
|
|
614
803
|
result = evaluate_census(root)
|
|
@@ -617,6 +806,8 @@ def main() -> int:
|
|
|
617
806
|
"detail": f"target repository unavailable ({error})",
|
|
618
807
|
"reasons": ["repository"], "overrides": [],
|
|
619
808
|
"override_applied": False}
|
|
809
|
+
if arguments[1:] != ["--verbose"]:
|
|
810
|
+
result["delta"] = cap_delta(result.get("delta"))
|
|
620
811
|
print(json.dumps(result, sort_keys=True))
|
|
621
812
|
return 0
|
|
622
813
|
try:
|
|
@@ -105,6 +105,19 @@ release contain the same artifact.
|
|
|
105
105
|
and safe routes—never file content. Automated update pull
|
|
106
106
|
requests carry the same availability summary and remain manual-merge only.
|
|
107
107
|
|
|
108
|
+
Read the required consumer migrations alongside the file delta. A release
|
|
109
|
+
that forces the Consumer to commit a decision registers it as declarative,
|
|
110
|
+
versioned data in the Kit's migration registry — never as release-note prose.
|
|
111
|
+
Preview and the terminal report expose the same structured record, so an
|
|
112
|
+
interactive run, a `--yes` run, `update --json`, and the automated update
|
|
113
|
+
pull request all name the same outstanding actions. Each action names the
|
|
114
|
+
workflow that resolves it, the Consumer file that carries the decision, and
|
|
115
|
+
the exact missing decision. `kit-update` only detects and reports it: it
|
|
116
|
+
never writes the decision, never infers a value, and never invents or
|
|
117
|
+
auto-accepts a pattern that would grant deletion authority. An empty but
|
|
118
|
+
explicit decision is a committed decision and clears the action; a missing
|
|
119
|
+
one keeps it pending on every rerun.
|
|
120
|
+
|
|
108
121
|
For each conflicted kit-shipped file, always ask the user whether the local
|
|
109
122
|
edit is a generic improvement or project-specific; never decide or act
|
|
110
123
|
automatically. For a generic improvement retained as a bridge, run
|
|
@@ -131,7 +144,26 @@ release contain the same artifact.
|
|
|
131
144
|
resume the transaction through the update API's `resumeFrom` option. Do not
|
|
132
145
|
copy staged files into the consumer by hand.
|
|
133
146
|
|
|
134
|
-
5.
|
|
147
|
+
5. Review the consumer profile's glob dialect after the update:
|
|
148
|
+
|
|
149
|
+
```sh
|
|
150
|
+
python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json
|
|
151
|
+
```
|
|
152
|
+
|
|
153
|
+
Every consumer-profile glob — Worktree Lifecycle `scratchPatterns` and
|
|
154
|
+
`wrapup.landingGeneratedArtifactPatterns`, Workflow Advisories
|
|
155
|
+
`baseline.sourceGlobs` and the `preRefactor`/`stopChecks` surface globs — is
|
|
156
|
+
matched by one shared repository-relative dialect. A pattern written for an
|
|
157
|
+
older matcher can narrow or widen. The check names each such pattern with
|
|
158
|
+
the witness path that proves the difference and marks the keys that carry
|
|
159
|
+
deletion authority; exit code 1 means at least one needs review. Report the
|
|
160
|
+
named patterns and let the user rewrite them. The updater never rewrites a
|
|
161
|
+
consumer pattern, and a widened deletion-authority pattern is never accepted
|
|
162
|
+
silently — an update must not expand what cleanup may remove. A missing or
|
|
163
|
+
disabled profile leaves nothing to review and does not invalidate the
|
|
164
|
+
update.
|
|
165
|
+
|
|
166
|
+
6. Check the optional project census after the update:
|
|
135
167
|
|
|
136
168
|
```sh
|
|
137
169
|
python3 .claude/hooks/drift-guard.py --census-status
|
|
@@ -141,7 +141,7 @@ replace the ~30-second heartbeat; otherwise the standing heartbeat remains requi
|
|
|
141
141
|
LOCAL annotated tag, so two sessions racing one wave cannot both win. Either
|
|
142
142
|
`acquired: false` or a work signal you did not create means another session
|
|
143
143
|
owns the wave — **STOP**, report the returned `claim.owner` plus the exact
|
|
144
|
-
branch/worktree, touch nothing.
|
|
144
|
+
branch/worktree, touch nothing. After acquisition, begin the claim-bound teardown receipt with `python3 scripts/worktree-lifecycle/session.py begin --anchor <anchor> --owner <claim-owner> --base <wave-HEAD>`; claim and receipt stay local — never push them.
|
|
145
145
|
4. **Wave worktree**: reuse the planning worktree (never re-create; the handoff
|
|
146
146
|
points there). Bring its branch to current `main`:
|
|
147
147
|
`git -C <wave> merge --ff-only origin/main` (if your repo guards destructive
|
|
@@ -195,8 +195,8 @@ disjoint wave, conflict hub first. Dispatch only `FREI` slices: re-read
|
|
|
195
195
|
`frontier <anchor#>` before each wave and clear changed edges only via `dep-remove`.
|
|
196
196
|
Before each slice, bind **(a) inline vs delegate** and **(b) tier + effort** under
|
|
197
197
|
Standing rules. Tiny mechanical work may stay inline.
|
|
198
|
-
For Path A/B, create
|
|
199
|
-
`git worktree add
|
|
198
|
+
For Path A/B, create each new worktree through the active receipt: `python3 scripts/worktree-lifecycle/session.py create --anchor <anchor> --owner <claim-owner> --base <wave-HEAD> --profile <profile> <slice-issue> <slug> <type>`.
|
|
199
|
+
It refuses reused/pre-existing targets; never replace this ownership proof with raw `git worktree add`.
|
|
200
200
|
- **Claim each slice issue at builder launch — the wave claim is not a slice
|
|
201
201
|
claim.** It guards the anchor on this machine only; the slice issue is the
|
|
202
202
|
grabbable unit a second machine or cloud agent sees. Before dispatch, skip and
|
|
@@ -358,8 +358,8 @@ written · merge order documented.
|
|
|
358
358
|
- A skill edited during the wave → sync its dual-surface mirror in the SAME PR
|
|
359
359
|
using the tool named by `§Landing` when present; mirror parity remains a pre-PR
|
|
360
360
|
gate.
|
|
361
|
-
-
|
|
362
|
-
|
|
361
|
+
- After reading every `ANNAHMEN.md`, seal the receipt, inspect it against fetched canonical `main`, then run its `teardown`; only ancestry/one-to-one patch-equivalent exact owned OIDs are compare-deleted, while foreign targets, unique/ambiguous content, changed OIDs, dirty/protected worktrees, and open/unknown PR evidence stop.
|
|
362
|
+
- Keep the completed receipt as recovery-OID evidence; only then release this run's wave claim.
|
|
363
363
|
|
|
364
364
|
**Done when:** no orphan process · this run's wave claim removed · this run's
|
|
365
365
|
unlanded slice claims released · ANNAHMEN propagated ·
|
|
@@ -204,14 +204,27 @@ Worktree Lifecycle for this repository?"* Offer exactly **Yes**, **Later**, and
|
|
|
204
204
|
- **Yes** — create or deepen only the `worktreeLifecycle` section in
|
|
205
205
|
`docs/agents/workflow-capabilities.json`, preserve every other section and
|
|
206
206
|
unknown key, reconcile an explicit consumer-derived `scratchPatterns` array,
|
|
207
|
+
explicitly derive and review the consumer's
|
|
208
|
+
`wrapup.landingGeneratedArtifactPatterns` array from real landing outputs,
|
|
207
209
|
and reconcile only the exact kit-owned hook commands listed in the seed.
|
|
208
210
|
- **Later / No** — record the choice in the tracked profile without installing
|
|
209
211
|
hook wiring. Ordinary reruns do not ask again.
|
|
210
212
|
- **Existing** — adopt the current section and wiring without normalizing
|
|
211
|
-
consumer-owned values.
|
|
213
|
+
consumer-owned values. Preserve an existing landing-artifact policy
|
|
214
|
+
byte-for-byte; if it is missing, surface the explicit derivation decision and
|
|
215
|
+
do not write a default.
|
|
212
216
|
- **Disable** — remove only the exact kit-owned hook commands first, then set
|
|
213
217
|
`enabled: false`; retain the profile, setup policy, and unknown keys.
|
|
214
218
|
|
|
219
|
+
Whenever a profile already carries patterns — on **Yes** over an existing
|
|
220
|
+
section and on **Existing** — run
|
|
221
|
+
`python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json` and
|
|
222
|
+
report every pattern it names. Patterns whose match set narrows or widens under
|
|
223
|
+
the shared dialect are a consumer decision: name them with their witness path,
|
|
224
|
+
mark the deletion-authority keys, and let the consumer rewrite them. Never
|
|
225
|
+
rewrite a pattern for them, and never accept a widened deletion-authority
|
|
226
|
+
pattern silently.
|
|
227
|
+
|
|
215
228
|
The default setup entry is
|
|
216
229
|
`python3 scripts/worktree-lifecycle/setup.py`; a proven consumer-native helper
|
|
217
230
|
may remain the configured `setupEntry` for parity. The handoff advisory always
|
|
@@ -269,6 +282,12 @@ repository?"* Offer exactly **Yes**, **Later**, and **No**.
|
|
|
269
282
|
- **Disable** — remove exact kit-owned commands first, then set `enabled:
|
|
270
283
|
false`; preserve every profile value and unknown key.
|
|
271
284
|
|
|
285
|
+
`baseline.sourceGlobs` and the `preRefactor`/`stopChecks` surface globs use the
|
|
286
|
+
same shared dialect as the Worktree Lifecycle patterns above. An adopted
|
|
287
|
+
profile written for the older whole-string matcher can change meaning, so run
|
|
288
|
+
`python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json` and
|
|
289
|
+
report every pattern it names before treating the section as reconciled.
|
|
290
|
+
|
|
272
291
|
### 2f. Section A7 — Optional Safety Guardrails
|
|
273
292
|
|
|
274
293
|
> Safety Guardrails is a counted group of seven independently selectable
|
|
@@ -407,13 +426,29 @@ Seed `docs/agents/domain.md` from [domain.md](./domain.md).
|
|
|
407
426
|
|
|
408
427
|
**Skip entirely if Section A is not GitHub** → write `board-sync.md` with `state=not-applicable`, `mode=none`, a one-line "this project does not use a GitHub-Projects board" note. Terminal; never re-prompts.
|
|
409
428
|
|
|
410
|
-
**Preflight:** `gh auth status
|
|
429
|
+
**Preflight:** `gh auth status` (or `python3 scripts/board_bootstrap.py preflight --json` for the machine-readable answer). If `project`/`read:project` scope is missing, surface the exact remedy: `gh auth refresh -s project,read:project`, then fall through to the stub path below — without the write scope, never attempt creation.
|
|
411
430
|
|
|
412
|
-
**Discover (the
|
|
431
|
+
**Discover (the existing-board path):** `gh project list --owner <remote-owner> --format json`. If **exactly one** project clearly belongs to this repo's owner, read its fields: `gh project field-list <number> --owner <owner> --format json`. Record each field's `id`, `name`, `dataType` (single-select / number / text), and for single-selects the option `id`s. Write `board-sync.md` with `state=filled`, `mode=github-projects-v2`, the project node id + repo + the discovered field/option IDs, seeded from [board-sync.md](./board-sync.md). Fill the `<!-- board-sync:profile -->` **JSON block** (the machine SSOT `scripts/board_config.py` parses) — replace each `<…>` placeholder under `repo`/`project`/`fields` with the discovered value; leave the convention values (`labels`/`branchPrefixes`/`prMarkers`/`headings`) at their seeded defaults unless this project's conventions differ.
|
|
413
432
|
|
|
414
433
|
**Map the status roles (`fields.status.roles`):** the seeded map carries the recommended English stage names (`Idea/Triaged/Spec/In Progress/Review/Done`). Reconcile it against the **discovered** Status option names: an exact (case-sensitive) match keeps its seeded entry; for every role whose seeded name is NOT among the discovered options, ask the user which of their actual option names plays that role (a stage they don't have may be omitted from the map — say so). Never guess a mapping from similarity. Finish with a validation pass: every role value in the map MUST appear in `fields.status.options`, else fix before writing `state=filled`.
|
|
415
434
|
|
|
416
|
-
**
|
|
435
|
+
**Offer creation (no board found, scope present):** a fresh repo can reach a working board in this run — `gh project create` provisions the shell and `gh project field-create --single-select-options` provisions the Status single-select together with its options. Minting a GitHub Project is an outward, user-visible action, so **ask first and never create a board without an explicit yes**. Name the stage options the board will get (the seed's `fields.status.roles` values) in the question, and edit those role values in the seed *before* creating if the user wants a different stage vocabulary — they become the board's Status options verbatim. Present exactly two choices:
|
|
436
|
+
|
|
437
|
+
- **Create it now** — run the one creation path (never hand-assemble the `gh` sequence):
|
|
438
|
+
|
|
439
|
+
```bash
|
|
440
|
+
python3 scripts/board_bootstrap.py create --owner <owner> --repo <owner>/<repo> \
|
|
441
|
+
--title "<repo> Workflow" --seed <path of this skill folder>/board-sync.md \
|
|
442
|
+
--out docs/agents/board-sync.md
|
|
443
|
+
```
|
|
444
|
+
|
|
445
|
+
It re-checks the destination and the scope before it writes anything, creates the project, the Status single-select (option names read from the seed profile's `fields.status.roles` — the board's own stage vocabulary, never a literal), and the Wave / Cluster / Spec-Path / Plan-Path fields, then discovers the real field/option IDs via `gh project field-list` and writes `docs/agents/board-sync.md` at `state=filled`, `mode=github-projects-v2` **from that readback**. Show the user `--dry-run` output first if they want to see the exact sequence. Report the created project's number and URL, and that its Status roles now resolve.
|
|
446
|
+
|
|
447
|
+
- **Not now** — create nothing and take the stub path below unchanged.
|
|
448
|
+
|
|
449
|
+
**A non-zero exit is a stub, never a partial claim.** The helper writes no profile when the destination is consumer-owned, the scope is missing (exit 3), or creation/readback/validation fails — so a half-created board can never produce a profile claiming fields that do not exist. On any non-zero exit, relay its message (it names the project it created, if any, so the user can finish or delete it), then take the stub path; never hand-write a `state=filled` profile and never retry creation without asking again.
|
|
450
|
+
|
|
451
|
+
**Fallback (the single catch-all — declined / no board / >1 / ambiguous / scope error / read failure / creation failure):** write `board-sync.md` with `state=stub`, `mode=github-projects-v2`, and inline **instructions**: which fields the workflow profile needs (a Status single-select with your stage options; optionally a Wave number, a Cluster text, a Spec-Path / Plan-Path text), how to create the board in the GitHub UI / `gh`, and "then run `/setup-workflow` again — it will discover and fill the IDs." Retryable.
|
|
417
452
|
|
|
418
453
|
**Optional — Phase field + saved Views (Program route only):** never
|
|
419
454
|
auto-discovered or auto-created, unlike the fields above — a Phase field's
|
|
@@ -520,6 +555,9 @@ unknown consumer keys share this profile. Apply the transition and hook
|
|
|
520
555
|
ownership rules from [worktree-lifecycle.md](./worktree-lifecycle.md),
|
|
521
556
|
[workflow-advisories.md](./workflow-advisories.md), and
|
|
522
557
|
[safety-guardrails.md](./safety-guardrails.md) transactionally.
|
|
558
|
+
The landing-artifact policy is deletion authority: it is always an explicit
|
|
559
|
+
consumer-reviewed setup value, never an update-time default or an inference
|
|
560
|
+
from ignored paths.
|
|
523
561
|
|
|
524
562
|
For Memory Lifecycle, use only the deterministic setup helper from Section A5.
|
|
525
563
|
Do not copy templates with shell commands or edit the capability profile by
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
How the board-managed workflow skills (`to-prd`, `to-issues`, `board-to-waves`, …) address this project's GitHub-Projects board. A board stores its fields under opaque GraphQL IDs that differ per board, so they are recorded here rather than hardcoded in any skill.
|
|
4
4
|
|
|
5
5
|
`/setup-workflow` writes this file in one of three states (see the sentinel on the first line):
|
|
6
|
-
- **`state=filled`** — the IDs below were discovered from your board.
|
|
7
|
-
- **`state=stub`** — no single board was
|
|
6
|
+
- **`state=filled`** — the IDs below were discovered from your board (an existing one, or the one `/setup-workflow` offered to create for you).
|
|
7
|
+
- **`state=stub`** — no single board was filled: you declined the creation offer, `gh` lacked the `project` scope, several boards were ambiguous, or creation failed. Create the board (below) and re-run `/setup-workflow`.
|
|
8
8
|
- **`state=not-applicable`** (`mode=none`) — this project does not use a GitHub-Projects board.
|
|
9
9
|
|
|
10
10
|
## Board profile — fields the workflow skills use
|
|
@@ -106,6 +106,10 @@ their titles unchanged; set it once to match your board's language.
|
|
|
106
106
|
|
|
107
107
|
## If the IDs are not yet filled (stub)
|
|
108
108
|
|
|
109
|
+
**Let setup create the board.** Ensure `gh` has the scopes (`gh auth refresh -s project,read:project`) and re-run `/setup-workflow`: with no board and the scope present it *offers* to create one. On your yes it runs `scripts/board_bootstrap.py`, which creates the project plus the Status single-select (its options named after the `roles` map below) and the Wave / Cluster / Spec-Path / Plan-Path fields, reads the real IDs back, and rewrites this file at `state=filled`. Nothing is created without that explicit yes, and a failed run leaves this stub in place rather than a half-true profile.
|
|
110
|
+
|
|
111
|
+
**Or build it by hand:**
|
|
112
|
+
|
|
109
113
|
1. Create a GitHub-Projects (v2) board for this owner and add the fields above (at minimum a `Status` single-select with your stage options — the recommended stage names are `Idea, Triaged, Spec, In Progress, Review, Done`, matching the seeded `roles` defaults).
|
|
110
114
|
2. Ensure `gh` has the scopes: `gh auth refresh -s project,read:project`.
|
|
111
115
|
3. Re-run `/setup-workflow` — it discovers the board (`gh project field-list`) and fills the IDs here automatically.
|
|
@@ -74,6 +74,40 @@ key. Consumer values are never normalized on adoption.
|
|
|
74
74
|
Empty command surfaces are honest inactive defaults. Setup recommends concrete
|
|
75
75
|
project commands from the tools already present, then asks before activating.
|
|
76
76
|
|
|
77
|
+
## Profile glob dialect
|
|
78
|
+
|
|
79
|
+
`baseline.sourceGlobs`, `preRefactor.surfaces[].globs`, and
|
|
80
|
+
`stopChecks.surfaces[].globs` are matched by the one shared dialect in
|
|
81
|
+
`scripts/profile_globs.py` — the same matcher Worktree Lifecycle uses for its
|
|
82
|
+
scratch and landing-artifact policies. There is no second matcher and no
|
|
83
|
+
per-capability variant:
|
|
84
|
+
|
|
85
|
+
- `*` matches any run of characters inside one path segment, never `/`.
|
|
86
|
+
- `?` matches exactly one character inside one path segment.
|
|
87
|
+
- `[seq]` and `[!seq]` are per-segment character classes; `/` is always a
|
|
88
|
+
separator and never a class member.
|
|
89
|
+
- `**` as a complete segment matches zero or more segments, so a leading `**/`
|
|
90
|
+
also matches the repository root and `dir/**` also matches `dir` itself.
|
|
91
|
+
- Matching is always case-sensitive, on every host filesystem.
|
|
92
|
+
- A pattern must match the whole repository-relative path.
|
|
93
|
+
|
|
94
|
+
Thus `src/**` covers `src/a/b.ts`, `**/*.ts` covers both `index.ts` and
|
|
95
|
+
`src/index.ts`, and `*.ts` covers only a root-level file.
|
|
96
|
+
|
|
97
|
+
A profile written for the older whole-string matcher can therefore change
|
|
98
|
+
meaning. When adopting an existing profile, or after any kit update, review it:
|
|
99
|
+
|
|
100
|
+
```bash
|
|
101
|
+
python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json
|
|
102
|
+
```
|
|
103
|
+
|
|
104
|
+
The check names every pattern whose match set narrows or widens, prints the
|
|
105
|
+
concrete witness path that proves the difference, and separately flags patterns
|
|
106
|
+
that only matched case-insensitively on the previous matcher. Exit code 1 means
|
|
107
|
+
at least one pattern needs review. Report the named patterns and let the
|
|
108
|
+
consumer rewrite them; never migrate a pattern automatically. The check reads
|
|
109
|
+
the profile and never edits it.
|
|
110
|
+
|
|
77
111
|
## Hook ownership
|
|
78
112
|
|
|
79
113
|
- PreToolUse on Read:
|
|
@@ -29,6 +29,7 @@ advisory, and safe cleanup policy as one unit.
|
|
|
29
29
|
"operations": [
|
|
30
30
|
"record-choice",
|
|
31
31
|
"reconcile-profile-enabled",
|
|
32
|
+
"reconcile-landing-artifact-policy",
|
|
32
33
|
"reconcile-hook-wiring"
|
|
33
34
|
]
|
|
34
35
|
},
|
|
@@ -50,7 +51,8 @@ advisory, and safe cleanup policy as one unit.
|
|
|
50
51
|
"state": "existing",
|
|
51
52
|
"choice": "yes",
|
|
52
53
|
"operations": [
|
|
53
|
-
"adopt-existing"
|
|
54
|
+
"adopt-existing",
|
|
55
|
+
"reconcile-landing-artifact-policy"
|
|
54
56
|
]
|
|
55
57
|
},
|
|
56
58
|
{
|
|
@@ -87,6 +89,50 @@ Derive its glob values from the consumer's ignored planning artefacts; an empty
|
|
|
87
89
|
array is valid. Existing values are consumer-owned and remain byte-identical on
|
|
88
90
|
adoption or rerun. Core never supplies filename defaults.
|
|
89
91
|
|
|
92
|
+
Also reconcile an explicit
|
|
93
|
+
`wrapup.landingGeneratedArtifactPatterns` array. Derive candidates from the
|
|
94
|
+
consumer's real landing commands and ignored outputs, show the exact list for
|
|
95
|
+
review, and write it only as part of that explicit setup decision. Never copy
|
|
96
|
+
another repository's values or infer deletion authority from `.gitignore`
|
|
97
|
+
alone. An empty array is valid. Existing configured values remain byte-identical.
|
|
98
|
+
If an existing enabled profile lacks the key, report one actionable setup
|
|
99
|
+
decision and leave the project layer unchanged until the consumer confirms the
|
|
100
|
+
derived list.
|
|
101
|
+
|
|
102
|
+
## Profile glob dialect
|
|
103
|
+
|
|
104
|
+
Every consumer-profile glob in this kit — Worktree Lifecycle and Workflow
|
|
105
|
+
Advisories alike — is matched by the one shared dialect in
|
|
106
|
+
`scripts/profile_globs.py`. There is no second matcher and no per-capability
|
|
107
|
+
variant:
|
|
108
|
+
|
|
109
|
+
- `*` matches any run of characters inside one path segment, never `/`.
|
|
110
|
+
- `?` matches exactly one character inside one path segment.
|
|
111
|
+
- `[seq]` and `[!seq]` are per-segment character classes; `/` is always a
|
|
112
|
+
separator and never a class member.
|
|
113
|
+
- `**` as a complete segment matches zero or more segments, so a leading `**/`
|
|
114
|
+
also matches the repository root and `dir/**` also matches `dir` itself.
|
|
115
|
+
- Matching is always case-sensitive, on every host filesystem.
|
|
116
|
+
- A pattern must match the whole repository-relative path.
|
|
117
|
+
|
|
118
|
+
Thus `**/__pycache__/**` covers root and nested caches, while `dist-kit/*` does
|
|
119
|
+
not cover `dist-kit/a/b`.
|
|
120
|
+
|
|
121
|
+
When adopting an existing profile, or after any kit update, review it before
|
|
122
|
+
trusting its patterns:
|
|
123
|
+
|
|
124
|
+
```bash
|
|
125
|
+
python3 scripts/profile_globs.py docs/agents/workflow-capabilities.json
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
The check names every pattern whose match set narrows or widens against that
|
|
129
|
+
key's legacy matcher, prints the concrete witness path that proves the
|
|
130
|
+
difference, and marks the keys that carry deletion authority. Exit code 1 means
|
|
131
|
+
at least one pattern needs review. Report the named patterns and let the
|
|
132
|
+
consumer rewrite them; never migrate a pattern automatically and never treat a
|
|
133
|
+
widened deletion-authority pattern as an accepted default. The check reads the
|
|
134
|
+
profile and never edits it.
|
|
135
|
+
|
|
90
136
|
The shipped read-only inventory is
|
|
91
137
|
`python3 scripts/worktree-lifecycle/cleanup.py sweep`. The same profile powers
|
|
92
138
|
its branch issue extraction and scratch-only cleanup verdicts.
|