@clossys/launcher 0.1.5 → 0.3.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 (101) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +209 -20
  3. package/contracts/conversation-contract.md +40 -0
  4. package/dist/apply-plan-cli.d.ts +7 -0
  5. package/dist/apply-plan-cli.d.ts.map +1 -0
  6. package/dist/apply-plan-cli.js +96 -0
  7. package/dist/apply-plan-cli.js.map +1 -0
  8. package/dist/apply-plan.d.ts +79 -0
  9. package/dist/apply-plan.d.ts.map +1 -0
  10. package/dist/apply-plan.js +129 -0
  11. package/dist/apply-plan.js.map +1 -0
  12. package/dist/check-cli.d.ts.map +1 -1
  13. package/dist/check-cli.js +3 -0
  14. package/dist/check-cli.js.map +1 -1
  15. package/dist/cli.d.ts +2 -1
  16. package/dist/cli.d.ts.map +1 -1
  17. package/dist/cli.js +37 -11
  18. package/dist/cli.js.map +1 -1
  19. package/dist/contract.d.ts +28 -0
  20. package/dist/contract.d.ts.map +1 -0
  21. package/dist/contract.js +78 -0
  22. package/dist/contract.js.map +1 -0
  23. package/dist/core.d.ts +38 -6
  24. package/dist/core.d.ts.map +1 -1
  25. package/dist/core.js +289 -42
  26. package/dist/core.js.map +1 -1
  27. package/dist/doctor-cli.d.ts +4 -0
  28. package/dist/doctor-cli.d.ts.map +1 -0
  29. package/dist/doctor-cli.js +32 -0
  30. package/dist/doctor-cli.js.map +1 -0
  31. package/dist/doctor.d.ts +28 -0
  32. package/dist/doctor.d.ts.map +1 -0
  33. package/dist/doctor.js +68 -0
  34. package/dist/doctor.js.map +1 -0
  35. package/dist/host.d.ts.map +1 -1
  36. package/dist/host.js +3 -0
  37. package/dist/host.js.map +1 -1
  38. package/dist/hosts.d.ts +14 -0
  39. package/dist/hosts.d.ts.map +1 -0
  40. package/dist/hosts.js +61 -0
  41. package/dist/hosts.js.map +1 -0
  42. package/dist/index.d.ts +15 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/index.js +7 -1
  45. package/dist/index.js.map +1 -1
  46. package/dist/inventory-adoption.d.ts +20 -0
  47. package/dist/inventory-adoption.d.ts.map +1 -0
  48. package/dist/inventory-adoption.js +67 -0
  49. package/dist/inventory-adoption.js.map +1 -0
  50. package/dist/manifest.d.ts +20 -0
  51. package/dist/manifest.d.ts.map +1 -0
  52. package/dist/manifest.js +106 -0
  53. package/dist/manifest.js.map +1 -0
  54. package/dist/model-profile.d.ts +46 -0
  55. package/dist/model-profile.d.ts.map +1 -0
  56. package/dist/model-profile.js +98 -0
  57. package/dist/model-profile.js.map +1 -0
  58. package/dist/product-repository.d.ts +26 -0
  59. package/dist/product-repository.d.ts.map +1 -0
  60. package/dist/product-repository.js +49 -0
  61. package/dist/product-repository.js.map +1 -0
  62. package/dist/skills.d.ts +20 -1
  63. package/dist/skills.d.ts.map +1 -1
  64. package/dist/skills.js +98 -7
  65. package/dist/skills.js.map +1 -1
  66. package/dist/types.d.ts +57 -1
  67. package/dist/types.d.ts.map +1 -1
  68. package/model-profiles/claude-code.json +10 -0
  69. package/model-profiles/codex.json +10 -0
  70. package/model-profiles/cursor.json +10 -0
  71. package/package.json +9 -4
  72. package/skeleton/README.md +5 -0
  73. package/skeleton/package.json +1 -1
  74. package/skill/SKILL.md +53 -0
  75. package/skill-catalogue/advisor/SKILL.md +3 -1
  76. package/skill-catalogue/controller/SKILL.md +8 -0
  77. package/skill-catalogue/customer/SKILL.md +92 -0
  78. package/skill-catalogue/designer/SKILL.md +18 -2
  79. package/skill-catalogue/inspector/SKILL.md +1 -1
  80. package/skill-catalogue/publisher/SKILL.md +17 -3
  81. package/skill-catalogue/strategist/SKILL.md +37 -3
  82. package/skill-catalogue/writer/SKILL.md +7 -2
  83. package/src/apply-plan-cli.ts +94 -0
  84. package/src/apply-plan.ts +172 -0
  85. package/src/check-cli.ts +3 -0
  86. package/src/cli.ts +45 -10
  87. package/src/contract.ts +81 -0
  88. package/src/core.ts +337 -37
  89. package/src/doctor-cli.ts +33 -0
  90. package/src/doctor.ts +145 -0
  91. package/src/host.ts +3 -0
  92. package/src/hosts.ts +79 -0
  93. package/src/index.ts +33 -0
  94. package/src/inventory-adoption.ts +85 -0
  95. package/src/manifest.ts +103 -0
  96. package/src/model-profile.ts +148 -0
  97. package/src/product-repository.ts +73 -0
  98. package/src/skills.ts +113 -8
  99. package/src/types.ts +58 -1
  100. /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
  101. /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
@@ -7,7 +7,7 @@ disable-model-invocation: true
7
7
 
8
8
  You are Inspector. Your job is to judge whether a change satisfies every applicable rule before it lands.
9
9
 
10
- You assess caller-supplied rules and evidence pre-landing. You do not author operating rules or mutate the candidate change.
10
+ You assess caller-supplied rules and evidence pre-landing. You do not author operating rules or mutate the candidate change. You do not inhabit the named Audience — that is a synthetic user with a fresh look via `@clossys-customer` (and on-demand lived feedback, comparison, referral, churn, adopt, and worth), not rule assurance.
11
11
 
12
12
 
13
13
  ## Foundry voices
@@ -7,7 +7,7 @@ disable-model-invocation: true
7
7
 
8
8
  You are Publisher. Your job is to release approved surfaces to their audience and prove the exact shipped result.
9
9
 
10
- You render named surfaces for channels and verify audience release. You do not select templates from business intent or author strategy.
10
+ You seal an approved named surface after a keep — head, OG/meta join, and release proof — and verify the exact shipped result. You do not author the in-tree `SectionedView` or `MarketingView` page document, select templates from business intent, own the app router, or lock final copy; Designer and Writer land that document first.
11
11
 
12
12
 
13
13
  ## Foundry voices
@@ -17,11 +17,23 @@ The same team is in every inventoried repo. Name another `@clossys-<package>` to
17
17
  ## Operating wave
18
18
 
19
19
  1. **Strategist first** — direction and brand facts, across every inventoried product repo that needs it, until the record is current enough to cite.
20
- 2. **Designer and Writer together** — tokens→atoms→blocks in parallel with copy structure for pre-auth pages. Do not start if Strategist still has no citable direction.
21
- 3. **Publisher last** — seal approved surfaces (OG/meta consistency and release proof). Start in each repo when that repo's pages exist; do not wait for every sibling.
20
+ 2. **Designer and Writer together** — tokens→atoms→blocks in parallel with copy structure for pre-auth pages on `MarketingView`. Do not start if Strategist still has no citable direction.
21
+ 3. **Customer inhabit** — independent `@clossys-customer` session speaks first person as the named Audience, fresh look, not a checklist. Publisher does not inhabit and does not treat render as the keep.
22
+ 4. **Publisher last** — seal approved surfaces (OG/meta consistency and release proof) only after a keep. Start in each repo when that repo's pages exist; do not wait for every sibling.
22
23
 
23
24
  An engine gap or a missing check is a Foundry issue about the package that owns it. Never dump a consumer's strategy. Never name a consumer.
24
25
 
26
+ ## Page shape — shipped templates first, `defineWebTemplate` for the rest
27
+
28
+ 1. Name a shipped template (`MarketingView`, `SectionedView`, `AuthView`, `ErrorView`) when its slots cover the page. Pre-auth marketing uses `MarketingView`, not `SectionedView`.
29
+ 2. If a required band is not a slot or one of the six `SectionedView` kinds (`hero`, `feature-grid`, `faq`, `ordered-step-sequence`, `status-list`, `stat-grid`), do not flatten it into `feature-grid` or any other shipped kind — refuse and register `defineWebTemplate` in the consumer with a `blocks` sequence (page-header, node-chapter, stat-grid, and the other kinds this package documents). Consumer templates are data, not a React `build` function. Hero `media` uses `resolveAssetId` at render time; metrics belong in `stat-grid`, not `feature-grid`. `section-header` / `article-body` stay out of contract — register them through `defineWebTemplate` blocks, not route-local JSX.
30
+ 3. Composing blocks in an unregistered route file is a workaround, not the architecture. Run `publisher-web-route-check` on the consumer's web-route manifest in CI so every publishing route names a template from `listWebTemplateNames()` and no route composes Designer blocks directly.
31
+ 4. Run `publisher-preview` with the repo's `brand.css` and brand-asset roster (the optional third argument). It writes the public brand guide and the internal system audit alongside the shipped-view gallery. Do not invent those pages in chat.
32
+
33
+ ## Pre-auth page
34
+
35
+ Done is exceptional (5) as defined in the PRE-AUTH-QUALITY brief that ships with `@clossys/designer`, not in this package. `designer-hero-css-check`, `designer-fold-check`, and `writer-check --live` prove 3 only — never call 3 done or world class. After `designer-fold-check` is green, a bounded taste pass uses desktop and narrow screenshots in a separate session that is not this doer walk; at most 3 inhabit rounds or 45 minutes wall clock, whichever first — see PRE-AUTH-QUALITY (the brief that ships with `@clossys/designer`). This walk does not self-certify exceptional keep. A 5 keep is a synthetic user in that separate session, first person as the named Strategist Audience, not a checklist. This role seals after that keep; it does not author keep-review evidence and does not inhabit the persona.
36
+
25
37
  ## How we work together
26
38
 
27
39
  1. **Status** — Say where things stand in plain language.
@@ -37,6 +49,8 @@ Ask one question. Prefer the host multiple-choice control when it exists; otherw
37
49
 
38
50
  If `node_modules/@clossys/publisher` is present (or this package's bins are on PATH), use the exact pin in the tree. Read `package.json` `bin` for the real command names.
39
51
  - Assessment CLI: `publisher-rate-check`
52
+ - Web route gate: `publisher-web-route-check`
53
+ - Shipped-view preview: `publisher-preview <brand.css> <output-directory> [roster.json]` — runs Designer brand-file coverage first, then writes `gallery.html` with every shipped web view skinned by that brand file. With the optional `roster.json` (a complete brand-asset roster), also writes `guide.html` (public brand guide) and `audit.html` (internal system audit). Use this command; do not invent preview pages in chat.
40
54
 
41
55
  Summarize gate results in human language; keep machine kinds for tooling, not as the default reply.
42
56
 
@@ -7,7 +7,7 @@ disable-model-invocation: true
7
7
 
8
8
  You are Strategist. Your job is to keep business direction true, current, and recognizably ours.
9
9
 
10
- You maintain evidence-backed strategy records and brand derivation. You do not supply a consumer's strategy values, author product copy, or publish surfaces.
10
+ You maintain evidence-backed strategy records and brand derivation — essence, attributes, which token slots and voice rules an attribute obligates, and the do-nots. You do not own the consumer brand overlay bytes, author the in-tree page document (Designer and Writer together), invent product copy, or publish surfaces.
11
11
 
12
12
 
13
13
  ## Foundry voices
@@ -16,12 +16,17 @@ The same team is in every inventoried repo. Name another `@clossys-<package>` to
16
16
 
17
17
  ## Operating wave
18
18
 
19
- 1. **Strategist first** — direction and brand facts, across every inventoried product repo that needs it, until the record is current enough to cite.
19
+ 1. **Strategist first** — direction and brand facts, across every inventoried product repo that needs it, until the record is current enough to cite. You author who the person is; you do not inhabit them. That inhabit is `@clossys-customer`.
20
20
  2. **Designer and Writer together** — tokens→atoms→blocks in parallel with copy structure for pre-auth pages. Do not start if Strategist still has no citable direction.
21
- 3. **Publisher last** — seal approved surfaces (OG/meta consistency and release proof). Start in each repo when that repo's pages exist; do not wait for every sibling.
21
+ 3. **Customer inhabit** — independent `@clossys-customer` session speaks first person as the named Audience this role recorded, fresh look, not a checklist. Strategist supplies who the user is and does not inhabit them.
22
+ 4. **Publisher last** — seal approved surfaces (OG/meta consistency and release proof) only after a keep. Start in each repo when that repo's pages exist; do not wait for every sibling.
22
23
 
23
24
  An engine gap or a missing check is a Foundry issue about the package that owns it. Never dump a consumer's strategy. Never name a consumer.
24
25
 
26
+ ## Pre-auth acceptance
27
+
28
+ Done is exceptional (5) as defined in PRE-AUTH-QUALITY (the brief that ships with `@clossys/designer`, not in this package); Strategist does not redefine it. `strategist-check`, `strategist-rate-check`, and brand-coverage prove 3 only — never call 3 done, never treat gate-green as keep, and a walk that stops at 3 is a defect. A 5 keep is a synthetic user in a separate `@clossys-customer` session, first person as the named Audience this role recorded; Strategist supplies who that person is, does not author keep-review evidence, and does not inhabit them.
29
+
25
30
  ## How we work together
26
31
 
27
32
  1. **Status** — Say where things stand in plain language.
@@ -39,8 +44,37 @@ If `node_modules/@clossys/strategist` is present (or this package's bins are on
39
44
  - Assessment CLI: `strategist-rate-check`
40
45
  - Additional gate CLI: `strategist-check`
41
46
 
47
+ `strategist-check brand-coverage` reporting every brandable slot owned is necessary, not sufficient — full N/N slot coverage is not keep when Designer-facing surfaces have no explicit do-not language; declare those surfaces with `--surfaces`.
48
+
42
49
  Summarize gate results in human language; keep machine kinds for tooling, not as the default reply.
43
50
 
51
+ ## Strategy directory (this package only)
52
+
53
+ Only `@clossys-strategist` edits the consumer's `clossys/strategist/` directory. Downstream skills cite handoff ids; they do not author strategy records.
54
+
55
+ For one release only, a consumer whose `clossys/strategist/` does not exist yet but who still has the retired `strategy/` directory is read from there instead, with a notice to move it; both present at once is refused rather than silently picked. See `strategist-check --help` and the package CHANGELOG.
56
+
57
+ Author one directory. Bound fields must validate; room fields are prose storage only.
58
+
59
+ | File | Bound | Room | Refused in this directory |
60
+ | --- | --- | --- | --- |
61
+ | `facts.json` | Fact keys, values, sources | — | not a direction subject |
62
+ | `audiences.json` | `id`, `name`, `situation`, `pains` | `notes` | persona scripts |
63
+ | `markets.json` | `id`, `name`, `audienceIds`, `factRefs` | `description` | optional at handoff |
64
+ | `positioning.json` | `productName`, `category`, `audienceIds`, `weAre`, `unlike`, `claimIds` | `notes` | no `forWhom` / `reasonToBelieve` |
65
+ | `claims.json` | `id`, `status`, `assertion`, `basis` (required when approved) | `example` | no headline copy |
66
+ | `constraints.json` | `id`, `target`, `instruction` | `why` | empty array is valid |
67
+ | `brand.json` | essence, attribute `id`/`statement`/`basis`, derivation slots or voice rules | derivation `rationale` | no hex colors or type pairings |
68
+ | `mission.json` | `statement`, `vision`, value `id`/`rule` | — | optional at handoff |
69
+ | `roadmap.json` | `id`, `title`, `status`; shipped needs `factRef` or `claimId` | `description` | optional at handoff |
70
+ | `direction.json` | `id`, `subject`, `decidedOn`, `supersedes`, `derivesFrom` | `rationale` | no `statement`; facts are not subjects |
71
+
72
+ Retired filenames: `brand-essence.json`, `brand-attributes.json`, `brand-derivations.json` — use `brand.json`.
73
+
74
+ `strategist-check handoff <strategy-dir>` exits 0 only when facts, audiences, positioning, at least one approved claim, `constraints.json`, brand refs, and direction refs resolve. A facts-only directory still passes `readStrategy` and fails handoff.
75
+
76
+ Do not author a parallel `StrategyContract` file — project with `projectStrategyContract` when a consumer needs the portable contract.
77
+
44
78
  ## When this package is not installed
45
79
 
46
80
  You are here as a person in this repo the same way you are in every other inventoried repo.
@@ -7,7 +7,7 @@ disable-model-invocation: true
7
7
 
8
8
  You are Writer. Your job is to keep audience-facing language approved, traceable, and well said.
9
9
 
10
- You maintain the copy registry, voice conformance, and language traceability. You do not invent strategy facts, design primitives, or transport messages.
10
+ You maintain approved copy records for a named page, voice conformance, and language traceability. With Designer you author the in-tree page document — sections, copy ids, block kinds — and iterate until a local render of that document is the page; you do not invent strategy facts, treat yourself as outline-only for Publisher to finish, or publish surfaces.
11
11
 
12
12
 
13
13
  ## Foundry voices
@@ -18,10 +18,15 @@ The same team is in every inventoried repo. Name another `@clossys-<package>` to
18
18
 
19
19
  1. **Strategist first** — direction and brand facts, across every inventoried product repo that needs it, until the record is current enough to cite.
20
20
  2. **Designer and Writer together** — tokens→atoms→blocks in parallel with copy structure for pre-auth pages. Do not start if Strategist still has no citable direction.
21
- 3. **Publisher last** — seal approved surfaces (OG/meta consistency and release proof). Start in each repo when that repo's pages exist; do not wait for every sibling.
21
+ 3. **Customer inhabit** — independent `@clossys-customer` session speaks first person as the named Audience, fresh look, not a checklist. That person can also be asked for lived feedback on any topic, comparison from their consideration set, what it would take to start or to refer, whether it is worth what it costs them, and what would make them leave. This role does not inhabit the user.
22
+ 4. **Publisher last** — seal approved surfaces (OG/meta consistency and release proof) only after a keep. Start in each repo when that repo's pages exist; do not wait for every sibling.
22
23
 
23
24
  An engine gap or a missing check is a Foundry issue about the package that owns it. Never dump a consumer's strategy. Never name a consumer.
24
25
 
26
+ ## Pre-auth page
27
+
28
+ Done is exceptional (5) as defined in PRE-AUTH-QUALITY (the brief that ships with `@clossys/designer`). `designer-hero-css-check`, `designer-fold-check`, and `writer-check --live` prove 3 only — never call 3 done or world class. After `designer-fold-check` is green, a bounded taste pass uses desktop and narrow screenshots in a separate session that is not this doer walk; at most 3 inhabit rounds or 45 minutes wall clock, whichever first — see PRE-AUTH-QUALITY (the brief that ships with `@clossys/designer`). This walk does not self-certify exceptional keep. A 5 keep is a synthetic user in that separate session, first person as the named Strategist Audience, not a copy score and not a checklist. This role does not author keep-review evidence and does not inhabit the persona. Name `MarketingView`, `SectionedView`, or a registered web template before filling bands; do not author a page shape the shipped views cannot hold.
29
+
25
30
  ## How we work together
26
31
 
27
32
  1. **Status** — Say where things stand in plain language.
@@ -0,0 +1,94 @@
1
+ #!/usr/bin/env node
2
+ import { isDirectInvocation } from "./cli.js";
3
+ import { createNodeHost } from "./host.js";
4
+ import { applyEngagementBrief, validateAdvisorPlan, validateEngagementBrief, type AdvisorPlan, type EngagementBrief } from "./apply-plan.js";
5
+
6
+ export const APPLY_PLAN_USAGE = `Usage: launcher-apply-plan --plan <plan.json> --brief <brief.json> --repo <directory>
7
+
8
+ Writes clossys/brief.json into <directory> from the given brief, once the
9
+ given plan's most recent decision is "approved". Refuses, and writes
10
+ nothing, otherwise.
11
+
12
+ Deterministic mechanics only: this does not decide whether a plan should be
13
+ approved (that is Advisor's job) and does not compute the brief's content
14
+ (that is @clossys/advisor's EngagementBrief, #1193) -- it validates the
15
+ exact shapes recorded on issue #1175 and writes the one file.
16
+
17
+ Exit codes: 0 = applied, 1 = refused (not approved, or a shape does not
18
+ validate), 2 = a given file could not be read as JSON.`;
19
+
20
+ export class ApplyPlanInputError extends Error {}
21
+
22
+ function parseArgs(argv: readonly string[]): { help: boolean; planPath?: string; briefPath?: string; repoDirectory?: string } {
23
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true };
24
+ const flags = new Map<string, string>();
25
+ for (let index = 0; index < argv.length; index += 2) {
26
+ const name = argv[index];
27
+ const value = argv[index + 1];
28
+ if ((name !== "--plan" && name !== "--brief" && name !== "--repo") || value === undefined) {
29
+ throw new ApplyPlanInputError("usage: launcher-apply-plan --plan <path> --brief <path> --repo <directory>");
30
+ }
31
+ flags.set(name, value);
32
+ }
33
+ const planPath = flags.get("--plan");
34
+ const briefPath = flags.get("--brief");
35
+ const repoDirectory = flags.get("--repo");
36
+ if (planPath === undefined || briefPath === undefined || repoDirectory === undefined) {
37
+ throw new ApplyPlanInputError("--plan, --brief, and --repo are all required");
38
+ }
39
+ return { help: false, planPath, briefPath, repoDirectory };
40
+ }
41
+
42
+ function readJson(readText: (path: string) => string | null, path: string, label: string): unknown {
43
+ const raw = readText(path);
44
+ if (raw === null) throw new ApplyPlanInputError(`${label} could not be read: ${path}`);
45
+ try {
46
+ return JSON.parse(raw);
47
+ } catch {
48
+ throw new ApplyPlanInputError(`${label} is not valid JSON: ${path}`);
49
+ }
50
+ }
51
+
52
+ export function main(argv: readonly string[], host: ReturnType<typeof createNodeHost>): number {
53
+ const parsed = parseArgs(argv);
54
+ if (parsed.help) {
55
+ console.log(APPLY_PLAN_USAGE);
56
+ return 0;
57
+ }
58
+ let planRaw: unknown;
59
+ let briefRaw: unknown;
60
+ try {
61
+ planRaw = readJson(host.readText, parsed.planPath as string, "--plan");
62
+ briefRaw = readJson(host.readText, parsed.briefPath as string, "--brief");
63
+ } catch (cause) {
64
+ console.error(`launcher-apply-plan: ${cause instanceof Error ? cause.message : String(cause)}`);
65
+ return 2;
66
+ }
67
+ const planValidation = validateAdvisorPlan(planRaw);
68
+ if (!planValidation.valid) {
69
+ console.error(`launcher-apply-plan: --plan does not validate: ${planValidation.reason}`);
70
+ return 1;
71
+ }
72
+ const briefValidation = validateEngagementBrief(briefRaw);
73
+ if (!briefValidation.valid) {
74
+ console.error(`launcher-apply-plan: --brief does not validate: ${briefValidation.reason}`);
75
+ return 1;
76
+ }
77
+ const result = applyEngagementBrief(host, parsed.repoDirectory as string, planRaw as AdvisorPlan, briefRaw as EngagementBrief, "clossys/brief.json");
78
+ if (result.state === "refused") {
79
+ console.error(`launcher-apply-plan: refused -- ${result.reason}`);
80
+ return 1;
81
+ }
82
+ console.log(`wrote ${result.path}`);
83
+ return 0;
84
+ }
85
+
86
+ function run(): void {
87
+ try {
88
+ process.exitCode = main(process.argv.slice(2), createNodeHost());
89
+ } catch (cause) {
90
+ console.error(`launcher-apply-plan: ${cause instanceof Error ? cause.message : String(cause)}`);
91
+ process.exitCode = 2;
92
+ }
93
+ }
94
+ if (isDirectInvocation(import.meta.url, process.argv[1])) run();
@@ -0,0 +1,172 @@
1
+ // Apply an approved plan (#1178): writes clossys/brief.json into a staffed
2
+ // repository from the exact EngagementBrief shape and plan.json contract
3
+ // recorded on issue #1175 ("Plan file contract", posted 2026-09-22).
4
+ //
5
+ // SCOPE OF THIS MODULE (see the wave-2 PR body for the full explanation):
6
+ // this lands the mechanical, auditable core the landed contract fully
7
+ // specifies -- reading clossys/advisor/plan.json, confirming it is
8
+ // approved, validating an EngagementBrief, and writing clossys/brief.json
9
+ // byte-identically. Multi-repository orchestration (branch creation, exact
10
+ // package installs, Starter's caller workflow, opening one pull request
11
+ // per repository) is deferred: the landed contract does not yet specify
12
+ // how a plan's approved roles map to inventory repository ids or to
13
+ // install/remove/relocate work items, and building that mapping now would
14
+ // mean inventing an interface Advisor's still-open PR (#1193) might define
15
+ // differently.
16
+ //
17
+ // clossys/brief.json's shape is NOT re-derived here -- @clossys/advisor's
18
+ // EngagementBrief export (landing in #1193) is the one owner of that
19
+ // computation. This module receives an already-computed brief (as a file
20
+ // path today; a direct call once #1193 lands and a caller can import the
21
+ // package) and only validates its shape and writes it, exactly the split
22
+ // #1187's governing principle draws between package-owned definition and
23
+ // judgment versus Launcher's deterministic mechanics.
24
+
25
+ import type { WorkspaceHost } from "./types.js";
26
+
27
+ export interface EngagementBriefRole {
28
+ readonly role: string;
29
+ readonly why: string;
30
+ readonly goal: { readonly metric: string; readonly direction: "increase" | "decrease" };
31
+ readonly inputsFrom: readonly string[];
32
+ readonly outputsTo: readonly string[];
33
+ }
34
+
35
+ export interface EngagementBrief {
36
+ readonly schemaVersion: 1;
37
+ readonly problem: string;
38
+ readonly roles: readonly EngagementBriefRole[];
39
+ readonly sequence: readonly string[];
40
+ readonly deliverables: readonly string[];
41
+ }
42
+
43
+ export type BlockerKind = "missing-input" | "missing-authority" | "failing-evidence" | "unavailable-environment" | "contradiction";
44
+
45
+ export interface PlanBlocker {
46
+ readonly kind: BlockerKind;
47
+ readonly description: string;
48
+ readonly owner: string;
49
+ readonly dueDate?: string;
50
+ }
51
+
52
+ export interface PlanDecision {
53
+ readonly at: string;
54
+ readonly recommended: string;
55
+ readonly chosen: string;
56
+ readonly by: string;
57
+ }
58
+
59
+ export interface AdvisorPlan {
60
+ readonly schemaVersion: 1;
61
+ readonly asOf: string;
62
+ readonly mandate: { readonly problem: string; readonly primaryProblemId: string; readonly roles: readonly string[] };
63
+ readonly whereWeAre: readonly string[];
64
+ readonly recommendedNext: { readonly action: string; readonly owner: string; readonly due: string } | null;
65
+ readonly decisions: readonly PlanDecision[];
66
+ readonly blockers: readonly PlanBlocker[];
67
+ }
68
+
69
+ export type ValidationResult = { readonly valid: true } | { readonly valid: false; readonly reason: string };
70
+
71
+ function isRecord(value: unknown): value is Record<string, unknown> {
72
+ return typeof value === "object" && value !== null && !Array.isArray(value);
73
+ }
74
+ function nonEmptyString(value: unknown): value is string {
75
+ return typeof value === "string" && value.trim() !== "";
76
+ }
77
+ function stringArray(value: unknown): value is string[] {
78
+ return Array.isArray(value) && value.every((item) => typeof item === "string");
79
+ }
80
+
81
+ /** Validates an EngagementBrief's shape exactly against the #1175 contract. Never mutates, never re-derives content. */
82
+ export function validateEngagementBrief(value: unknown): ValidationResult {
83
+ if (!isRecord(value)) return { valid: false, reason: "brief must be an object" };
84
+ if (value.schemaVersion !== 1) return { valid: false, reason: "brief.schemaVersion must be 1" };
85
+ if (!nonEmptyString(value.problem)) return { valid: false, reason: "brief.problem must be a non-empty string" };
86
+ if (!Array.isArray(value.roles) || value.roles.length === 0) return { valid: false, reason: "brief.roles must be a non-empty array" };
87
+ for (const [index, role] of value.roles.entries()) {
88
+ if (!isRecord(role)) return { valid: false, reason: `brief.roles[${index}] must be an object` };
89
+ if (!nonEmptyString(role.role)) return { valid: false, reason: `brief.roles[${index}].role must be a non-empty string` };
90
+ if (!nonEmptyString(role.why)) return { valid: false, reason: `brief.roles[${index}].why must be a non-empty string` };
91
+ if (!isRecord(role.goal) || !nonEmptyString(role.goal.metric) || (role.goal.direction !== "increase" && role.goal.direction !== "decrease")) {
92
+ return { valid: false, reason: `brief.roles[${index}].goal must have a metric and a direction of increase or decrease` };
93
+ }
94
+ if (!stringArray(role.inputsFrom)) return { valid: false, reason: `brief.roles[${index}].inputsFrom must be a string array` };
95
+ if (!stringArray(role.outputsTo)) return { valid: false, reason: `brief.roles[${index}].outputsTo must be a string array` };
96
+ }
97
+ if (!stringArray(value.sequence) || value.sequence.length === 0) return { valid: false, reason: "brief.sequence must be a non-empty string array" };
98
+ if (!stringArray(value.deliverables)) return { valid: false, reason: "brief.deliverables must be a string array" };
99
+ return { valid: true };
100
+ }
101
+
102
+ const BLOCKER_KINDS = new Set<BlockerKind>(["missing-input", "missing-authority", "failing-evidence", "unavailable-environment", "contradiction"]);
103
+
104
+ /** Validates an AdvisorPlan's shape exactly against the #1175 contract. */
105
+ export function validateAdvisorPlan(value: unknown): ValidationResult {
106
+ if (!isRecord(value)) return { valid: false, reason: "plan must be an object" };
107
+ if (value.schemaVersion !== 1) return { valid: false, reason: "plan.schemaVersion must be 1" };
108
+ if (!nonEmptyString(value.asOf)) return { valid: false, reason: "plan.asOf must be a non-empty string" };
109
+ if (!isRecord(value.mandate) || !nonEmptyString(value.mandate.problem) || !nonEmptyString(value.mandate.primaryProblemId) || !stringArray(value.mandate.roles)) {
110
+ return { valid: false, reason: "plan.mandate must have problem, primaryProblemId, and a roles string array" };
111
+ }
112
+ if (!stringArray(value.whereWeAre)) return { valid: false, reason: "plan.whereWeAre must be a string array" };
113
+ if (value.recommendedNext !== null) {
114
+ if (!isRecord(value.recommendedNext) || !nonEmptyString(value.recommendedNext.action) || !nonEmptyString(value.recommendedNext.owner) || !nonEmptyString(value.recommendedNext.due)) {
115
+ return { valid: false, reason: "plan.recommendedNext must be null or have action, owner, and due" };
116
+ }
117
+ }
118
+ if (!Array.isArray(value.decisions)) return { valid: false, reason: "plan.decisions must be an array" };
119
+ for (const [index, decision] of value.decisions.entries()) {
120
+ if (!isRecord(decision) || !nonEmptyString(decision.at) || !nonEmptyString(decision.recommended) || !nonEmptyString(decision.chosen) || !nonEmptyString(decision.by)) {
121
+ return { valid: false, reason: `plan.decisions[${index}] must have at, recommended, chosen, and by` };
122
+ }
123
+ }
124
+ if (!Array.isArray(value.blockers)) return { valid: false, reason: "plan.blockers must be an array" };
125
+ for (const [index, blocker] of value.blockers.entries()) {
126
+ if (!isRecord(blocker) || !BLOCKER_KINDS.has(blocker.kind as BlockerKind) || !nonEmptyString(blocker.description) || !nonEmptyString(blocker.owner)) {
127
+ return { valid: false, reason: `plan.blockers[${index}] must have a valid kind, description, and owner` };
128
+ }
129
+ }
130
+ return { valid: true };
131
+ }
132
+
133
+ /**
134
+ * The plan is approved when its most recent decision (by `at`) records
135
+ * chosen === "approved". No decisions, or a most-recent decision that
136
+ * isn't "approved", is not approved -- this never assumes approval from
137
+ * absence.
138
+ */
139
+ export function isPlanApproved(plan: AdvisorPlan): boolean {
140
+ if (plan.decisions.length === 0) return false;
141
+ const mostRecent = [...plan.decisions].sort((left, right) => Date.parse(left.at) - Date.parse(right.at)).at(-1);
142
+ return mostRecent?.chosen === "approved";
143
+ }
144
+
145
+ export type ApplyBriefResult =
146
+ | { readonly state: "applied"; readonly path: string }
147
+ | { readonly state: "refused"; readonly reason: string };
148
+
149
+ /**
150
+ * Writes clossys/brief.json into `repositoryDirectory`, byte-identically
151
+ * from the validated brief -- never re-authors its prose. Refuses (does
152
+ * not write) unless both the plan is approved and the brief validates.
153
+ */
154
+ export function applyEngagementBrief(
155
+ host: WorkspaceHost,
156
+ repositoryDirectory: string,
157
+ plan: AdvisorPlan,
158
+ brief: EngagementBrief,
159
+ briefRelPath: string,
160
+ ): ApplyBriefResult {
161
+ if (!isPlanApproved(plan)) {
162
+ return { state: "refused", reason: "the plan's most recent decision is not \"approved\"" };
163
+ }
164
+ const validation = validateEngagementBrief(brief);
165
+ if (!validation.valid) {
166
+ return { state: "refused", reason: `brief does not validate: ${validation.reason}` };
167
+ }
168
+ const path = `${repositoryDirectory}/${briefRelPath}`;
169
+ host.mkdirp(path.slice(0, path.lastIndexOf("/")));
170
+ host.writeText(path, `${JSON.stringify(brief, null, 2)}\n`);
171
+ return { state: "applied", path };
172
+ }
package/src/check-cli.ts CHANGED
@@ -120,6 +120,9 @@ export function planningHost(): WorkspaceHost {
120
120
  symlink: () => {
121
121
  throw new Error("launcher-check does not write");
122
122
  },
123
+ remove: () => {
124
+ throw new Error("launcher-check does not write");
125
+ },
123
126
  readDir: () => [],
124
127
  run: () => unused(),
125
128
  prompt: () => null,
package/src/cli.ts CHANGED
@@ -2,11 +2,19 @@
2
2
  import { realpathSync } from "node:fs";
3
3
  import { resolve } from "node:path";
4
4
  import { fileURLToPath } from "node:url";
5
- import { applyWorkspacePlan, observeWorkspace, planWorkspace, launcherPackageRootFromModule, skeletonRootFromModule } from "./core.js";
5
+ import {
6
+ applyWorkspacePlan,
7
+ cloneMissingInventoryRepositories,
8
+ observeWorkspace,
9
+ planWorkspace,
10
+ launcherPackageRootFromModule,
11
+ readLiveLauncherVersion,
12
+ skeletonRootFromModule,
13
+ } from "./core.js";
6
14
  import { createNodeHost } from "./host.js";
7
15
  import type { WorkspaceHost } from "./types.js";
8
16
 
9
- export const USAGE = `Usage: launcher [--inventory <path>]
17
+ export const USAGE = `Usage: launcher [--inventory <path>] [--clone-missing]
10
18
 
11
19
  Create, resume, or appoint a GitHub repository as the account workspace hub.
12
20
 
@@ -16,9 +24,15 @@ hub to appoint it — it does not have to be a new exclusive repo, and it keeps
16
24
  its current name and files.
17
25
 
18
26
  Appointing requires a populated generated hub inventory (packed template
19
- skeleton/.clossys/inventory.json; the generated path does not ship), or
27
+ skeleton/clossys/.state/inventory.json; the generated path does not ship), or
20
28
  --inventory <path> pointing at one. Resume refreshes composed skills and
21
- stale hub guidance. Create may write an empty inventory.
29
+ stale hub guidance, and migrates a legacy .clossys/ hub state to
30
+ clossys/.state/ automatically. Create may write an empty inventory.
31
+
32
+ By default launcher never \`gh repo clone\`s a missing inventory entry --
33
+ that is not how you talk to the team. --clone-missing is the one explicit,
34
+ approved exception (#1179): on resume only, it clones every inventoried
35
+ repository not yet sitting beside the hub, and only those.
22
36
 
23
37
  GitHub-only. Owner is inferred from \`gh\` and git remotes. Public npm reads
24
38
  need no token.
@@ -31,11 +45,20 @@ function exitCodeFor(state: "satisfied" | "violated" | "indeterminate"): number
31
45
  return state === "satisfied" ? 0 : state === "violated" ? 1 : 2;
32
46
  }
33
47
 
34
- export function parseLauncherArgs(argv: readonly string[]): { help: boolean; inventoryPath?: string } {
35
- if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true };
36
- if (argv.length === 0) return { help: false };
37
- if (argv.length === 2 && argv[0] === "--inventory" && argv[1]) return { help: false, inventoryPath: argv[1] };
38
- throw new LauncherInputError("launcher takes no arguments except optional --inventory <path>; run it from the directory to create or appoint");
48
+ export function parseLauncherArgs(argv: readonly string[]): { help: boolean; inventoryPath?: string; cloneMissing: boolean } {
49
+ if (argv.length === 1 && (argv[0] === "--help" || argv[0] === "-h")) return { help: true, cloneMissing: false };
50
+ const rest = [...argv];
51
+ let cloneMissing = false;
52
+ const cloneIndex = rest.indexOf("--clone-missing");
53
+ if (cloneIndex !== -1) {
54
+ cloneMissing = true;
55
+ rest.splice(cloneIndex, 1);
56
+ }
57
+ if (rest.length === 0) return { help: false, cloneMissing };
58
+ if (rest.length === 2 && rest[0] === "--inventory" && rest[1]) return { help: false, inventoryPath: rest[1], cloneMissing };
59
+ throw new LauncherInputError(
60
+ "launcher takes no arguments except optional --inventory <path> and/or --clone-missing; run it from the directory to create or appoint",
61
+ );
39
62
  }
40
63
 
41
64
  /** Testable CLI dispatcher. Unknown arguments throw; the executable maps them to exit 2. */
@@ -62,16 +85,28 @@ export function main(argv: readonly string[], host: WorkspaceHost, skeletonRoot:
62
85
  }
63
86
  if (parsed.inventoryPath !== undefined && decision.action !== "adopt") {
64
87
  if (decision.action === "resume") {
65
- console.error("launcher: this hub is already appointed; edit .clossys/inventory.json to change its inventory");
88
+ console.error("launcher: this hub is already appointed; edit clossys/.state/inventory.json to change its inventory");
66
89
  } else {
67
90
  console.error("launcher: --inventory is only valid when appointing a GitHub repository");
68
91
  }
69
92
  return 1;
70
93
  }
94
+ if (parsed.cloneMissing && decision.action !== "resume") {
95
+ console.error("launcher: --clone-missing is only valid on an already-appointed hub (resume)");
96
+ return 1;
97
+ }
71
98
  const result = applyWorkspacePlan(host, decision, skeletonRoot, {
72
99
  launcherPackageRoot: launcherPackageRootFromModule(import.meta.url),
100
+ liveLauncherVersion: readLiveLauncherVersion(host),
73
101
  });
74
102
  console.log(result.message);
103
+ if (parsed.cloneMissing && decision.action === "resume") {
104
+ const outcomes = cloneMissingInventoryRepositories(host, decision.directory, decision.owner);
105
+ for (const outcome of outcomes) {
106
+ if (outcome.result === "skipped-other-reason") continue;
107
+ console.log(`clone-missing (${outcome.inventoryId}): ${outcome.result} -- ${outcome.note}`);
108
+ }
109
+ }
75
110
  return 0;
76
111
  }
77
112
 
@@ -0,0 +1,81 @@
1
+ /**
2
+ * The single conversation contract every composed skill carries (#1182).
3
+ * Source of truth: the conversation-contract document kept in this
4
+ * monorepo's shared contracts directory (not part of this package's own
5
+ * published files). This package's own build step packs it into `contracts/`
6
+ * so the published tarball is self-contained.
7
+ */
8
+
9
+ const CONTRACT_HEADING = "## How we work together";
10
+ const LEGACY_HEADING = "## One question at a time";
11
+ const INSTALLED_HEADING = "## When this package is installed";
12
+
13
+ function lineIndex(lines: readonly string[], heading: string, from = 0): number {
14
+ for (let index = from; index < lines.length; index += 1) {
15
+ if ((lines[index] ?? "").trim() === heading) return index;
16
+ }
17
+ return -1;
18
+ }
19
+
20
+ /**
21
+ * Extracts the injectable block from the raw conversation-contract.md text:
22
+ * everything from its `## How we work together` heading to end of file,
23
+ * trimmed. Content above that heading (a title, a provenance note) is
24
+ * documentation for a human reader of the contract file itself and is never
25
+ * injected.
26
+ */
27
+ export function extractContractBlock(rawDocText: string): string {
28
+ const lines = rawDocText.split("\n");
29
+ const index = lineIndex(lines, CONTRACT_HEADING);
30
+ if (index === -1) {
31
+ throw new Error("conversation contract document is missing its `## How we work together` heading");
32
+ }
33
+ return lines.slice(index).join("\n").trim();
34
+ }
35
+
36
+ /**
37
+ * Replaces a skill's own `## How we work together` and `## One question at a
38
+ * time` sections (if present) with the shared conversation contract, at the
39
+ * same position. When neither heading is present, inserts the contract
40
+ * before `## When this package is installed` if that heading exists, else
41
+ * appends it at the end of the file. A blank line is preserved (or added)
42
+ * on both sides of the inserted block; existing content is otherwise left
43
+ * untouched. Idempotent: composing an already-composed skill a second time
44
+ * (the contract's own heading is `## How we work together`, so a repeat run
45
+ * finds and replaces exactly the block it wrote) leaves it unchanged.
46
+ */
47
+ export function injectContract(skillBody: string, contractBlock: string): string {
48
+ const contract = contractBlock.trim();
49
+ const lines = skillBody.split("\n");
50
+ const howIdx = lineIndex(lines, CONTRACT_HEADING);
51
+ const oneIdx = lineIndex(lines, LEGACY_HEADING);
52
+
53
+ let start: number;
54
+ let end: number;
55
+ if (howIdx !== -1 || oneIdx !== -1) {
56
+ start = howIdx === -1 ? oneIdx : oneIdx === -1 ? howIdx : Math.min(howIdx, oneIdx);
57
+ end = lines.length;
58
+ for (let index = start + 1; index < lines.length; index += 1) {
59
+ const trimmed = (lines[index] ?? "").trim();
60
+ if (trimmed.startsWith("## ") && trimmed !== CONTRACT_HEADING && trimmed !== LEGACY_HEADING) {
61
+ end = index;
62
+ break;
63
+ }
64
+ }
65
+ } else {
66
+ const installedIdx = lineIndex(lines, INSTALLED_HEADING);
67
+ start = installedIdx === -1 ? lines.length : installedIdx;
68
+ end = start;
69
+ }
70
+
71
+ const before = lines.slice(0, start);
72
+ const after = lines.slice(end);
73
+ const needsLeadingBlank = before.length > 0 && (before[before.length - 1] ?? "").trim() !== "";
74
+ const needsTrailingBlank = after.length > 0 && (after[0] ?? "").trim() !== "";
75
+ const block = [
76
+ ...(needsLeadingBlank ? [""] : []),
77
+ ...contract.split("\n"),
78
+ ...(needsTrailingBlank ? [""] : []),
79
+ ];
80
+ return [...before, ...block, ...after].join("\n");
81
+ }