@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.
- package/bin/maestro.mjs +6 -2
- package/docs/guides/front-door-session.md +49 -0
- package/lib/cli/design.mjs +185 -0
- package/lib/cli/design.test.mjs +270 -0
- package/lib/cli/global-setup-extras.mjs +44 -0
- package/lib/cli/global-setup-extras.test.mjs +95 -0
- package/lib/cli/session.mjs +11 -1
- package/lib/cli/session.test.mjs +17 -6
- package/lib/collective/global-config.mjs +5 -0
- package/lib/collective/global-config.test.mjs +5 -0
- package/lib/collective/vendor-skills.mjs +305 -0
- package/lib/collective/vendor-skills.test.mjs +306 -0
- package/lib/design/design-md.mjs +793 -0
- package/lib/design/design-md.test.mjs +318 -0
- package/lib/design/fixtures/DESIGN.golden.md +238 -0
- package/lib/design/fixtures/PRODUCT.golden.md +67 -0
- package/lib/design/fixtures/foundation.json +133 -0
- package/lib/design/refresh-gate.mjs +154 -0
- package/lib/design/refresh-gate.test.mjs +144 -0
- package/lib/design/write.mjs +275 -0
- package/lib/design/write.test.mjs +241 -0
- package/lib/prompts/parallelism.mjs +79 -0
- package/lib/prompts/parallelism.test.mjs +177 -0
- package/package.json +1 -1
- package/plugins/maestro-skills/plugin.json +4 -0
- package/plugins/maestro-skills/skills/cohort-design.md +153 -0
- package/plugins/maestro-skills/vendor/emilkowalski/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/emilkowalski/UPSTREAM.json +70 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/RECIPES.md +324 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animate/SKILL.md +199 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/animation-vocabulary/SKILL.md +173 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/apple-design/SKILL.md +282 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/emil-design-eng/SKILL.md +674 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/find-animation-opportunities/SKILL.md +132 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/AUDIT.md +115 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/PLAN-TEMPLATE.md +73 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/improve-animations/SKILL.md +101 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/PICKER.md +197 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/prototype/SKILL.md +90 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/SKILL.md +112 -0
- package/plugins/maestro-skills/vendor/emilkowalski/skills/review-animations/STANDARDS.md +187 -0
- package/plugins/maestro-skills/vendor/impeccable/LICENSE +191 -0
- package/plugins/maestro-skills/vendor/impeccable/NOTICE.md +11 -0
- package/plugins/maestro-skills/vendor/impeccable/SKILL.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/UPSTREAM.json +201 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-asset-producer.md +42 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-documenter.md +29 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-finish-reviewer.md +43 -0
- package/plugins/maestro-skills/vendor/impeccable/agents/impeccable-manual-edit-applier.md +97 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.md +312 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/adapt.native.md +58 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/android.md +46 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/animate.md +89 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.md +136 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/audit.native.md +139 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/bolder.md +33 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/clarify.md +94 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/colorize.md +86 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft-floor.md +44 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/craft.md +5 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/critique.md +806 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/asset-producer.md +37 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/documenter.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/finish-reviewer.md +38 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/degraded/manual-edit-applier.md +92 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/delight.md +70 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/distill.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/doctor.md +54 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/document.md +416 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/extract.md +69 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/harden.md +336 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/hooks.md +111 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/init.md +131 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/ios.md +51 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/layout.md +84 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live-setup.md +104 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/live.md +325 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/new-work.md +147 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/onboard.md +234 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/operate.md +61 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/optimize.md +258 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/overdrive.md +127 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/polish.md +105 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/quieter.md +99 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/routing.md +24 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/shape.md +59 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/typeset.md +80 -0
- package/plugins/maestro-skills/vendor/impeccable/reference/visualize.md +46 -0
- package/plugins/maestro-skills/vendor/taste-skill/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/taste-skill/UPSTREAM.json +37 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/minimalist-skill/SKILL.md +85 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/redesign-skill/SKILL.md +178 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/soft-skill/SKILL.md +98 -0
- package/plugins/maestro-skills/vendor/taste-skill/skills/taste-skill/SKILL.md +1206 -0
- package/plugins/maestro-skills/vendor/unlazy/LICENSE +21 -0
- package/plugins/maestro-skills/vendor/unlazy/SECURITY.md +72 -0
- package/plugins/maestro-skills/vendor/unlazy/SKILL.md +104 -0
- package/plugins/maestro-skills/vendor/unlazy/UPSTREAM.json +94 -0
- package/plugins/maestro-skills/vendor/unlazy/references/dispatch.md +82 -0
- package/plugins/maestro-skills/vendor/unlazy/references/gates.md +149 -0
- package/plugins/maestro-skills/vendor/unlazy/references/method.md +49 -0
- package/plugins/maestro-skills/vendor/unlazy/references/orchestration.md +107 -0
- package/plugins/maestro-skills/vendor/unlazy/references/parallel.md +133 -0
- package/plugins/maestro-skills/vendor/unlazy/references/token-economy.md +48 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/dispatch-check.mjs +139 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-check.mjs +960 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/gate-lint.mjs +245 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/check-supervisor.mjs +46 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/dispatch.mjs +293 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/gates.mjs +953 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/process-tree.mjs +161 -0
- package/plugins/maestro-skills/vendor/unlazy/scripts/lib/regex-worker.mjs +9 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/PLAN.md +116 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-leaf.md +51 -0
- package/plugins/maestro-skills/vendor/unlazy/templates/gates-node.md +51 -0
- package/scripts/ci/check-skill-packs.mjs +388 -0
- package/scripts/ci/check-skill-packs.test.mjs +495 -0
- package/scripts/ci/check.mjs +3 -0
- package/scripts/daemon/agent-daemon-design.test.mjs +238 -0
- package/scripts/daemon/agent-daemon.mjs +108 -0
- package/scripts/daemon/cadence-consumer-frontdoor.test.mjs +61 -2
- package/scripts/daemon/cadence-consumer.mjs +46 -22
- package/scripts/daemon/prompt-builder.mjs +19 -3
- package/scripts/local-triggers/autoupdate.test.mjs +33 -3
- package/scripts/vendor/skill-packs.mjs +354 -0
- package/scripts/vendor/sync-skill-packs.mjs +242 -0
- 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
|
+
});
|