@cassiomc1/forgeloop 1.2.1 → 1.2.3
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/.cursor/rules/project-loop.mdc +1 -1
- package/.github/copilot-instructions.md +1 -1
- package/AGENTS.md +1 -1
- package/CLAUDE.md +1 -1
- package/DOCS_INDEX.md +3 -0
- package/ENG/design-code-eng.md +59 -0
- package/ENG/premium-sites-studio-eng.md +28 -0
- package/LOOP_ENGINEERING.md +23 -0
- package/LOOP_SYSTEM_DESIGN.md +9 -5
- package/ORCHESTRATOR_INTEGRATION.md +37 -4
- package/PROTOCOL_INTEGRATION.md +13 -0
- package/README.md +34 -2
- package/TERMINOLOGY.md +10 -0
- package/THIRD_PARTY_NOTICES.md +34 -0
- package/THREAT_MODEL.md +12 -1
- package/docs/ARTIFACT_REFERENCE.md +150 -0
- package/docs/CLI_REFERENCE.md +263 -30
- package/docs/CROSS_HARNESS_CONTINUITY.md +1 -0
- package/docs/DOCUMENTATION_GUIDE.md +41 -4
- package/docs/GETTING_STARTED.md +9 -4
- package/docs/RECIPES.md +31 -1
- package/docs/TROUBLESHOOTING.md +191 -0
- package/package.json +1 -1
- package/schemas/policy-baseline.schema.json +26 -0
- package/schemas/policy-discovery.schema.json +45 -0
- package/schemas/policy-lock.schema.json +16 -0
- package/schemas/policy-rules.schema.json +48 -0
- package/schemas/policy-snapshot.schema.json +16 -0
- package/src/cli.js +69 -1
- package/src/commands/baseline.js +120 -0
- package/src/commands/init.js +304 -6
- package/src/commands/policy-diff.js +51 -0
- package/src/commands/policy-discover.js +42 -0
- package/src/commands/policy-status.js +33 -0
- package/src/commands/profile-interview.js +50 -0
- package/src/commands/reconcile-closure.js +49 -0
- package/src/commands/rule-verify.js +36 -0
- package/src/commands/validate-receipt.js +38 -3
- package/src/core/artifact-registry.js +60 -0
- package/src/core/audit.js +24 -0
- package/src/core/cli-command-definitions.js +114 -7
- package/src/core/cli-metadata.js +1 -1
- package/src/core/completion-artifacts.js +29 -3
- package/src/core/completion.js +101 -10
- package/src/core/error-codes.js +227 -0
- package/src/core/events.js +22 -0
- package/src/core/execution-prerequisites.js +38 -20
- package/src/core/execution.js +20 -3
- package/src/core/native-adapters.js +14 -4
- package/src/core/next-action-model.js +9 -0
- package/src/core/next-action.js +128 -82
- package/src/core/policy-adapters.js +276 -0
- package/src/core/policy-baseline.js +144 -0
- package/src/core/policy-diff.js +133 -0
- package/src/core/policy-discovery.js +225 -0
- package/src/core/policy-engine.js +533 -0
- package/src/core/policy-mutation.js +139 -0
- package/src/core/preflight-consistency.js +23 -15
- package/src/core/preflight.js +65 -1
- package/src/core/reconcile-closure.js +175 -0
- package/src/core/schema-validation.js +6 -0
- package/src/core/task-context.js +11 -0
- package/src/core/task-discovery.js +67 -1
- package/src/core/task-paths.js +9 -0
- package/src/core/templates.js +5 -0
package/src/cli.js
CHANGED
|
@@ -21,10 +21,17 @@ import { formatCompleteResult, runComplete } from "./commands/complete.js";
|
|
|
21
21
|
import { formatAuditResult, runAudit } from "./commands/audit.js";
|
|
22
22
|
import { formatReportResult, runReport } from "./commands/report.js";
|
|
23
23
|
import { formatPolicyResult, runPolicy } from "./commands/policy.js";
|
|
24
|
+
import { formatPolicyDiscoverResult, runPolicyDiscover } from "./commands/policy-discover.js";
|
|
25
|
+
import { formatPolicyStatusResult, runPolicyStatus } from "./commands/policy-status.js";
|
|
26
|
+
import { formatPolicyDiffResult, runPolicyDiff } from "./commands/policy-diff.js";
|
|
27
|
+
import { formatRuleVerifyResult, runRuleVerify } from "./commands/rule-verify.js";
|
|
28
|
+
import { formatBaselineResult, runBaseline } from "./commands/baseline.js";
|
|
29
|
+
import { formatProfileInterviewResult, runProfileInterview } from "./commands/profile-interview.js";
|
|
24
30
|
import { formatBundleResult, runBundle } from "./commands/bundle.js";
|
|
25
31
|
import { formatPrepareCompletionResult, runPrepareCompletion } from "./commands/prepare-completion.js";
|
|
26
32
|
import { formatRecordCheckResult, runRecordCheck } from "./commands/record-check.js";
|
|
27
33
|
import { formatRunCheckResult, runCheck } from "./commands/run-check.js";
|
|
34
|
+
import { formatReconcileClosureResult, reconcileClosure } from "./commands/reconcile-closure.js";
|
|
28
35
|
import { formatRecordTerminalResult, runRecordTerminalResult } from "./commands/record-terminal-result.js";
|
|
29
36
|
import { formatRecordDiagnosisResult, runRecordDiagnosis } from "./commands/record-diagnosis.js";
|
|
30
37
|
import { formatProgressResult, runProgress } from "./commands/progress.js";
|
|
@@ -339,6 +346,18 @@ export function validateCliSemantics({ command, options } = {}) {
|
|
|
339
346
|
throw new Error("run-check requires -- followed by an exact command argv");
|
|
340
347
|
}
|
|
341
348
|
}
|
|
349
|
+
if (command === "reconcile-closure" && !options.help) {
|
|
350
|
+
if (!options.task) throw new Error("reconcile-closure requires --task");
|
|
351
|
+
if (!options.checkId) throw new Error("reconcile-closure requires --id");
|
|
352
|
+
if (!options.checkRequirement) throw new Error("reconcile-closure requires --requirement");
|
|
353
|
+
if (options.checkKind || options.checkStatus || options.checkEvidenceKind || options.checkCommand
|
|
354
|
+
|| options.checkResult || options.checkExitCode !== null || options.checkExecutionRef || options.checkProvenance) {
|
|
355
|
+
throw new Error("reconcile-closure accepts only --id, --requirement, --details, and -- <argv>");
|
|
356
|
+
}
|
|
357
|
+
if (!Array.isArray(options.commandArgv) || options.commandArgv.length === 0) {
|
|
358
|
+
throw new Error("reconcile-closure requires -- followed by an exact command argv");
|
|
359
|
+
}
|
|
360
|
+
}
|
|
342
361
|
}
|
|
343
362
|
|
|
344
363
|
export function parseArgs(argv) {
|
|
@@ -553,6 +572,42 @@ export const COMMAND_HANDLERS = Object.freeze({
|
|
|
553
572
|
console.log(options.json ? JSON.stringify(result, null, 2) : formatPolicyResult(result));
|
|
554
573
|
return 0;
|
|
555
574
|
},
|
|
575
|
+
"policy-discover": async ({ target, packageRoot, options }) => {
|
|
576
|
+
const result = await runPolicyDiscover({ target, packageRoot, write: options.write });
|
|
577
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : formatPolicyDiscoverResult(result));
|
|
578
|
+
return 0;
|
|
579
|
+
},
|
|
580
|
+
"policy-status": async ({ target, packageRoot, options }) => {
|
|
581
|
+
const result = await runPolicyStatus({ target, packageRoot, taskId: options.task });
|
|
582
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : formatPolicyStatusResult(result));
|
|
583
|
+
return result.status === "VALID" ? 0 : 1;
|
|
584
|
+
},
|
|
585
|
+
"policy-diff": async ({ target, packageRoot, options }) => {
|
|
586
|
+
const result = await runPolicyDiff({ target, packageRoot, taskId: options.task, before: options.before, after: options.after });
|
|
587
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : formatPolicyDiffResult(result));
|
|
588
|
+
return 0;
|
|
589
|
+
},
|
|
590
|
+
"rule-verify": async ({ target, packageRoot, options }) => {
|
|
591
|
+
const result = await runRuleVerify({ target, packageRoot, rule: options.rule });
|
|
592
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : formatRuleVerifyResult(result));
|
|
593
|
+
return result.status === "VALID" ? 0 : 1;
|
|
594
|
+
},
|
|
595
|
+
baseline: async ({ target, packageRoot, options }) => {
|
|
596
|
+
const result = await runBaseline({
|
|
597
|
+
target,
|
|
598
|
+
packageRoot,
|
|
599
|
+
record: options.record,
|
|
600
|
+
update: options.update,
|
|
601
|
+
policyResetAuthorized: options.policyResetAuthorized,
|
|
602
|
+
});
|
|
603
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : formatBaselineResult(result));
|
|
604
|
+
return 0;
|
|
605
|
+
},
|
|
606
|
+
"profile-interview": async ({ target, packageRoot, options }) => {
|
|
607
|
+
const result = await runProfileInterview({ target, packageRoot, dryRun: options.dryRun });
|
|
608
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : formatProfileInterviewResult(result));
|
|
609
|
+
return 0;
|
|
610
|
+
},
|
|
556
611
|
bundle: async ({ target, packageRoot, options }) => {
|
|
557
612
|
const result = await runBundle({ target, packageRoot, taskId: options.task });
|
|
558
613
|
console.log(options.json ? JSON.stringify(result, null, 2) : formatBundleResult(result));
|
|
@@ -564,7 +619,7 @@ export const COMMAND_HANDLERS = Object.freeze({
|
|
|
564
619
|
return result.ok ? 0 : 1;
|
|
565
620
|
},
|
|
566
621
|
"validate-receipt": async ({ target, packageRoot, options }) => {
|
|
567
|
-
const result = await runValidateReceipt({ target, packageRoot, file: options.file });
|
|
622
|
+
const result = await runValidateReceipt({ target, packageRoot, file: options.file, taskId: options.task });
|
|
568
623
|
console.log(options.json ? JSON.stringify(result, null, 2) : "valid: execution receipt");
|
|
569
624
|
return 0;
|
|
570
625
|
},
|
|
@@ -640,6 +695,19 @@ export const COMMAND_HANDLERS = Object.freeze({
|
|
|
640
695
|
console.log(options.json ? JSON.stringify(result, null, 2) : formatTaskUnlockResult(result));
|
|
641
696
|
return 0;
|
|
642
697
|
},
|
|
698
|
+
"reconcile-closure": async ({ target, packageRoot, options }) => {
|
|
699
|
+
const result = await reconcileClosure({
|
|
700
|
+
target,
|
|
701
|
+
packageRoot,
|
|
702
|
+
taskId: options.task,
|
|
703
|
+
checkId: options.checkId,
|
|
704
|
+
checkRequirement: options.checkRequirement,
|
|
705
|
+
checkDetails: options.checkDetails,
|
|
706
|
+
commandArgv: options.commandArgv,
|
|
707
|
+
});
|
|
708
|
+
console.log(options.json ? JSON.stringify(result, null, 2) : formatReconcileClosureResult(result));
|
|
709
|
+
return 0;
|
|
710
|
+
},
|
|
643
711
|
update: async ({ target, packageRoot, packageVersion, options }) => {
|
|
644
712
|
const result = await runUpdate({ target, dryRun: options.dryRun, packageRoot, packageVersion });
|
|
645
713
|
printActions(result.actions);
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
import {
|
|
2
|
+
computePolicyLockData,
|
|
3
|
+
evaluateTargetPolicy,
|
|
4
|
+
loadEffectiveRules,
|
|
5
|
+
writePolicyLock,
|
|
6
|
+
readTaskPolicySnapshot,
|
|
7
|
+
} from "../core/policy-engine.js";
|
|
8
|
+
import {
|
|
9
|
+
createBaselineFromViolations,
|
|
10
|
+
readBaseline,
|
|
11
|
+
writeBaseline,
|
|
12
|
+
} from "../core/policy-baseline.js";
|
|
13
|
+
import { discoverTasks } from "../core/task-discovery.js";
|
|
14
|
+
|
|
15
|
+
function baselineError(code, message) {
|
|
16
|
+
const err = new Error(message);
|
|
17
|
+
err.code = code;
|
|
18
|
+
return err;
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export async function runBaseline({
|
|
22
|
+
target = process.cwd(),
|
|
23
|
+
packageRoot,
|
|
24
|
+
record = false,
|
|
25
|
+
update = false,
|
|
26
|
+
policyResetAuthorized = false,
|
|
27
|
+
} = {}) {
|
|
28
|
+
const policyEval = await evaluateTargetPolicy({ target, packageRoot });
|
|
29
|
+
let baseline = await readBaseline(target, packageRoot);
|
|
30
|
+
|
|
31
|
+
const tasks = await discoverTasks(target, packageRoot);
|
|
32
|
+
let hasActivePolicyTask = false;
|
|
33
|
+
for (const t of tasks) {
|
|
34
|
+
if (t.healthy && t.phase && t.phase !== "COMPLETE" && t.phase !== "BLOCKED") {
|
|
35
|
+
const snap = await readTaskPolicySnapshot(target, t.taskId, packageRoot);
|
|
36
|
+
if (snap) {
|
|
37
|
+
hasActivePolicyTask = true;
|
|
38
|
+
break;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
if (record) {
|
|
44
|
+
if (hasActivePolicyTask && !policyResetAuthorized) {
|
|
45
|
+
throw baselineError(
|
|
46
|
+
"E_BASELINE_RECORD_DURING_ACTIVE_TASK",
|
|
47
|
+
"Cannot re-record baseline during an active task with policy snapshot. Resolve new violations or use monotonic baseline --update.",
|
|
48
|
+
);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
const allViolations = [];
|
|
52
|
+
for (const r of policyEval.rules) {
|
|
53
|
+
if (r.violations) allViolations.push(...r.violations);
|
|
54
|
+
}
|
|
55
|
+
baseline = createBaselineFromViolations(allViolations);
|
|
56
|
+
await writeBaseline(target, baseline, packageRoot);
|
|
57
|
+
|
|
58
|
+
const rules = await loadEffectiveRules(target, packageRoot);
|
|
59
|
+
const lock = computePolicyLockData(rules, baseline);
|
|
60
|
+
await writePolicyLock(target, lock, packageRoot);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
status: "RECORDED",
|
|
64
|
+
baseline,
|
|
65
|
+
violationCount: allViolations.length,
|
|
66
|
+
lock: lock.digest,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (update && policyEval.ratchetedBaseline) {
|
|
71
|
+
// Monotonic check: new baseline cannot contain any fingerprint not in old baseline
|
|
72
|
+
const oldBaselineMap = new Map((baseline?.entries ?? []).map((e) => [e.ruleId, new Set(e.fingerprints ?? [])]));
|
|
73
|
+
for (const entry of policyEval.ratchetedBaseline.entries) {
|
|
74
|
+
const oldFps = oldBaselineMap.get(entry.ruleId) ?? new Set();
|
|
75
|
+
const hasExpansion = entry.fingerprints.some((fp) => !oldFps.has(fp));
|
|
76
|
+
if (hasExpansion) {
|
|
77
|
+
throw baselineError(
|
|
78
|
+
"E_BASELINE_EXPANSION",
|
|
79
|
+
`Cannot expand baseline debt for rule ${entry.ruleId}. Monotonic ratchet down allows removing debt only.`,
|
|
80
|
+
);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
baseline = policyEval.ratchetedBaseline;
|
|
85
|
+
await writeBaseline(target, baseline, packageRoot);
|
|
86
|
+
|
|
87
|
+
const rules = await loadEffectiveRules(target, packageRoot);
|
|
88
|
+
const lock = computePolicyLockData(rules, baseline);
|
|
89
|
+
await writePolicyLock(target, lock, packageRoot);
|
|
90
|
+
|
|
91
|
+
return {
|
|
92
|
+
status: "UPDATED",
|
|
93
|
+
baseline,
|
|
94
|
+
resolvedCount: policyEval.resolvedViolations?.length ?? 0,
|
|
95
|
+
remainingCount: baseline.entries.reduce((sum, e) => sum + e.fingerprints.length, 0),
|
|
96
|
+
lock: lock.digest,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return {
|
|
101
|
+
status: baseline ? "VALID" : "NOT_PRESENT",
|
|
102
|
+
baseline,
|
|
103
|
+
baselinedViolations: policyEval.baselineViolations,
|
|
104
|
+
newViolations: policyEval.newViolations?.length ?? 0,
|
|
105
|
+
resolvedViolations: policyEval.resolvedViolations?.length ?? 0,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
export function formatBaselineResult(result) {
|
|
110
|
+
const lines = [
|
|
111
|
+
`FORGELOOP POLICY BASELINE: ${result.status}`,
|
|
112
|
+
`Baselined Debt: ${result.baselinedViolations ?? (result.baseline?.entries?.reduce((sum, e) => sum + e.fingerprints.length, 0) ?? 0)}`,
|
|
113
|
+
`New Violations: ${result.newViolations ?? 0}`,
|
|
114
|
+
`Resolved Debt: ${result.resolvedViolations ?? result.resolvedCount ?? 0}`,
|
|
115
|
+
];
|
|
116
|
+
if (result.lock) {
|
|
117
|
+
lines.push(`Lock: ${result.lock}`);
|
|
118
|
+
}
|
|
119
|
+
return `${lines.join("\n")}\n`;
|
|
120
|
+
}
|
package/src/commands/init.js
CHANGED
|
@@ -6,8 +6,153 @@ import {
|
|
|
6
6
|
writeManifest,
|
|
7
7
|
} from "../core/manifest.js";
|
|
8
8
|
import { readTemplateEntries } from "../core/templates.js";
|
|
9
|
+
import { PROJECT_ARTIFACT_PATHS } from "../core/task-paths.js";
|
|
10
|
+
import { isKitPath } from "../core/target-layout.js";
|
|
11
|
+
import { E_INIT_KIT_CONFLICT, E_POLICY_INITIALIZATION_FAILED } from "../core/error-codes.js";
|
|
9
12
|
|
|
10
|
-
|
|
13
|
+
// Compatibility re-exports: the canonical semantic definitions live in
|
|
14
|
+
// src/core/error-codes.js; this keeps existing import paths working while
|
|
15
|
+
// ensuring there is exactly one literal source for each constant.
|
|
16
|
+
export { E_INIT_KIT_CONFLICT, E_POLICY_INITIALIZATION_FAILED };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Executable-policy artifacts initialized by `forgeloop init`. The manifest is
|
|
20
|
+
* the initialization commit marker and must never be written before these have
|
|
21
|
+
* been produced and verified.
|
|
22
|
+
*/
|
|
23
|
+
export const POLICY_INIT_ARTIFACTS = Object.freeze([
|
|
24
|
+
PROJECT_ARTIFACT_PATHS.policyDiscovery,
|
|
25
|
+
PROJECT_ARTIFACT_PATHS.policyBaseline,
|
|
26
|
+
PROJECT_ARTIFACT_PATHS.policyLock,
|
|
27
|
+
]);
|
|
28
|
+
|
|
29
|
+
function policyInitializationError(cause) {
|
|
30
|
+
const error = new Error(`Executable policy initialization failed: ${cause.message}`);
|
|
31
|
+
error.code = E_POLICY_INITIALIZATION_FAILED;
|
|
32
|
+
error.cause = cause;
|
|
33
|
+
error.artifacts = [...POLICY_INIT_ARTIFACTS];
|
|
34
|
+
return error;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function baselineSemanticallyEqual(a, b) {
|
|
38
|
+
return a?.schemaVersion === b?.schemaVersion
|
|
39
|
+
&& JSON.stringify(a?.entries ?? []) === JSON.stringify(b?.entries ?? []);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function lockSemanticallyEqual(a, b) {
|
|
43
|
+
return a?.algorithm === b?.algorithm
|
|
44
|
+
&& a?.digest === b?.digest
|
|
45
|
+
&& a?.rulesDigest === b?.rulesDigest
|
|
46
|
+
&& a?.baselineDigest === b?.baselineDigest;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reconciles an existing policy artifact against the freshly computed
|
|
51
|
+
* canonical initialized state.
|
|
52
|
+
*
|
|
53
|
+
* - missing -> write it
|
|
54
|
+
* - valid + equal -> reuse (resumable output of a failed init)
|
|
55
|
+
* - valid + different -> not provably ForgeLoop-owned: preserve and fail
|
|
56
|
+
* with a deterministic conflict
|
|
57
|
+
* - invalid/corrupt -> fail closed; never overwrite unknown content
|
|
58
|
+
*/
|
|
59
|
+
async function reconcilePolicyArtifact(target, packageRoot, readExisting, expected, { semanticEqual, label }) {
|
|
60
|
+
let existing = null;
|
|
61
|
+
try {
|
|
62
|
+
existing = await readExisting(target, packageRoot);
|
|
63
|
+
} catch (cause) {
|
|
64
|
+
throw policyInitializationError(
|
|
65
|
+
new Error(`${label} exists but is invalid and cannot be safely reconciled: ${cause.message}`),
|
|
66
|
+
);
|
|
67
|
+
}
|
|
68
|
+
if (existing === null) return { action: "write" };
|
|
69
|
+
if (semanticEqual(existing, expected)) return { action: "reuse" };
|
|
70
|
+
throw policyInitializationError(
|
|
71
|
+
new Error(`${label} does not match the canonical initialized state and is not provably ForgeLoop-owned; preserve it or remove it before re-running init`),
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Classifies an existing template destination against the shipped template.
|
|
77
|
+
* Shared by the pre-mutation conflict scan and the mutation loop so the two
|
|
78
|
+
* phases cannot drift apart:
|
|
79
|
+
*
|
|
80
|
+
* - MATCH existing bytes equal the shipped template (resumable)
|
|
81
|
+
* - PRESERVE_PROFILE custom PROJECT_PROFILE.md (intentionally preservable)
|
|
82
|
+
* - KIT_CONFLICT canonical hidden kit content differs (fail closed)
|
|
83
|
+
* - PRESERVE_UNOWNED pre-existing root/brownfield file (preserve, unowned)
|
|
84
|
+
*/
|
|
85
|
+
export function classifyExistingInitTemplate({ entry, existingBytes }) {
|
|
86
|
+
const existingHash = sha256(existingBytes);
|
|
87
|
+
const templateHash = sha256(entry.bytes);
|
|
88
|
+
|
|
89
|
+
if (existingHash === templateHash) {
|
|
90
|
+
return { kind: "MATCH", existingHash, templateHash };
|
|
91
|
+
}
|
|
92
|
+
if (entry.sourcePath === "PROJECT_PROFILE.md") {
|
|
93
|
+
return { kind: "PRESERVE_PROFILE", existingHash, templateHash };
|
|
94
|
+
}
|
|
95
|
+
if (isKitPath(entry.relativePath)) {
|
|
96
|
+
return { kind: "KIT_CONFLICT", existingHash, templateHash };
|
|
97
|
+
}
|
|
98
|
+
return { kind: "PRESERVE_UNOWNED", existingHash, templateHash };
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function kitConflictError(relativePath) {
|
|
102
|
+
const error = new Error(
|
|
103
|
+
`Canonical ForgeLoop kit file conflicts with shipped template: ${relativePath}`,
|
|
104
|
+
);
|
|
105
|
+
error.code = E_INIT_KIT_CONFLICT;
|
|
106
|
+
error.artifacts = [relativePath];
|
|
107
|
+
return error;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/**
|
|
111
|
+
* Initializes a target project with the ForgeLoop kit and executable-policy
|
|
112
|
+
* bootstrap, committing manifest authority LAST.
|
|
113
|
+
*
|
|
114
|
+
* Execution order:
|
|
115
|
+
* PHASE 1 — READ / PLAN (no writes):
|
|
116
|
+
* 1. validate all destination paths
|
|
117
|
+
* 2. read template entries
|
|
118
|
+
* 3. inspect the manifest
|
|
119
|
+
* 4. classify every existing template destination; a canonical kit
|
|
120
|
+
* conflict throws E_INIT_KIT_CONFLICT before ANY write
|
|
121
|
+
* 5. compute policy discovery, baseline, effective rules, and policy.lock
|
|
122
|
+
* in memory
|
|
123
|
+
* 6. pre-reconcile existing policy artifacts (read-only) so a policy
|
|
124
|
+
* conflict also fails before any write
|
|
125
|
+
* PHASE 2 — MUTATE:
|
|
126
|
+
* 7. write/reuse allowed kit files per the precomputed plan
|
|
127
|
+
* 8. write/reuse policy artifacts per the precomputed decisions
|
|
128
|
+
* 9. verify executable-policy capability and policy.lock
|
|
129
|
+
* 10. write manifest atomically
|
|
130
|
+
* 11. return success
|
|
131
|
+
*
|
|
132
|
+
* Any policy-bootstrap failure raises E_POLICY_INITIALIZATION_FAILED and
|
|
133
|
+
* leaves no committed manifest, so a retry reconciles already-correct files
|
|
134
|
+
* without manual cleanup.
|
|
135
|
+
*
|
|
136
|
+
* `init --dry-run` performs the same deterministic initialization planning and
|
|
137
|
+
* conflict detection as real init (existing manifest, unsafe paths, canonical
|
|
138
|
+
* kit conflicts, invalid or conflicting policy artifacts, and the computable
|
|
139
|
+
* policy state), but performs NO mutation: it does not simulate filesystem
|
|
140
|
+
* failures that only occur during a real write, power loss, post-write races,
|
|
141
|
+
* or verification of bytes that were never written.
|
|
142
|
+
*
|
|
143
|
+
* `hooks` is a deterministic failure-injection seam used only by tests:
|
|
144
|
+
* afterPolicyDiscovery(discovery, { target, packageRoot })
|
|
145
|
+
* beforeBaselineWrite({ target, packageRoot, baseline })
|
|
146
|
+
* beforePolicyLockWrite({ target, packageRoot, lock })
|
|
147
|
+
* beforeManifestWrite({ target, packageRoot, manifest, actions })
|
|
148
|
+
*/
|
|
149
|
+
export async function runInit({
|
|
150
|
+
target,
|
|
151
|
+
dryRun,
|
|
152
|
+
packageRoot,
|
|
153
|
+
packageVersion,
|
|
154
|
+
hooks = {},
|
|
155
|
+
} = {}) {
|
|
11
156
|
const entries = await readTemplateEntries(packageRoot);
|
|
12
157
|
const existingManifest = await readManifest(target);
|
|
13
158
|
if (existingManifest) {
|
|
@@ -16,20 +161,112 @@ export async function runInit({ target, dryRun, packageRoot, packageVersion }) {
|
|
|
16
161
|
const manifest = createManifest(packageVersion);
|
|
17
162
|
const actions = [];
|
|
18
163
|
|
|
19
|
-
// Validate every destination before any write so a symlinked kit cannot
|
|
164
|
+
// 1. Validate every destination before any write so a symlinked kit cannot
|
|
20
165
|
// leave a partially initialized target behind.
|
|
21
166
|
for (const entry of entries) await assertSafePath(target, entry.relativePath);
|
|
167
|
+
for (const relativePath of POLICY_INIT_ARTIFACTS) await assertSafePath(target, relativePath);
|
|
22
168
|
|
|
169
|
+
// 4. Pre-scan every existing template destination during planning. A
|
|
170
|
+
// deterministic canonical kit conflict must fail before ANY initialization
|
|
171
|
+
// write: no adapter, no kit file, no policy artifact, no manifest.
|
|
172
|
+
const initPlan = new Map();
|
|
23
173
|
for (const entry of entries) {
|
|
24
174
|
const destination = ensureWithin(target, entry.relativePath);
|
|
25
|
-
if (await fileExists(destination))
|
|
26
|
-
|
|
27
|
-
|
|
175
|
+
if (!(await fileExists(destination))) continue;
|
|
176
|
+
const existingBytes = await readBytes(destination);
|
|
177
|
+
const classification = classifyExistingInitTemplate({ entry, existingBytes });
|
|
178
|
+
if (classification.kind === "KIT_CONFLICT") {
|
|
179
|
+
throw kitConflictError(entry.relativePath);
|
|
180
|
+
}
|
|
181
|
+
initPlan.set(entry.relativePath, { ...classification, existingBytes });
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
// 5. Compute the full executable-policy bootstrap in memory first. Unknown
|
|
185
|
+
// or low-confidence discovery is legitimate autonomy and succeeds; only
|
|
186
|
+
// genuine discovery/serialization/computation failures fail closed.
|
|
187
|
+
const { discoverPolicy } = await import("../core/policy-discovery.js");
|
|
188
|
+
const {
|
|
189
|
+
loadEffectiveRules,
|
|
190
|
+
computePolicyLockData,
|
|
191
|
+
writeDiscoveryReport,
|
|
192
|
+
writePolicyLock,
|
|
193
|
+
detectPolicyCapability,
|
|
194
|
+
verifyPolicyLock,
|
|
195
|
+
readDiscoveryReport,
|
|
196
|
+
readPolicyLock,
|
|
197
|
+
readBaseline,
|
|
198
|
+
writeBaseline,
|
|
199
|
+
} = await import("../core/policy-engine.js");
|
|
200
|
+
const { createBaselineFromViolations } = await import("../core/policy-baseline.js");
|
|
201
|
+
|
|
202
|
+
let discovery;
|
|
203
|
+
let baseline;
|
|
204
|
+
let effectiveRules;
|
|
205
|
+
let lock;
|
|
206
|
+
try {
|
|
207
|
+
discovery = await discoverPolicy({ target });
|
|
208
|
+
if (hooks.afterPolicyDiscovery) {
|
|
209
|
+
discovery = await hooks.afterPolicyDiscovery(discovery, { target, packageRoot });
|
|
210
|
+
}
|
|
211
|
+
baseline = createBaselineFromViolations([]);
|
|
212
|
+
effectiveRules = await loadEffectiveRules(target, packageRoot);
|
|
213
|
+
lock = computePolicyLockData(effectiveRules, baseline);
|
|
214
|
+
} catch (cause) {
|
|
215
|
+
throw policyInitializationError(cause);
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 6. Pre-reconcile existing policy artifacts during planning (read-only) for
|
|
219
|
+
// BOTH real init and dry-run, so a deterministic policy conflict is detected
|
|
220
|
+
// with identical semantics and before any mutation. dry-run stays zero-write
|
|
221
|
+
// and only reports the resulting plan.
|
|
222
|
+
const discoveryReconcile = await reconcilePolicyArtifact(
|
|
223
|
+
target,
|
|
224
|
+
packageRoot,
|
|
225
|
+
readDiscoveryReport,
|
|
226
|
+
discovery,
|
|
227
|
+
{ semanticEqual: (a, b) => JSON.stringify(a) === JSON.stringify(b), label: `${PROJECT_ARTIFACT_PATHS.policyDiscovery}` },
|
|
228
|
+
);
|
|
229
|
+
const baselineReconcile = await reconcilePolicyArtifact(
|
|
230
|
+
target,
|
|
231
|
+
packageRoot,
|
|
232
|
+
readBaseline,
|
|
233
|
+
baseline,
|
|
234
|
+
{ semanticEqual: baselineSemanticallyEqual, label: `${PROJECT_ARTIFACT_PATHS.policyBaseline}` },
|
|
235
|
+
);
|
|
236
|
+
const lockReconcile = await reconcilePolicyArtifact(
|
|
237
|
+
target,
|
|
238
|
+
packageRoot,
|
|
239
|
+
readPolicyLock,
|
|
240
|
+
lock,
|
|
241
|
+
{ semanticEqual: lockSemanticallyEqual, label: `${PROJECT_ARTIFACT_PATHS.policyLock}` },
|
|
242
|
+
);
|
|
243
|
+
|
|
244
|
+
// 7. Mutate: write kit files per the precomputed plan. Existing files that
|
|
245
|
+
// match the shipped template are resumable init output; PROJECT_PROFILE.md
|
|
246
|
+
// is preserved by contract; canonical hidden kit conflicts were already
|
|
247
|
+
// rejected in planning; other pre-existing (brownfield) files are preserved
|
|
248
|
+
// as unowned.
|
|
249
|
+
for (const entry of entries) {
|
|
250
|
+
const destination = ensureWithin(target, entry.relativePath);
|
|
251
|
+
const planned = initPlan.get(entry.relativePath);
|
|
252
|
+
if (planned) {
|
|
253
|
+
if (planned.kind === "MATCH") {
|
|
254
|
+
actions.push({ action: "reuse", path: entry.relativePath, reason: "matches template" });
|
|
255
|
+
} else if (planned.kind === "PRESERVE_PROFILE") {
|
|
256
|
+
actions.push({ action: "skip", path: entry.relativePath, reason: "preserved" });
|
|
28
257
|
manifest.files[entry.relativePath] = {
|
|
29
|
-
sha256:
|
|
258
|
+
sha256: planned.existingHash,
|
|
30
259
|
preserve: true,
|
|
31
260
|
};
|
|
261
|
+
continue;
|
|
262
|
+
} else {
|
|
263
|
+
actions.push({ action: "skip", path: entry.relativePath, reason: "pre-existing" });
|
|
264
|
+
continue;
|
|
32
265
|
}
|
|
266
|
+
manifest.files[entry.relativePath] = {
|
|
267
|
+
sha256: planned.templateHash,
|
|
268
|
+
preserve: entry.sourcePath === "PROJECT_PROFILE.md",
|
|
269
|
+
};
|
|
33
270
|
continue;
|
|
34
271
|
}
|
|
35
272
|
|
|
@@ -44,6 +281,67 @@ export async function runInit({ target, dryRun, packageRoot, packageVersion }) {
|
|
|
44
281
|
};
|
|
45
282
|
}
|
|
46
283
|
|
|
284
|
+
// 8-9. Write/reuse policy artifacts and verify before committing authority.
|
|
285
|
+
// dry-run performs no mutation but reports the accurate plan (reuse vs
|
|
286
|
+
// would-write) computed by the read-only reconciliation above.
|
|
287
|
+
if (dryRun) {
|
|
288
|
+
actions.push(
|
|
289
|
+
{ action: discoveryReconcile.action === "reuse" ? "reuse" : "would-write", path: PROJECT_ARTIFACT_PATHS.policyDiscovery, reason: discoveryReconcile.action === "reuse" ? "matches canonical state" : "dry-run" },
|
|
290
|
+
{ action: baselineReconcile.action === "reuse" ? "reuse" : "would-write", path: PROJECT_ARTIFACT_PATHS.policyBaseline, reason: baselineReconcile.action === "reuse" ? "matches canonical state" : "dry-run" },
|
|
291
|
+
{ action: lockReconcile.action === "reuse" ? "reuse" : "would-write", path: PROJECT_ARTIFACT_PATHS.policyLock, reason: lockReconcile.action === "reuse" ? "matches canonical state" : "dry-run" },
|
|
292
|
+
);
|
|
293
|
+
} else {
|
|
294
|
+
actions.push(
|
|
295
|
+
{ action: discoveryReconcile.action === "reuse" ? "reuse" : "created", path: PROJECT_ARTIFACT_PATHS.policyDiscovery, reason: discoveryReconcile.action === "reuse" ? "matches canonical state" : "initialized" },
|
|
296
|
+
{ action: baselineReconcile.action === "reuse" ? "reuse" : "created", path: PROJECT_ARTIFACT_PATHS.policyBaseline, reason: baselineReconcile.action === "reuse" ? "matches canonical state" : "initialized" },
|
|
297
|
+
{ action: lockReconcile.action === "reuse" ? "reuse" : "created", path: PROJECT_ARTIFACT_PATHS.policyLock, reason: lockReconcile.action === "reuse" ? "matches canonical state" : "initialized" },
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
try {
|
|
301
|
+
if (discoveryReconcile.action === "write") {
|
|
302
|
+
await writeDiscoveryReport(target, discovery, packageRoot);
|
|
303
|
+
}
|
|
304
|
+
if (hooks.beforeBaselineWrite) {
|
|
305
|
+
await hooks.beforeBaselineWrite({ target, packageRoot, baseline });
|
|
306
|
+
}
|
|
307
|
+
if (baselineReconcile.action === "write") {
|
|
308
|
+
await writeBaseline(target, baseline, packageRoot);
|
|
309
|
+
}
|
|
310
|
+
if (hooks.beforePolicyLockWrite) {
|
|
311
|
+
await hooks.beforePolicyLockWrite({ target, packageRoot, lock });
|
|
312
|
+
}
|
|
313
|
+
if (lockReconcile.action === "write") {
|
|
314
|
+
await writePolicyLock(target, lock, packageRoot);
|
|
315
|
+
}
|
|
316
|
+
} catch (cause) {
|
|
317
|
+
throw policyInitializationError(cause);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// 9. Verify canonical policy state before committing authority.
|
|
321
|
+
try {
|
|
322
|
+
const capability = await detectPolicyCapability(target, packageRoot);
|
|
323
|
+
if (capability !== "AVAILABLE") {
|
|
324
|
+
throw new Error(`Executable policy capability is ${capability} after bootstrap`);
|
|
325
|
+
}
|
|
326
|
+
const lockVerification = await verifyPolicyLock(target, packageRoot);
|
|
327
|
+
if (lockVerification.status !== "VALID") {
|
|
328
|
+
throw new Error(`Executable policy lock verification failed: ${lockVerification.status}`);
|
|
329
|
+
}
|
|
330
|
+
} catch (cause) {
|
|
331
|
+
throw policyInitializationError(cause);
|
|
332
|
+
}
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// 10. Commit manifest authority LAST: successful initialization is only
|
|
336
|
+
// real once the manifest exists.
|
|
337
|
+
if (hooks.beforeManifestWrite) {
|
|
338
|
+
try {
|
|
339
|
+
await hooks.beforeManifestWrite({ target, packageRoot, manifest, actions });
|
|
340
|
+
} catch (cause) {
|
|
341
|
+
throw policyInitializationError(cause);
|
|
342
|
+
}
|
|
343
|
+
}
|
|
47
344
|
await writeManifest(target, manifest, { dryRun });
|
|
345
|
+
|
|
48
346
|
return { actions, manifest };
|
|
49
347
|
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { readFile } from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { diffPolicies } from "../core/policy-diff.js";
|
|
4
|
+
import {
|
|
5
|
+
loadEffectiveRules,
|
|
6
|
+
readBaseline,
|
|
7
|
+
readTaskPolicySnapshot,
|
|
8
|
+
} from "../core/policy-engine.js";
|
|
9
|
+
|
|
10
|
+
export async function runPolicyDiff({
|
|
11
|
+
target = process.cwd(),
|
|
12
|
+
packageRoot,
|
|
13
|
+
taskId = null,
|
|
14
|
+
before = null,
|
|
15
|
+
after = null,
|
|
16
|
+
} = {}) {
|
|
17
|
+
let beforePolicy;
|
|
18
|
+
let afterPolicy;
|
|
19
|
+
|
|
20
|
+
if (before) {
|
|
21
|
+
const rawBefore = await readFile(path.resolve(target, before), "utf8");
|
|
22
|
+
beforePolicy = JSON.parse(rawBefore);
|
|
23
|
+
} else if (taskId) {
|
|
24
|
+
const snapshot = await readTaskPolicySnapshot(target, taskId, packageRoot);
|
|
25
|
+
beforePolicy = snapshot ? { rules: snapshot.rules, baseline: { entries: [] } } : { rules: [], baseline: { entries: [] } };
|
|
26
|
+
} else {
|
|
27
|
+
beforePolicy = { rules: [], baseline: { entries: [] } };
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
if (after) {
|
|
31
|
+
const rawAfter = await readFile(path.resolve(target, after), "utf8");
|
|
32
|
+
afterPolicy = JSON.parse(rawAfter);
|
|
33
|
+
} else {
|
|
34
|
+
const rules = await loadEffectiveRules(target, packageRoot);
|
|
35
|
+
const baseline = await readBaseline(target, packageRoot);
|
|
36
|
+
afterPolicy = { rules, baseline };
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return diffPolicies(beforePolicy, afterPolicy);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function formatPolicyDiffResult(result) {
|
|
43
|
+
const lines = [
|
|
44
|
+
`FORGELOOP POLICY DIFF: ${result.classification}`,
|
|
45
|
+
`Changes: ${result.changes?.length ?? 0}`,
|
|
46
|
+
];
|
|
47
|
+
for (const c of result.changes ?? []) {
|
|
48
|
+
lines.push(` - [${c.type}] ${c.path}: ${c.description}`);
|
|
49
|
+
}
|
|
50
|
+
return `${lines.join("\n")}\n`;
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { discoverPolicy } from "../core/policy-discovery.js";
|
|
2
|
+
import {
|
|
3
|
+
computePolicyLockData,
|
|
4
|
+
loadEffectiveRules,
|
|
5
|
+
readBaseline,
|
|
6
|
+
writeDiscoveryReport,
|
|
7
|
+
writePolicyLock,
|
|
8
|
+
} from "../core/policy-engine.js";
|
|
9
|
+
|
|
10
|
+
export async function runPolicyDiscover({ target = process.cwd(), packageRoot, write = false } = {}) {
|
|
11
|
+
const discovery = await discoverPolicy({ target });
|
|
12
|
+
let lock = null;
|
|
13
|
+
|
|
14
|
+
if (write) {
|
|
15
|
+
await writeDiscoveryReport(target, discovery, packageRoot);
|
|
16
|
+
const baseline = await readBaseline(target, packageRoot);
|
|
17
|
+
const rules = await loadEffectiveRules(target, packageRoot);
|
|
18
|
+
lock = computePolicyLockData(rules, baseline);
|
|
19
|
+
await writePolicyLock(target, lock, packageRoot);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
return {
|
|
23
|
+
...discovery,
|
|
24
|
+
written: Boolean(write),
|
|
25
|
+
lock: lock?.digest ?? null,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function formatPolicyDiscoverResult(result) {
|
|
30
|
+
const lines = [
|
|
31
|
+
"FORGELOOP POLICY DISCOVERY:",
|
|
32
|
+
`Languages: ${result.languages?.join(", ") || "none detected"}`,
|
|
33
|
+
`Testing: ${result.testing?.detected ? `detected (${result.testing.framework || result.testing.command?.join(" ")}) [${result.testing.confidence}]` : "none"}`,
|
|
34
|
+
`Linting: ${result.linting?.detected ? `detected (${result.linting.tool || result.linting.command?.join(" ")}) [${result.linting.confidence}]` : "none"}`,
|
|
35
|
+
`Architecture: ${result.architecture?.value ? `${result.architecture.value} [${result.architecture.confidence}]` : "none [UNKNOWN]"}`,
|
|
36
|
+
`Discovered Rules: ${result.discoveredRules?.length ?? 0}`,
|
|
37
|
+
];
|
|
38
|
+
for (const rule of result.discoveredRules ?? []) {
|
|
39
|
+
lines.push(` - ${rule.id} (${rule.severity}${rule.blocking ? ", BLOCKING" : ", ADVISORY"}): ${rule.why}`);
|
|
40
|
+
}
|
|
41
|
+
return `${lines.join("\n")}\n`;
|
|
42
|
+
}
|