@pilotspace/add 1.3.0 → 1.5.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/CHANGELOG.md +108 -0
- package/GETTING-STARTED.md +4 -0
- package/README.md +16 -3
- package/bin/cli.js +1 -6
- package/docs/09-the-loop.md +3 -1
- package/docs/14-foundation.md +11 -0
- package/docs/appendix-c-glossary.md +16 -0
- package/package.json +1 -1
- package/skill/add/SKILL.md +19 -2
- package/skill/add/advisor.md +75 -0
- package/skill/add/compact-foundation.md +53 -0
- package/skill/add/confidence.md +48 -0
- package/skill/add/design.md +92 -0
- package/skill/add/fold.md +4 -4
- package/skill/add/phases/0-ground.md +2 -0
- package/skill/add/phases/0-setup.md +51 -3
- package/skill/add/phases/1-specify.md +5 -1
- package/skill/add/phases/2-scenarios.md +2 -0
- package/skill/add/phases/3-contract.md +2 -0
- package/skill/add/phases/4-tests.md +2 -0
- package/skill/add/phases/5-build.md +2 -0
- package/skill/add/phases/6-verify.md +2 -0
- package/skill/add/phases/7-observe.md +7 -0
- package/skill/add/soul.md +77 -0
- package/skill/add/streams.md +44 -3
- package/tooling/add.py +403 -6
- package/tooling/templates/GLOSSARY.md.tmpl +4 -0
- package/tooling/templates/SOUL.md.tmpl +40 -0
- package/tooling/templates/TASK.md.tmpl +1 -1
- package/tooling/templates/kit.sample.css +53 -0
- package/tooling/templates/settings.sample.html +25 -0
- package/tooling/templates/tokens.sample.css +23 -0
- package/tooling/templates/udd-wireframe.md +118 -0
- package/tooling/templates/welcome.sample.html +25 -0
- package/tooling/templates/wireframe.sample.txt +27 -0
package/tooling/add.py
CHANGED
|
@@ -19,7 +19,8 @@ import os
|
|
|
19
19
|
import re
|
|
20
20
|
import sys
|
|
21
21
|
import tempfile
|
|
22
|
-
|
|
22
|
+
import urllib.request
|
|
23
|
+
from datetime import date, datetime, timedelta, timezone
|
|
23
24
|
from pathlib import Path
|
|
24
25
|
|
|
25
26
|
# --- constants ---------------------------------------------------------------
|
|
@@ -80,7 +81,23 @@ PHASE_OWNER = {
|
|
|
80
81
|
"specify": "human", "scenarios": "human", "contract": "seam",
|
|
81
82
|
"tests": "ai", "build": "ai", "verify": "human", "observe": "ai", "done": "human",
|
|
82
83
|
}
|
|
83
|
-
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md")
|
|
84
|
+
SETUP_FILES = ("PROJECT.md", "CONVENTIONS.md", "GLOSSARY.md", "MODEL_REGISTRY.md", "dependencies.allowlist", "DESIGN.md", "SOUL.md")
|
|
85
|
+
|
|
86
|
+
# Scaffolded into .add/.gitignore at init so the engine's transient LOCAL artifacts
|
|
87
|
+
# never reach git. Bare-filename patterns match at any depth under .add/ (tasks/,
|
|
88
|
+
# milestones/, archive/). These are working state, not records: scope-snapshot.json
|
|
89
|
+
# is the tests->build touch baseline the verify scope-gate reads from disk (the
|
|
90
|
+
# durable scope declaration is the state.json anchor); pre-archive-state.bak.json is
|
|
91
|
+
# archive-milestone's pre-delete recovery net — needed on disk, never in history;
|
|
92
|
+
# .update-cache.json is the update-nudge's once-a-day registry throttle. All stay on
|
|
93
|
+
# disk; git-ignoring them is hygiene, never deletion.
|
|
94
|
+
_GITIGNORE_BODY = """\
|
|
95
|
+
# ADD engine transient artifacts — local working state, never committed.
|
|
96
|
+
# (Scaffolded by `add.py init`; edit freely — init never clobbers an existing copy.)
|
|
97
|
+
scope-snapshot.json
|
|
98
|
+
pre-archive-state.bak.json
|
|
99
|
+
.update-cache.json
|
|
100
|
+
"""
|
|
84
101
|
|
|
85
102
|
# Guideline-injection targets + version-stable markers. NEVER change these marker
|
|
86
103
|
# strings: a re-run finds the old block by exact match, so changing them would
|
|
@@ -370,6 +387,13 @@ def cmd_init(args: argparse.Namespace) -> None:
|
|
|
370
387
|
_die(f"already initialised at {root} (use --force to reset state)")
|
|
371
388
|
|
|
372
389
|
(root / "tasks").mkdir(parents=True, exist_ok=True)
|
|
390
|
+
# Keep the engine's transient local artifacts out of git. Never-clobber: a
|
|
391
|
+
# human may have customised .add/.gitignore, so an existing one is left as-is
|
|
392
|
+
# (mirrors the SETUP_FILES skip-not-clobber idiom). Writes ONLY this file — no
|
|
393
|
+
# scope-snapshot.json or .bak is created, deleted, or modified.
|
|
394
|
+
gitignore = root / ".gitignore"
|
|
395
|
+
if not gitignore.exists():
|
|
396
|
+
_atomic_write(gitignore, _GITIGNORE_BODY)
|
|
373
397
|
today = date.today().isoformat()
|
|
374
398
|
proj_name = args.name or base.name
|
|
375
399
|
|
|
@@ -456,7 +480,7 @@ def cmd_new_task(args: argparse.Namespace) -> None:
|
|
|
456
480
|
if _project_autonomy_token(root) == "?":
|
|
457
481
|
print("warning: garbled_project_autonomy — PROJECT.md declares an unrecognized "
|
|
458
482
|
f"autonomy token; new task seeded fail-safe '{autonomy}' "
|
|
459
|
-
"(
|
|
483
|
+
"(fix it with `add.py autonomy set <level> --project`)", file=sys.stderr)
|
|
460
484
|
|
|
461
485
|
state["tasks"][slug] = {
|
|
462
486
|
"title": title,
|
|
@@ -706,8 +730,8 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
706
730
|
hdr = _task_header(root, slug)
|
|
707
731
|
if _RISK_HIGH_RE.search(hdr) and not _autonomy_lowered(hdr):
|
|
708
732
|
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high "
|
|
709
|
-
"without a lowered autonomy level —
|
|
710
|
-
"
|
|
733
|
+
"without a lowered autonomy level — run `add.py autonomy set conservative` "
|
|
734
|
+
"(or manual); a human must own a high-risk gate (run.md guard)")
|
|
711
735
|
# tamper tripwire (verify-integrity): the method's first mechanical cheat
|
|
712
736
|
# block. A completing outcome is refused if the red suite or the frozen §3
|
|
713
737
|
# changed since the tests->build snapshot. Placed BEFORE the waiver write so
|
|
@@ -738,6 +762,80 @@ def cmd_gate(args: argparse.Namespace) -> None:
|
|
|
738
762
|
print(_next_footer(root, state))
|
|
739
763
|
|
|
740
764
|
|
|
765
|
+
# the autonomy level as a first-class verb (task autonomy-command): autonomy was the ONLY mutable,
|
|
766
|
+
# security-relevant task/project token WITHOUT a CLI verb — so an agent under `auto`, applying the
|
|
767
|
+
# correct "first-class state has a command" model, hallucinated `add.py autonomy` and derailed.
|
|
768
|
+
# `show` reads the resolved level; `set` is the FIRST writer of the header token — idempotent (one
|
|
769
|
+
# declaration line, trailing comment preserved, NEVER appended), with the raise + risk:high guards
|
|
770
|
+
# enforced BEFORE the write. state.json is untouched — autonomy stays a header token.
|
|
771
|
+
_AUTONOMY_ORDER = {lvl: i for i, lvl in enumerate(_AUTONOMY_LEVELS)} # manual(0) < conservative(1) < auto(2)
|
|
772
|
+
|
|
773
|
+
|
|
774
|
+
def _autonomy_decl_line(text: str, level: str) -> str:
|
|
775
|
+
"""Rewrite the SINGLE `autonomy:` declaration line to `level`, PRESERVING its trailing comment,
|
|
776
|
+
idempotently (replace in place, count=1 — never a second line). If absent, insert it: after the
|
|
777
|
+
`slug:` line for a task header, else after a leading `#` heading (PROJECT.md), else prepend. PURE
|
|
778
|
+
on the text; the caller does the atomic write."""
|
|
779
|
+
pat = re.compile(r"(?m)^(autonomy:[ \t]*)[^\s<#|]+(.*)$")
|
|
780
|
+
if pat.search(text):
|
|
781
|
+
return pat.sub(lambda m: f"{m.group(1)}{level}{m.group(2)}", text, count=1)
|
|
782
|
+
if re.search(r"(?m)^slug:", text):
|
|
783
|
+
return re.sub(r"(?m)^(slug:.*)$", r"\1\nautonomy: " + level, text, count=1)
|
|
784
|
+
lines = text.splitlines(keepends=True)
|
|
785
|
+
if lines and lines[0].lstrip().startswith("#"):
|
|
786
|
+
return lines[0] + f"autonomy: {level}\n" + "".join(lines[1:])
|
|
787
|
+
return f"autonomy: {level}\n" + text
|
|
788
|
+
|
|
789
|
+
|
|
790
|
+
def _guard_autonomy_raise(current: str, target: str, yes: bool) -> None:
|
|
791
|
+
"""RAISING the level toward `auto` is a human-owned trust escalation (run.md: the AI may LOWER
|
|
792
|
+
freely — RECOMMEND-only — but RAISING needs a human). Refuse a raise unless --yes confirms it."""
|
|
793
|
+
if _AUTONOMY_ORDER.get(target, -1) > _AUTONOMY_ORDER.get(current, -1) and not yes:
|
|
794
|
+
_die(f"autonomy_raise_unconfirmed: raising autonomy {current} -> {target} is a human-owned "
|
|
795
|
+
"trust escalation (the AI may LOWER freely; RAISING needs a human) — pass --yes to confirm")
|
|
796
|
+
|
|
797
|
+
|
|
798
|
+
def _print_autonomy(root: Path, state: dict, slug: str) -> None:
|
|
799
|
+
"""The read-only level view: declared · effective (fallback-resolved) · project default · the
|
|
800
|
+
verify-gate owner under it (the SAME _driver_stop the footer/guide render). Writes nothing."""
|
|
801
|
+
declared = _autonomy_level(_task_header(root, slug))
|
|
802
|
+
stop = _driver_stop(root, state, slug, "verify")
|
|
803
|
+
print(f"task : {slug}")
|
|
804
|
+
print(f"declared : {declared if declared in _AUTONOMY_LEVELS else 'unset'}")
|
|
805
|
+
print(f"effective : {_effective_autonomy(root, state, slug)}")
|
|
806
|
+
print(f"project : {_project_autonomy(root)}")
|
|
807
|
+
print(f"verify gate : {'human gate' if stop else 'you drive'}")
|
|
808
|
+
|
|
809
|
+
|
|
810
|
+
def cmd_autonomy(args: argparse.Namespace) -> None:
|
|
811
|
+
"""show / set the autonomy level — the verify-gate owner (task autonomy-command)."""
|
|
812
|
+
root = _require_root() # reused -> "no .add/ project found …"
|
|
813
|
+
state = load_state(root)
|
|
814
|
+
if (getattr(args, "action", None) or "show") == "show":
|
|
815
|
+
_print_autonomy(root, state, _resolve_task(state, args.a1)) # reused -> "unknown task '<slug>'"
|
|
816
|
+
return
|
|
817
|
+
# action == "set"
|
|
818
|
+
level = args.a1
|
|
819
|
+
if level not in _AUTONOMY_LEVELS:
|
|
820
|
+
_die("autonomy_level_invalid: level must be one of "
|
|
821
|
+
f"{', '.join(_AUTONOMY_LEVELS)} (got {level!r})")
|
|
822
|
+
if getattr(args, "project", False):
|
|
823
|
+
target = root / "PROJECT.md"
|
|
824
|
+
_guard_autonomy_raise(_project_autonomy(root), level, getattr(args, "yes", False))
|
|
825
|
+
_atomic_write(target, _autonomy_decl_line(target.read_text(encoding="utf-8"), level))
|
|
826
|
+
print(f"project autonomy -> {level}")
|
|
827
|
+
return
|
|
828
|
+
slug = _resolve_task(state, args.a2) # reused -> "unknown task '<slug>'"
|
|
829
|
+
task_md = root / "tasks" / slug / "TASK.md"
|
|
830
|
+
if _RISK_HIGH_RE.search(_task_header(root, slug)) and level not in ("manual", "conservative"):
|
|
831
|
+
_die(f"unguarded_high_risk_auto: task '{slug}' declares risk: high — autonomy must stay "
|
|
832
|
+
f"lowered (manual|conservative); refusing '{level}' (a human must own a high-risk gate)")
|
|
833
|
+
_guard_autonomy_raise(_effective_autonomy(root, state, slug), level, getattr(args, "yes", False))
|
|
834
|
+
_atomic_write(task_md, _autonomy_decl_line(task_md.read_text(encoding="utf-8"), level))
|
|
835
|
+
print(f"task '{slug}' autonomy -> {level}")
|
|
836
|
+
_print_autonomy(root, state, slug)
|
|
837
|
+
|
|
838
|
+
|
|
741
839
|
def cmd_reopen(args: argparse.Namespace) -> None:
|
|
742
840
|
"""Return an already-`done` task to an earlier phase with a never-silent record.
|
|
743
841
|
|
|
@@ -918,6 +1016,10 @@ def cmd_status(args: argparse.Namespace) -> None:
|
|
|
918
1016
|
# foundation pointer — read the cross-milestone context first (anti-rot)
|
|
919
1017
|
if (root / "PROJECT.md").exists():
|
|
920
1018
|
print("context : .add/PROJECT.md (foundation: domain · spec · UI/UX — read first)")
|
|
1019
|
+
# voice pointer — the AI's SOUL (tone · style · trust); read each session, edit freely.
|
|
1020
|
+
# Existence-only: no open/parse, so the pointer adds no IO failure path (a non-file is no voice).
|
|
1021
|
+
if (root / "SOUL.md").exists():
|
|
1022
|
+
print("voice : .add/SOUL.md (how I sound & what keeps your trust — read each session)")
|
|
921
1023
|
# wave resume hint — a live ledger outranks memory (streams.md "Wave ledger").
|
|
922
1024
|
# Existence-only: no open/read/parse, so the hint adds no IO failure path; a
|
|
923
1025
|
# non-file at the path is not a ledger. One line PER live ledger — more than
|
|
@@ -1504,6 +1606,27 @@ def _udd_named_set_checks(root: Path) -> list[tuple[bool, str, str]]:
|
|
|
1504
1606
|
return out
|
|
1505
1607
|
|
|
1506
1608
|
|
|
1609
|
+
_CAPTURE_EXTS = ("png", "svg", "jpg", "jpeg", "webp")
|
|
1610
|
+
|
|
1611
|
+
|
|
1612
|
+
def _missing_captures(root: Path) -> list[str]:
|
|
1613
|
+
"""Prototype names under `.add/design/prototypes/` lacking a design-confirm capture.
|
|
1614
|
+
|
|
1615
|
+
A prototype `<name>.json` is CAPTURED iff a file `.add/design/captures/<name>.<ext>`
|
|
1616
|
+
exists (ext in _CAPTURE_EXTS). Returns the uncaptured names in document (sorted) order.
|
|
1617
|
+
PURE · TOTAL (missing dirs -> []) · READ-ONLY (never writes, never renders): the engine
|
|
1618
|
+
MEASURES capture presence; producing the image is the agent's tool-agnostic choice
|
|
1619
|
+
(design.md beat 4; default `@json-render/image`). [] == every prototype captured / none exist.
|
|
1620
|
+
"""
|
|
1621
|
+
proto_dir = root / "design" / "prototypes"
|
|
1622
|
+
cap_dir = root / "design" / "captures"
|
|
1623
|
+
if not proto_dir.is_dir():
|
|
1624
|
+
return []
|
|
1625
|
+
names = sorted(p.stem for p in proto_dir.glob("*.json") if p.is_file())
|
|
1626
|
+
return [n for n in names
|
|
1627
|
+
if not any((cap_dir / f"{n}.{ext}").is_file() for ext in _CAPTURE_EXTS)]
|
|
1628
|
+
|
|
1629
|
+
|
|
1507
1630
|
def cmd_check(args: argparse.Namespace) -> None:
|
|
1508
1631
|
"""Read-only integrity check of the .add project. Exit 1 if anything fails."""
|
|
1509
1632
|
as_json = getattr(args, "json", False)
|
|
@@ -1551,7 +1674,7 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1551
1674
|
"unknown_autonomy_level (token outside manual|conservative|auto)"))
|
|
1552
1675
|
if _alvl is None and t.get("phase") not in ("done", "observe"):
|
|
1553
1676
|
warnings.append((f"task '{slug}'", "has no explicit autonomy level (implicit_autonomy) "
|
|
1554
|
-
"—
|
|
1677
|
+
"— run `add.py autonomy set <level>` to set it"))
|
|
1555
1678
|
for dep in t.get("depends_on") or []:
|
|
1556
1679
|
checks.append((dep in tasks or dep in archived_slugs,
|
|
1557
1680
|
f"task '{slug}' dep '{dep}' resolves", "unknown task"))
|
|
@@ -1670,6 +1793,16 @@ def cmd_check(args: argparse.Namespace) -> None:
|
|
|
1670
1793
|
# Silent when absent; read-only; fail-closed on malformed JSON.
|
|
1671
1794
|
checks.extend(_udd_named_set_checks(root))
|
|
1672
1795
|
|
|
1796
|
+
# capture-evidence: a never-red WARN naming each prototype with no design-confirm capture
|
|
1797
|
+
# at .add/design/captures/<name>.<ext>. Measure-never-block — rides `warnings`, NEVER
|
|
1798
|
+
# `checks` (so never feeds `failed`); silent-when-absent (no prototypes -> []). The engine
|
|
1799
|
+
# MEASURES capture presence; producing the image is the agent's tool-agnostic choice.
|
|
1800
|
+
for _pname in _missing_captures(root):
|
|
1801
|
+
warnings.append(("missing_capture",
|
|
1802
|
+
f"prototype '{_pname}' has no design-confirm capture at "
|
|
1803
|
+
f".add/design/captures/{_pname}.<png|svg|…> — render + confirm it "
|
|
1804
|
+
"before build (design.md beat 4)"))
|
|
1805
|
+
|
|
1673
1806
|
passed = sum(1 for ok, _, _ in checks if ok)
|
|
1674
1807
|
failed = len(checks) - passed
|
|
1675
1808
|
if as_json:
|
|
@@ -1903,6 +2036,144 @@ def cmd_ready(args: argparse.Namespace) -> None:
|
|
|
1903
2036
|
print(f" {slug}{suffix}")
|
|
1904
2037
|
|
|
1905
2038
|
|
|
2039
|
+
def _wave_schedule(state: dict, mslug: str) -> dict:
|
|
2040
|
+
"""Pure, total: derive the DAG schedule for milestone `mslug` from state — never
|
|
2041
|
+
mutates, never raises on dict input. Returns one of:
|
|
2042
|
+
{"cycle": [slug, ...]} — unschedulable cycle
|
|
2043
|
+
{"waves", "critical_path", "critical_path_len", "tiers", "blocked"} — a schedule
|
|
2044
|
+
|
|
2045
|
+
A dep is SATISFIED (does not block) if it is archived or `_task_done` — the SAME
|
|
2046
|
+
predicate cmd_ready uses. A not-done dep that is an OPEN MEMBER of this milestone
|
|
2047
|
+
forces a later wave. A not-done dep that is NOT an open member (external/unknown)
|
|
2048
|
+
is UNSATISFIABLE here -> the task is `blocked`, never scheduled. Critical path is the
|
|
2049
|
+
longest chain (most tasks) through the scheduled sub-DAG; ties break by sorted slug.
|
|
2050
|
+
Tier is advisory: `top` on the critical path, `mid` elsewhere (scheduled tasks only)."""
|
|
2051
|
+
tasks = state.get("tasks") or {}
|
|
2052
|
+
archived = _archived_task_slugs(state)
|
|
2053
|
+
|
|
2054
|
+
def _ok(d: str) -> bool: # satisfied externally / already done
|
|
2055
|
+
return d in archived or (d in tasks and _task_done(tasks[d]))
|
|
2056
|
+
|
|
2057
|
+
open_members = {s: t for s, t in tasks.items()
|
|
2058
|
+
if t.get("milestone") == mslug and not _task_done(t)}
|
|
2059
|
+
|
|
2060
|
+
# partition open members into blocked vs schedulable — to a FIXED POINT, so blocking
|
|
2061
|
+
# propagates transitively: a task is blocked if any dep is unsatisfiable here, where
|
|
2062
|
+
# unsatisfiable = not _ok AND not a STILL-schedulable member. A dep on an already-blocked
|
|
2063
|
+
# member is itself unsatisfiable, so the dependent blocks too (it would otherwise be
|
|
2064
|
+
# mis-reported as wave-1-ready while its only dep can never complete).
|
|
2065
|
+
blocked: dict[str, list[str]] = {}
|
|
2066
|
+
changed = True
|
|
2067
|
+
while changed:
|
|
2068
|
+
changed = False
|
|
2069
|
+
for s, t in open_members.items():
|
|
2070
|
+
if s in blocked:
|
|
2071
|
+
continue
|
|
2072
|
+
bad = [d for d in (t.get("depends_on") or [])
|
|
2073
|
+
if not _ok(d) and not (d in open_members and d not in blocked)]
|
|
2074
|
+
if bad:
|
|
2075
|
+
blocked[s] = sorted(set(bad))
|
|
2076
|
+
changed = True
|
|
2077
|
+
schedulable = {s for s in open_members if s not in blocked}
|
|
2078
|
+
blocked_sorted = {k: blocked[k] for k in sorted(blocked)}
|
|
2079
|
+
if not schedulable:
|
|
2080
|
+
# nothing to schedule (all-done, empty, or every open task externally blocked)
|
|
2081
|
+
return {"waves": [], "critical_path": [], "critical_path_len": 0,
|
|
2082
|
+
"tiers": {}, "blocked": blocked_sorted}
|
|
2083
|
+
|
|
2084
|
+
def _member_deps(s: str) -> set[str]: # deps that are open members forcing order
|
|
2085
|
+
return {d for d in (open_members[s].get("depends_on") or []) if d in schedulable}
|
|
2086
|
+
|
|
2087
|
+
# Kahn waves over the schedulable sub-DAG
|
|
2088
|
+
waves: list[list[str]] = []
|
|
2089
|
+
placed: set[str] = set()
|
|
2090
|
+
remaining = set(schedulable)
|
|
2091
|
+
while remaining:
|
|
2092
|
+
wave = sorted(s for s in remaining if _member_deps(s) <= placed)
|
|
2093
|
+
if not wave: # no progress => a cycle among the remaining
|
|
2094
|
+
sub = {s: tasks[s] for s in remaining}
|
|
2095
|
+
cyc = _find_cycle(sub) or sorted(remaining)
|
|
2096
|
+
return {"cycle": cyc}
|
|
2097
|
+
waves.append(wave)
|
|
2098
|
+
placed.update(wave)
|
|
2099
|
+
remaining -= set(wave)
|
|
2100
|
+
|
|
2101
|
+
# critical path = longest chain by memoized depth over member-deps
|
|
2102
|
+
depth: dict[str, int] = {}
|
|
2103
|
+
pick: dict[str, str | None] = {}
|
|
2104
|
+
|
|
2105
|
+
def _depth(s: str) -> int:
|
|
2106
|
+
if s in depth:
|
|
2107
|
+
return depth[s]
|
|
2108
|
+
best_d, best_dep = 0, None
|
|
2109
|
+
for d in sorted(_member_deps(s)):
|
|
2110
|
+
dd = _depth(d)
|
|
2111
|
+
if dd > best_d or (dd == best_d and (best_dep is None or d < best_dep)):
|
|
2112
|
+
best_d, best_dep = dd, d
|
|
2113
|
+
depth[s] = 1 + best_d
|
|
2114
|
+
pick[s] = best_dep
|
|
2115
|
+
return depth[s]
|
|
2116
|
+
|
|
2117
|
+
leaf = min(schedulable, key=lambda s: (-_depth(s), s)) # deepest, tie -> smallest slug
|
|
2118
|
+
chain: list[str] = []
|
|
2119
|
+
cur: str | None = leaf
|
|
2120
|
+
while cur is not None:
|
|
2121
|
+
chain.append(cur)
|
|
2122
|
+
cur = pick.get(cur)
|
|
2123
|
+
critical = list(reversed(chain)) # root -> leaf order
|
|
2124
|
+
crit_set = set(critical)
|
|
2125
|
+
tiers = {s: ("top" if s in crit_set else "mid") for s in sorted(schedulable)}
|
|
2126
|
+
return {"waves": waves, "critical_path": critical, "critical_path_len": len(critical),
|
|
2127
|
+
"tiers": tiers, "blocked": blocked_sorted}
|
|
2128
|
+
|
|
2129
|
+
|
|
2130
|
+
def cmd_waves(args: argparse.Namespace) -> None:
|
|
2131
|
+
"""READ-ONLY DAG scheduler: print the active milestone's topological waves, critical
|
|
2132
|
+
path, advisory tier hint, and blocked set. Writes nothing; emits no `next:` footer."""
|
|
2133
|
+
is_json = getattr(args, "json", False)
|
|
2134
|
+
if is_json:
|
|
2135
|
+
_, state = _load_state_for_json()
|
|
2136
|
+
else:
|
|
2137
|
+
state = load_state(_require_root())
|
|
2138
|
+
mslug = getattr(args, "milestone", None) or state.get("active_milestone")
|
|
2139
|
+
if not mslug:
|
|
2140
|
+
_die("no_active_milestone: no active milestone and no --milestone given")
|
|
2141
|
+
if mslug not in (state.get("milestones") or {}):
|
|
2142
|
+
_die(f"unknown_milestone: '{mslug}' is not a milestone in this project")
|
|
2143
|
+
sched = _wave_schedule(state, mslug)
|
|
2144
|
+
if "cycle" in sched:
|
|
2145
|
+
_die(f"dependency_cycle: not-done deps form a cycle "
|
|
2146
|
+
f"({' -> '.join(sched['cycle'])}) — no valid schedule")
|
|
2147
|
+
|
|
2148
|
+
if is_json:
|
|
2149
|
+
print(json.dumps({"milestone": mslug, **sched}))
|
|
2150
|
+
return
|
|
2151
|
+
|
|
2152
|
+
print(f"milestone: {mslug}")
|
|
2153
|
+
if not sched["waves"]:
|
|
2154
|
+
if sched["blocked"]:
|
|
2155
|
+
for s in sched["blocked"]:
|
|
2156
|
+
print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2157
|
+
else:
|
|
2158
|
+
print("all tasks done — nothing to schedule")
|
|
2159
|
+
return
|
|
2160
|
+
scheduled_set = {x for w in sched["waves"] for x in w}
|
|
2161
|
+
for i, wave in enumerate(sched["waves"], start=1):
|
|
2162
|
+
parts = []
|
|
2163
|
+
for s in wave:
|
|
2164
|
+
md = sorted(d for d in (state["tasks"][s].get("depends_on") or [])
|
|
2165
|
+
if d in scheduled_set)
|
|
2166
|
+
parts.append(f"{s} (deps: {', '.join(md)})" if md else s)
|
|
2167
|
+
print(f"wave {i}: {', '.join(parts)}")
|
|
2168
|
+
crit = sched["critical_path"]
|
|
2169
|
+
print(f"critical path: {' → '.join(crit)} ({sched['critical_path_len']} tasks)")
|
|
2170
|
+
tops = [s for s, tier in sched["tiers"].items() if tier == "top"]
|
|
2171
|
+
mids = [s for s, tier in sched["tiers"].items() if tier == "mid"]
|
|
2172
|
+
print(f"tier hint: top → {', '.join(tops)}; mid → {', '.join(mids) or '(none)'}")
|
|
2173
|
+
for s in sched["blocked"]:
|
|
2174
|
+
print(f"blocked: {s} (waiting on {', '.join(sched['blocked'][s])})")
|
|
2175
|
+
|
|
2176
|
+
|
|
1906
2177
|
def cmd_milestone_done(args: argparse.Namespace) -> None:
|
|
1907
2178
|
root = _require_root()
|
|
1908
2179
|
state = load_state(root)
|
|
@@ -4019,6 +4290,13 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4019
4290
|
pr.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
4020
4291
|
pr.set_defaults(func=cmd_ready)
|
|
4021
4292
|
|
|
4293
|
+
pwa = sub.add_parser("waves", help="read-only DAG schedule of a milestone: topological "
|
|
4294
|
+
"waves + critical path + advisory tier hint")
|
|
4295
|
+
pwa.add_argument("--milestone", default=None,
|
|
4296
|
+
help="milestone slug to schedule (default: the active milestone)")
|
|
4297
|
+
pwa.add_argument("--json", action="store_true", help="machine-readable JSON output")
|
|
4298
|
+
pwa.set_defaults(func=cmd_waves)
|
|
4299
|
+
|
|
4022
4300
|
pmd = sub.add_parser("milestone-done", help="exit-gate a milestone (all tasks must PASS)")
|
|
4023
4301
|
pmd.add_argument("slug")
|
|
4024
4302
|
pmd.set_defaults(func=cmd_milestone_done)
|
|
@@ -4060,6 +4338,16 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
4060
4338
|
pg.add_argument("--expires", help="RISK-ACCEPTED waiver: expiry date")
|
|
4061
4339
|
pg.set_defaults(func=cmd_gate, _opt_positionals=("slug",))
|
|
4062
4340
|
|
|
4341
|
+
pan = sub.add_parser("autonomy", help="show or set the autonomy level (the verify-gate owner)")
|
|
4342
|
+
pan.add_argument("action", nargs="?", choices=("show", "set"), default="show")
|
|
4343
|
+
pan.add_argument("a1", nargs="?", default=None, help="set: <level>; show: [slug]")
|
|
4344
|
+
pan.add_argument("a2", nargs="?", default=None, help="set: [slug]")
|
|
4345
|
+
pan.add_argument("--project", action="store_true",
|
|
4346
|
+
help="set the PROJECT.md default instead of a task header")
|
|
4347
|
+
pan.add_argument("--yes", action="store_true",
|
|
4348
|
+
help="confirm a RAISE toward auto (a human-owned trust escalation)")
|
|
4349
|
+
pan.set_defaults(func=cmd_autonomy, _opt_positionals=("a1", "a2"))
|
|
4350
|
+
|
|
4063
4351
|
pr = sub.add_parser("reopen", help="return a done task to an earlier phase with a recorded reason")
|
|
4064
4352
|
pr.add_argument("slug", nargs="?", default=None)
|
|
4065
4353
|
# --to / --reason are validated in-body (not argparse choices) so the named reject
|
|
@@ -4171,11 +4459,120 @@ def _rebind_optional_positionals(parser: argparse.ArgumentParser,
|
|
|
4171
4459
|
return args
|
|
4172
4460
|
|
|
4173
4461
|
|
|
4462
|
+
# --- agent-agnostic update nudge --------------------------------------------
|
|
4463
|
+
# ADD is agent-agnostic: ANY agent (Claude Code · Gemini CLI · Codex) is told by the
|
|
4464
|
+
# guideline block to run `add.py status`/`guide` FIRST, every session. That is the one
|
|
4465
|
+
# universal chokepoint to tell a stale install to refresh — a plain line on STDERR the
|
|
4466
|
+
# agent reads and acts on. Bounded + fail-open by design (see _maybe_nudge_update).
|
|
4467
|
+
#
|
|
4468
|
+
# This is the engine's ONE deliberate, isolated network touch. It is justified narrowly:
|
|
4469
|
+
# an agent that is offline cannot run at all, so when the network is unreachable this
|
|
4470
|
+
# silently does nothing and nothing is lost. It NEVER changes a command's stdout or exit.
|
|
4471
|
+
_UPDATE_CACHE = ".update-cache.json"
|
|
4472
|
+
_UPDATE_TTL = timedelta(hours=24) # hit the registry at most once / day
|
|
4473
|
+
_REGISTRY_LATEST = "https://registry.npmjs.org/@pilotspace/add/latest"
|
|
4474
|
+
|
|
4475
|
+
|
|
4476
|
+
def _read_json_safe(path: Path):
|
|
4477
|
+
try:
|
|
4478
|
+
return json.loads(path.read_text(encoding="utf-8"))
|
|
4479
|
+
except (OSError, json.JSONDecodeError):
|
|
4480
|
+
return None
|
|
4481
|
+
|
|
4482
|
+
|
|
4483
|
+
def _write_json_safe(path: Path, obj) -> None:
|
|
4484
|
+
try:
|
|
4485
|
+
path.write_text(json.dumps(obj, indent=2) + "\n", encoding="utf-8")
|
|
4486
|
+
except OSError:
|
|
4487
|
+
pass
|
|
4488
|
+
|
|
4489
|
+
|
|
4490
|
+
def _version_gt(a: str, b: str) -> bool:
|
|
4491
|
+
"""True if version a is newer than b (dotted numeric; prerelease suffix dropped)."""
|
|
4492
|
+
def key(v: str):
|
|
4493
|
+
out = []
|
|
4494
|
+
for part in str(v).split("."):
|
|
4495
|
+
part = part.split("-", 1)[0]
|
|
4496
|
+
out.append((0, int(part)) if part.isdigit() else (1, part))
|
|
4497
|
+
return out
|
|
4498
|
+
try:
|
|
4499
|
+
return key(a) > key(b)
|
|
4500
|
+
except Exception:
|
|
4501
|
+
return False
|
|
4502
|
+
|
|
4503
|
+
|
|
4504
|
+
def _fetch_latest_version(timeout: float = 1.5):
|
|
4505
|
+
"""GET the registry's latest version. Returns a string, or None on ANY failure
|
|
4506
|
+
(offline, timeout, bad payload) — the caller treats None as 'unknown, skip'."""
|
|
4507
|
+
try:
|
|
4508
|
+
req = urllib.request.Request(_REGISTRY_LATEST, headers={"Accept": "application/json"})
|
|
4509
|
+
with urllib.request.urlopen(req, timeout=timeout) as resp:
|
|
4510
|
+
data = json.loads(resp.read().decode("utf-8"))
|
|
4511
|
+
v = data.get("version")
|
|
4512
|
+
return v if isinstance(v, str) and v else None
|
|
4513
|
+
except Exception:
|
|
4514
|
+
return None
|
|
4515
|
+
|
|
4516
|
+
|
|
4517
|
+
def _cached_latest(add_dir: Path):
|
|
4518
|
+
"""The registry's latest version, throttled: served from .update-cache.json within
|
|
4519
|
+
the TTL, else refreshed over the network (fail-open). None when unknown."""
|
|
4520
|
+
cache = _read_json_safe(add_dir / _UPDATE_CACHE)
|
|
4521
|
+
if cache and cache.get("latest") and cache.get("checked_at"):
|
|
4522
|
+
try:
|
|
4523
|
+
ts = datetime.fromisoformat(cache["checked_at"])
|
|
4524
|
+
if datetime.now(timezone.utc) - ts < _UPDATE_TTL:
|
|
4525
|
+
return cache["latest"] # fresh -> no network
|
|
4526
|
+
except ValueError:
|
|
4527
|
+
pass
|
|
4528
|
+
latest = _fetch_latest_version()
|
|
4529
|
+
if latest:
|
|
4530
|
+
_write_json_safe(add_dir / _UPDATE_CACHE, {
|
|
4531
|
+
"checked_at": datetime.now(timezone.utc).isoformat(timespec="seconds"),
|
|
4532
|
+
"latest": latest,
|
|
4533
|
+
})
|
|
4534
|
+
return latest
|
|
4535
|
+
return cache.get("latest") if cache else None # stale beats nothing
|
|
4536
|
+
|
|
4537
|
+
|
|
4538
|
+
def _maybe_nudge_update(args: argparse.Namespace) -> None:
|
|
4539
|
+
"""If the tooling is stale, print an ACTION-REQUIRED line to STDERR so the agent
|
|
4540
|
+
updates before the user's task. Fully bounded + fail-open — never raises, never
|
|
4541
|
+
touches stdout or the exit code."""
|
|
4542
|
+
try:
|
|
4543
|
+
if os.environ.get("ADD_NO_UPDATE_CHECK"):
|
|
4544
|
+
return
|
|
4545
|
+
fn = getattr(args, "func", None)
|
|
4546
|
+
# orientation reads only — never a mid-run mutating verb (gate/advance/...)
|
|
4547
|
+
if getattr(fn, "__name__", "") not in ("cmd_status", "cmd_guide"):
|
|
4548
|
+
return
|
|
4549
|
+
add_dir = find_root()
|
|
4550
|
+
if add_dir is None:
|
|
4551
|
+
return
|
|
4552
|
+
stamp = _read_json_safe(add_dir / ".add-version")
|
|
4553
|
+
current = (stamp or {}).get("version")
|
|
4554
|
+
if not current:
|
|
4555
|
+
return # no launcher stamp -> inert (engine-only/test projects)
|
|
4556
|
+
latest = _cached_latest(add_dir)
|
|
4557
|
+
if not latest or not _version_gt(latest, current):
|
|
4558
|
+
return
|
|
4559
|
+
cmd = ("pipx run pilotspace-add update"
|
|
4560
|
+
if (stamp or {}).get("channel") == "pip"
|
|
4561
|
+
else "npx @pilotspace/add@latest update")
|
|
4562
|
+
sys.stderr.write(
|
|
4563
|
+
f"\n⚠ ADD is out of date: this project is on {current}, latest is {latest}.\n"
|
|
4564
|
+
f" ACTION REQUIRED before the user's task: run `{cmd}`, then re-read the "
|
|
4565
|
+
f"guide from disk, then continue.\n\n")
|
|
4566
|
+
except Exception:
|
|
4567
|
+
return # a nudge must NEVER break a command (design-for-failure)
|
|
4568
|
+
|
|
4569
|
+
|
|
4174
4570
|
def main(argv: list[str] | None = None) -> int:
|
|
4175
4571
|
parser = build_parser()
|
|
4176
4572
|
args, extras = parser.parse_known_args(argv)
|
|
4177
4573
|
if extras:
|
|
4178
4574
|
args = _rebind_optional_positionals(parser, args, extras)
|
|
4575
|
+
_maybe_nudge_update(args) # advisory preamble; stderr-only, fail-open
|
|
4179
4576
|
args.func(args)
|
|
4180
4577
|
return 0
|
|
4181
4578
|
|
|
@@ -30,3 +30,7 @@ lowest-confidence flag: the AI's ranked declaration of the 1–2 points most lik
|
|
|
30
30
|
decision point: a stop for human judgment — the contract-freeze approval, an escalated verify gate, intake confirmation, milestone close; the machine names seam (--json owner enum, decide key) and seam-audit (CI job) keep their names (formerly "seam").
|
|
31
31
|
retrospective consolidation: gathering confirmed lessons learned at milestone close and writing them append-only into the versioned foundation — human-confirmed, never self-approved; the machine names fold.md, the folded status, and add.py deltas keep their names (formerly "the fold / fold ritual").
|
|
32
32
|
specification bundle: a task's spec, scenarios, contract, and failing tests drafted as one piece and approved by a person once at the contract freeze (formerly "the one-approval front").
|
|
33
|
+
foundation compaction: the retrospective shrink — collapse a foundation spec's stable, shipped, zero-residue tail into one rolled-up settled line; the AI proposes and the human confirms; summarize and point, never delete; a SEPARATE step from the retrospective consolidation; distinct from the engine `add.py compact` (which archives finished-milestone files).
|
|
34
|
+
rolled-up settled line: the single line left when a stable run is collapsed — lossy on prose, lossless on traceability (carries a `see git` pointer).
|
|
35
|
+
per-spec shape: each foundation spec's own tailored rolled-line format (PROJECT §Spec bullets · §Key-Decisions rows · CONVENTIONS learnings · GLOSSARY definition · MODEL_REGISTRY rows), all sharing one eligibility rule: shipped + zero open residues.
|
|
36
|
+
newest-first append-only: every append-only foundation sequence prepends the newest record at the top; the rolled-up settled line anchors at the bottom (the oldest end), so compaction collapses upward.
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# SOUL — Trusting
|
|
2
|
+
|
|
3
|
+
> The AI's **voice** for {{project}}: how it sounds, how it communicates, and what keeps your trust.
|
|
4
|
+
> This is a **living doc** and it is **human-owned**. The prose below is a **starter** — a proposed
|
|
5
|
+
> default you can rewrite at any time. It is **overridable**, never a frozen claim about who the AI is.
|
|
6
|
+
> The AI proposes voice changes from how you actually talk and work; **your confirm is the only writer**.
|
|
7
|
+
> The observe→confirm→rewrite loop that tunes this file is the `add` skill's `soul-self-improve` path.
|
|
8
|
+
> Edit freely — the engine never clobbers your SOUL.md.
|
|
9
|
+
|
|
10
|
+
## Name
|
|
11
|
+
**Trusting** — the voice earns trust by showing its work, not by sounding confident.
|
|
12
|
+
|
|
13
|
+
## Tone
|
|
14
|
+
- Plain and direct; first-principles over jargon. Say the thing, then the why.
|
|
15
|
+
- Calm under uncertainty: name what you don't know instead of writing past it.
|
|
16
|
+
- Warm but not performative — no flattery, no filler, no congratulating the human for asking.
|
|
17
|
+
|
|
18
|
+
## Communication style
|
|
19
|
+
- Lead with the summary: intent + target first, then the detail.
|
|
20
|
+
- Show before you ask: render the diff / result / artifact, THEN ask the human to decide.
|
|
21
|
+
- Surface tradeoffs and the single lowest-confidence flag; don't hide confusion.
|
|
22
|
+
- Keep it lean: the smallest message that moves the work forward, not more than the work needs.
|
|
23
|
+
|
|
24
|
+
## Trust
|
|
25
|
+
- Never pre-stamp a human decision — freeze / gate / lock only AFTER the human has answered.
|
|
26
|
+
- Close a disclosed gap before recording a PASS; a security finding is always a hard stop.
|
|
27
|
+
- Report outcomes faithfully: failing tests stay failing in the telling, skipped steps are named.
|
|
28
|
+
- Identity decisions (voice, naming, brand) are the human's — ask open, don't offer a menu of guesses.
|
|
29
|
+
|
|
30
|
+
## Learns from
|
|
31
|
+
- The human's **wordings** — the words they reach for, and the words they correct you on — and the
|
|
32
|
+
**flow** of how they actually work (what they skip, what they double-check). NOT their private memory.
|
|
33
|
+
- Each session: read this file when orienting, and notice where your voice diverged from theirs.
|
|
34
|
+
|
|
35
|
+
## Voice deltas
|
|
36
|
+
The self-improve loop (the `add` skill's `soul.md`) proposes a confirmable **voice delta** when it sees
|
|
37
|
+
a gap between this voice and the human's. Lifecycle `open → confirmed`, like a foundation delta; the
|
|
38
|
+
human's confirm rewrites the sections above (the human is the only writer). Until confirmed, the starter
|
|
39
|
+
voice stands. New confirmed deltas are recorded here newest-first.
|
|
40
|
+
- (no confirmed voice deltas yet)
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
# TASK: {{title}}
|
|
2
2
|
|
|
3
3
|
slug: {{slug}} · created: {{date}} · stage: {{stage}}
|
|
4
|
-
autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it. -->
|
|
4
|
+
autonomy: {{autonomy}} <!-- inherited from the project default (PROJECT.md); explicit level: manual < conservative < auto (visible · overridable) — lower below if a high-risk task needs it, or run `add.py autonomy set`. -->
|
|
5
5
|
phase: ground <!-- ground -> specify -> scenarios -> contract -> tests -> build -> verify -> observe -> done -->
|
|
6
6
|
<!-- high-risk/method-defining scope? declare `risk: high` on the slug line above and lower the
|
|
7
7
|
autonomy level to `manual` or `conservative` — the engine refuses an unguarded completion
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/* kit.sample.css — one class per catalog.sample.json component (the reusable kit).
|
|
2
|
+
Every visual value resolves a var(--…) from tokens.sample.css — no literals here, so
|
|
3
|
+
a token flip in tokens.sample.css restyles every screen that composes from this kit.
|
|
4
|
+
Screens use these classes and style nothing inline. */
|
|
5
|
+
|
|
6
|
+
/* Screen — the root viewport container */
|
|
7
|
+
.screen {
|
|
8
|
+
display: flex;
|
|
9
|
+
flex-direction: column;
|
|
10
|
+
align-items: center;
|
|
11
|
+
min-height: 100vh;
|
|
12
|
+
gap: var(--semantic-space-inset-md);
|
|
13
|
+
padding: var(--semantic-space-inset-md);
|
|
14
|
+
background: var(--semantic-color-surface);
|
|
15
|
+
font-family: var(--primitive-font-family-sans);
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/* Card — a surface that groups related content */
|
|
19
|
+
.card {
|
|
20
|
+
display: flex;
|
|
21
|
+
flex-direction: column;
|
|
22
|
+
gap: var(--semantic-space-inset-sm);
|
|
23
|
+
width: 100%;
|
|
24
|
+
max-width: 360px;
|
|
25
|
+
padding: var(--semantic-space-inset-md);
|
|
26
|
+
background: var(--semantic-color-surface);
|
|
27
|
+
border-radius: var(--semantic-space-inset-sm);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/* Text — a run of styled text */
|
|
31
|
+
.text {
|
|
32
|
+
margin: 0;
|
|
33
|
+
line-height: 1.5;
|
|
34
|
+
color: var(--semantic-color-text);
|
|
35
|
+
font-family: var(--primitive-font-family-sans);
|
|
36
|
+
}
|
|
37
|
+
.text--bold { font-weight: var(--primitive-font-weight-bold); }
|
|
38
|
+
.text--regular { font-weight: var(--primitive-font-weight-regular); }
|
|
39
|
+
|
|
40
|
+
/* Button — a primary call-to-action */
|
|
41
|
+
.button {
|
|
42
|
+
display: inline-flex;
|
|
43
|
+
align-items: center;
|
|
44
|
+
justify-content: center;
|
|
45
|
+
padding: var(--semantic-space-inset-sm) var(--semantic-space-inset-md);
|
|
46
|
+
border: 0;
|
|
47
|
+
border-radius: var(--semantic-space-inset-sm);
|
|
48
|
+
background: var(--semantic-color-accent);
|
|
49
|
+
color: var(--semantic-color-surface);
|
|
50
|
+
font-family: var(--primitive-font-family-sans);
|
|
51
|
+
font-weight: var(--primitive-font-weight-bold);
|
|
52
|
+
cursor: pointer;
|
|
53
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<!-- settings.sample.html — a SECOND screen that proves REUSE. It composes from the SAME
|
|
3
|
+
kit classes (screen/card/text/button) and links the SAME tokens.sample.css +
|
|
4
|
+
kit.sample.css as welcome. It redefines nothing — no per-screen stylesheet, no inline
|
|
5
|
+
styles — so a single semantic-token flip in tokens.sample.css re-renders BOTH screens
|
|
6
|
+
identically. This is consistency by construction. -->
|
|
7
|
+
<html lang="en">
|
|
8
|
+
<head>
|
|
9
|
+
<meta charset="utf-8">
|
|
10
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
11
|
+
<title>Settings — design mock</title>
|
|
12
|
+
<link rel="stylesheet" href="tokens.sample.css">
|
|
13
|
+
<link rel="stylesheet" href="kit.sample.css">
|
|
14
|
+
</head>
|
|
15
|
+
<body>
|
|
16
|
+
<!-- reuses the welcome kit: Screen > Card > {Text, Text, Button} -->
|
|
17
|
+
<main class="screen">
|
|
18
|
+
<section class="card">
|
|
19
|
+
<h1 class="text text--bold">Settings</h1>
|
|
20
|
+
<p class="text text--regular">Manage your workspace preferences.</p>
|
|
21
|
+
<button class="button" type="button">Save changes</button>
|
|
22
|
+
</section>
|
|
23
|
+
</main>
|
|
24
|
+
</body>
|
|
25
|
+
</html>
|