From 19bb5abcc07e9ce4f3c6156a1e496b14d5153360 Mon Sep 17 00:00:00 2001 From: Davide Imola Date: Thu, 27 Aug 2026 13:06:48 +0200 Subject: [PATCH 1/3] feat: the Landing climbs from the toolbox to the colleague MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Argus derives one of two shapes from a single fact — whether an LLM provider is configured — and the Landing was written when only one of them existed. It opened at its maximum, Argus arguing back on a pull request, and then descended into configuration; the third story beat closed on "A colleague, not a toolbox" while Toolbox was a shipped shape. So the middle of the page ascends. It starts where a reader can start today, with Argus serving its scanners and what the organization knows to the AI tool they already pay for, adds the model that makes it a colleague, and only then tells the pull-request story — which now reads as what the model unlocked. Trust closes, so the boundaries land after the climb instead of interrupting it. The hero deliberately does not carry the first step: a reader at the top has not yet learned that Argus uses a model at all, so "no model required" up there answers an objection nobody has raised. ADR 0013 records the order and the exception. Toolbox and Colleague are the Guide's words, adopted rather than invented, and `agentless` is refused however well it would have fitted — in application security it already means "nothing installed on the host", and a toolbox is a daemon on the machine the code is on. ADR 0012 records both, and `CONTEXT.md` gains the three terms with the words each one is not. Two things the page now says because the Guide says them and it was about to stop short of them: the shared instance shares what Argus knows and not where it reads, since scanning stays on the host Argus runs on; and the consultation panel moved out of the toolbox section it used to share, because `consult` is served by a colleague only and a toolbox section was demonstrating the one thing that shape cannot do. The raised band was decided section by section, which survives exactly until the page is reordered. `src/landing/bands.ts` derives it from the sequence instead: the page declares its order once and every section asks, and asking about a section the order does not name throws, so a stale id fails the build rather than silently shifting the rhythm of everything under it. Three guards in `landing.test.ts` hold the rest. The refused words are read out of `CONTEXT.md`, so arming the glossary arms the test; the page has to name both shapes, which is the half that went wrong quietly while the first half passed; and no snake_case name may appear anywhere in the copy, because MCP tool names, config keys and environment variables are all snake_case and all Inventories the Guide owns. --- CONTEXT.md | 25 + ...ding-speaks-the-guides-shape-vocabulary.md | 16 + ...e-landing-ascends-and-the-hero-does-not.md | 20 + src/landing/bands.test.ts | 50 ++ src/landing/bands.ts | 47 ++ src/landing/landing.test.ts | 95 ++++ src/pages/index.astro | 508 +++++++++++++----- 7 files changed, 629 insertions(+), 132 deletions(-) create mode 100644 docs/adr/0012-the-landing-speaks-the-guides-shape-vocabulary.md create mode 100644 docs/adr/0013-the-landing-ascends-and-the-hero-does-not.md create mode 100644 src/landing/bands.test.ts create mode 100644 src/landing/bands.ts 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(/