@namewta/speculo 0.8.9 → 0.8.11
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/dist/src/structured.js +186 -0
- package/dist/src/structured.js.map +1 -1
- package/package.json +1 -1
- package/template/canonical/canonical-specdev-goal-plan.md +14 -4
- package/template/canonical/canonical-specdev-orchestrate-implementation.md +27 -7
- package/template/canonical/canonical-specdev-tickets.md +5 -2
- package/template/commands/archive-and-consolidate.md +9 -0
- package/template/commands/status.md +3 -3
- package/template/workflows/ops/A-archive-and-learn/A-archive-and-learn.md +64 -0
- package/template/workflows/ops/A-archive-and-learn/promotion-plan-template.md +41 -0
- package/template/workflows/ops/A-archive-and-learn/retrospective-template.md +53 -0
- package/template/workflows/ops/E-execute-and-stabilize/E-execute-and-stabilize.md +75 -0
- package/template/workflows/ops/E-execute-and-stabilize/attempt-summary-template.md +44 -0
- package/template/workflows/ops/E-execute-and-stabilize/diagnosis-template.md +24 -0
- package/template/workflows/ops/E-execute-and-stabilize/handoff-template.md +32 -0
- package/template/workflows/ops/E-execute-and-stabilize/rollback-template.md +26 -0
- package/template/workflows/ops/E-execute-and-stabilize/verification-state-template.json +30 -0
- package/template/workflows/ops/E-execute-and-stabilize/verification-template.md +55 -0
- package/template/workflows/ops/I-intake-and-assess/I-intake-and-assess.md +66 -0
- package/template/workflows/ops/I-intake-and-assess/change-status-template.json +26 -0
- package/template/workflows/ops/I-intake-and-assess/collector-catalog.md +28 -0
- package/template/workflows/ops/I-intake-and-assess/deployment-dossier-template.md +62 -0
- package/template/workflows/ops/I-intake-and-assess/global-change-status-template.json +26 -0
- package/template/workflows/ops/I-intake-and-assess/project-detection.md +38 -0
- package/template/workflows/ops/I-intake-and-assess/request-template.md +44 -0
- package/template/workflows/ops/I-intake-and-assess/system-report-template.md +34 -0
- package/template/workflows/ops/I-intake-and-assess/target-profile-template.json +24 -0
- package/template/workflows/ops/INDEX.md +30 -0
- package/template/workflows/ops/P-plan-and-approve/P-plan-and-approve.md +65 -0
- package/template/workflows/ops/P-plan-and-approve/plan-review-template.md +78 -0
- package/template/workflows/ops/README.md +120 -0
- package/template/workflows/ops/_state/archive/.gitkeep +1 -0
- package/template/workflows/ops/_state/changes/.gitkeep +1 -0
- package/template/workflows/ops/_state/status.json +6 -0
- package/template/workflows/ops/common/rules/artifact-contract.md +37 -0
- package/template/workflows/ops/common/rules/closure-and-learning.md +25 -0
- package/template/workflows/ops/common/rules/evidence-and-redaction.md +22 -0
- package/template/workflows/ops/common/rules/execution-loop.md +27 -0
- package/template/workflows/ops/common/rules/path-and-scope-contract.md +21 -0
- package/template/workflows/ops/common/rules/plan-and-approval.md +25 -0
- package/template/workflows/ops/common/rules/project-and-change-scope.md +20 -0
- package/template/workflows/ops/common/rules/target-profile-and-release-gates.md +44 -0
- package/template/workflows/ops/common/schemas/approval.schema.json +28 -0
- package/template/workflows/ops/common/schemas/attempt.schema.json +42 -0
- package/template/workflows/ops/common/schemas/change-status.schema.json +43 -0
- package/template/workflows/ops/common/schemas/deployment-model.schema.json +26 -0
- package/template/workflows/ops/common/schemas/implementation-plan.schema.json +221 -0
- package/template/workflows/ops/common/schemas/inventory-snapshot.schema.json +31 -0
- package/template/workflows/ops/common/schemas/journal-event.schema.json +22 -0
- package/template/workflows/ops/common/schemas/project.schema.json +19 -0
- package/template/workflows/ops/common/schemas/promotion-approval.schema.json +20 -0
- package/template/workflows/ops/common/schemas/promotion-manifest.schema.json +22 -0
- package/template/workflows/ops/common/schemas/status.schema.json +30 -0
- package/template/workflows/ops/common/schemas/target-profile.schema.json +32 -0
- package/template/workflows/ops/common/schemas/verification-state.schema.json +30 -0
- package/template/workflows/ops/common/tools/close-change.mjs +177 -0
- package/template/workflows/ops/common/tools/validate-ops.mjs +992 -0
- package/template/workflows/ops/runtime-contract.json +14 -0
- package/template/workflows/specdev/I-implement/I-implement.md +6 -3
- package/template/workflows/specdev/I-implement/evidence-template.md +13 -0
- package/template/workflows/specdev/O-orchestrate-implementation/execution-loop.md +3 -2
- package/template/workflows/specdev/P-goal-plan/completion-control.md +3 -0
- package/template/workflows/specdev/P-goal-plan/lead-orchestration.md +6 -2
- package/template/workflows/specdev/common/skills/dev-worktree/references/finalize.md +5 -2
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:speculo:ops:promotion-manifest:v1",
|
|
4
|
+
"title": "Ops Promotion Manifest",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema_version", "artifact", "scope", "project_id", "change", "created_at", "source_path", "archive_path", "retrospective_path", "writes", "archive_only", "summary"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schema_version": {"const": 1},
|
|
9
|
+
"artifact": {"const": "ops-promotion-manifest"},
|
|
10
|
+
"scope": {"enum": ["global", "project"]},
|
|
11
|
+
"project_id": {"type": ["string", "null"], "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
|
12
|
+
"change": {"type": "string"},
|
|
13
|
+
"created_at": {"type": "string", "format": "date-time"},
|
|
14
|
+
"source_path": {"type": "string", "minLength": 1},
|
|
15
|
+
"archive_path": {"type": "string", "minLength": 1},
|
|
16
|
+
"retrospective_path": {"const": "RETROSPECTIVE.md"},
|
|
17
|
+
"writes": {"type": "array", "items": {"type": "object", "required": ["stable_key", "knowledge_scope", "action", "staging_path", "target_path", "content_digest", "expected_target_digest", "evidence"], "properties": {"stable_key": {"type": "string", "minLength": 1}, "knowledge_scope": {"enum": ["project-context", "project-adr", "project-runbook", "global-context", "global-adr", "global-runbook"]}, "action": {"enum": ["create", "merge", "supersede"]}, "staging_path": {"type": "string", "minLength": 1}, "target_path": {"type": "string", "minLength": 1}, "content_digest": {"type": "string", "pattern": "^[a-f0-9]{64}$"}, "expected_target_digest": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "uniqueItems": true}}, "additionalProperties": false}},
|
|
18
|
+
"archive_only": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
|
|
19
|
+
"summary": {"type": "string", "minLength": 1}
|
|
20
|
+
},
|
|
21
|
+
"additionalProperties": false
|
|
22
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:speculo:ops:status:v2",
|
|
4
|
+
"title": "Ops Global Status",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema_version", "workflow", "active", "archived"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schema_version": {"const": 2},
|
|
9
|
+
"workflow": {"const": "ops"},
|
|
10
|
+
"active": {"type": "array", "items": {"$ref": "#/$defs/entry"}, "uniqueItems": true},
|
|
11
|
+
"archived": {"type": "array", "items": {"$ref": "#/$defs/entry"}, "uniqueItems": true}
|
|
12
|
+
},
|
|
13
|
+
"$defs": {
|
|
14
|
+
"entry": {
|
|
15
|
+
"type": "object",
|
|
16
|
+
"required": ["scope", "project_id", "change"],
|
|
17
|
+
"properties": {
|
|
18
|
+
"scope": {"enum": ["global", "project"]},
|
|
19
|
+
"project_id": {"type": ["string", "null"], "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
|
20
|
+
"change": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$"}
|
|
21
|
+
},
|
|
22
|
+
"allOf": [
|
|
23
|
+
{"if": {"properties": {"scope": {"const": "global"}}}, "then": {"properties": {"project_id": {"const": null}}}},
|
|
24
|
+
{"if": {"properties": {"scope": {"const": "project"}}}, "then": {"properties": {"project_id": {"type": "string"}}}}
|
|
25
|
+
],
|
|
26
|
+
"additionalProperties": false
|
|
27
|
+
}
|
|
28
|
+
},
|
|
29
|
+
"additionalProperties": false
|
|
30
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:speculo:ops:target-profile:v1",
|
|
4
|
+
"title": "Ops Target Profile",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema_version", "artifact", "scope", "project_id", "change", "created_at", "operation_mode", "environment_class", "deployment_root", "existing_state", "identity_confirmed", "identity_confirmation_evidence", "identity_assertions", "ownership", "differences", "secret_requirements", "readiness", "blockers"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schema_version": {"const": 1},
|
|
9
|
+
"artifact": {"const": "ops-target-profile"},
|
|
10
|
+
"scope": {"enum": ["global", "project"]},
|
|
11
|
+
"project_id": {"type": ["string", "null"], "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
|
12
|
+
"change": {"type": "string", "pattern": "^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
|
13
|
+
"created_at": {"type": "string", "format": "date-time"},
|
|
14
|
+
"operation_mode": {"enum": ["audit", "takeover", "fresh", "release", "upgrade", "rollback"]},
|
|
15
|
+
"environment_class": {"enum": ["local", "shared-nonprod", "production"]},
|
|
16
|
+
"deployment_root": {"type": "string", "minLength": 1},
|
|
17
|
+
"existing_state": {"enum": ["absent", "present", "unknown"]},
|
|
18
|
+
"identity_confirmed": {"type": "boolean"},
|
|
19
|
+
"identity_confirmation_evidence": {"type": ["string", "null"]},
|
|
20
|
+
"identity_assertions": {"type": "array", "items": {"type": "object", "required": ["id", "provider", "key", "comparison", "expected", "evidence"], "properties": {"id": {"type": "string", "pattern": "^ID[0-9]{3}$"}, "provider": {"type": "string", "minLength": 1}, "key": {"type": "string", "minLength": 1}, "comparison": {"enum": ["exact", "ordered-list", "set", "digest"]}, "expected": {"anyOf": [{"type": "string", "minLength": 1}, {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "uniqueItems": true}]}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "uniqueItems": true}}, "additionalProperties": false}, "minItems": 1},
|
|
21
|
+
"ownership": {"type": "object", "required": ["owned_targets", "protected_targets", "unknown_targets"], "properties": {"owned_targets": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, "protected_targets": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, "unknown_targets": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}}, "additionalProperties": false},
|
|
22
|
+
"differences": {"type": "array", "items": {"type": "object", "required": ["id", "target", "classification", "evidence"], "properties": {"id": {"type": "string", "pattern": "^DF[0-9]{3}$"}, "target": {"type": "string", "minLength": 1}, "classification": {"enum": ["expected", "safe-reconcile", "requires-backup", "ownership-conflict", "unknown"]}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "uniqueItems": true}}, "additionalProperties": false}},
|
|
23
|
+
"secret_requirements": {"type": "array", "items": {"type": "object", "required": ["key", "source_ref", "presence_required", "version_ref"], "properties": {"key": {"type": "string", "minLength": 1}, "source_ref": {"type": "string", "minLength": 1}, "presence_required": {"type": "boolean"}, "version_ref": {"type": ["string", "null"]}}, "additionalProperties": false}},
|
|
24
|
+
"readiness": {"enum": ["blocked", "ready"]},
|
|
25
|
+
"blockers": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}
|
|
26
|
+
},
|
|
27
|
+
"allOf": [
|
|
28
|
+
{"if": {"properties": {"scope": {"const": "global"}}}, "then": {"properties": {"project_id": {"const": null}}}},
|
|
29
|
+
{"if": {"properties": {"scope": {"const": "project"}}}, "then": {"properties": {"project_id": {"type": "string"}}}}
|
|
30
|
+
],
|
|
31
|
+
"additionalProperties": false
|
|
32
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
{
|
|
2
|
+
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
|
3
|
+
"$id": "urn:speculo:ops:verification-state:v1",
|
|
4
|
+
"title": "Ops Verification State",
|
|
5
|
+
"type": "object",
|
|
6
|
+
"required": ["schema_version", "artifact", "scope", "project_id", "change", "attempt_id", "captured_at", "target_profile_path", "target_profile_digest", "identity_results", "gates", "artifacts", "services", "probes", "convergence", "data_protection", "recovery", "retained_artifacts", "risks", "verdict"],
|
|
7
|
+
"properties": {
|
|
8
|
+
"schema_version": {"const": 1},
|
|
9
|
+
"artifact": {"const": "ops-verification-state"},
|
|
10
|
+
"scope": {"enum": ["global", "project"]},
|
|
11
|
+
"project_id": {"type": ["string", "null"], "pattern": "^[a-z0-9]+(?:-[a-z0-9]+)*$"},
|
|
12
|
+
"change": {"type": "string"},
|
|
13
|
+
"attempt_id": {"type": "string", "pattern": "^ATTEMPT-[0-9]{3}$"},
|
|
14
|
+
"captured_at": {"type": "string", "format": "date-time"},
|
|
15
|
+
"target_profile_path": {"type": ["string", "null"]},
|
|
16
|
+
"target_profile_digest": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"},
|
|
17
|
+
"identity_results": {"type": "array", "items": {"type": "object", "required": ["assertion_id", "comparison", "actual", "matched", "evidence"], "properties": {"assertion_id": {"type": "string", "pattern": "^ID[0-9]{3}$"}, "comparison": {"enum": ["exact", "ordered-list", "set", "digest"]}, "actual": {"anyOf": [{"type": "string", "minLength": 1}, {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "uniqueItems": true}]}, "matched": {"type": "boolean"}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 1, "uniqueItems": true}}, "additionalProperties": false}},
|
|
18
|
+
"gates": {"type": "array", "items": {"type": "object", "required": ["id", "sequence", "status", "started_at", "ended_at", "evidence"], "properties": {"id": {"type": "string", "pattern": "^G[0-9]{2}$"}, "sequence": {"type": "integer", "minimum": 1}, "status": {"enum": ["passed", "failed", "blocked", "skipped"]}, "started_at": {"type": "string", "format": "date-time"}, "ended_at": {"type": "string", "format": "date-time"}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}}, "additionalProperties": false}},
|
|
19
|
+
"artifacts": {"type": "array", "items": {"type": "object", "required": ["requirement_id", "immutable_digest", "target_ref", "server_verified", "matched", "evidence"], "properties": {"requirement_id": {"type": "string", "pattern": "^AR[0-9]{3}$"}, "immutable_digest": {"type": "string", "pattern": "^(?:sha256:)?[a-f0-9]{64}$"}, "target_ref": {"type": "string", "minLength": 1}, "server_verified": {"type": "boolean"}, "matched": {"type": "boolean"}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}}, "additionalProperties": false}},
|
|
20
|
+
"services": {"type": "array", "items": {"type": "object", "required": ["id", "status", "immutable_ref", "restart_count", "runtime_digest", "evidence"], "properties": {"id": {"type": "string", "minLength": 1}, "status": {"enum": ["healthy", "running", "degraded", "failed", "absent"]}, "immutable_ref": {"type": ["string", "null"]}, "restart_count": {"type": "integer", "minimum": 0}, "runtime_digest": {"type": ["string", "null"]}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}}, "additionalProperties": false}},
|
|
21
|
+
"probes": {"type": "array", "items": {"type": "object", "required": ["verification_id", "ok", "http_status", "business_code", "authenticated", "consecutive_successes", "elapsed_seconds", "transient_failures", "evidence"], "properties": {"verification_id": {"type": "string", "pattern": "^VR[0-9]{3}$"}, "ok": {"type": "boolean"}, "http_status": {"type": ["integer", "null"]}, "business_code": {"type": ["integer", "string", "null"]}, "authenticated": {"type": ["boolean", "null"]}, "consecutive_successes": {"type": "integer", "minimum": 0}, "elapsed_seconds": {"type": "number", "minimum": 0}, "transient_failures": {"type": "array", "items": {"type": "string"}}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}}, "additionalProperties": false}},
|
|
22
|
+
"convergence": {"type": "array", "items": {"type": "object", "required": ["group", "member_ids", "digests", "matched", "evidence"], "properties": {"group": {"type": "string", "minLength": 1}, "member_ids": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2, "uniqueItems": true}, "digests": {"type": "array", "items": {"type": "string", "minLength": 1}, "minItems": 2}, "matched": {"type": "boolean"}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}}, "additionalProperties": false}},
|
|
23
|
+
"data_protection": {"type": "array", "items": {"type": "object", "required": ["protection_id", "status", "evidence_path", "evidence_digest", "readability_verified", "restore_verified"], "properties": {"protection_id": {"type": "string", "pattern": "^DP[0-9]{3}$"}, "status": {"enum": ["verified", "waived", "not-required", "failed"]}, "evidence_path": {"type": ["string", "null"]}, "evidence_digest": {"type": ["string", "null"], "pattern": "^[a-f0-9]{64}$"}, "readability_verified": {"type": "boolean"}, "restore_verified": {"type": "boolean"}}, "additionalProperties": false}},
|
|
24
|
+
"recovery": {"type": "object", "required": ["strategy", "previous_release_ref", "rollback_operation_refs", "material_refs", "compatibility_verified", "data_restore_authorized", "evidence"], "properties": {"strategy": {"enum": ["restore-previous", "reverse-change", "forward-fix", "not-applicable"]}, "previous_release_ref": {"type": ["string", "null"]}, "rollback_operation_refs": {"type": "array", "items": {"type": "string", "pattern": "^OP[0-9]{3}$"}, "uniqueItems": true}, "material_refs": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}, "compatibility_verified": {"type": "boolean"}, "data_restore_authorized": {"type": ["boolean", "null"]}, "evidence": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true}}, "additionalProperties": false},
|
|
25
|
+
"retained_artifacts": {"type": "array", "items": {"type": "object", "required": ["id", "kind", "locator", "reason"], "properties": {"id": {"type": "string", "minLength": 1}, "kind": {"type": "string", "minLength": 1}, "locator": {"type": "string", "minLength": 1}, "reason": {"type": "string", "minLength": 1}}, "additionalProperties": false}},
|
|
26
|
+
"risks": {"type": "array", "items": {"type": "string", "minLength": 1}, "uniqueItems": true},
|
|
27
|
+
"verdict": {"enum": ["passed", "failed", "blocked"]}
|
|
28
|
+
},
|
|
29
|
+
"additionalProperties": false
|
|
30
|
+
}
|
|
@@ -0,0 +1,177 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { createHash } from "node:crypto";
|
|
4
|
+
import { existsSync, lstatSync, readFileSync, readdirSync } from "node:fs";
|
|
5
|
+
import { mkdir, rename, rm, writeFile } from "node:fs/promises";
|
|
6
|
+
import { dirname, isAbsolute, join, relative, resolve, sep } from "node:path";
|
|
7
|
+
|
|
8
|
+
const CHANGE_NAME = /^[0-9]{4}-[0-9]{2}-[0-9]{2}-[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
9
|
+
const PROJECT_ID = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
10
|
+
const DIGEST = /^[a-f0-9]{64}$/;
|
|
11
|
+
|
|
12
|
+
function parseArgs(argv) {
|
|
13
|
+
const options = { stateRoot: null, scope: null, project: null, change: null, apply: false, expectedDigest: null };
|
|
14
|
+
for (let index = 0; index < argv.length; index += 1) {
|
|
15
|
+
const arg = argv[index];
|
|
16
|
+
if (arg === "--state-root") options.stateRoot = resolve(argv[++index] ?? "");
|
|
17
|
+
else if (arg === "--scope") options.scope = argv[++index] ?? null;
|
|
18
|
+
else if (arg === "--project") options.project = argv[++index] ?? null;
|
|
19
|
+
else if (arg === "--change") options.change = argv[++index] ?? null;
|
|
20
|
+
else if (arg === "--apply") options.apply = true;
|
|
21
|
+
else if (arg === "--expected-digest") options.expectedDigest = argv[++index] ?? null;
|
|
22
|
+
else throw new Error(`unknown option: ${arg}`);
|
|
23
|
+
}
|
|
24
|
+
if (!options.stateRoot || !["global", "project"].includes(options.scope) || !CHANGE_NAME.test(options.change ?? "")) throw new Error("--state-root, --scope and a valid --change are required");
|
|
25
|
+
if (options.scope === "project" && !PROJECT_ID.test(options.project ?? "")) throw new Error("project scope requires a valid --project");
|
|
26
|
+
if (options.scope === "global" && options.project) throw new Error("global scope cannot use --project");
|
|
27
|
+
if (options.apply && !DIGEST.test(options.expectedDigest ?? "")) throw new Error("--apply requires --expected-digest");
|
|
28
|
+
return options;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function canonicalize(value) {
|
|
32
|
+
if (Array.isArray(value)) return value.map(canonicalize);
|
|
33
|
+
if (value && typeof value === "object") return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonicalize(value[key])]));
|
|
34
|
+
return value;
|
|
35
|
+
}
|
|
36
|
+
function digestJson(value) { return createHash("sha256").update(JSON.stringify(canonicalize(value))).digest("hex"); }
|
|
37
|
+
function digestBytes(value) { return createHash("sha256").update(value).digest("hex"); }
|
|
38
|
+
function readJson(path, label) { try { return JSON.parse(readFileSync(path, "utf8")); } catch (error) { throw new Error(`${label}: invalid JSON (${error.message})`); } }
|
|
39
|
+
function entryKey(entry) { return `${entry.scope}:${entry.project_id ?? "-"}:${entry.change}`; }
|
|
40
|
+
function sameEntry(left, right) { return left?.scope === right.scope && left?.project_id === right.project_id && left?.change === right.change; }
|
|
41
|
+
|
|
42
|
+
function roots(options) {
|
|
43
|
+
const entry = { scope: options.scope, project_id: options.scope === "project" ? options.project : null, change: options.change };
|
|
44
|
+
const prefix = entry.scope === "global" ? "" : `projects/${entry.project_id}/`;
|
|
45
|
+
const sourcePath = `${prefix}changes/${entry.change}`;
|
|
46
|
+
const archivePath = `${prefix}archive/${entry.change.slice(0, 7)}/${entry.change}`;
|
|
47
|
+
return { entry, sourcePath, archivePath, source: resolveState(options.stateRoot, sourcePath), archive: resolveState(options.stateRoot, archivePath) };
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
function resolveState(stateRoot, locator) {
|
|
51
|
+
if (typeof locator !== "string" || !locator || locator.startsWith("/") || locator.includes("\\") || locator.split("/").includes("..")) throw new Error(`invalid state-relative path: ${String(locator)}`);
|
|
52
|
+
const target = resolve(stateRoot, ...locator.split("/"));
|
|
53
|
+
const rel = relative(resolve(stateRoot), target);
|
|
54
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) throw new Error(`state-relative path escapes Ops root: ${locator}`);
|
|
55
|
+
return target;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function resolveChange(changeRoot, locator) {
|
|
59
|
+
if (typeof locator !== "string" || !locator || locator.startsWith("/") || locator.includes("\\") || locator.split("/").includes("..")) throw new Error(`invalid change-relative path: ${String(locator)}`);
|
|
60
|
+
const target = resolve(changeRoot, ...locator.split("/"));
|
|
61
|
+
const rel = relative(resolve(changeRoot), target);
|
|
62
|
+
if (rel === ".." || rel.startsWith(".." + sep) || isAbsolute(rel)) throw new Error(`change-relative path escapes change root: ${locator}`);
|
|
63
|
+
return target;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
function allowedTarget(entry, locator) {
|
|
67
|
+
const allowed = entry.scope === "project"
|
|
68
|
+
? [`projects/${entry.project_id}/context/`, `projects/${entry.project_id}/adr/`, `projects/${entry.project_id}/runbooks/`, "context/", "adr/", "runbooks/"]
|
|
69
|
+
: ["context/", "adr/", "runbooks/"];
|
|
70
|
+
return allowed.some((root) => locator.startsWith(root));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function assertNoSymlinks(root) {
|
|
74
|
+
if (!existsSync(root)) return;
|
|
75
|
+
function visit(path) {
|
|
76
|
+
for (const entry of readdirSync(path, { withFileTypes: true })) {
|
|
77
|
+
const child = join(path, entry.name);
|
|
78
|
+
if (lstatSync(child).isSymbolicLink()) throw new Error(`Ops state contains symlink: ${relative(root, child)}`);
|
|
79
|
+
if (entry.isDirectory()) visit(child);
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
visit(root);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
async function atomicWrite(path, bytes) {
|
|
86
|
+
await mkdir(dirname(path), { recursive: true });
|
|
87
|
+
const temp = join(dirname(path), `.${process.pid}-${Date.now()}-${Math.random().toString(16).slice(2)}.tmp`);
|
|
88
|
+
await writeFile(temp, bytes);
|
|
89
|
+
await rename(temp, path);
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function validate(options) {
|
|
93
|
+
assertNoSymlinks(options.stateRoot);
|
|
94
|
+
const located = roots(options);
|
|
95
|
+
if (!existsSync(located.source)) throw new Error(`active change is missing: ${located.sourcePath}`);
|
|
96
|
+
if (existsSync(located.archive)) throw new Error(`archive target already exists: ${located.archivePath}`);
|
|
97
|
+
const statusPath = join(options.stateRoot, "status.json");
|
|
98
|
+
const globalStatus = readJson(statusPath, "status.json");
|
|
99
|
+
if (globalStatus.schema_version !== 2 || globalStatus.workflow !== "ops" || !Array.isArray(globalStatus.active) || !Array.isArray(globalStatus.archived)) throw new Error("status.json must use Ops schema v2");
|
|
100
|
+
if (globalStatus.active.filter((item) => sameEntry(item, located.entry)).length !== 1 || globalStatus.archived.some((item) => sameEntry(item, located.entry))) throw new Error("global status does not identify exactly one active tuple");
|
|
101
|
+
const changeStatusPath = join(located.source, ".status.json");
|
|
102
|
+
const changeStatus = readJson(changeStatusPath, ".status.json");
|
|
103
|
+
if (changeStatus.schema_version !== 2 || changeStatus.scope !== located.entry.scope || changeStatus.project_id !== located.entry.project_id || changeStatus.change !== located.entry.change || changeStatus.change_status !== "completed" || changeStatus.phase !== "ready_to_archive" || changeStatus.outcome === "pending" || changeStatus.blockers?.length) throw new Error("change is not ready to archive");
|
|
104
|
+
if (!existsSync(join(located.source, "RETROSPECTIVE.md"))) throw new Error("RETROSPECTIVE.md is required");
|
|
105
|
+
const manifestPath = join(located.source, "promotion", "manifest.json");
|
|
106
|
+
const manifest = readJson(manifestPath, "promotion/manifest.json");
|
|
107
|
+
const manifestDigest = digestJson(manifest);
|
|
108
|
+
if (manifest.schema_version !== 1 || manifest.artifact !== "ops-promotion-manifest" || manifest.scope !== located.entry.scope || manifest.project_id !== located.entry.project_id || manifest.change !== located.entry.change || manifest.source_path !== located.sourcePath || manifest.archive_path !== located.archivePath || manifest.retrospective_path !== "RETROSPECTIVE.md" || !Array.isArray(manifest.writes)) throw new Error("promotion manifest identity/path mismatch");
|
|
109
|
+
const targetSet = new Set();
|
|
110
|
+
const writes = manifest.writes.map((item, index) => {
|
|
111
|
+
if (!item || typeof item !== "object" || !item.staging_path?.startsWith("promotion/staging/") || !allowedTarget(located.entry, item.target_path) || !DIGEST.test(item.content_digest ?? "") || !(item.expected_target_digest === null || DIGEST.test(item.expected_target_digest))) throw new Error(`promotion write ${index} is invalid`);
|
|
112
|
+
if (targetSet.has(item.target_path)) throw new Error(`duplicate promotion target: ${item.target_path}`);
|
|
113
|
+
targetSet.add(item.target_path);
|
|
114
|
+
const staging = resolveChange(located.source, item.staging_path);
|
|
115
|
+
const target = resolveState(options.stateRoot, item.target_path);
|
|
116
|
+
if (!existsSync(staging) || digestBytes(readFileSync(staging)) !== item.content_digest) throw new Error(`staging digest mismatch: ${item.staging_path}`);
|
|
117
|
+
if (item.expected_target_digest === null ? existsSync(target) : !existsSync(target) || digestBytes(readFileSync(target)) !== item.expected_target_digest) throw new Error(`promotion target drift: ${item.target_path}`);
|
|
118
|
+
if ((item.action === "create") !== (item.expected_target_digest === null)) throw new Error(`promotion action/target expectation mismatch: ${item.target_path}`);
|
|
119
|
+
return { item, staging, target };
|
|
120
|
+
});
|
|
121
|
+
return { ...located, statusPath, globalStatus, changeStatusPath, changeStatus, manifest, manifestDigest, writes };
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
async function apply(options, context) {
|
|
125
|
+
if (context.manifestDigest !== options.expectedDigest) throw new Error("expected digest does not match promotion manifest");
|
|
126
|
+
const approval = readJson(join(context.source, "promotion", "approval.json"), "promotion/approval.json");
|
|
127
|
+
if (approval.schema_version !== 1 || approval.artifact !== "ops-promotion-approval" || approval.scope !== context.entry.scope || approval.project_id !== context.entry.project_id || approval.change !== context.entry.change || approval.manifest_path !== "promotion/manifest.json" || approval.manifest_digest !== context.manifestDigest || approval.decision !== "approved" || !approval.decision_summary || Number.isNaN(Date.parse(approval.decided_at))) throw new Error("promotion approval does not bind the current manifest");
|
|
128
|
+
|
|
129
|
+
const originalGlobal = readFileSync(context.statusPath);
|
|
130
|
+
const originalChange = readFileSync(context.changeStatusPath);
|
|
131
|
+
const originals = context.writes.map(({ target }) => existsSync(target) ? readFileSync(target) : null);
|
|
132
|
+
const rollbackRoot = join(context.source, "promotion", "rollback");
|
|
133
|
+
if (existsSync(rollbackRoot)) throw new Error("promotion rollback directory already exists");
|
|
134
|
+
await mkdir(rollbackRoot, { recursive: true });
|
|
135
|
+
const rollbackManifest = context.writes.map(({ item }, index) => ({ target_path: item.target_path, existed: originals[index] !== null, backup_path: originals[index] === null ? null : `promotion/rollback/${String(index + 1).padStart(3, "0")}.bak` }));
|
|
136
|
+
for (const [index, bytes] of originals.entries()) if (bytes !== null) await writeFile(join(rollbackRoot, `${String(index + 1).padStart(3, "0")}.bak`), bytes);
|
|
137
|
+
await writeFile(join(rollbackRoot, "manifest.json"), JSON.stringify({ schema_version: 1, writes: rollbackManifest }, null, 2) + "\n", "utf8");
|
|
138
|
+
|
|
139
|
+
let moved = false;
|
|
140
|
+
try {
|
|
141
|
+
for (const { item, staging, target } of context.writes) await atomicWrite(target, readFileSync(staging));
|
|
142
|
+
const archivedAt = new Date().toISOString();
|
|
143
|
+
const nextChange = { ...context.changeStatus, change_status: "archived", phase: "archived", current_work: null, works_run: [...new Set([...(context.changeStatus.works_run ?? []), "ops/archive-and-learn"])], updated_at: archivedAt, archived_at: archivedAt, archive_path: `<Path>{roots.state}/ops/${context.archivePath}</Path>` };
|
|
144
|
+
const nextGlobal = { ...context.globalStatus, active: context.globalStatus.active.filter((item) => !sameEntry(item, context.entry)), archived: [...context.globalStatus.archived, context.entry] };
|
|
145
|
+
await atomicWrite(context.changeStatusPath, JSON.stringify(nextChange, null, 2) + "\n");
|
|
146
|
+
await atomicWrite(context.statusPath, JSON.stringify(nextGlobal, null, 2) + "\n");
|
|
147
|
+
await mkdir(dirname(context.archive), { recursive: true });
|
|
148
|
+
await rename(context.source, context.archive);
|
|
149
|
+
moved = true;
|
|
150
|
+
} catch (error) {
|
|
151
|
+
if (moved && existsSync(context.archive) && !existsSync(context.source)) await rename(context.archive, context.source);
|
|
152
|
+
await atomicWrite(context.statusPath, originalGlobal);
|
|
153
|
+
await atomicWrite(join(context.source, ".status.json"), originalChange);
|
|
154
|
+
for (const [index, { target }] of context.writes.entries()) {
|
|
155
|
+
if (originals[index] === null) await rm(target, { force: true });
|
|
156
|
+
else await atomicWrite(target, originals[index]);
|
|
157
|
+
}
|
|
158
|
+
throw error;
|
|
159
|
+
}
|
|
160
|
+
if (!moved || existsSync(context.source) || !existsSync(context.archive)) throw new Error("archive move did not complete");
|
|
161
|
+
console.log(`Ops close applied: ${entryKey(context.entry)} -> ${context.archivePath}`);
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async function main() {
|
|
165
|
+
const options = parseArgs(process.argv.slice(2));
|
|
166
|
+
const context = validate(options);
|
|
167
|
+
if (!options.apply) {
|
|
168
|
+
console.log(`Ops close dry-run: ${entryKey(context.entry)}`);
|
|
169
|
+
console.log(`Manifest digest: ${context.manifestDigest}`);
|
|
170
|
+
console.log(`Knowledge writes: ${context.writes.length}`);
|
|
171
|
+
console.log(`Archive target: ${context.archivePath}`);
|
|
172
|
+
return;
|
|
173
|
+
}
|
|
174
|
+
await apply(options, context);
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
main().catch((error) => { console.error(`Ops close failed: ${error.message}`); process.exitCode = 1; });
|