@cohortapp/agent-sdk 2.12.0 → 2.13.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.
Files changed (127) hide show
  1. package/bin/maestro.mjs +6 -2
  2. package/docs/guides/front-door-session.md +49 -0
  3. package/lib/cli/design.mjs +185 -0
  4. package/lib/cli/design.test.mjs +270 -0
  5. package/lib/cli/global-setup-extras.mjs +44 -0
  6. package/lib/cli/global-setup-extras.test.mjs +95 -0
  7. package/lib/cli/session.mjs +11 -1
  8. package/lib/cli/session.test.mjs +17 -6
  9. package/lib/collective/global-config.mjs +5 -0
  10. package/lib/collective/global-config.test.mjs +5 -0
  11. package/lib/collective/vendor-skills.mjs +305 -0
  12. package/lib/collective/vendor-skills.test.mjs +306 -0
  13. package/lib/design/design-md.mjs +793 -0
  14. package/lib/design/design-md.test.mjs +318 -0
  15. package/lib/design/fixtures/DESIGN.golden.md +238 -0
  16. package/lib/design/fixtures/PRODUCT.golden.md +67 -0
  17. package/lib/design/fixtures/foundation.json +133 -0
  18. package/lib/design/refresh-gate.mjs +154 -0
  19. package/lib/design/refresh-gate.test.mjs +144 -0
  20. package/lib/design/write.mjs +275 -0
  21. package/lib/design/write.test.mjs +241 -0
  22. package/lib/prompts/parallelism.mjs +79 -0
  23. package/lib/prompts/parallelism.test.mjs +177 -0
  24. package/package.json +1 -1
  25. package/plugins/maestro-skills/plugin.json +4 -0
  26. package/plugins/maestro-skills/skills/cohort-design.md +153 -0
  27. package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
  28. package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
  29. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
  30. package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
  31. package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
  32. package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
  33. package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
  34. package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
  35. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
  36. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
  37. package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
  38. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
  39. package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
  40. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
  41. package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
  42. package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
  43. package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
  44. package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
  45. package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
  46. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
  47. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
  48. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
  49. package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
  50. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
  51. package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
  52. package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
  53. package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
  54. package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
  55. package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
  56. package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
  57. package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
  58. package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
  59. package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
  60. package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
  61. package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
  62. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
  63. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
  64. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
  65. package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
  66. package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
  67. package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
  68. package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
  69. package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
  70. package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
  71. package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
  72. package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
  73. package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
  74. package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
  75. package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
  76. package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
  77. package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
  78. package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
  79. package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
  80. package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
  81. package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
  82. package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
  83. package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
  84. package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
  85. package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
  86. package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
  87. package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
  88. package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
  89. package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
  90. package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
  91. package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
  92. package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
  93. package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
  94. package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
  95. package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
  96. package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
  97. package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
  98. package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
  99. package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
  100. package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
  101. package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
  102. package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
  103. package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
  104. package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
  105. package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
  106. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
  107. package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
  108. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
  109. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
  110. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
  111. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
  112. package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
  113. package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
  114. package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
  115. package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
  116. package/scripts/ci/check-skill-packs.mjs +388 -0
  117. package/scripts/ci/check-skill-packs.test.mjs +495 -0
  118. package/scripts/ci/check.mjs +3 -0
  119. package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
  120. package/scripts/daemon/agent-daemon.mjs +108 -0
  121. package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
  122. package/scripts/daemon/cadence-consumer.mjs +46 -22
  123. package/scripts/daemon/prompt-builder.mjs +19 -3
  124. package/scripts/local-triggers/autoupdate.test.mjs +33 -3
  125. package/scripts/vendor/skill-packs.mjs +354 -0
  126. package/scripts/vendor/sync-skill-packs.mjs +242 -0
  127. package/scripts/vendor/sync-skill-packs.test.mjs +103 -0
@@ -0,0 +1,133 @@
1
+ {
2
+ "v": 2,
3
+ "colors": [
4
+ { "name": "Ink", "value": "#0d0d0d", "dark": "#f5f5f4" },
5
+ { "name": "Paper", "value": "#ffffff", "dark": "#0b0b0c" },
6
+ { "name": "Primary · Moss", "value": "#2d5be3", "dark": "#7ea0ff" },
7
+ { "name": "Muted", "value": "#6b7280" },
8
+ { "name": "Accent · Amber", "value": "#c9851f" },
9
+ { "name": "Line", "value": "#e7e5e4", "dark": "#26262a" }
10
+ ],
11
+ "fonts": { "display": "Outfit", "body": "Inter", "mono": "JetBrains Mono" },
12
+ "type": [
13
+ { "role": "Display", "size": 76, "weight": 600 },
14
+ { "role": "Heading", "size": 38, "weight": 600 },
15
+ { "role": "Body", "size": 14, "weight": 400 },
16
+ { "role": "Small", "size": 11.5, "weight": 500 }
17
+ ],
18
+ "space": [
19
+ { "name": "xs", "value": 4 },
20
+ { "name": "sm", "value": 8 },
21
+ { "name": "md", "value": 12 },
22
+ { "name": "lg", "value": 16 },
23
+ { "name": "xl", "value": 22 }
24
+ ],
25
+ "radii": [
26
+ { "name": "chip", "value": 8 },
27
+ { "name": "field", "value": 9 },
28
+ { "name": "card", "value": 13 },
29
+ { "name": "modal", "value": 14 },
30
+ { "name": "pill", "value": 999 }
31
+ ],
32
+ "shadow": [
33
+ { "name": "sm", "value": "0 1px 2px rgba(13,13,13,0.06)" },
34
+ { "name": "md", "value": "0 18px 50px rgba(13,13,13,0.13)" }
35
+ ],
36
+ "border": [
37
+ { "name": "hairline", "value": "1px solid #e7e5e4" },
38
+ { "name": "strong", "value": "1px solid #d6d3d1" }
39
+ ],
40
+ "voice": "Concise, specific, accountable. Write like a capable peer — no hype, no emoji, no hedging.",
41
+ "positioning": "The operating system for companies that run on agents as well as people.",
42
+ "taglines": ["Work that runs itself.", "One company, many hands."],
43
+ "toneTraits": ["Plain", "Exact", "Unhurried"],
44
+ "logos": {
45
+ "brandIcon": "https://assets.example.test/icon.png",
46
+ "wordmark": "https://assets.example.test/wordmark.svg",
47
+ "favicon": null
48
+ },
49
+ "examples": { "hero": "A crew at first light.", "cta": "Start the walkthrough" },
50
+ "visualLanguage": {
51
+ "v": 2,
52
+ "source": "generated",
53
+ "imagery": {
54
+ "needsImagery": true,
55
+ "medium": "photography",
56
+ "secondaryMedia": ["diagram"],
57
+ "colorGrade": "natural",
58
+ "grain": "fine",
59
+ "crop": "wide",
60
+ "aspectRatios": ["16/9", "1/1"],
61
+ "subjects": ["hands at work", "quiet rooms"],
62
+ "mood": ["calm", "considered"],
63
+ "composition": "off-centre, generous negative space",
64
+ "duotone": { "shadow": "#12213f", "highlight": "#f3ede1" }
65
+ },
66
+ "iconography": { "style": "line", "weight": "1.5px", "corner": "rounded", "grid": 24 },
67
+ "motion": {
68
+ "character": "settled",
69
+ "easing": "cubic-bezier(0.2, 0, 0, 1)",
70
+ "durationMs": 220,
71
+ "principles": ["Move once, arrive.", "Never animate what a person is reading."]
72
+ },
73
+ "texture": { "surfaces": ["paper", "frosted glass"], "background": "flat", "notes": "No gradients behind text." },
74
+ "guardrails": {
75
+ "dos": ["Let type carry the page.", "Use one accent per view."],
76
+ "donts": ["No stock handshakes.", "No drop shadows on text."]
77
+ },
78
+ "promptSeed": "quiet rooms, natural light, fine grain",
79
+ "seedLocked": true,
80
+ "negativeSeed": "neon, lens flare",
81
+ "references": ["moodboard/2026-03"]
82
+ },
83
+ "system": {
84
+ "typography": {
85
+ "families": {
86
+ "display": { "family": "Outfit", "category": "sans", "weights": [500, 600] },
87
+ "heading": { "family": "Outfit", "category": "sans", "weights": [600] },
88
+ "body": { "family": "Inter", "category": "sans", "weights": [400, 500] },
89
+ "mono": { "family": "JetBrains Mono", "category": "mono", "weights": [400] }
90
+ },
91
+ "scale": [
92
+ { "role": "Display", "size": 76, "weight": 600, "leading": 1.05, "tracking": -0.02 },
93
+ { "role": "Heading", "size": 38, "weight": 600, "leading": 1.15, "tracking": -0.01 },
94
+ { "role": "Body", "size": 14, "weight": 400, "leading": 1.5 },
95
+ { "role": "Caption", "size": 11, "weight": 500, "leading": 1.4, "tracking": 0.04 }
96
+ ],
97
+ "pairingRationale": "One geometric face for structure, one humanist face for reading.",
98
+ "usageRules": ["Display only above 32px.", "Never set body copy below 13px."]
99
+ },
100
+ "color": {
101
+ "roles": {
102
+ "primary": { "name": "Signal Blue", "value": "#2d5be3", "dark": "#7ea0ff" },
103
+ "accent": { "name": "Amber", "value": "#c9851f" }
104
+ },
105
+ "neutralRamp": [
106
+ { "step": 100, "value": "#f5f5f4" },
107
+ { "step": 500, "value": "#78716c" },
108
+ { "step": 900, "value": "#1c1917" }
109
+ ],
110
+ "semantic": { "success": "#177245", "warning": "#c9851f", "danger": "#a11d1d" },
111
+ "usageRules": ["One primary per view.", "Semantic colour is for state, never decoration."]
112
+ },
113
+ "imagery": {
114
+ "medium": "documentary photography",
115
+ "style": "unstaged",
116
+ "treatment": "warm highlights, cool shadows",
117
+ "mood": "assured",
118
+ "subjects": ["workshops", "field crews"],
119
+ "donts": ["stock handshakes", "isolated laptops"]
120
+ },
121
+ "motifs": ["A single hairline rule.", "Numbers set in the mono face."],
122
+ "iconography": "Line icons on a 24px grid, 1.5px stroke.",
123
+ "layoutPrinciples": ["Left-align everything that reads.", "One idea per band."],
124
+ "voice": {
125
+ "voice": "Concise, specific, accountable.",
126
+ "toneTraits": ["Plain", "Direct"],
127
+ "lexicon": { "use": ["decide", "ship", "owe"], "avoid": ["leverage", "synergy", "unlock"] },
128
+ "exampleCopy": "Two invoices need a decision today. Both are under £5,000."
129
+ }
130
+ },
131
+ "elevationCurve": { "0": "none", "1": "0 1px 2px rgba(0,0,0,0.05)" },
132
+ "gridUnit": 4
133
+ }
@@ -0,0 +1,154 @@
1
+ /**
2
+ * lib/design/refresh-gate.mjs — WHEN the seat re-syncs its brand foundation
3
+ * (WP-M7 mechanic 4). Pure; the daemon does the I/O.
4
+ *
5
+ * Two triggers, one gate:
6
+ *
7
+ * 1. **The foundation moved.** `branding.getFoundation` returns a version id
8
+ * (`headVersionId`). A read whose version differs from the one on disk is
9
+ * a change, and DESIGN.md must follow it immediately — a designer working
10
+ * from a stale palette is the exact failure this whole work package
11
+ * exists to prevent.
12
+ * 2. **The clock.** Absent any signal, poll every 15 minutes. hq has no cheap
13
+ * "has the foundation changed?" ETag the way `integration.toolsetVersion`
14
+ * does for tools, so the version comparison in (1) can only happen AFTER a
15
+ * read — the interval is what bounds how often that read happens.
16
+ *
17
+ * The daemon calls this twice per tick: once with `currentVersion` unknown (may
18
+ * I spend a read?) and once with the version the read returned (did anything
19
+ * actually change?). That is why `currentVersion` being null/undefined is not a
20
+ * change — an unknown version must never look like a new one, or every tick
21
+ * would rewrite the files.
22
+ *
23
+ * A `lastAt` in the FUTURE also refreshes: a clock that went backwards must not
24
+ * be able to freeze the foundation on disk forever.
25
+ *
26
+ * Node builtins only (none needed). ESM.
27
+ *
28
+ * @module lib/design/refresh-gate
29
+ */
30
+
31
+ "use strict";
32
+
33
+ /** The poll interval, per the design spec: 15 minutes. */
34
+ export const DESIGN_REFRESH_INTERVAL_MS = 15 * 60_000;
35
+
36
+ /** Where the seat keeps the last synced foundation, relative to the agent root. */
37
+ export const DESIGN_STATE_REL = "state/design/foundation.json";
38
+
39
+ /** The two generated files, relative to the output directory. */
40
+ export const DESIGN_FILES = ["DESIGN.md", "PRODUCT.md"];
41
+
42
+ /**
43
+ * Should the foundation be re-synced now? Pure.
44
+ *
45
+ * @param {object} o
46
+ * @param {string|null} [o.lastVersion] version id recorded on disk (null = never synced)
47
+ * @param {string|null} [o.currentVersion] version id just read from hq (null/undefined = not known yet)
48
+ * @param {string|number|null} [o.lastAt] when the last sync happened (ISO or epoch ms)
49
+ * @param {number} o.now epoch ms
50
+ * @param {number} [o.intervalMs] poll interval (default {@link DESIGN_REFRESH_INTERVAL_MS})
51
+ * @returns {{refresh:boolean, reason:"never"|"version-changed"|"interval"|"clock-skew"|"fresh"}}
52
+ */
53
+ export function shouldRefreshDesign(o = {}) {
54
+ const now = Number.isFinite(o.now) ? o.now : 0;
55
+ const intervalMs = Number.isFinite(o.intervalMs) && o.intervalMs > 0 ? o.intervalMs : DESIGN_REFRESH_INTERVAL_MS;
56
+ const last = toMs(o.lastAt);
57
+ if (last === null) return { refresh: true, reason: "never" };
58
+ const current = version(o.currentVersion);
59
+ if (current !== null && current !== version(o.lastVersion)) return { refresh: true, reason: "version-changed" };
60
+ if (last > now) return { refresh: true, reason: "clock-skew" };
61
+ if (now - last >= intervalMs) return { refresh: true, reason: "interval" };
62
+ return { refresh: false, reason: "fresh" };
63
+ }
64
+
65
+ /**
66
+ * The version identity of a `branding.getFoundation` result. `headVersionId` is
67
+ * the etag hq itself uses for compare-and-swap; `version.id` is the fallback
68
+ * when retention has pruned the head's snapshot row. An org that has never
69
+ * saved a foundation reads as `"unversioned"` — a stable identity, so an
70
+ * un-versioned workspace does not re-render on every poll.
71
+ *
72
+ * @param {unknown} result the `result` of the read
73
+ * @returns {string|null} null when the frame carries no foundation at all
74
+ */
75
+ export function foundationVersion(result) {
76
+ if (!result || typeof result !== "object") return null;
77
+ const r = /** @type {Record<string, unknown>} */ (result);
78
+ if (typeof r.headVersionId === "string" && r.headVersionId) return r.headVersionId;
79
+ const v = r.version;
80
+ if (v && typeof v === "object" && typeof (/** @type {any} */ (v).id) === "string" && (/** @type {any} */ (v).id)) {
81
+ return String(/** @type {any} */ (v).id);
82
+ }
83
+ if (r.foundation && typeof r.foundation === "object") return "unversioned";
84
+ return null;
85
+ }
86
+
87
+ /**
88
+ * Has this workspace ever SAVED a brand foundation?
89
+ *
90
+ * WHY THIS IS NOT `!result.foundation`. hq never returns a null foundation. An
91
+ * org that has never saved one reads back `dsDefaults()` — the hq product's own
92
+ * palette and typefaces — with `unversioned: true` and `headVersionId: null`
93
+ * (`loadSavedFoundation` → `loadFoundationHead` → `coerceFoundation(null)`). A
94
+ * caller that guards on the foundation being absent therefore NEVER fires, and
95
+ * happily renders a DESIGN.md whose own prose calls that palette "the source of
96
+ * truth for colour, type, radius and spacing". Every design skill on the seat
97
+ * then grounds on another product's defaults believing they are the brand: a
98
+ * wrong palette that looks authoritative, which is precisely the failure this
99
+ * whole work package exists to prevent. So the seat writes nothing until a
100
+ * human has saved a foundation.
101
+ *
102
+ * `unversioned` is trusted when hq states it; an older hq that omits the field
103
+ * falls back to "no version identity of any kind", which is the same condition.
104
+ *
105
+ * @param {unknown} result the `result` of a `branding.getFoundation` read
106
+ * @returns {boolean}
107
+ */
108
+ export function isUnversionedFoundation(result) {
109
+ if (!result || typeof result !== "object") return false;
110
+ const r = /** @type {Record<string, unknown>} */ (result);
111
+ if (r.unversioned === true) return true;
112
+ if (r.unversioned === false) return false;
113
+ return foundationVersion(r) === "unversioned";
114
+ }
115
+
116
+ /**
117
+ * The human label of the read's version, for the provenance footer. `""` when
118
+ * there is none — the renderer omits the line rather than inventing one.
119
+ * @param {unknown} result
120
+ * @returns {string}
121
+ */
122
+ export function foundationVersionLabel(result) {
123
+ const v = result && typeof result === "object" ? /** @type {any} */ (result).version : null;
124
+ if (!v || typeof v !== "object") return "";
125
+ const label = typeof v.label === "string" ? v.label.trim() : "";
126
+ const seq = Number.isFinite(v.seq) ? `V2.${v.seq}` : "";
127
+ return label || seq;
128
+ }
129
+
130
+ /** ISO string or epoch ms → epoch ms, or null. */
131
+ function toMs(v) {
132
+ if (typeof v === "number" && Number.isFinite(v)) return v;
133
+ if (typeof v === "string" && v.trim()) {
134
+ const n = Date.parse(v);
135
+ if (Number.isFinite(n)) return n;
136
+ }
137
+ return null;
138
+ }
139
+
140
+ /** A version id as a comparable string, or null. */
141
+ function version(v) {
142
+ if (typeof v === "string" && v) return v;
143
+ return null;
144
+ }
145
+
146
+ export default {
147
+ shouldRefreshDesign,
148
+ foundationVersion,
149
+ isUnversionedFoundation,
150
+ foundationVersionLabel,
151
+ DESIGN_REFRESH_INTERVAL_MS,
152
+ DESIGN_STATE_REL,
153
+ DESIGN_FILES,
154
+ };
@@ -0,0 +1,144 @@
1
+ /**
2
+ * refresh-gate.test.mjs — WHEN a seat re-syncs its brand foundation
3
+ * (WP-M7 mechanic 4). The gate is pure, so every case here is a table row.
4
+ *
5
+ * The two rules worth stating in prose because they are the ones a reader
6
+ * would get wrong:
7
+ *
8
+ * · An UNKNOWN current version is not a change. The daemon asks the gate
9
+ * twice per tick — before the read (version unknown) and after it — and if
10
+ * "unknown" counted as a new version, the pre-read call would say yes on
11
+ * every tick and the interval would mean nothing.
12
+ * · A `lastAt` in the FUTURE refreshes. A clock that jumped backwards (or a
13
+ * state file copied from another box) must not be able to freeze DESIGN.md
14
+ * on disk until the wall clock catches up.
15
+ */
16
+
17
+ import { test } from "node:test";
18
+ import assert from "node:assert/strict";
19
+
20
+ import {
21
+ isUnversionedFoundation,
22
+ shouldRefreshDesign,
23
+ foundationVersion,
24
+ foundationVersionLabel,
25
+ DESIGN_REFRESH_INTERVAL_MS,
26
+ DESIGN_STATE_REL,
27
+ DESIGN_FILES,
28
+ } from "./refresh-gate.mjs";
29
+
30
+ const NOW = Date.parse("2026-09-08T12:00:00Z");
31
+
32
+ test("the interval is 15 minutes and the paths are the ones the CLI and daemon write", () => {
33
+ assert.equal(DESIGN_REFRESH_INTERVAL_MS, 15 * 60_000);
34
+ assert.equal(DESIGN_STATE_REL, "state/design/foundation.json");
35
+ assert.deepEqual(DESIGN_FILES, ["DESIGN.md", "PRODUCT.md"]);
36
+ });
37
+
38
+ test("never synced → refresh", () => {
39
+ assert.deepEqual(shouldRefreshDesign({ now: NOW }), { refresh: true, reason: "never" });
40
+ assert.deepEqual(shouldRefreshDesign({ lastAt: null, now: NOW }), { refresh: true, reason: "never" });
41
+ assert.deepEqual(shouldRefreshDesign({ lastAt: "not a date", now: NOW }), { refresh: true, reason: "never" });
42
+ });
43
+
44
+ test("inside the interval with an unchanged version → no refresh", () => {
45
+ assert.deepEqual(
46
+ shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_1", lastAt: NOW - 60_000, now: NOW }),
47
+ { refresh: false, reason: "fresh" },
48
+ );
49
+ });
50
+
51
+ test("a changed version refreshes immediately, however recent the last sync", () => {
52
+ assert.deepEqual(
53
+ shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_2", lastAt: NOW - 1_000, now: NOW }),
54
+ { refresh: true, reason: "version-changed" },
55
+ );
56
+ // First-ever version on a seat that had synced an unversioned org.
57
+ assert.deepEqual(
58
+ shouldRefreshDesign({ lastVersion: null, currentVersion: "bv_1", lastAt: NOW - 1_000, now: NOW }),
59
+ { refresh: true, reason: "version-changed" },
60
+ );
61
+ });
62
+
63
+ test("an UNKNOWN current version is not a change — the pre-read call must not always say yes", () => {
64
+ for (const currentVersion of [null, undefined, "", 7, {}]) {
65
+ assert.deepEqual(
66
+ shouldRefreshDesign({ lastVersion: "bv_1", currentVersion, lastAt: NOW - 60_000, now: NOW }),
67
+ { refresh: false, reason: "fresh" },
68
+ `currentVersion=${JSON.stringify(currentVersion)}`,
69
+ );
70
+ }
71
+ });
72
+
73
+ test("the interval fires at the boundary and after it, not before", () => {
74
+ const at = (dt) => shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_1", lastAt: NOW - dt, now: NOW });
75
+ assert.equal(at(DESIGN_REFRESH_INTERVAL_MS - 1).refresh, false);
76
+ assert.deepEqual(at(DESIGN_REFRESH_INTERVAL_MS), { refresh: true, reason: "interval" });
77
+ assert.deepEqual(at(DESIGN_REFRESH_INTERVAL_MS * 10), { refresh: true, reason: "interval" });
78
+ });
79
+
80
+ test("a lastAt in the future refreshes rather than freezing the seat", () => {
81
+ assert.deepEqual(
82
+ shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_1", lastAt: NOW + 86_400_000, now: NOW }),
83
+ { refresh: true, reason: "clock-skew" },
84
+ );
85
+ });
86
+
87
+ test("lastAt is accepted as an ISO string or epoch ms", () => {
88
+ const iso = shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_1", lastAt: "2026-09-08T11:59:00Z", now: NOW });
89
+ const ms = shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_1", lastAt: NOW - 60_000, now: NOW });
90
+ assert.deepEqual(iso, ms);
91
+ });
92
+
93
+ test("a bad or absent intervalMs falls back to the 15-minute default", () => {
94
+ for (const intervalMs of [undefined, null, 0, -5, NaN, "soon"]) {
95
+ const r = shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_1", lastAt: NOW - 14 * 60_000, now: NOW, intervalMs });
96
+ assert.equal(r.refresh, false, `intervalMs=${String(intervalMs)}`);
97
+ }
98
+ assert.equal(
99
+ shouldRefreshDesign({ lastVersion: "bv_1", currentVersion: "bv_1", lastAt: NOW - 60_000, now: NOW, intervalMs: 30_000 }).reason,
100
+ "interval",
101
+ "an explicit shorter interval is honoured",
102
+ );
103
+ });
104
+
105
+ test("foundationVersion prefers headVersionId, then version.id, then a stable 'unversioned'", () => {
106
+ assert.equal(foundationVersion({ headVersionId: "bv_1", version: { id: "bv_0" }, foundation: {} }), "bv_1");
107
+ assert.equal(foundationVersion({ headVersionId: null, version: { id: "bv_0" }, foundation: {} }), "bv_0");
108
+ assert.equal(foundationVersion({ headVersionId: null, version: null, foundation: {} }), "unversioned");
109
+ assert.equal(foundationVersion({}), null, "a frame with no foundation has no version identity");
110
+ assert.equal(foundationVersion(null), null);
111
+ assert.equal(foundationVersion("nope"), null);
112
+ });
113
+
114
+ test("'unversioned' is STABLE, so an org that never saved a foundation does not re-render every poll", () => {
115
+ const frame = { headVersionId: null, version: null, foundation: { colors: [] } };
116
+ const v = foundationVersion(frame);
117
+ assert.equal(shouldRefreshDesign({ lastVersion: v, currentVersion: foundationVersion(frame), lastAt: NOW - 60_000, now: NOW }).refresh, false);
118
+ });
119
+
120
+ test("foundationVersionLabel reads the label, falls back to V2.<seq>, and never invents", () => {
121
+ assert.equal(foundationVersionLabel({ version: { label: "Palette refresh", seq: 7 } }), "Palette refresh");
122
+ assert.equal(foundationVersionLabel({ version: { seq: 7 } }), "V2.7");
123
+ assert.equal(foundationVersionLabel({ version: { label: " " } }), "");
124
+ assert.equal(foundationVersionLabel({ version: null }), "");
125
+ assert.equal(foundationVersionLabel(null), "");
126
+ });
127
+
128
+ test("isUnversionedFoundation catches the frame hq ACTUALLY sends for a workspace with no brand", () => {
129
+ // hq's `loadSavedFoundation` falls back to `dsDefaults()`, so the foundation
130
+ // is never null. `unversioned` is the only truthful signal, and an older hq
131
+ // that omits it is judged by having no version identity at all.
132
+ const stock = { foundation: { colors: [] }, version: null, headVersionId: null, unversioned: true };
133
+ assert.equal(isUnversionedFoundation(stock), true);
134
+ assert.equal(isUnversionedFoundation({ ...stock, unversioned: undefined }), true, "older hq: no version identity of any kind");
135
+
136
+ const saved = { foundation: { colors: [] }, version: { id: "bv_1", seq: 3 }, headVersionId: "bv_1", unversioned: false };
137
+ assert.equal(isUnversionedFoundation(saved), false);
138
+ // hq is trusted when it states the field, even where retention pruned the
139
+ // snapshot row and only the etag survives.
140
+ assert.equal(isUnversionedFoundation({ foundation: {}, version: null, headVersionId: "bv_9", unversioned: false }), false);
141
+
142
+ assert.equal(isUnversionedFoundation(null), false, "an absent frame is a read failure, not an unversioned workspace");
143
+ assert.equal(isUnversionedFoundation({}), false);
144
+ });