@clossys/launcher 0.3.1 → 0.4.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/README.md +1324 -60
- package/contracts/conversation-contract.md +2 -2
- package/contracts/product-ci-workflow.yml +74 -0
- package/contracts/repository-inventory.json +53 -0
- package/dist/admission-fixture.d.ts +168 -0
- package/dist/admission-fixture.d.ts.map +1 -0
- package/dist/admission-fixture.js +453 -0
- package/dist/admission-fixture.js.map +1 -0
- package/dist/admission.d.ts +124 -0
- package/dist/admission.d.ts.map +1 -0
- package/dist/admission.js +799 -0
- package/dist/admission.js.map +1 -0
- package/dist/agents-guide.d.ts +9 -0
- package/dist/agents-guide.d.ts.map +1 -0
- package/dist/agents-guide.js +26 -0
- package/dist/agents-guide.js.map +1 -0
- package/dist/apply-command-options.check.d.ts +12 -0
- package/dist/apply-command-options.check.d.ts.map +1 -0
- package/dist/apply-command-options.check.js +20 -0
- package/dist/apply-command-options.check.js.map +1 -0
- package/dist/apply-plan-cli.d.ts +39 -1
- package/dist/apply-plan-cli.d.ts.map +1 -1
- package/dist/apply-plan-cli.js +432 -15
- package/dist/apply-plan-cli.js.map +1 -1
- package/dist/apply-plan.d.ts +46 -59
- package/dist/apply-plan.d.ts.map +1 -1
- package/dist/apply-plan.js +112 -97
- package/dist/apply-plan.js.map +1 -1
- package/dist/apply-step-fixture.d.ts +87 -0
- package/dist/apply-step-fixture.d.ts.map +1 -0
- package/dist/apply-step-fixture.js +199 -0
- package/dist/apply-step-fixture.js.map +1 -0
- package/dist/apply-store.d.ts +93 -0
- package/dist/apply-store.d.ts.map +1 -0
- package/dist/apply-store.js +625 -0
- package/dist/apply-store.js.map +1 -0
- package/dist/approval-sheet.d.ts +21 -0
- package/dist/approval-sheet.d.ts.map +1 -0
- package/dist/approval-sheet.js +157 -0
- package/dist/approval-sheet.js.map +1 -0
- package/dist/body-command.d.ts +42 -0
- package/dist/body-command.d.ts.map +1 -0
- package/dist/body-command.js +143 -0
- package/dist/body-command.js.map +1 -0
- package/dist/change-set-contract.d.ts +381 -0
- package/dist/change-set-contract.d.ts.map +1 -0
- package/dist/change-set-contract.js +738 -0
- package/dist/change-set-contract.js.map +1 -0
- package/dist/change-set-digest.d.ts +28 -0
- package/dist/change-set-digest.d.ts.map +1 -0
- package/dist/change-set-digest.js +65 -0
- package/dist/change-set-digest.js.map +1 -0
- package/dist/check-cli.d.ts.map +1 -1
- package/dist/check-cli.js +14 -3
- package/dist/check-cli.js.map +1 -1
- package/dist/cli.d.ts +17 -6
- package/dist/cli.d.ts.map +1 -1
- package/dist/cli.js +84 -23
- package/dist/cli.js.map +1 -1
- package/dist/core.d.ts +79 -22
- package/dist/core.d.ts.map +1 -1
- package/dist/core.js +843 -268
- package/dist/core.js.map +1 -1
- package/dist/dry-materialize.d.ts +63 -0
- package/dist/dry-materialize.d.ts.map +1 -0
- package/dist/dry-materialize.js +330 -0
- package/dist/dry-materialize.js.map +1 -0
- package/dist/generated/contract-schema.generated.d.ts +97 -0
- package/dist/generated/contract-schema.generated.d.ts.map +1 -0
- package/dist/generated/contract-schema.generated.js +496 -0
- package/dist/generated/contract-schema.generated.js.map +1 -0
- package/dist/generated/package-scope.generated.d.ts +6 -0
- package/dist/generated/package-scope.generated.d.ts.map +1 -0
- package/dist/generated/package-scope.generated.js +10 -0
- package/dist/generated/package-scope.generated.js.map +1 -0
- package/dist/generated/plan-contracts.generated.d.ts +3 -0
- package/dist/generated/plan-contracts.generated.d.ts.map +1 -0
- package/dist/generated/plan-contracts.generated.js +2840 -0
- package/dist/generated/plan-contracts.generated.js.map +1 -0
- package/dist/host.d.ts.map +1 -1
- package/dist/host.js +11 -0
- package/dist/host.js.map +1 -1
- package/dist/identity.d.ts +15 -0
- package/dist/identity.d.ts.map +1 -0
- package/dist/identity.js +48 -0
- package/dist/identity.js.map +1 -0
- package/dist/index.d.ts +34 -5
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +19 -2
- package/dist/index.js.map +1 -1
- package/dist/inventory-adoption.d.ts +24 -5
- package/dist/inventory-adoption.d.ts.map +1 -1
- package/dist/inventory-adoption.js +70 -25
- package/dist/inventory-adoption.js.map +1 -1
- package/dist/inventory-choice.d.ts +40 -0
- package/dist/inventory-choice.d.ts.map +1 -0
- package/dist/inventory-choice.js +156 -0
- package/dist/inventory-choice.js.map +1 -0
- package/dist/inventory-contract.d.ts +89 -0
- package/dist/inventory-contract.d.ts.map +1 -0
- package/dist/inventory-contract.js +121 -0
- package/dist/inventory-contract.js.map +1 -0
- package/dist/key-editor.d.ts +30 -0
- package/dist/key-editor.d.ts.map +1 -0
- package/dist/key-editor.js +445 -0
- package/dist/key-editor.js.map +1 -0
- package/dist/ledger-contract.d.ts +187 -0
- package/dist/ledger-contract.d.ts.map +1 -0
- package/dist/ledger-contract.js +532 -0
- package/dist/ledger-contract.js.map +1 -0
- package/dist/ledger-trust.d.ts +90 -0
- package/dist/ledger-trust.d.ts.map +1 -0
- package/dist/ledger-trust.js +198 -0
- package/dist/ledger-trust.js.map +1 -0
- package/dist/lockfile-invariants.d.ts +48 -0
- package/dist/lockfile-invariants.d.ts.map +1 -0
- package/dist/lockfile-invariants.js +375 -0
- package/dist/lockfile-invariants.js.map +1 -0
- package/dist/lockfile-readers.d.ts +72 -0
- package/dist/lockfile-readers.d.ts.map +1 -0
- package/dist/lockfile-readers.js +713 -0
- package/dist/lockfile-readers.js.map +1 -0
- package/dist/lockfile-regen.d.ts +106 -0
- package/dist/lockfile-regen.d.ts.map +1 -0
- package/dist/lockfile-regen.js +760 -0
- package/dist/lockfile-regen.js.map +1 -0
- package/dist/lockfile-tool-env.d.ts +29 -0
- package/dist/lockfile-tool-env.d.ts.map +1 -0
- package/dist/lockfile-tool-env.js +111 -0
- package/dist/lockfile-tool-env.js.map +1 -0
- package/dist/materialize.d.ts +113 -0
- package/dist/materialize.d.ts.map +1 -0
- package/dist/materialize.js +840 -0
- package/dist/materialize.js.map +1 -0
- package/dist/observe-repository.d.ts +90 -0
- package/dist/observe-repository.d.ts.map +1 -0
- package/dist/observe-repository.js +1367 -0
- package/dist/observe-repository.js.map +1 -0
- package/dist/plan-bundle-setup-fixture.d.ts +68 -0
- package/dist/plan-bundle-setup-fixture.d.ts.map +1 -0
- package/dist/plan-bundle-setup-fixture.js +167 -0
- package/dist/plan-bundle-setup-fixture.js.map +1 -0
- package/dist/plan-bundle.d.ts +250 -0
- package/dist/plan-bundle.d.ts.map +1 -0
- package/dist/plan-bundle.js +827 -0
- package/dist/plan-bundle.js.map +1 -0
- package/dist/plan-command.d.ts +29 -0
- package/dist/plan-command.d.ts.map +1 -0
- package/dist/plan-command.js +493 -0
- package/dist/plan-command.js.map +1 -0
- package/dist/plan-contract.d.ts +153 -0
- package/dist/plan-contract.d.ts.map +1 -0
- package/dist/plan-contract.js +61 -0
- package/dist/plan-contract.js.map +1 -0
- package/dist/plan-digest.d.ts +25 -0
- package/dist/plan-digest.d.ts.map +1 -0
- package/dist/plan-digest.js +106 -0
- package/dist/plan-digest.js.map +1 -0
- package/dist/plan-rules.d.ts +23 -0
- package/dist/plan-rules.d.ts.map +1 -0
- package/dist/plan-rules.js +177 -0
- package/dist/plan-rules.js.map +1 -0
- package/dist/planned-bundle.d.ts +20 -0
- package/dist/planned-bundle.d.ts.map +1 -0
- package/dist/planned-bundle.js +191 -0
- package/dist/planned-bundle.js.map +1 -0
- package/dist/product-repository.d.ts +4 -0
- package/dist/product-repository.d.ts.map +1 -1
- package/dist/product-repository.js +9 -1
- package/dist/product-repository.js.map +1 -1
- package/dist/provenance-gate.d.ts +48 -0
- package/dist/provenance-gate.d.ts.map +1 -0
- package/dist/provenance-gate.js +324 -0
- package/dist/provenance-gate.js.map +1 -0
- package/dist/pull-request-body.d.ts +45 -0
- package/dist/pull-request-body.d.ts.map +1 -0
- package/dist/pull-request-body.js +232 -0
- package/dist/pull-request-body.js.map +1 -0
- package/dist/registry-snapshot.d.ts +141 -0
- package/dist/registry-snapshot.d.ts.map +1 -0
- package/dist/registry-snapshot.js +483 -0
- package/dist/registry-snapshot.js.map +1 -0
- package/dist/release-age-edit.d.ts +52 -0
- package/dist/release-age-edit.d.ts.map +1 -0
- package/dist/release-age-edit.js +413 -0
- package/dist/release-age-edit.js.map +1 -0
- package/dist/root-entries.d.ts +36 -0
- package/dist/root-entries.d.ts.map +1 -0
- package/dist/root-entries.js +80 -0
- package/dist/root-entries.js.map +1 -0
- package/dist/setup-template-scripts.d.ts +34 -0
- package/dist/setup-template-scripts.d.ts.map +1 -0
- package/dist/setup-template-scripts.js +557 -0
- package/dist/setup-template-scripts.js.map +1 -0
- package/dist/setup-templates.d.ts +54 -0
- package/dist/setup-templates.d.ts.map +1 -0
- package/dist/setup-templates.js +427 -0
- package/dist/setup-templates.js.map +1 -0
- package/dist/skills.d.ts +34 -1
- package/dist/skills.d.ts.map +1 -1
- package/dist/skills.js +129 -17
- package/dist/skills.js.map +1 -1
- package/dist/status.d.ts +63 -0
- package/dist/status.d.ts.map +1 -0
- package/dist/status.js +539 -0
- package/dist/status.js.map +1 -0
- package/dist/types.d.ts +151 -13
- package/dist/types.d.ts.map +1 -1
- package/package.json +4 -4
- package/skeleton/README.md +14 -9
- package/skeleton/package.json +2 -1
- package/skill/SKILL.md +23 -7
- package/skill-catalogue/advisor/SKILL.md +59 -6
- package/skill-catalogue/architect/SKILL.md +2 -2
- package/skill-catalogue/bouncer/SKILL.md +2 -2
- package/skill-catalogue/builder/SKILL.md +2 -2
- package/skill-catalogue/butler/SKILL.md +2 -2
- package/skill-catalogue/controller/SKILL.md +2 -2
- package/skill-catalogue/customer/SKILL.md +2 -2
- package/skill-catalogue/designer/SKILL.md +4 -2
- package/skill-catalogue/giver/SKILL.md +2 -2
- package/skill-catalogue/influencer/SKILL.md +2 -2
- package/skill-catalogue/inspector/SKILL.md +2 -2
- package/skill-catalogue/integrator/SKILL.md +2 -2
- package/skill-catalogue/keeper/SKILL.md +2 -2
- package/skill-catalogue/launcher/SKILL.md +23 -7
- package/skill-catalogue/locksmith/SKILL.md +2 -2
- package/skill-catalogue/messenger/SKILL.md +2 -2
- package/skill-catalogue/observer/SKILL.md +2 -2
- package/skill-catalogue/publisher/SKILL.md +2 -2
- package/skill-catalogue/starter/SKILL.md +3 -2
- package/skill-catalogue/strategist/SKILL.md +12 -4
- package/skill-catalogue/writer/SKILL.md +2 -2
- package/src/admission-fixture.ts +572 -0
- package/src/admission.ts +816 -0
- package/src/agents-guide.ts +29 -0
- package/src/apply-command-options.check.ts +27 -0
- package/src/apply-plan-cli.ts +454 -14
- package/src/apply-plan.ts +112 -124
- package/src/apply-step-fixture.ts +236 -0
- package/src/apply-store.ts +584 -0
- package/src/approval-sheet.ts +164 -0
- package/src/body-command.ts +162 -0
- package/src/change-set-contract.ts +937 -0
- package/src/change-set-digest.ts +70 -0
- package/src/check-cli.ts +14 -3
- package/src/cli.ts +90 -22
- package/src/core.ts +973 -275
- package/src/dry-materialize.ts +353 -0
- package/src/generated/contract-schema.generated.ts +520 -0
- package/src/generated/package-scope.generated.ts +10 -0
- package/src/generated/plan-contracts.generated.ts +2840 -0
- package/src/host.ts +10 -0
- package/src/identity.ts +51 -0
- package/src/index.ts +72 -3
- package/src/inventory-adoption.ts +107 -29
- package/src/inventory-choice.ts +172 -0
- package/src/inventory-contract.ts +166 -0
- package/src/key-editor.ts +446 -0
- package/src/ledger-contract.ts +637 -0
- package/src/ledger-trust.ts +267 -0
- package/src/lockfile-invariants.ts +421 -0
- package/src/lockfile-readers.ts +749 -0
- package/src/lockfile-regen.ts +851 -0
- package/src/lockfile-tool-env.ts +131 -0
- package/src/materialize.ts +886 -0
- package/src/observe-repository.ts +1365 -0
- package/src/plan-bundle-setup-fixture.ts +200 -0
- package/src/plan-bundle.ts +964 -0
- package/src/plan-command.ts +509 -0
- package/src/plan-contract.ts +179 -0
- package/src/plan-digest.ts +102 -0
- package/src/plan-rules.ts +188 -0
- package/src/planned-bundle.ts +211 -0
- package/src/product-repository.ts +10 -1
- package/src/provenance-gate.ts +352 -0
- package/src/pull-request-body.ts +261 -0
- package/src/registry-snapshot.ts +534 -0
- package/src/release-age-edit.ts +430 -0
- package/src/root-entries.ts +81 -0
- package/src/setup-template-scripts.ts +571 -0
- package/src/setup-templates.ts +471 -0
- package/src/skills.ts +161 -18
- package/src/status.ts +557 -0
- package/src/types.ts +148 -13
package/src/apply-plan.ts
CHANGED
|
@@ -1,155 +1,128 @@
|
|
|
1
1
|
// Apply an approved plan (#1178): writes clossys/brief.json into a staffed
|
|
2
|
-
// repository
|
|
3
|
-
//
|
|
2
|
+
// repository once clossys/advisor/plan.json is approved, both validated
|
|
3
|
+
// against the shared plan and brief contracts (#1175, #1475).
|
|
4
4
|
//
|
|
5
5
|
// SCOPE OF THIS MODULE (see the wave-2 PR body for the full explanation):
|
|
6
6
|
// this lands the mechanical, auditable core the landed contract fully
|
|
7
7
|
// specifies -- reading clossys/advisor/plan.json, confirming it is
|
|
8
8
|
// approved, validating an EngagementBrief, and writing clossys/brief.json
|
|
9
|
-
// byte-identically.
|
|
10
|
-
//
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
9
|
+
// byte-identically. The plan contract now says which roles work in which
|
|
10
|
+
// inventory repository (`staffing`) and which exact package acts are
|
|
11
|
+
// authorized (`packages`), and approvedSubject() below says what an
|
|
12
|
+
// approval binds (#1178). Multi-repository orchestration (computing each
|
|
13
|
+
// repository's change, branch creation, exact package installs, Starter's
|
|
14
|
+
// caller workflow, opening one pull request per repository) is not built
|
|
15
|
+
// yet, and this module does not act on those fields.
|
|
16
16
|
//
|
|
17
17
|
// clossys/brief.json's shape is NOT re-derived here -- @clossys/advisor's
|
|
18
|
-
//
|
|
19
|
-
//
|
|
20
|
-
//
|
|
21
|
-
// package
|
|
22
|
-
//
|
|
23
|
-
//
|
|
18
|
+
// toEngagementBrief() is the one owner of that computation. This module
|
|
19
|
+
// receives an already-computed brief and only validates it and writes it,
|
|
20
|
+
// exactly the split #1187's governing principle draws between
|
|
21
|
+
// package-owned definition and judgment versus Launcher's deterministic
|
|
22
|
+
// mechanics.
|
|
23
|
+
//
|
|
24
|
+
// VALIDATION (issue #1475): see plan-contract.ts. There is no second,
|
|
25
|
+
// hand-written shape check here to drift from Advisor's: a plan or brief
|
|
26
|
+
// Advisor accepts, Launcher accepts, and every object in them is closed, so
|
|
27
|
+
// an unknown field is refused. This package still has no runtime
|
|
28
|
+
// dependency on @clossys/advisor.
|
|
24
29
|
|
|
30
|
+
import { validateAdvisorPlan, validateEngagementBrief } from "./plan-contract.js";
|
|
31
|
+
import type { AdvisorPlan, EngagementBrief, PlanDecision } from "./plan-contract.js";
|
|
32
|
+
import { planDigest } from "./plan-digest.js";
|
|
25
33
|
import type { WorkspaceHost } from "./types.js";
|
|
26
34
|
|
|
27
|
-
export
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
}
|
|
35
|
+
export { validateAdvisorPlan, validateEngagementBrief } from "./plan-contract.js";
|
|
36
|
+
export type {
|
|
37
|
+
AdvisorPlan, BlockerKind, EngagementBrief, EngagementBriefRole, EngagementContext, EngagementContextField, EngagementContextFieldId, GoalDirection, PlanBlocker, PlanDecision,
|
|
38
|
+
PlanKit, PlanPackageAct, PlanStaffing, ValidationResult,
|
|
39
|
+
} from "./plan-contract.js";
|
|
68
40
|
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 };
|
|
41
|
+
/**
|
|
42
|
+
* The decisions made at the latest instant, by `at` -- never by array
|
|
43
|
+
* position -- or null when there are none, or when any decision's `at` does
|
|
44
|
+
* not parse to a finite time (the plan contract already refuses one; this
|
|
45
|
+
* does not rely on that), because then time cannot say which is latest.
|
|
46
|
+
*/
|
|
47
|
+
function latestDecisions(plan: AdvisorPlan): readonly PlanDecision[] | null {
|
|
48
|
+
if (plan.decisions.length === 0) return null;
|
|
49
|
+
const times = plan.decisions.map((decision) => Date.parse(decision.at));
|
|
50
|
+
if (!times.every(Number.isFinite)) return null;
|
|
51
|
+
// A reduce, not Math.max(...times): spreading a very long list into
|
|
52
|
+
// arguments throws a RangeError.
|
|
53
|
+
const latest = times.reduce((highest, time) => (time > highest ? time : highest), Number.NEGATIVE_INFINITY);
|
|
54
|
+
return plan.decisions.filter((_, index) => times[index] === latest);
|
|
100
55
|
}
|
|
101
56
|
|
|
102
|
-
const
|
|
57
|
+
const SUBJECT_DIGEST = /^sha256:[0-9a-f]{64}$/;
|
|
103
58
|
|
|
104
|
-
/**
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
59
|
+
/**
|
|
60
|
+
* What an approval binds (#1178): the `subjectDigest` of the plan's latest
|
|
61
|
+
* decision, by `at`, when that decision has chosen "approved" -- the digest of
|
|
62
|
+
* the exact change the approver was shown. Otherwise null, and null binds
|
|
63
|
+
* nothing. Fails closed:
|
|
64
|
+
*
|
|
65
|
+
* - a plan that does not validate against the plan contract, rules
|
|
66
|
+
* included: null. It checks this itself rather than trusting its caller,
|
|
67
|
+
* because on an unchecked plan a time with no offset parses as local time,
|
|
68
|
+
* so the machine's time zone could decide which decision is latest;
|
|
69
|
+
* - no decisions, or any decision time that does not parse: null;
|
|
70
|
+
* - a latest decision that is not "approved": null;
|
|
71
|
+
* - an approval with no `subjectDigest`, or one that is not a sha256 digest:
|
|
72
|
+
* null -- an approval that names no bytes approves no bytes;
|
|
73
|
+
* - several decisions at the latest instant: their subject only when every
|
|
74
|
+
* one of them is "approved" with the same `subjectDigest`, else null.
|
|
75
|
+
*
|
|
76
|
+
* It says what was approved, not whether that is what is about to be applied:
|
|
77
|
+
* the caller recomputes the digest of the change it holds and refuses unless
|
|
78
|
+
* the two are equal.
|
|
79
|
+
*/
|
|
80
|
+
export function approvedSubject(plan: AdvisorPlan): string | null {
|
|
81
|
+
if (!validateAdvisorPlan(plan).valid) return null;
|
|
82
|
+
const latest = latestDecisions(plan);
|
|
83
|
+
if (latest === null || !latest.every((decision) => decision.chosen === "approved")) return null;
|
|
84
|
+
const subject = latest[0]!.subjectDigest;
|
|
85
|
+
if (typeof subject !== "string" || !SUBJECT_DIGEST.test(subject)) return null;
|
|
86
|
+
return latest.every((decision) => decision.subjectDigest === subject) ? subject : null;
|
|
131
87
|
}
|
|
132
88
|
|
|
133
89
|
/**
|
|
134
90
|
* The plan is approved when its most recent decision (by `at`) records
|
|
135
91
|
* chosen === "approved". No decisions, or a most-recent decision that
|
|
136
92
|
* isn't "approved", is not approved -- this never assumes approval from
|
|
137
|
-
* absence.
|
|
93
|
+
* absence. Fails closed on anything that would let array order decide
|
|
94
|
+
* instead of time: a decision whose `at` does not parse to a finite time
|
|
95
|
+
* (the plan contract already refuses one; this does not rely on that), or
|
|
96
|
+
* two decisions at the same latest instant that do not all say "approved".
|
|
97
|
+
*
|
|
98
|
+
* It binds no bytes (#1178): it does not look at `subjectDigest`, so it is
|
|
99
|
+
* true for an approval that names no change at all. It says only that the
|
|
100
|
+
* latest decision is an approval. Anything that applies a plan must use
|
|
101
|
+
* `approvedSubject()` instead, and compare the subject it returns with the
|
|
102
|
+
* digest of the change it holds. The one stated exception is the legacy
|
|
103
|
+
* brief-only path, `applyEngagementBrief()`, which predates the binding.
|
|
138
104
|
*/
|
|
139
105
|
export function isPlanApproved(plan: AdvisorPlan): boolean {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
return mostRecent?.chosen === "approved";
|
|
106
|
+
const latest = latestDecisions(plan);
|
|
107
|
+
return latest !== null && latest.every((decision) => decision.chosen === "approved");
|
|
143
108
|
}
|
|
144
109
|
|
|
145
110
|
export type ApplyBriefResult =
|
|
146
|
-
| { readonly state: "applied"; readonly path: string }
|
|
111
|
+
| { readonly state: "applied"; readonly path: string; readonly planDigest: string }
|
|
147
112
|
| { readonly state: "refused"; readonly reason: string };
|
|
148
113
|
|
|
149
114
|
/**
|
|
150
115
|
* Writes clossys/brief.json into `repositoryDirectory`, byte-identically
|
|
151
116
|
* from the validated brief -- never re-authors its prose. Refuses (does
|
|
152
|
-
* not write) unless
|
|
117
|
+
* not write) unless the plan validates and is approved and the brief,
|
|
118
|
+
* including its context snapshot, validates. Reports the canonical digest
|
|
119
|
+
* of the plan it applied.
|
|
120
|
+
*
|
|
121
|
+
* LEGACY (#1178): this path predates the approval binding. It accepts an
|
|
122
|
+
* approval whether or not it carries a `subjectDigest` and checks no
|
|
123
|
+
* binding (it uses `isPlanApproved()`), exactly as before; it is kept
|
|
124
|
+
* working, not extended. Anything that must know what an approval binds uses
|
|
125
|
+
* `approvedSubject()`.
|
|
153
126
|
*/
|
|
154
127
|
export function applyEngagementBrief(
|
|
155
128
|
host: WorkspaceHost,
|
|
@@ -158,15 +131,30 @@ export function applyEngagementBrief(
|
|
|
158
131
|
brief: EngagementBrief,
|
|
159
132
|
briefRelPath: string,
|
|
160
133
|
): ApplyBriefResult {
|
|
134
|
+
const planValidation = validateAdvisorPlan(plan);
|
|
135
|
+
if (!planValidation.valid) {
|
|
136
|
+
return { state: "refused", reason: `plan does not validate: ${planValidation.reason}` };
|
|
137
|
+
}
|
|
161
138
|
if (!isPlanApproved(plan)) {
|
|
162
|
-
return { state: "refused", reason: "the plan's most recent decision is not \"approved\"" };
|
|
139
|
+
return { state: "refused", reason: "the plan's most recent decision is not \"approved\", or decisions made at that same time disagree" };
|
|
163
140
|
}
|
|
164
141
|
const validation = validateEngagementBrief(brief);
|
|
165
142
|
if (!validation.valid) {
|
|
166
143
|
return { state: "refused", reason: `brief does not validate: ${validation.reason}` };
|
|
167
144
|
}
|
|
145
|
+
// Everything that can refuse runs before the first write, so a refusal
|
|
146
|
+
// never leaves a brief behind. A plan that validates always has a digest
|
|
147
|
+
// (the contract refuses what canonical JSON cannot carry); this catch is
|
|
148
|
+
// the backstop that keeps that a refusal rather than a throw after writing.
|
|
149
|
+
let digest: string;
|
|
150
|
+
try {
|
|
151
|
+
digest = planDigest(plan);
|
|
152
|
+
} catch (cause) {
|
|
153
|
+
return { state: "refused", reason: `plan has no canonical digest: ${cause instanceof Error ? cause.message : String(cause)}` };
|
|
154
|
+
}
|
|
155
|
+
const contents = `${JSON.stringify(brief, null, 2)}\n`;
|
|
168
156
|
const path = `${repositoryDirectory}/${briefRelPath}`;
|
|
169
157
|
host.mkdirp(path.slice(0, path.lastIndexOf("/")));
|
|
170
|
-
host.writeText(path,
|
|
171
|
-
return { state: "applied", path };
|
|
158
|
+
host.writeText(path, contents);
|
|
159
|
+
return { state: "applied", path, planDigest: digest };
|
|
172
160
|
}
|
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import { execFileSync } from "node:child_process";
|
|
2
|
+
import { createHash } from "node:crypto";
|
|
3
|
+
import { existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, readlinkSync, realpathSync, writeFileSync } from "node:fs";
|
|
4
|
+
import { tmpdir } from "node:os";
|
|
5
|
+
import { dirname, join } from "node:path";
|
|
6
|
+
import {
|
|
7
|
+
SITE_ID,
|
|
8
|
+
approvedPlan,
|
|
9
|
+
basePlan,
|
|
10
|
+
buildWorld,
|
|
11
|
+
bundleOf,
|
|
12
|
+
committedPlanPackages,
|
|
13
|
+
hubRepo,
|
|
14
|
+
lockfileText,
|
|
15
|
+
reseal,
|
|
16
|
+
siteRepo,
|
|
17
|
+
} from "./admission-fixture.js";
|
|
18
|
+
import type { HubOptions, World, WorldOptions } from "./admission-fixture.js";
|
|
19
|
+
import { contentDigest, discoveryLinkRole, discoveryLinkTarget } from "./change-set-contract.js";
|
|
20
|
+
import type { ApplyBundle, RepositoryChangeSet } from "./change-set-contract.js";
|
|
21
|
+
import type { LockfileSpawn } from "./lockfile-regen.js";
|
|
22
|
+
import type { AdvisorPlan } from "./plan-contract.js";
|
|
23
|
+
import { planDigest } from "./plan-digest.js";
|
|
24
|
+
|
|
25
|
+
export { reseal };
|
|
26
|
+
|
|
27
|
+
const REPO = new URL("../../../", import.meta.url);
|
|
28
|
+
const read = (path: string): string => readFileSync(new URL(path, REPO), "utf8");
|
|
29
|
+
|
|
30
|
+
type Loose = Record<string, any>;
|
|
31
|
+
|
|
32
|
+
const gitEnv = {
|
|
33
|
+
...process.env,
|
|
34
|
+
PATH: process.env.PATH ?? "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin",
|
|
35
|
+
GIT_CONFIG_GLOBAL: "/dev/null",
|
|
36
|
+
GIT_CONFIG_NOSYSTEM: "1",
|
|
37
|
+
GIT_AUTHOR_NAME: "Example Author",
|
|
38
|
+
GIT_AUTHOR_EMAIL: "author@example.com",
|
|
39
|
+
GIT_COMMITTER_NAME: "Example Author",
|
|
40
|
+
GIT_COMMITTER_EMAIL: "author@example.com",
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
function git(cwd: string, ...args: string[]): string {
|
|
44
|
+
return execFileSync("git", ["-c", "commit.gpgsign=false", "-c", "core.hooksPath=/dev/null", ...args], {
|
|
45
|
+
cwd,
|
|
46
|
+
env: gitEnv,
|
|
47
|
+
encoding: "utf8",
|
|
48
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export interface MaterializedFixtureOptions {
|
|
53
|
+
/** Overrides for the hub: options, or a function of what the fixture built (its plan, its approved bundle and its set). */
|
|
54
|
+
readonly hub?: Partial<HubOptions> | ((built: { plan: AdvisorPlan; bundle: ApplyBundle; set: RepositoryChangeSet }) => Partial<HubOptions>);
|
|
55
|
+
/** Store the set, with its whole-file texts, in the hub's change-set store, where the command line finds it. Default false. */
|
|
56
|
+
readonly storeSet?: boolean;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A setup change set whose whole-file bytes are known, with the pin already
|
|
61
|
+
* satisfied and no lockfile to regenerate, checked out from a local clone, and
|
|
62
|
+
* a hub that approved it: a git repository whose HEAD holds a plan (its latest
|
|
63
|
+
* decision approves the bundle that holds the set) and an execution
|
|
64
|
+
* authorization, with the bundle stored and the hub's readiness executable
|
|
65
|
+
* installed. `binding` is the binding admission is expected to compute; it is
|
|
66
|
+
* never an input to materialize or verify.
|
|
67
|
+
*/
|
|
68
|
+
export function buildMaterializedFixture(roots: string[], options: MaterializedFixtureOptions = {}) {
|
|
69
|
+
const parent = mkdtempSync(join(tmpdir(), "launcher-apply-step-"));
|
|
70
|
+
roots.push(parent);
|
|
71
|
+
const origin = join(parent, "origin.git");
|
|
72
|
+
const clone = join(parent, "site");
|
|
73
|
+
mkdirSync(clone, { recursive: true });
|
|
74
|
+
git(clone, "init", "-b", "main");
|
|
75
|
+
git(clone, "config", "core.autocrlf", "false");
|
|
76
|
+
writeFileSync(join(clone, "README.md"), "# Site\n");
|
|
77
|
+
git(clone, "add", "README.md");
|
|
78
|
+
git(clone, "commit", "-m", "init");
|
|
79
|
+
execFileSync("git", ["init", "--bare", origin], { env: gitEnv, stdio: "ignore" });
|
|
80
|
+
git(clone, "remote", "add", "origin", origin);
|
|
81
|
+
git(clone, "push", "-u", "origin", "HEAD");
|
|
82
|
+
const baseCommit = git(clone, "rev-parse", "HEAD").trim();
|
|
83
|
+
|
|
84
|
+
const corpus = JSON.parse(read("docs/contracts/apply-change-set-digest.fixture.json")) as {
|
|
85
|
+
changeSets: { name: string; changeSet: RepositoryChangeSet }[];
|
|
86
|
+
};
|
|
87
|
+
const set = structuredClone(corpus.changeSets.find((entry) => entry.name === "setup-site")!.changeSet) as unknown as Loose;
|
|
88
|
+
set.repository.baseCommit = baseCommit;
|
|
89
|
+
set.keys = [];
|
|
90
|
+
set.deferred = [];
|
|
91
|
+
set.files = (set.files as Loose[]).filter((file) => file.path !== "package-lock.json");
|
|
92
|
+
for (const item of set.items as Loose[]) {
|
|
93
|
+
if (item.act === "pin-starter" || item.act === "install") item.satisfiedInBase = true;
|
|
94
|
+
}
|
|
95
|
+
const plan0 = basePlan() as unknown as AdvisorPlan;
|
|
96
|
+
set.planDigest = planDigest(plan0);
|
|
97
|
+
const texts: Record<string, string> = {};
|
|
98
|
+
for (const file of set.files as Loose[]) {
|
|
99
|
+
if (file.derived === true) continue;
|
|
100
|
+
if (file.mode === "120000") {
|
|
101
|
+
const role = discoveryLinkRole(file.path as string);
|
|
102
|
+
const target = discoveryLinkTarget(role!);
|
|
103
|
+
texts[file.path as string] = target;
|
|
104
|
+
file.before = null;
|
|
105
|
+
file.after = contentDigest(target);
|
|
106
|
+
} else {
|
|
107
|
+
const text = `fixture ${file.path}\n`;
|
|
108
|
+
texts[file.path as string] = text;
|
|
109
|
+
file.before = null;
|
|
110
|
+
file.after = contentDigest(text);
|
|
111
|
+
}
|
|
112
|
+
}
|
|
113
|
+
const sealed = reseal(set);
|
|
114
|
+
const bundle = bundleOf(plan0, [{ id: SITE_ID, set: sealed }]);
|
|
115
|
+
const built = { ...sealed, bundle: bundle.bundleDigest } as RepositoryChangeSet;
|
|
116
|
+
const plan = approvedPlan(bundle.bundleDigest, plan0);
|
|
117
|
+
const override = typeof options.hub === "function" ? options.hub({ plan, bundle, set: built }) : (options.hub ?? {});
|
|
118
|
+
// What the planner stores carries the whole-file texts, so the command line can write from the store alone.
|
|
119
|
+
const stored = { ...built, texts: Object.entries(texts).map(([path, text]) => ({ path, text })).sort((left, right) => left.path.localeCompare(right.path)) };
|
|
120
|
+
const hub = hubRepo(roots, { plans: [plan], sets: options.storeSet === true ? [stored] : [], bundles: [bundle], ...override });
|
|
121
|
+
const binding = { kind: "approved" as const, subjectDigest: bundle.bundleDigest };
|
|
122
|
+
return { clone: realpathSync(clone), hub: hub.hub, set: built, texts, binding, planPackages: committedPlanPackages(plan, SITE_ID), plan, bundle, origin };
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
export interface AdmittedFixtureOptions {
|
|
126
|
+
readonly world?: WorldOptions;
|
|
127
|
+
readonly hub?: Partial<HubOptions> | ((world: World) => Partial<HubOptions>);
|
|
128
|
+
/** How the setup set reached the default branch. Default `squash`: no ancestor of the setup branch. */
|
|
129
|
+
readonly mergeStyle?: "direct" | "squash";
|
|
130
|
+
/** Edits the base tree before it is committed. */
|
|
131
|
+
readonly tree?: (tree: World["tree"]) => void;
|
|
132
|
+
/** Store the apply set in the hub too. Default false. */
|
|
133
|
+
readonly storeApplySet?: boolean;
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
/**
|
|
137
|
+
* The two phases of one plan. The setup set was approved (the plan's latest
|
|
138
|
+
* decision approves the bundle that holds it), materialized and merged into
|
|
139
|
+
* the clone's default branch (by squash by default), and the apply set was
|
|
140
|
+
* then computed against the merged base and is held by a later run's bundle,
|
|
141
|
+
* which the hub stores. `spawn` stands in for the package manager: it
|
|
142
|
+
* regenerates the lockfile the apply set's installs write.
|
|
143
|
+
*/
|
|
144
|
+
export function buildAdmittedFixture(roots: string[], options: AdmittedFixtureOptions = {}) {
|
|
145
|
+
let site: ReturnType<typeof siteRepo> | undefined;
|
|
146
|
+
const world = buildWorld({
|
|
147
|
+
...options.world,
|
|
148
|
+
commitBase: (tree) => {
|
|
149
|
+
options.tree?.(tree);
|
|
150
|
+
site = siteRepo(roots, tree, { mergeStyle: options.mergeStyle ?? "squash" });
|
|
151
|
+
const origin = join(dirname(site.clone), "origin.git");
|
|
152
|
+
execFileSync("git", ["init", "--bare", origin], { env: gitEnv, stdio: "ignore" });
|
|
153
|
+
git(site.clone, "remote", "add", "origin", origin);
|
|
154
|
+
git(site.clone, "push", "-u", "origin", "main");
|
|
155
|
+
return site.baseCommit;
|
|
156
|
+
},
|
|
157
|
+
});
|
|
158
|
+
const override = typeof options.hub === "function" ? options.hub(world) : (options.hub ?? {});
|
|
159
|
+
const hub = hubRepo(roots, {
|
|
160
|
+
plans: [world.plan],
|
|
161
|
+
sets: options.storeApplySet === true ? [world.setup, world.apply] : [world.setup],
|
|
162
|
+
bundles: [world.approvedBundle, world.applyBundle],
|
|
163
|
+
...override,
|
|
164
|
+
});
|
|
165
|
+
const packages = committedPlanPackages(world.plan, SITE_ID);
|
|
166
|
+
const spawn: LockfileSpawn = async (request) => {
|
|
167
|
+
if (request.args.includes("--version")) return { status: 0, stdout: "10.9.0\n", stderr: "" };
|
|
168
|
+
writeFileSync(join(request.cwd, "package-lock.json"), lockfileText(packages));
|
|
169
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
170
|
+
};
|
|
171
|
+
return {
|
|
172
|
+
world,
|
|
173
|
+
hub: hub.hub,
|
|
174
|
+
clone: site!.clone,
|
|
175
|
+
baseCommit: site!.baseCommit,
|
|
176
|
+
sideTip: site!.sideTip,
|
|
177
|
+
set: world.apply,
|
|
178
|
+
setup: world.setup,
|
|
179
|
+
texts: world.texts,
|
|
180
|
+
spawn,
|
|
181
|
+
};
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function walk(root: string, skip: (relative: string) => boolean, into: string[], relative = ""): void {
|
|
185
|
+
let names: string[];
|
|
186
|
+
try {
|
|
187
|
+
names = readdirSync(join(root, relative)).sort();
|
|
188
|
+
} catch {
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
for (const name of names) {
|
|
192
|
+
const path = relative === "" ? name : `${relative}/${name}`;
|
|
193
|
+
if (skip(path)) continue;
|
|
194
|
+
const stat = lstatSync(join(root, path));
|
|
195
|
+
if (stat.isDirectory()) walk(root, skip, into, path);
|
|
196
|
+
else if (stat.isSymbolicLink()) into.push(`${path} -> ${readlinkSync(join(root, path))}`);
|
|
197
|
+
else into.push(`${path} ${createHash("sha256").update(readFileSync(join(root, path))).digest("hex")} ${stat.mode & 0o777}`);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/**
|
|
202
|
+
* Everything a refused materialize must leave alone: the clone's refs, HEAD,
|
|
203
|
+
* index, status and every file outside .git, and every file the hub keeps
|
|
204
|
+
* under clossys/.state. Two snapshots are equal only when nothing moved.
|
|
205
|
+
*/
|
|
206
|
+
export function writeSnapshot(clone: string, hub: string): string {
|
|
207
|
+
const files: string[] = [];
|
|
208
|
+
walk(clone, (path) => path === ".git", files);
|
|
209
|
+
const hubFiles: string[] = [];
|
|
210
|
+
walk(join(hub, "clossys", ".state"), () => false, hubFiles);
|
|
211
|
+
return JSON.stringify({
|
|
212
|
+
refs: git(clone, "for-each-ref").trim(),
|
|
213
|
+
head: git(clone, "rev-parse", "HEAD").trim(),
|
|
214
|
+
branch: git(clone, "rev-parse", "--abbrev-ref", "HEAD").trim(),
|
|
215
|
+
index: git(clone, "ls-files", "-s").trim(),
|
|
216
|
+
status: git(clone, "status", "--porcelain", "--untracked-files=all").trim(),
|
|
217
|
+
files,
|
|
218
|
+
hub: hubFiles,
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/** Whether the clone holds a local branch of this name. */
|
|
223
|
+
export function branchExists(clone: string, branch: string): boolean {
|
|
224
|
+
try {
|
|
225
|
+
git(clone, "show-ref", "--verify", "--quiet", `refs/heads/${branch}`);
|
|
226
|
+
return true;
|
|
227
|
+
} catch {
|
|
228
|
+
return false;
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/** The ledger a clone holds at clossys/.state/installed.json, parsed, or null when it holds none. */
|
|
233
|
+
export function readCloneLedger(clone: string): Loose | null {
|
|
234
|
+
const path = join(clone, "clossys/.state/installed.json");
|
|
235
|
+
return existsSync(path) ? (JSON.parse(readFileSync(path, "utf8")) as Loose) : null;
|
|
236
|
+
}
|