@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.
- package/CHANGELOG.md +67 -0
- package/README.md +209 -20
- package/contracts/conversation-contract.md +40 -0
- package/dist/apply-plan-cli.d.ts +7 -0
- package/dist/apply-plan-cli.d.ts.map +1 -0
- package/dist/apply-plan-cli.js +96 -0
- package/dist/apply-plan-cli.js.map +1 -0
- package/dist/apply-plan.d.ts +79 -0
- package/dist/apply-plan.d.ts.map +1 -0
- package/dist/apply-plan.js +129 -0
- package/dist/apply-plan.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +3 -0
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +2 -1
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +37 -11
- package/dist/cli.js.map +1 -1
- package/dist/contract.d.ts +28 -0
- package/dist/contract.d.ts.map +1 -0
- package/dist/contract.js +78 -0
- package/dist/contract.js.map +1 -0
- package/dist/core.d.ts +38 -6
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +289 -42
- package/dist/core.js.map +1 -1
- package/dist/doctor-cli.d.ts +4 -0
- package/dist/doctor-cli.d.ts.map +1 -0
- package/dist/doctor-cli.js +32 -0
- package/dist/doctor-cli.js.map +1 -0
- package/dist/doctor.d.ts +28 -0
- package/dist/doctor.d.ts.map +1 -0
- package/dist/doctor.js +68 -0
- package/dist/doctor.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +3 -0
- package/dist/host.js.map +1 -1
- package/dist/hosts.d.ts +14 -0
- package/dist/hosts.d.ts.map +1 -0
- package/dist/hosts.js +61 -0
- package/dist/hosts.js.map +1 -0
- package/dist/index.d.ts +15 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -1
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +20 -0
- package/dist/inventory-adoption.d.ts.map +1 -0
- package/dist/inventory-adoption.js +67 -0
- package/dist/inventory-adoption.js.map +1 -0
- package/dist/manifest.d.ts +20 -0
- package/dist/manifest.d.ts.map +1 -0
- package/dist/manifest.js +106 -0
- package/dist/manifest.js.map +1 -0
- package/dist/model-profile.d.ts +46 -0
- package/dist/model-profile.d.ts.map +1 -0
- package/dist/model-profile.js +98 -0
- package/dist/model-profile.js.map +1 -0
- package/dist/product-repository.d.ts +26 -0
- package/dist/product-repository.d.ts.map +1 -0
- package/dist/product-repository.js +49 -0
- package/dist/product-repository.js.map +1 -0
- package/dist/skills.d.ts +20 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +98 -7
- package/dist/skills.js.map +1 -1
- package/dist/types.d.ts +57 -1
- package/dist/types.d.ts.map +1 -1
- package/model-profiles/claude-code.json +10 -0
- package/model-profiles/codex.json +10 -0
- package/model-profiles/cursor.json +10 -0
- package/package.json +9 -4
- package/skeleton/README.md +5 -0
- package/skeleton/package.json +1 -1
- package/skill/SKILL.md +53 -0
- package/skill-catalogue/advisor/SKILL.md +3 -1
- package/skill-catalogue/controller/SKILL.md +8 -0
- package/skill-catalogue/customer/SKILL.md +92 -0
- package/skill-catalogue/designer/SKILL.md +18 -2
- package/skill-catalogue/inspector/SKILL.md +1 -1
- package/skill-catalogue/publisher/SKILL.md +17 -3
- package/skill-catalogue/strategist/SKILL.md +37 -3
- package/skill-catalogue/writer/SKILL.md +7 -2
- package/src/apply-plan-cli.ts +94 -0
- package/src/apply-plan.ts +172 -0
- package/src/check-cli.ts +3 -0
- package/src/cli.ts +45 -10
- package/src/contract.ts +81 -0
- package/src/core.ts +337 -37
- package/src/doctor-cli.ts +33 -0
- package/src/doctor.ts +145 -0
- package/src/host.ts +3 -0
- package/src/hosts.ts +79 -0
- package/src/index.ts +33 -0
- package/src/inventory-adoption.ts +85 -0
- package/src/manifest.ts +103 -0
- package/src/model-profile.ts +148 -0
- package/src/product-repository.ts +73 -0
- package/src/skills.ts +113 -8
- package/src/types.ts +58 -1
- /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
- /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
|
|
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
|
|
21
|
-
3. **
|
|
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
|
|
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. **
|
|
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
|
|
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. **
|
|
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 {
|
|
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/.
|
|
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
|
|
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
|
-
|
|
37
|
-
|
|
38
|
-
|
|
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
|
|
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
|
|
package/src/contract.ts
ADDED
|
@@ -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
|
+
}
|