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
@@ -1,4 +1,4 @@
1
- <!-- generated-by design-playbook v0.22.1 -->
1
+ <!-- generated-by design-playbook v0.22.2 -->
2
2
  # design-playbook for Codex
3
3
 
4
4
  ## Install (path of record)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "design-playbook",
3
- "version": "0.22.1",
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",
@@ -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 _plan_fill_declarations(plan_text: str) -> tuple[str, ...]:
235
- """Capture every unfenced plan Fill token without checking filesystem state."""
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 fenced or not line.startswith("fill:"):
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 declared and declared not in found:
246
- found.append(declared)
247
- return tuple(found)
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
- plan_text: str,
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
- declared
272
- for declared in _plan_fill_declarations(plan_text)
273
- if resolve_declared_fill(run_root, declared) is not None
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, optional.plan_text),
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),
@@ -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
- return ("Design-baseline draft needs confirmation — "
416
- "accept/waive via design-baseline confirm before Fill.")
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