@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.
Files changed (114) hide show
  1. package/README.md +213 -20
  2. package/contracts/conversation-contract.md +41 -0
  3. package/dist/apply-plan-cli.d.ts +7 -0
  4. package/dist/apply-plan-cli.d.ts.map +1 -0
  5. package/dist/apply-plan-cli.js +96 -0
  6. package/dist/apply-plan-cli.js.map +1 -0
  7. package/dist/apply-plan.d.ts +79 -0
  8. package/dist/apply-plan.d.ts.map +1 -0
  9. package/dist/apply-plan.js +129 -0
  10. package/dist/apply-plan.js.map +1 -0
  11. package/dist/check-cli.d.ts.map +1 -1
  12. package/dist/check-cli.js +3 -0
  13. package/dist/check-cli.js.map +1 -1
  14. package/dist/cli.d.ts +2 -1
  15. package/dist/cli.d.ts.map +1 -1
  16. package/dist/cli.js +37 -11
  17. package/dist/cli.js.map +1 -1
  18. package/dist/contract.d.ts +28 -0
  19. package/dist/contract.d.ts.map +1 -0
  20. package/dist/contract.js +78 -0
  21. package/dist/contract.js.map +1 -0
  22. package/dist/core.d.ts +38 -6
  23. package/dist/core.d.ts.map +1 -1
  24. package/dist/core.js +289 -42
  25. package/dist/core.js.map +1 -1
  26. package/dist/doctor-cli.d.ts +4 -0
  27. package/dist/doctor-cli.d.ts.map +1 -0
  28. package/dist/doctor-cli.js +32 -0
  29. package/dist/doctor-cli.js.map +1 -0
  30. package/dist/doctor.d.ts +28 -0
  31. package/dist/doctor.d.ts.map +1 -0
  32. package/dist/doctor.js +68 -0
  33. package/dist/doctor.js.map +1 -0
  34. package/dist/host.d.ts.map +1 -1
  35. package/dist/host.js +3 -0
  36. package/dist/host.js.map +1 -1
  37. package/dist/hosts.d.ts +14 -0
  38. package/dist/hosts.d.ts.map +1 -0
  39. package/dist/hosts.js +61 -0
  40. package/dist/hosts.js.map +1 -0
  41. package/dist/index.d.ts +15 -2
  42. package/dist/index.d.ts.map +1 -1
  43. package/dist/index.js +7 -1
  44. package/dist/index.js.map +1 -1
  45. package/dist/inventory-adoption.d.ts +20 -0
  46. package/dist/inventory-adoption.d.ts.map +1 -0
  47. package/dist/inventory-adoption.js +67 -0
  48. package/dist/inventory-adoption.js.map +1 -0
  49. package/dist/manifest.d.ts +20 -0
  50. package/dist/manifest.d.ts.map +1 -0
  51. package/dist/manifest.js +106 -0
  52. package/dist/manifest.js.map +1 -0
  53. package/dist/model-profile.d.ts +46 -0
  54. package/dist/model-profile.d.ts.map +1 -0
  55. package/dist/model-profile.js +98 -0
  56. package/dist/model-profile.js.map +1 -0
  57. package/dist/product-repository.d.ts +26 -0
  58. package/dist/product-repository.d.ts.map +1 -0
  59. package/dist/product-repository.js +49 -0
  60. package/dist/product-repository.js.map +1 -0
  61. package/dist/skills.d.ts +20 -1
  62. package/dist/skills.d.ts.map +1 -1
  63. package/dist/skills.js +98 -7
  64. package/dist/skills.js.map +1 -1
  65. package/dist/types.d.ts +57 -1
  66. package/dist/types.d.ts.map +1 -1
  67. package/model-profiles/claude-code.json +10 -0
  68. package/model-profiles/codex.json +10 -0
  69. package/model-profiles/cursor.json +10 -0
  70. package/package.json +9 -5
  71. package/skeleton/README.md +5 -0
  72. package/skeleton/package.json +1 -1
  73. package/skill/SKILL.md +41 -0
  74. package/skill-catalogue/advisor/SKILL.md +46 -8
  75. package/skill-catalogue/architect/SKILL.md +0 -11
  76. package/skill-catalogue/bouncer/SKILL.md +0 -11
  77. package/skill-catalogue/builder/SKILL.md +0 -11
  78. package/skill-catalogue/butler/SKILL.md +0 -11
  79. package/skill-catalogue/controller/SKILL.md +6 -9
  80. package/skill-catalogue/customer/SKILL.md +83 -0
  81. package/skill-catalogue/designer/SKILL.md +18 -9
  82. package/skill-catalogue/giver/SKILL.md +0 -11
  83. package/skill-catalogue/influencer/SKILL.md +0 -11
  84. package/skill-catalogue/inspector/SKILL.md +1 -12
  85. package/skill-catalogue/integrator/SKILL.md +0 -11
  86. package/skill-catalogue/keeper/SKILL.md +0 -11
  87. package/skill-catalogue/launcher/SKILL.md +0 -12
  88. package/skill-catalogue/locksmith/SKILL.md +0 -11
  89. package/skill-catalogue/messenger/SKILL.md +0 -11
  90. package/skill-catalogue/observer/SKILL.md +0 -11
  91. package/skill-catalogue/publisher/SKILL.md +18 -10
  92. package/skill-catalogue/starter/SKILL.md +0 -11
  93. package/skill-catalogue/strategist/SKILL.md +35 -12
  94. package/skill-catalogue/writer/SKILL.md +7 -9
  95. package/src/apply-plan-cli.ts +94 -0
  96. package/src/apply-plan.ts +172 -0
  97. package/src/check-cli.ts +3 -0
  98. package/src/cli.ts +45 -10
  99. package/src/contract.ts +81 -0
  100. package/src/core.ts +337 -37
  101. package/src/doctor-cli.ts +33 -0
  102. package/src/doctor.ts +145 -0
  103. package/src/host.ts +3 -0
  104. package/src/hosts.ts +79 -0
  105. package/src/index.ts +33 -0
  106. package/src/inventory-adoption.ts +85 -0
  107. package/src/manifest.ts +103 -0
  108. package/src/model-profile.ts +148 -0
  109. package/src/product-repository.ts +73 -0
  110. package/src/skills.ts +113 -8
  111. package/src/types.ts +58 -1
  112. package/CHANGELOG.md +0 -64
  113. /package/skeleton/{.clossys → clossys/.state}/inventory.json +0 -0
  114. /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 { 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
+ }