@clossys/launcher 0.1.5 → 0.3.1
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/README.md +213 -20
- package/contracts/conversation-contract.md +41 -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 -5
- package/skeleton/README.md +5 -0
- package/skeleton/package.json +1 -1
- package/skill/SKILL.md +41 -0
- package/skill-catalogue/advisor/SKILL.md +46 -8
- package/skill-catalogue/architect/SKILL.md +0 -11
- package/skill-catalogue/bouncer/SKILL.md +0 -11
- package/skill-catalogue/builder/SKILL.md +0 -11
- package/skill-catalogue/butler/SKILL.md +0 -11
- package/skill-catalogue/controller/SKILL.md +6 -9
- package/skill-catalogue/customer/SKILL.md +83 -0
- package/skill-catalogue/designer/SKILL.md +18 -9
- package/skill-catalogue/giver/SKILL.md +0 -11
- package/skill-catalogue/influencer/SKILL.md +0 -11
- package/skill-catalogue/inspector/SKILL.md +1 -12
- package/skill-catalogue/integrator/SKILL.md +0 -11
- package/skill-catalogue/keeper/SKILL.md +0 -11
- package/skill-catalogue/launcher/SKILL.md +0 -12
- package/skill-catalogue/locksmith/SKILL.md +0 -11
- package/skill-catalogue/messenger/SKILL.md +0 -11
- package/skill-catalogue/observer/SKILL.md +0 -11
- package/skill-catalogue/publisher/SKILL.md +18 -10
- package/skill-catalogue/starter/SKILL.md +0 -11
- package/skill-catalogue/strategist/SKILL.md +35 -12
- package/skill-catalogue/writer/SKILL.md +7 -9
- 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/CHANGELOG.md +0 -64
- /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
- /package/skeleton/{.clossys → clossys/.state}/workspace.json +0 -0
|
@@ -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
|
+
}
|