design-playbook 0.22.1 → 0.22.2
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/codex/AGENTS.md
CHANGED
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "design-playbook",
|
|
3
|
-
"version": "0.22.
|
|
3
|
+
"version": "0.22.2",
|
|
4
4
|
"description": "Design I/O for coding agents: controllable UI generation via declarations (spec/domain/craft/design/components/template) and contracts (skill/evaluator). Use for product UI—console, dashboard, agent-ops, CJK-first apps.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"pi-package",
|
package/scripts/run_facts.py
CHANGED
|
@@ -81,6 +81,9 @@ class RunFacts:
|
|
|
81
81
|
shaping_events: tuple[dict[str, Any], ...] | None = None
|
|
82
82
|
shaping_error: str | None = None
|
|
83
83
|
_artifact_states: tuple[tuple[str, str], ...] = ()
|
|
84
|
+
# fill: lines inside fenced blocks are ignored by declaration parsing;
|
|
85
|
+
# kept as a fact so run-status can name them instead of hiding the skip.
|
|
86
|
+
fenced_fill_declarations: tuple[str, ...] = ()
|
|
84
87
|
|
|
85
88
|
@property
|
|
86
89
|
def manifest_entries(self) -> tuple[dict[str, Any], ...]:
|
|
@@ -231,20 +234,36 @@ def _existing_paths(run_root: Path | None) -> frozenset[str]:
|
|
|
231
234
|
return frozenset(paths)
|
|
232
235
|
|
|
233
236
|
|
|
234
|
-
def
|
|
235
|
-
|
|
237
|
+
def _scan_fill_declarations(
|
|
238
|
+
plan_text: str,
|
|
239
|
+
) -> tuple[tuple[str, ...], tuple[str, ...]]:
|
|
240
|
+
"""Split column-0 ``fill:`` lines into (declared, ignored-inside-fences).
|
|
241
|
+
|
|
242
|
+
Declarations inside fenced blocks are a misdeclaration the plan author
|
|
243
|
+
must fix, not a silent skip: the second tuple feeds the run-status
|
|
244
|
+
diagnostic that names them and states the column-0 unfenced rule.
|
|
245
|
+
"""
|
|
236
246
|
found: list[str] = []
|
|
247
|
+
ignored: list[str] = []
|
|
237
248
|
fenced = False
|
|
238
249
|
for line in plan_text.splitlines():
|
|
239
250
|
if line.lstrip().startswith("```"):
|
|
240
251
|
fenced = not fenced
|
|
241
252
|
continue
|
|
242
|
-
if
|
|
253
|
+
if not line.startswith("fill:"):
|
|
243
254
|
continue
|
|
244
255
|
declared = line[5:].strip().split()[0].rstrip(",") if line[5:].strip() else ""
|
|
245
|
-
if
|
|
246
|
-
|
|
247
|
-
|
|
256
|
+
if not declared:
|
|
257
|
+
continue
|
|
258
|
+
target = ignored if fenced else found
|
|
259
|
+
if declared not in target:
|
|
260
|
+
target.append(declared)
|
|
261
|
+
return tuple(found), tuple(ignored)
|
|
262
|
+
|
|
263
|
+
|
|
264
|
+
def _plan_fill_declarations(plan_text: str) -> tuple[str, ...]:
|
|
265
|
+
"""Capture every unfenced plan Fill token without checking filesystem state."""
|
|
266
|
+
return _scan_fill_declarations(plan_text)[0]
|
|
248
267
|
|
|
249
268
|
|
|
250
269
|
def resolve_declared_fill(run_root: Path, declared: str) -> Path | None:
|
|
@@ -262,15 +281,15 @@ def resolve_declared_fill(run_root: Path, declared: str) -> Path | None:
|
|
|
262
281
|
|
|
263
282
|
def _plan_fill_artifacts(
|
|
264
283
|
run_root: Path | None,
|
|
265
|
-
|
|
284
|
+
declared: tuple[str, ...],
|
|
266
285
|
) -> tuple[str, ...]:
|
|
267
286
|
"""Capture existing fill declarations while the run snapshot is loaded."""
|
|
268
287
|
if run_root is None:
|
|
269
288
|
return ()
|
|
270
289
|
return tuple(
|
|
271
|
-
|
|
272
|
-
for
|
|
273
|
-
if resolve_declared_fill(run_root,
|
|
290
|
+
token
|
|
291
|
+
for token in declared
|
|
292
|
+
if resolve_declared_fill(run_root, token) is not None
|
|
274
293
|
)
|
|
275
294
|
|
|
276
295
|
|
|
@@ -436,6 +455,7 @@ def capture_run_facts(
|
|
|
436
455
|
manifest_lines, manifest_errors, manifest_state = _read_manifest(evidence_dir)
|
|
437
456
|
preview = inspect_preview(preview_dir) if preview_dir is not None else None
|
|
438
457
|
optional = _read_optional_run_facts(run_root)
|
|
458
|
+
plan_declared, plan_fenced = _scan_fill_declarations(optional.plan_text)
|
|
439
459
|
return RunFacts(
|
|
440
460
|
run_root=run_root,
|
|
441
461
|
spec_path=spec_path,
|
|
@@ -445,7 +465,8 @@ def capture_run_facts(
|
|
|
445
465
|
spec_text=spec_text,
|
|
446
466
|
pointback_text=pointback_text,
|
|
447
467
|
plan_text=optional.plan_text,
|
|
448
|
-
plan_fill_artifacts=_plan_fill_artifacts(run_root,
|
|
468
|
+
plan_fill_artifacts=_plan_fill_artifacts(run_root, plan_declared),
|
|
469
|
+
fenced_fill_declarations=plan_fenced,
|
|
449
470
|
craft_guard_text=optional.craft_guard_text,
|
|
450
471
|
ledger=parse_ledger(pointback_text),
|
|
451
472
|
verdict=parse_verdict(pointback_text),
|
package/scripts/run_status.py
CHANGED
|
@@ -101,6 +101,9 @@ def render(run_root: Path, *, as_json: bool) -> int:
|
|
|
101
101
|
}
|
|
102
102
|
for s in states
|
|
103
103
|
],
|
|
104
|
+
# fill: lines inside fenced blocks are ignored by declaration
|
|
105
|
+
# parsing; surfaced so a silently unmarked fill stage is explainable.
|
|
106
|
+
"fenced_fill_declarations": list(facts.fenced_fill_declarations),
|
|
104
107
|
"next": action,
|
|
105
108
|
"verdict": verdict_of(run_root, facts),
|
|
106
109
|
# Fail closed: malformed/ambiguous markers project False, while
|
|
@@ -150,6 +153,12 @@ def render(run_root: Path, *, as_json: bool) -> int:
|
|
|
150
153
|
mark = "x" if s.present else " "
|
|
151
154
|
detail = f" ({', '.join(s.evidence)})" if s.evidence else ""
|
|
152
155
|
print(f" [{mark}] {s.key:10} {s.skill}{detail}")
|
|
156
|
+
if facts.fenced_fill_declarations:
|
|
157
|
+
listed = ", ".join(facts.fenced_fill_declarations)
|
|
158
|
+
print(
|
|
159
|
+
f"fill: declarations ignored (inside fenced blocks): {listed}; "
|
|
160
|
+
"declare Fill as column-0 unfenced `fill: <path>` lines in plan.md"
|
|
161
|
+
)
|
|
153
162
|
if vnext.tier is not None:
|
|
154
163
|
confirmed = "confirmed by user" if (
|
|
155
164
|
vnext.confirmed_by or "").casefold().startswith("user") else (
|
|
@@ -412,8 +412,19 @@ def _baseline_next_action(facts: RunFacts) -> str | None:
|
|
|
412
412
|
decision = state.get("decision") if isinstance(state.get("decision"), dict) else {}
|
|
413
413
|
|
|
414
414
|
if status == "needs_confirmation":
|
|
415
|
-
|
|
416
|
-
|
|
415
|
+
# Surface why a draft exists at all: an existing candidate that was
|
|
416
|
+
# rejected (accepting the draft replaces it) vs. no baseline found.
|
|
417
|
+
rejection = state.get("baseline_rejection")
|
|
418
|
+
detail = ""
|
|
419
|
+
if isinstance(rejection, dict):
|
|
420
|
+
path = rejection.get("path")
|
|
421
|
+
reason = rejection.get("reason")
|
|
422
|
+
if isinstance(path, str) and isinstance(reason, str) and reason.strip():
|
|
423
|
+
detail = f" (existing {path} was rejected: {reason.strip()})"
|
|
424
|
+
return (
|
|
425
|
+
"Design-baseline draft needs confirmation — "
|
|
426
|
+
f"accept/waive via design-baseline confirm before Fill.{detail}"
|
|
427
|
+
)
|
|
417
428
|
if status == "ambiguous":
|
|
418
429
|
return ("Design-baseline candidates are ambiguous — "
|
|
419
430
|
"resolve DESIGN.md vs .stitch/DESIGN.md before Fill.")
|
|
@@ -45,7 +45,7 @@ State is a cache, not authority. Every public call resolves paths against the su
|
|
|
45
45
|
| `status` | Meaning |
|
|
46
46
|
| --- | --- |
|
|
47
47
|
| `ready` | Bound baseline (`decision.kind` = `existing` or `accepted`) |
|
|
48
|
-
| `needs_confirmation` | Provenance-backed draft awaits accept/waive |
|
|
48
|
+
| `needs_confirmation` | Provenance-backed draft awaits accept/waive; `baseline_rejection` records why an existing candidate was not bound (`null` when none existed) |
|
|
49
49
|
| `waived` | Explicit user waiver with non-empty reason |
|
|
50
50
|
| `ambiguous` | Conflicting candidates; human choice required |
|
|
51
51
|
|
|
@@ -70,7 +70,7 @@ Agent work after prepare:
|
|
|
70
70
|
|
|
71
71
|
- if `status` is `ready` → cite path + sha256 and continue;
|
|
72
72
|
- if `ambiguous` → stop for the smallest user decision; never invent a third authority;
|
|
73
|
-
- if `needs_confirmation` → review the draft; optionally enrich only material claims with `[inferred confidence=…]` **in the draft file**, then re-run prepare if structure/sources changed (do not hand-edit hashes).
|
|
73
|
+
- if `needs_confirmation` → review the draft; optionally enrich only material claims with `[inferred confidence=…]` **in the draft file**, then re-run prepare if structure/sources changed (do not hand-edit hashes). When `baseline_rejection` is set, an existing `DESIGN.md` was rejected for the stated reason — accepting the draft **replaces** it (a backup is kept; see Confirm).
|
|
74
74
|
|
|
75
75
|
Never write or overwrite project `DESIGN.md` in this step.
|
|
76
76
|
|
|
@@ -80,7 +80,7 @@ Never write or overwrite project `DESIGN.md` in this step.
|
|
|
80
80
|
|
|
81
81
|
Show a compact summary: atmosphere, core tokens, typography, layout, primitives, conflicting evidence, inferred claims. Ask before the durable write.
|
|
82
82
|
|
|
83
|
-
- **Accept:** `confirm(..., decision="accept")` atomically writes canonical `<project-root>/DESIGN.md` from the bound draft and returns a `ready` state.
|
|
83
|
+
- **Accept:** `confirm(..., decision="accept")` atomically writes canonical `<project-root>/DESIGN.md` from the bound draft and returns a `ready` state. If a differing `DESIGN.md` already exists, the previous content is backed up byte-exact first and the state records it as `replaced_baseline` (`path`, `sha256`, `backup`); the CLI prints an overwrite warning with the backup path.
|
|
84
84
|
- **Waive:** `confirm(..., decision="waive", reason=<user reason>)` does not write `DESIGN.md`. Existing-product Fill may continue only after this explicit waiver.
|
|
85
85
|
- **Revise:** edit only the draft (or fix sources), then `prepare` again.
|
|
86
86
|
|
|
@@ -118,6 +118,7 @@ Third-party or sample `DESIGN.md` files remain `reference-intake` inputs. They n
|
|
|
118
118
|
.scratch/<run>/design-baseline/state.json # gate cache (schema design-baseline/v1)
|
|
119
119
|
.scratch/<run>/design-baseline/evidence.json # extraction evidence (when drafted)
|
|
120
120
|
.scratch/<run>/design-baseline/DESIGN.draft.md # proposal; never authority by itself
|
|
121
|
+
.scratch/<run>/design-baseline/previous-DESIGN.md # backup of a replaced DESIGN.md (when overwritten)
|
|
121
122
|
```
|
|
122
123
|
|
|
123
124
|
The deep module `prepare`/`confirm`/`verify` is the sole gate surface. An adopted existing `DESIGN.md` only needs to carry verifiable source provenance (path + SHA-256 under `## Source Evidence & Confidence`) to be bound; the other section names in [`references/design-template.md`](references/design-template.md) are draft guidance, not a structural contract imposed on hand-written baselines.
|
|
@@ -13,6 +13,7 @@ import hashlib
|
|
|
13
13
|
import json
|
|
14
14
|
import os
|
|
15
15
|
import re
|
|
16
|
+
import shutil
|
|
16
17
|
import sys
|
|
17
18
|
import tempfile
|
|
18
19
|
from datetime import datetime, timezone
|
|
@@ -24,6 +25,7 @@ SCHEMA = "design-baseline/v1"
|
|
|
24
25
|
STATE_RELATIVE = Path("design-baseline/state.json")
|
|
25
26
|
EVIDENCE_RELATIVE = Path("design-baseline/evidence.json")
|
|
26
27
|
DRAFT_RELATIVE = Path("design-baseline/DESIGN.draft.md")
|
|
28
|
+
PREVIOUS_RELATIVE = Path("design-baseline/previous-DESIGN.md")
|
|
27
29
|
CANDIDATES = (Path("DESIGN.md"), Path(".stitch/DESIGN.md"))
|
|
28
30
|
# Provenance-minimal gate: an existing project DESIGN.md only has to carry
|
|
29
31
|
# verifiable source provenance (path + SHA-256) to be bound. The other
|
|
@@ -157,6 +159,21 @@ def _atomic_write_json(path: Path, value: dict[str, Any]) -> None:
|
|
|
157
159
|
_atomic_write_text(path, json.dumps(value, indent=2, ensure_ascii=False) + "\n")
|
|
158
160
|
|
|
159
161
|
|
|
162
|
+
def _atomic_copy(source: Path, target: Path) -> None:
|
|
163
|
+
# Byte-exact copy via atomic replace: the backup of an overwritten
|
|
164
|
+
# authority must survive a crash mid-write with its content intact.
|
|
165
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
166
|
+
descriptor, temporary_name = tempfile.mkstemp(prefix=f".{target.name}.", dir=target.parent)
|
|
167
|
+
os.close(descriptor)
|
|
168
|
+
temporary = Path(temporary_name)
|
|
169
|
+
try:
|
|
170
|
+
shutil.copyfile(source, temporary)
|
|
171
|
+
os.replace(temporary, target)
|
|
172
|
+
finally:
|
|
173
|
+
if temporary.exists():
|
|
174
|
+
temporary.unlink()
|
|
175
|
+
|
|
176
|
+
|
|
160
177
|
def _candidate_files(project: Path) -> list[Path]:
|
|
161
178
|
candidates: list[Path] = []
|
|
162
179
|
for relative in CANDIDATES:
|
|
@@ -548,12 +565,18 @@ def prepare(project_root: Path | str, run_root: Path | str) -> dict[str, Any]:
|
|
|
548
565
|
# Existing candidates are validated by the same strict parser used by
|
|
549
566
|
# verify(). An incomplete candidate remains untouched while a replacement
|
|
550
567
|
# proposal is generated in the run directory.
|
|
568
|
+
rejected: dict[str, str] | None = None
|
|
551
569
|
if candidates:
|
|
552
570
|
selected = project / CANDIDATES[0] if (project / CANDIDATES[0]) in candidates else candidates[0]
|
|
553
571
|
try:
|
|
554
572
|
sources = _validate_baseline_document(selected, project)
|
|
555
|
-
except BaselineError:
|
|
573
|
+
except BaselineError as error:
|
|
574
|
+
# Keep the rejection reason visible: the draft generated below is
|
|
575
|
+
# a *replacement* proposal for this rejected candidate, and
|
|
576
|
+
# consumers must be able to distinguish an invalid existing
|
|
577
|
+
# baseline from no baseline at all.
|
|
556
578
|
sources = None
|
|
579
|
+
rejected = {"path": _relative(selected, project), "reason": str(error)}
|
|
557
580
|
if sources is not None:
|
|
558
581
|
state = {
|
|
559
582
|
"schema": SCHEMA,
|
|
@@ -589,6 +612,10 @@ def prepare(project_root: Path | str, run_root: Path | str) -> dict[str, Any]:
|
|
|
589
612
|
"decision": None,
|
|
590
613
|
"candidates": candidate_names,
|
|
591
614
|
"candidate_sha256": _candidate_snapshot(project, candidates),
|
|
615
|
+
# Why a prepared draft exists at all: ``null`` means no existing
|
|
616
|
+
# candidate was found; a record means one existed but was rejected
|
|
617
|
+
# for that stated reason (the draft proposes replacing it).
|
|
618
|
+
"baseline_rejection": rejected,
|
|
592
619
|
},
|
|
593
620
|
)
|
|
594
621
|
|
|
@@ -669,6 +696,26 @@ def confirm(
|
|
|
669
696
|
if canonical.is_symlink():
|
|
670
697
|
raise BaselineError("canonical DESIGN.md must not be a symlink")
|
|
671
698
|
draft_text = _read_text_capped(draft_path, "baseline draft")
|
|
699
|
+
# Overwrite protection: accept may legitimately replace an invalid
|
|
700
|
+
# existing baseline, but the previous authority is never silently lost —
|
|
701
|
+
# differing content is backed up (byte-exact) and recorded in the state
|
|
702
|
+
# so the overwrite is explicit and reversible.
|
|
703
|
+
replaced: dict[str, str] | None = None
|
|
704
|
+
if canonical.is_file():
|
|
705
|
+
try:
|
|
706
|
+
existing_hash = _sha256(canonical)
|
|
707
|
+
except OSError as error:
|
|
708
|
+
raise BaselineError(
|
|
709
|
+
"cannot hash existing canonical DESIGN.md; "
|
|
710
|
+
f"refusing to overwrite it without a backup: {error}"
|
|
711
|
+
) from error
|
|
712
|
+
if existing_hash != expected_draft_hash:
|
|
713
|
+
_atomic_copy(canonical, run / PREVIOUS_RELATIVE)
|
|
714
|
+
replaced = {
|
|
715
|
+
"path": CANDIDATES[0].as_posix(),
|
|
716
|
+
"sha256": existing_hash,
|
|
717
|
+
"backup": PREVIOUS_RELATIVE.as_posix(),
|
|
718
|
+
}
|
|
672
719
|
_atomic_write_text(canonical, draft_text)
|
|
673
720
|
# Post-write TOCTOU hardening (issue M1): between the pre-write symlink
|
|
674
721
|
# check and os.replace, a concurrent writer could swap DESIGN.md for a
|
|
@@ -688,6 +735,8 @@ def confirm(
|
|
|
688
735
|
state["sources"] = sources
|
|
689
736
|
state["decision"] = {"kind": "accepted", "confirmed_at": _utc_now()}
|
|
690
737
|
state["candidate_sha256"] = _candidate_snapshot(project, _candidate_files(project))
|
|
738
|
+
if replaced is not None:
|
|
739
|
+
state["replaced_baseline"] = replaced
|
|
691
740
|
_write_state(run, state)
|
|
692
741
|
return verify(project, run)
|
|
693
742
|
|
|
@@ -773,6 +822,14 @@ def main(argv: list[str] | None = None) -> int:
|
|
|
773
822
|
except BaselineError as error:
|
|
774
823
|
print(json.dumps({"error": str(error)}, ensure_ascii=False), file=sys.stderr)
|
|
775
824
|
return 2
|
|
825
|
+
replaced = result.get("replaced_baseline")
|
|
826
|
+
if isinstance(replaced, dict) and replaced.get("backup"):
|
|
827
|
+
# The overwrite is explicit in the output, not just in the state.
|
|
828
|
+
print(
|
|
829
|
+
f"WARNING: accept replaced the existing {replaced.get('path')}; "
|
|
830
|
+
f"the previous content is backed up at {replaced['backup']}",
|
|
831
|
+
file=sys.stderr,
|
|
832
|
+
)
|
|
776
833
|
print(json.dumps(result, indent=2, ensure_ascii=False))
|
|
777
834
|
return 0
|
|
778
835
|
|