diff --git a/CONTEXT.md b/CONTEXT.md index fb3c0dc..79eb0ae 100644 --- a/CONTEXT.md +++ b/CONTEXT.md @@ -37,6 +37,31 @@ The repository-root `docs/` directory: skill configuration and this repository's ADRs. Unrelated to the Mirror despite the shared word. _Avoid_: docs (unqualified), the docs folder +## The product + +The three terms this repository does not own but has to speak, because a word +that means one thing in the Guide and another on the Landing is how a site +starts contradicting itself. All three are authoritative in the Guide, under +`deployment-shapes`. + +**Shape**: +Which of the two forms an Argus installation is running in, derived from one +fact: whether an LLM provider is configured. Never picked by a setting, so the +Landing describes Shapes rather than offering a choice between them. +_Avoid_: mode, tier, plan, edition + +**Toolbox**: +The Shape with no provider configured. Argus serves its scanners, the +organization's knowledge, its memory and its skills, and the reader's own AI +tool does the reasoning. The floor, and never a trial or a crippled mode. +_Avoid_: agentless, tooling mode, free tier, MCP-only + +**Colleague**: +The Shape with a provider configured. Everything a Toolbox serves, plus Argus +reasoning on its own behalf. A storey above a Toolbox, never a different +building. +_Avoid_: agentic mode, full version, pro + ## The landing **Landing**: diff --git a/docs/adr/0012-the-landing-speaks-the-guides-shape-vocabulary.md b/docs/adr/0012-the-landing-speaks-the-guides-shape-vocabulary.md new file mode 100644 index 0000000..2c0d774 --- /dev/null +++ b/docs/adr/0012-the-landing-speaks-the-guides-shape-vocabulary.md @@ -0,0 +1,16 @@ +# The Landing speaks the Guide's shape vocabulary, and never says agentless + +The Guide names two deployment shapes — **Toolbox** with no LLM provider +configured, **Colleague** with one — and derives the shape from that single fact +rather than from a setting. The Landing adopts both words unchanged instead of +inventing its own, because this repository publishes prose it does not author, +and a term that means one thing upstream and another on the home page is how a +site starts contradicting itself. It already had: the third story beat closed on +"A colleague, not a toolbox" while Toolbox was a shipped shape. + +*Agentless* was the tempting word for what a Toolbox is, and it is refused. In +application security the word is already taken and means "nothing installed on +the host" — and a Toolbox is a daemon running on the machine the code is on, +scanning that host's own filesystem. To the readers most likely to know the term +it would be a claim about topology, and a false one. What it was reaching for is +a sentence the Guide already has: *your agent does the reasoning*. diff --git a/docs/adr/0013-the-landing-ascends-and-the-hero-does-not.md b/docs/adr/0013-the-landing-ascends-and-the-hero-does-not.md new file mode 100644 index 0000000..4b6c6dd --- /dev/null +++ b/docs/adr/0013-the-landing-ascends-and-the-hero-does-not.md @@ -0,0 +1,20 @@ +# The Landing ascends from Toolbox to Colleague, and the hero does not + +The middle of the page climbs. It starts where a reader can start today — a +Toolbox their own AI tool drives, with no model to pay for — and then adds the +model that makes Argus a Colleague. Two reasons: it is the product's own shape, +since a Colleague is a storey above a Toolbox rather than a different building; +and the page previously had no ascent at all, opening at its maximum with Argus +arguing back and then descending into configuration. Trust closes the page for +the same reason, so the boundaries land after the climb instead of interrupting +it. + +The hero deliberately does not carry the Toolbox pitch, and stays the thesis. A +reader at the top has not yet learned that Argus uses a model, so "no model +required" up there answers an objection nobody has raised, and reads as +defensive. Worse, an opening that sells the tool the reader drives sells the +product below its own ceiling: the first thing a stranger would learn is a +protocol and a subordinate role. The zero-cost first step is the strongest +acquisition argument we have, and it is not lost by this — it lands thirty +seconds in, in the first section of the climb, and in the meta description that +link previews show. diff --git a/src/landing/bands.test.ts b/src/landing/bands.test.ts new file mode 100644 index 0000000..ecc13a5 --- /dev/null +++ b/src/landing/bands.test.ts @@ -0,0 +1,50 @@ +import { describe, expect, it } from 'vitest'; + +import { raisedIn } from './bands'; + +/** + * The alternation is the page's rhythm, and a broken rhythm is the kind of + * failure nobody notices in review: two raised sections in a row read as one + * long section, and the reader loses the boundary the design was using to say + * "new argument". Nothing turns red, and the page looks almost right. + */ +describe('the raised band', () => { + const isRaised = raisedIn(['hero', 'one', 'two', 'three', 'four', 'five']); + + it('leaves the hero on the ground', () => { + // It closes with a rule instead of a change of band, so raising it would + // give it two endings. + expect(isRaised('hero')).toBe(false); + }); + + it('leaves the section under the hero on the ground too', () => { + // The hero's own rule is the boundary there; a band as well would draw the + // same line twice. + expect(isRaised('one')).toBe(false); + }); + + it('alternates from the second section down', () => { + expect(['two', 'three', 'four', 'five'].map(isRaised)).toEqual([true, false, true, false]); + }); + + it('holds the alternation whatever the section is called', () => { + // The rule is positional. A section renamed keeps its band, which is what + // lets the page be reordered by moving markup rather than by re-deciding + // every band by hand. + expect(['hero', 'a', 'b'].map(raisedIn(['hero', 'a', 'b']))).toEqual([false, false, true]); + }); + + it('refuses a section the page never declared', () => { + // Which is what makes this a build failure rather than a silent one: a + // typo in a section's id, or a section added to the markup and not to the + // order, stops the page being rendered at all. + expect(() => isRaised('your-editor')).toThrow(/your-editor/); + }); + + it('refuses an order that names a section twice', () => { + // Two sections sharing an id would each ask the same question and get the + // same answer, so one of them lands on the wrong band. A duplicate is a + // mistake in the page, not an input to interpret. + expect(() => raisedIn(['hero', 'one', 'one'])).toThrow(/one/); + }); +}); diff --git a/src/landing/bands.ts b/src/landing/bands.ts new file mode 100644 index 0000000..ad34085 --- /dev/null +++ b/src/landing/bands.ts @@ -0,0 +1,47 @@ +/** + * Which of the Landing's sections stand on the raised band. + * + * The page alternates: `bg`, then `surface`, then `bg` again, so each section + * announces itself as a new argument without needing a heading rule. That is a + * property of the *sequence*, not of any section, and it used to be decided + * section by section in the markup — which works right up until the page is + * reordered, and then it silently doesn't. Two raised sections in a row read as + * one long section and the boundary the design was drawing disappears. + * + * So the page declares its order once, at the top, and every section asks. A + * section moved keeps the band of its new position rather than the band it was + * written with, which is what makes reordering the Landing an edit to markup + * instead of a re-decision about every band on the page. + * + * The first two sections are both on the ground. The hero is the first, and it + * closes with a rule rather than with a change of band — raising the section + * under it would draw that same boundary twice. + * + * Asking about a section the order does not name throws, so a typo or a section + * added to the markup and not to the order fails the build. The alternative is + * the failure this exists to prevent, arriving silently by another door. + */ +export function raisedIn(order: readonly string[]): (id: string) => boolean { + const raised = new Map(); + + order.forEach((id, index) => { + if (raised.has(id)) { + throw new Error( + `the Landing names the section \`${id}\` twice — two sections sharing an id cannot sit on different bands`, + ); + } + + raised.set(id, index >= 2 && index % 2 === 0); + }); + + return (id) => { + const band = raised.get(id); + if (band === undefined) { + throw new Error( + `the Landing renders a section \`${id}\` its order never names — add it to the order, in the place it is rendered`, + ); + } + + return band; + }; +} diff --git a/src/landing/landing.test.ts b/src/landing/landing.test.ts index 28f7941..cde44b1 100644 --- a/src/landing/landing.test.ts +++ b/src/landing/landing.test.ts @@ -136,8 +136,103 @@ describe('the Landing', () => { expect(value, `${property}: ${value}`).toMatch(/var\(--color-/); } }); + + it('uses none of the words the glossary refuses for the product\'s own terms', () => { + // The Landing has to speak Toolbox and Colleague, and it does not own them: + // they are the Guide's words. `CONTEXT.md` is where that is written down, + // including the words each one is *not* — so the list is read out of the + // glossary rather than copied here, and adding a trap there arms this test + // with no edit. ADR 0012 is the one that matters: `agentless` already means + // "nothing installed on the host" to this page's audience, and a Toolbox is + // a daemon on the machine the code is on. + const refused = glossaryAvoids('The product'); + + expect(refused, 'no `_Avoid_` under `## The product` — has the glossary moved?').not.toHaveLength( + 0, + ); + + for (const word of refused) { + const used = new RegExp(`\\b${word.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')}\\b`, 'i'); + expect(copy(), `the glossary refuses \`${word}\``).not.toMatch(used); + } + }); + + it('names both shapes the Guide names', () => { + // The other half of the same rule, and the half that goes wrong quietly. + // ADR 0012 adopts Toolbox and Colleague *because* this page has to speak + // them, and a page that refuses `agentless` while never saying `colleague` + // has kept the letter of that and dropped the point. The word only ever + // reached the page inside one claim, so deleting the claim deleted the + // vocabulary — which is what happened, and what this now catches. + for (const shape of ['toolbox', 'colleague']) { + expect(copy(), `ADR 0012 adopts \`${shape}\`, and the page never says it`).toMatch( + new RegExp(`\\b${shape}\\b`, 'i'), + ); + } + }); + + it('writes no snake_case name anywhere in its copy', () => { + // The Inventory rule, over the one class of name that always breaks it. An + // MCP tool, a config key and an environment variable are all snake_case, + // and all three are enumerations Argus changes without telling this + // repository — the tool surface gained the scanners the week this page was + // last rewritten. So the page describes what happened in verbs and names + // none of them; a link goes to the Guide, which owns the list. + expect(copy()).not.toMatch(/\b[a-z][a-z0-9]*_[a-z0-9_]+\b/); + }); + + it('renders every section its declared order names', () => { + // `bandOf` fails the build on a section the order does not name. This is + // the other direction, which nothing at build time can see: an id left in + // the order after its section went shifts the band of every section under + // it, and the page still renders. + const declaration = /const ORDER = \[([\s\S]*?)\] as const;/.exec(SOURCE); + + expect(declaration, 'the Landing declares no section order').not.toBeNull(); + + const declared = [...declaration![1]!.matchAll(/'([^']+)'/g)].map(([, id]) => id!); + // Everything past the declaration, and never the declaration itself: the + // ids are written there, so a search that included it would find each one + // in its own definition and pass whatever the page renders. + const rest = SOURCE.slice(declaration!.index + declaration![0].length); + + expect(declared, 'the Landing declares an empty section order').not.toHaveLength(0); + + for (const id of declared) { + expect(rest, `\`${id}\` is declared and never rendered`).toContain(id); + } + }); }); +/** + * Everything on the page that can carry a word the reader sees: its markup and + * the frontmatter its copy is declared in, with the stylesheet and the comments + * taken out. A comment explaining why a word is refused must not itself read as + * the page using that word — the same reason `stylesheet()` below blanks them. + * + * Not prose, and deliberately not: most of the copy is declared in TypeScript, so + * anything narrower than this would police the half of the page that happens to + * be markup and leave the half that is data alone. + */ +function copy(): string { + return SOURCE.replace(/