@kontourai/flow-agents 1.4.0 → 2.0.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.github/CODEOWNERS +29 -0
- package/.github/actions/trust-verify/action.yml +145 -0
- package/.github/workflows/ci.yml +11 -4
- package/.github/workflows/kit-gates-demo.yml +2 -2
- package/.github/workflows/publish-npm.yml +10 -2
- package/.github/workflows/release-please.yml +1 -1
- package/.github/workflows/runtime-compat.yml +1 -1
- package/.github/workflows/trust-reconcile.yml +113 -0
- package/AGENTS.md +13 -0
- package/CHANGELOG.md +103 -0
- package/CONTRIBUTING.md +4 -4
- package/README.md +1 -0
- package/agents/tool-planner.json +1 -1
- package/build/src/cli/init.js +242 -20
- package/build/src/cli/validate-workflow-artifacts.js +19 -2
- package/build/src/cli/verify.d.ts +1 -0
- package/build/src/cli/verify.js +90 -0
- package/build/src/cli/workflow-sidecar.d.ts +316 -8
- package/build/src/cli/workflow-sidecar.js +1996 -91
- package/build/src/cli.js +2 -3
- package/build/src/lib/flow-resolver.d.ts +111 -0
- package/build/src/lib/flow-resolver.js +308 -0
- package/build/src/tools/build-universal-bundles.js +34 -22
- package/build/src/tools/generate-context-map.js +3 -16
- package/build/src/tools/validate-source-tree.d.ts +1 -1
- package/build/src/tools/validate-source-tree.js +42 -162
- package/context/contracts/artifact-contract.md +10 -0
- package/context/contracts/delivery-contract.md +1 -0
- package/context/contracts/review-contract.md +1 -0
- package/context/contracts/verification-contract.md +2 -0
- package/context/gate-awareness.md +39 -0
- package/context/scripts/hooks/stop-goal-fit.js +632 -70
- package/docs/adr/0001-flow-agents-consumes-flow.md +1 -1
- package/docs/adr/0002-flow-kits-as-extension-unit.md +1 -1
- package/docs/adr/0004-gates-expect-surface-claims.md +2 -0
- package/docs/adr/0005-kubernetes-inspired-resource-contracts.md +2 -0
- package/docs/adr/0007-skill-audit.md +1 -1
- package/docs/adr/0009-canonical-hook-core-kit-boundary.md +95 -0
- package/docs/adr/0010-workflow-trust-state-as-hachure-bundle.md +139 -0
- package/docs/adr/0011-mcp-posture.md +100 -0
- package/docs/adr/0012-agent-coordination-as-liveness-claims.md +119 -0
- package/docs/adr/0013-context-lifecycle.md +151 -0
- package/docs/adr/0014-core-vs-domain-kit-boundary.md +143 -0
- package/docs/adr/0015-flow-flow-agents-boundary-reconciliation.md +120 -0
- package/docs/adr/0016-three-hard-boundary-model.md +71 -0
- package/docs/adr/0017-anti-gaming-trust-security-model.md +155 -0
- package/docs/agent-system-guidebook.md +5 -12
- package/docs/context-map.md +4 -10
- package/docs/index.md +3 -2
- package/docs/integrations/framework-adapter.md +19 -6
- package/docs/integrations/index.md +2 -2
- package/docs/north-star.md +4 -4
- package/docs/operating-layers.md +3 -3
- package/docs/plans/adr-0010-phase2-gate-recompute.md +55 -0
- package/docs/repository-structure.md +2 -2
- package/docs/skills-map.md +1 -0
- package/docs/spec/runtime-hook-surface.md +62 -9
- package/docs/standards-register.md +3 -3
- package/docs/survey-utterance-check.md +1 -1
- package/docs/trust-anchor-adoption.md +197 -0
- package/docs/verifiable-trust.md +95 -0
- package/docs/veritas-integration.md +2 -2
- package/docs/workflow-usage-guide.md +69 -0
- package/evals/acceptance/DEMO-false-completion.md +144 -0
- package/evals/acceptance/demo-cast.sh +92 -0
- package/evals/acceptance/demo-false-completion.sh +72 -0
- package/evals/acceptance/demo-real-evidence.sh +104 -0
- package/evals/acceptance/demo.tape +29 -0
- package/evals/acceptance/prove-capture-teeth-declared.sh +335 -0
- package/evals/acceptance/prove-capture-teeth.sh +114 -0
- package/evals/acceptance/prove-teeth.sh +105 -0
- package/evals/ci/antigaming-suite.sh +55 -0
- package/evals/ci/run-baseline.sh +2 -0
- package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/invalid-missing-extension-asset/kit.json +20 -0
- package/evals/fixtures/flow-kit-repository/valid-unknown-extension/flows/review.flow.json +26 -0
- package/evals/fixtures/flow-kit-repository/valid-unknown-extension/kit.json +18 -0
- package/evals/integration/test_builder_step_producers.sh +379 -0
- package/evals/integration/test_bundle_install.sh +35 -71
- package/evals/integration/test_bundle_lifecycle.sh +39 -2
- package/evals/integration/test_captured_fail_reconciliation.sh +820 -0
- package/evals/integration/test_checkpoint_signing.sh +489 -0
- package/evals/integration/test_claim_lookup.sh +352 -0
- package/evals/integration/test_command_log_fork_classification.sh +134 -0
- package/evals/integration/test_command_log_integrity.sh +275 -0
- package/evals/integration/test_context_map.sh +0 -2
- package/evals/integration/test_dual_emit_flow_step.sh +278 -0
- package/evals/integration/test_enforcer_expects_driven.sh +281 -0
- package/evals/integration/test_evidence_capture_hook.sh +185 -0
- package/evals/integration/test_flow_kit_repository.sh +2 -0
- package/evals/integration/test_flowdef_session_activation.sh +273 -0
- package/evals/integration/test_flowdef_session_history_preservation.sh +250 -0
- package/evals/integration/test_gate_bypass_chain.sh +448 -0
- package/evals/integration/test_gate_lockdown.sh +1137 -0
- package/evals/integration/test_gate_review_inquiry_records.sh +399 -0
- package/evals/integration/test_goal_fit_escape_hatch.sh +73 -0
- package/evals/integration/test_goal_fit_hook.sh +69 -4
- package/evals/integration/test_goal_fit_rederive.sh +263 -0
- package/evals/integration/test_install_merge.sh +1176 -0
- package/evals/integration/test_kit_identity_trust.sh +393 -0
- package/evals/integration/test_mint_attestation.sh +373 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +365 -0
- package/evals/integration/test_publish_delivery.sh +269 -0
- package/evals/integration/test_reconcile_soundness.sh +528 -0
- package/evals/integration/test_resolvefirststep_security.sh +208 -0
- package/evals/integration/test_session_resume_roundtrip.sh +286 -0
- package/evals/integration/test_trust_checkpoint.sh +325 -0
- package/evals/integration/test_trust_reconcile.sh +293 -0
- package/evals/integration/test_verify_cli.sh +208 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +549 -34
- package/evals/lib/node.sh +0 -6
- package/evals/run.sh +47 -0
- package/evals/static/test_workflow_skills.sh +6 -13
- package/install.sh +0 -7
- package/integrations/strands-ts/README.md +25 -15
- package/integrations/veritas/flow-agents.adapter.json +1 -2
- package/kits/builder/flows/build.flow.json +59 -12
- package/kits/builder/kit.json +85 -15
- package/kits/builder/skills/continue-work/SKILL.md +116 -0
- package/kits/builder/skills/deliver/SKILL.md +36 -6
- package/kits/builder/skills/design-probe/SKILL.md +28 -0
- package/kits/builder/skills/execute-plan/SKILL.md +9 -1
- package/kits/builder/skills/gate-review/SKILL.md +234 -0
- package/kits/builder/skills/learning-review/SKILL.md +30 -0
- package/kits/builder/skills/pickup-probe/SKILL.md +29 -0
- package/kits/builder/skills/plan-work/SKILL.md +13 -1
- package/kits/builder/skills/pull-work/SKILL.md +19 -0
- package/kits/knowledge/adapters/default-store/index.js +38 -0
- package/kits/knowledge/adapters/flow-runner/index.js +1620 -0
- package/kits/knowledge/adapters/obsidian-store/index.js +36 -6
- package/kits/knowledge/docs/store-contract.md +314 -0
- package/kits/knowledge/evals/audit-freshness/suite.test.js +368 -0
- package/kits/knowledge/evals/canonicalize-category/suite.test.js +383 -0
- package/kits/knowledge/evals/contract-suite/suite.test.js +111 -0
- package/kits/knowledge/evals/detect-contradictions/suite.test.js +324 -0
- package/kits/knowledge/evals/entities/suite.test.js +40 -0
- package/kits/knowledge/evals/glossary-sync/suite.test.js +416 -0
- package/kits/knowledge/evals/hygiene-review/suite.test.js +396 -0
- package/kits/knowledge/evals/retirement/suite.test.js +145 -0
- package/kits/knowledge/flows/audit-freshness.flow.json +44 -0
- package/kits/knowledge/flows/canonicalize-category.flow.json +44 -0
- package/kits/knowledge/flows/detect-contradictions.flow.json +44 -0
- package/kits/knowledge/flows/glossary-sync.flow.json +61 -0
- package/kits/knowledge/flows/hygiene-review.flow.json +43 -0
- package/kits/knowledge/kit.json +51 -1
- package/package.json +6 -6
- package/packaging/conformance/README.md +10 -2
- package/packaging/conformance/fixtures/evidence-capture--allow-records-command.json +29 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-bundle-disputed-claim.json +29 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-capture-contradicts-claimed-pass.json +30 -0
- package/packaging/conformance/fixtures/stop-goal-fit--block-mode.json +23 -0
- package/packaging/conformance/fixtures/stop-goal-fit--off-mode.json +24 -0
- package/packaging/conformance/fixtures/stop-goal-fit--warn-active-delivery.json +5 -2
- package/packaging/conformance/fixtures/stop-goal-fit--warn-no-bundle.json +23 -0
- package/packaging/conformance/fixtures/workflow-steering--reground-active-prompt.json +30 -0
- package/packaging/conformance/fixtures/workflow-steering--reground-session-start.json +30 -0
- package/packaging/conformance/run-conformance.js +1 -1
- package/scripts/README.md +2 -1
- package/scripts/build-universal-bundles.js +0 -1
- package/scripts/ci/mint-attestation.js +221 -0
- package/scripts/ci/trust-reconcile.js +545 -0
- package/scripts/hooks/config-protection.js +423 -1
- package/scripts/hooks/evidence-capture.js +348 -0
- package/scripts/hooks/lib/liveness-read.js +113 -0
- package/scripts/hooks/run-hook.js +6 -1
- package/scripts/hooks/stop-goal-fit.js +1524 -79
- package/scripts/hooks/workflow-steering.js +135 -5
- package/scripts/install-codex-home.sh +39 -0
- package/scripts/install-merge.js +330 -0
- package/scripts/repair-command-log.js +115 -0
- package/src/cli/init.ts +218 -20
- package/src/cli/validate-workflow-artifacts.ts +18 -2
- package/src/cli/verify.ts +100 -0
- package/src/cli/workflow-sidecar.ts +2127 -84
- package/src/cli.ts +2 -3
- package/src/lib/flow-resolver.ts +369 -0
- package/src/tools/build-universal-bundles.ts +34 -21
- package/src/tools/generate-context-map.ts +3 -17
- package/src/tools/validate-source-tree.ts +44 -104
- package/build/src/tools/filter-installed-packs.d.ts +0 -2
- package/build/src/tools/filter-installed-packs.js +0 -135
- package/packaging/packs.json +0 -49
- package/scripts/filter-installed-packs.js +0 -2
- package/src/tools/filter-installed-packs.ts +0 -132
|
@@ -3,7 +3,11 @@
|
|
|
3
3
|
* Workflow Steering Hook
|
|
4
4
|
*
|
|
5
5
|
* Injects phase-transition reminders after use_subagent calls complete and
|
|
6
|
-
*
|
|
6
|
+
* re-grounds the active workflow state at the start of every user turn and on
|
|
7
|
+
* SessionStart. SessionStart fires after context compaction and on resume, so
|
|
8
|
+
* re-injecting the goal/phase/next-step there is what makes an in-flight goal
|
|
9
|
+
* survive context loss instead of relying on the model voluntarily re-reading
|
|
10
|
+
* the sidecar.
|
|
7
11
|
*
|
|
8
12
|
* Non-blocking — always exits 0.
|
|
9
13
|
*/
|
|
@@ -12,6 +16,7 @@
|
|
|
12
16
|
|
|
13
17
|
const fs = require('fs');
|
|
14
18
|
const path = require('path');
|
|
19
|
+
const { readLivenessEvents, freshHolders } = require('./lib/liveness-read');
|
|
15
20
|
|
|
16
21
|
const STEERING = {
|
|
17
22
|
'tool-planner': [
|
|
@@ -198,6 +203,118 @@ function contextMapSteering(root) {
|
|
|
198
203
|
].join(' ');
|
|
199
204
|
}
|
|
200
205
|
|
|
206
|
+
/**
|
|
207
|
+
* Compose the RESUME block for SessionStart.
|
|
208
|
+
*
|
|
209
|
+
* Reads trust.bundle, handoff.json, and the liveness stream beside state.json;
|
|
210
|
+
* all reads are fail-open (errors → skip that section, never throw).
|
|
211
|
+
*
|
|
212
|
+
* Returns a multi-line string starting with "RESUME: <slug> status:<s> phase:<p>"
|
|
213
|
+
* or '' if the current state has status 'done', 'archived', or 'accepted'.
|
|
214
|
+
*
|
|
215
|
+
* @param {string} root Repository root
|
|
216
|
+
* @param {{ file: string, payload: object }} current Latest active state entry
|
|
217
|
+
* @returns {string}
|
|
218
|
+
*/
|
|
219
|
+
function resumeSteering(root, current) {
|
|
220
|
+
try {
|
|
221
|
+
const state = current.payload;
|
|
222
|
+
const workflowDir = path.dirname(current.file);
|
|
223
|
+
const slug = state.task_slug || path.basename(workflowDir);
|
|
224
|
+
const next = state.next_action || {};
|
|
225
|
+
|
|
226
|
+
if (next.status === 'done' || state.status === 'archived' || state.status === 'accepted') return '';
|
|
227
|
+
|
|
228
|
+
const lines = [];
|
|
229
|
+
|
|
230
|
+
// Header line
|
|
231
|
+
lines.push(`RESUME: ${slug} status:${safeStateText(state.status, 60)} phase:${safeStateText(state.phase, 60)}`);
|
|
232
|
+
|
|
233
|
+
// Full next action (240-char display path, not the 80-char normalization)
|
|
234
|
+
const nextSummary = next.summary ? safeStateText(next.summary, 240) : 'none';
|
|
235
|
+
lines.push(`Next action: ${nextSummary}`);
|
|
236
|
+
|
|
237
|
+
// Plan artifact path
|
|
238
|
+
let planPath = 'not found';
|
|
239
|
+
try {
|
|
240
|
+
const artifactPaths = Array.isArray(state.artifact_paths) ? state.artifact_paths : [];
|
|
241
|
+
const planEntry = artifactPaths.find(p => typeof p === 'string' && p.endsWith('--plan-work.md'));
|
|
242
|
+
if (planEntry) {
|
|
243
|
+
planPath = planEntry;
|
|
244
|
+
} else {
|
|
245
|
+
const candidate = path.join(workflowDir, `${slug}--plan-work.md`);
|
|
246
|
+
if (fs.existsSync(candidate)) planPath = candidate;
|
|
247
|
+
}
|
|
248
|
+
} catch { /* skip */ }
|
|
249
|
+
lines.push(`Plan: ${planPath}`);
|
|
250
|
+
|
|
251
|
+
// Handoff: next_steps[0] and blockers
|
|
252
|
+
let nextStep = 'none';
|
|
253
|
+
let blockers = 'none';
|
|
254
|
+
try {
|
|
255
|
+
const handoff = readJson(path.join(workflowDir, 'handoff.json'));
|
|
256
|
+
if (handoff) {
|
|
257
|
+
const steps = Array.isArray(handoff.next_steps) ? handoff.next_steps : [];
|
|
258
|
+
if (steps.length > 0) nextStep = safeStateText(String(steps[0]), 240);
|
|
259
|
+
const bArr = Array.isArray(handoff.blockers) ? handoff.blockers : [];
|
|
260
|
+
if (bArr.length > 0) blockers = bArr.map(b => safeStateText(String(b), 120)).join(', ');
|
|
261
|
+
}
|
|
262
|
+
} catch { /* skip */ }
|
|
263
|
+
lines.push(`Next step: ${nextStep}`);
|
|
264
|
+
lines.push(`Blockers: ${blockers}`);
|
|
265
|
+
|
|
266
|
+
// Trust bundle
|
|
267
|
+
try {
|
|
268
|
+
const bundle = readJson(path.join(workflowDir, 'trust.bundle'));
|
|
269
|
+
if (bundle) {
|
|
270
|
+
const claims = Array.isArray(bundle.claims) ? bundle.claims : [];
|
|
271
|
+
let verified = 0;
|
|
272
|
+
let disputed = 0;
|
|
273
|
+
const unresolved = [];
|
|
274
|
+
for (const claim of claims) {
|
|
275
|
+
if (!claim || typeof claim !== 'object') continue;
|
|
276
|
+
const status = String(claim.status || '');
|
|
277
|
+
if (status === 'verified') {
|
|
278
|
+
verified++;
|
|
279
|
+
} else if (status === 'disputed' || status === 'unknown') {
|
|
280
|
+
disputed++;
|
|
281
|
+
unresolved.push(claim);
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
const total = claims.length;
|
|
285
|
+
lines.push(`Trust: ${verified} verified / ${disputed} disputed / ${total} total`);
|
|
286
|
+
for (const claim of unresolved) {
|
|
287
|
+
const id = safeStateText(String(claim.id || ''), 120);
|
|
288
|
+
const st = safeStateText(String(claim.status || ''), 30);
|
|
289
|
+
lines.push(` - ${id} (${st}) → npm run workflow:sidecar -- claim ${id} ${workflowDir}`);
|
|
290
|
+
}
|
|
291
|
+
} else {
|
|
292
|
+
lines.push('Trust: no trust data available');
|
|
293
|
+
}
|
|
294
|
+
} catch { /* skip */ }
|
|
295
|
+
|
|
296
|
+
// Liveness advisory
|
|
297
|
+
try {
|
|
298
|
+
const livenessFile = path.join(root, '.flow-agents', 'liveness', 'events.jsonl');
|
|
299
|
+
const events = readLivenessEvents(livenessFile);
|
|
300
|
+
if (events.length > 0) {
|
|
301
|
+
const selfActor = (process.env.FLOW_AGENTS_ACTOR || '').trim() || 'local';
|
|
302
|
+
const holders = freshHolders(events, slug, selfActor, Date.now());
|
|
303
|
+
for (const h of holders) {
|
|
304
|
+
lines.push(`[LIVENESS WARNING: another agent appears live on this work: actor ${h.actor}, last seen ${h.lastAt}]`);
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
} catch { /* skip */ }
|
|
308
|
+
|
|
309
|
+
// Pull-work route hint
|
|
310
|
+
lines.push('To continue: resume this work. Or run pull-work to assess WIP and start new/parallel work.');
|
|
311
|
+
|
|
312
|
+
return lines.join('\n');
|
|
313
|
+
} catch {
|
|
314
|
+
return '';
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
|
|
201
318
|
function run(rawInput) {
|
|
202
319
|
try {
|
|
203
320
|
const input = JSON.parse(rawInput);
|
|
@@ -217,9 +334,22 @@ function run(rawInput) {
|
|
|
217
334
|
shouldAppendWorkflowContext = hints.length > 0;
|
|
218
335
|
}
|
|
219
336
|
|
|
220
|
-
if (event === 'UserPromptSubmit'
|
|
221
|
-
|
|
222
|
-
|
|
337
|
+
if ((event === 'UserPromptSubmit' || event === 'SessionStart') && current) {
|
|
338
|
+
const stateHint = stateSteering(root);
|
|
339
|
+
if (stateHint) {
|
|
340
|
+
hints.push(stateNeedsAmbientSteering(current.payload)
|
|
341
|
+
? 'WORKFLOW STATE ATTENTION: current sidecars show unresolved workflow state at turn start.'
|
|
342
|
+
: 'WORKFLOW STATE: an active task is in progress — re-ground the recorded goal and resume the next step before doing anything else.');
|
|
343
|
+
hints.push(stateHint);
|
|
344
|
+
const contextHint = contextMapSteering(root);
|
|
345
|
+
if (contextHint) hints.push(contextHint);
|
|
346
|
+
}
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// SessionStart only: append the RESUME block for richer situational awareness
|
|
350
|
+
if (event === 'SessionStart' && current) {
|
|
351
|
+
const resumeBlock = resumeSteering(root, current);
|
|
352
|
+
if (resumeBlock) hints.push(resumeBlock);
|
|
223
353
|
}
|
|
224
354
|
|
|
225
355
|
if (shouldAppendWorkflowContext) {
|
|
@@ -247,4 +377,4 @@ if (require.main === module) {
|
|
|
247
377
|
});
|
|
248
378
|
}
|
|
249
379
|
|
|
250
|
-
module.exports = { run, stateSteering, critiqueSteering, contextMapSteering, latestWorkflowState, findRepoRoot, safeStateText, stateNeedsAmbientSteering };
|
|
380
|
+
module.exports = { run, stateSteering, critiqueSteering, contextMapSteering, latestWorkflowState, findRepoRoot, safeStateText, stateNeedsAmbientSteering, resumeSteering };
|
|
@@ -60,6 +60,14 @@ fi
|
|
|
60
60
|
|
|
61
61
|
mkdir -p "$DEST"
|
|
62
62
|
|
|
63
|
+
# Stash the user's existing hooks.json (if any) BEFORE cleaning, so the merge
|
|
64
|
+
# step below can preserve user hooks across re-installs.
|
|
65
|
+
FA_USER_HOOKS_STASH=""
|
|
66
|
+
if [[ -f "$DEST/hooks.json" ]]; then
|
|
67
|
+
FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
|
|
68
|
+
cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
|
|
69
|
+
fi
|
|
70
|
+
|
|
63
71
|
# This is an isolated generated Codex home. Clean generated bundle content before
|
|
64
72
|
# overlaying so renamed/deleted source files do not survive across installs.
|
|
65
73
|
rm -rf \
|
|
@@ -98,6 +106,37 @@ done
|
|
|
98
106
|
|
|
99
107
|
chmod 700 "$DEST" 2>/dev/null || true
|
|
100
108
|
[[ -f "$DEST/auth.json" ]] && chmod 600 "$DEST/auth.json" 2>/dev/null || true
|
|
109
|
+
|
|
110
|
+
# Merge FA hooks into the flattened hooks.json, preserving any user hooks already present.
|
|
111
|
+
# The managed-hooks source is the bundle's .codex/hooks.json (pre-flatten, always present in dist/codex/).
|
|
112
|
+
# The rsync above wrote the FA hooks.json directly to $DEST/hooks.json.
|
|
113
|
+
# If the user had a hooks.json before this install, use the stash as the "existing" config so
|
|
114
|
+
# user-owned hook groups survive. Otherwise, $DEST/hooks.json is already correct (FA only).
|
|
115
|
+
FA_VERSION="$(node -p "require('$ROOT_DIR/package.json').version" 2>/dev/null || echo unknown)"
|
|
116
|
+
FA_MANAGED_HOOKS="$ROOT_DIR/dist/codex/.codex/hooks.json"
|
|
117
|
+
if command -v node >/dev/null 2>&1 && [[ -f "$FA_MANAGED_HOOKS" ]]; then
|
|
118
|
+
if [[ -n "$FA_USER_HOOKS_STASH" && -f "$FA_USER_HOOKS_STASH" ]]; then
|
|
119
|
+
# Merge user's prior hooks (stash) with the current FA managed hooks.
|
|
120
|
+
node "$ROOT_DIR/scripts/install-merge.js" \
|
|
121
|
+
--config "$FA_USER_HOOKS_STASH" \
|
|
122
|
+
--managed-hooks "$FA_MANAGED_HOOKS" \
|
|
123
|
+
--version "$FA_VERSION" \
|
|
124
|
+
--install-record "$DEST/.flow-agents/install.json" \
|
|
125
|
+
--runtime "codex" || true
|
|
126
|
+
# Move the merged result into the destination.
|
|
127
|
+
cp "$FA_USER_HOOKS_STASH" "$DEST/hooks.json"
|
|
128
|
+
rm -f "$FA_USER_HOOKS_STASH"
|
|
129
|
+
else
|
|
130
|
+
# No prior user hooks: just write the version stamp (FA hooks are already correct from rsync).
|
|
131
|
+
node "$ROOT_DIR/scripts/install-merge.js" \
|
|
132
|
+
--config "$DEST/hooks.json" \
|
|
133
|
+
--managed-hooks "$FA_MANAGED_HOOKS" \
|
|
134
|
+
--version "$FA_VERSION" \
|
|
135
|
+
--install-record "$DEST/.flow-agents/install.json" \
|
|
136
|
+
--runtime "codex" || true
|
|
137
|
+
fi
|
|
138
|
+
fi
|
|
139
|
+
|
|
101
140
|
if [[ ${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "${FLOW_AGENTS_TELEMETRY_SINK:-}" || -n "${FLOW_AGENTS_TELEMETRY_SINKS:-}" || -n "${FLOW_AGENTS_CONSOLE_URL:-}" || -n "${CONSOLE_TELEMETRY_URL:-}" || -n "${CONSOLE_URL:-}" || -n "${FLOW_AGENTS_CONSOLE_TOKEN_FILE:-}" || -n "${CONSOLE_TELEMETRY_TOKEN_FILE:-}" ]]; then
|
|
102
141
|
bash "$DEST/scripts/telemetry/install-console-config.sh" "$DEST/scripts/telemetry/telemetry.conf" "${CONSOLE_CONFIG_ARGS[@]}"
|
|
103
142
|
fi
|
|
@@ -0,0 +1,330 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* install-merge.js — Merge-aware installer for flow-agents config files.
|
|
4
|
+
*
|
|
5
|
+
* Usage (CLI — full merge):
|
|
6
|
+
* node scripts/install-merge.js \
|
|
7
|
+
* --config <path-to-settings.json-or-hooks.json> \
|
|
8
|
+
* --managed-hooks <path-to-flow-agents-hooks-snippet.json> \
|
|
9
|
+
* --version <version-string> \
|
|
10
|
+
* --install-record <path-to-install.json> \
|
|
11
|
+
* --runtime <claude-code|codex|opencode|pi|kiro|base>
|
|
12
|
+
*
|
|
13
|
+
* Usage (CLI — stamp only, no config merge):
|
|
14
|
+
* node scripts/install-merge.js \
|
|
15
|
+
* --stamp-only \
|
|
16
|
+
* --version <version-string> \
|
|
17
|
+
* --install-record <path-to-install.json> \
|
|
18
|
+
* --runtime <pi|kiro|base|opencode>
|
|
19
|
+
*
|
|
20
|
+
* Design (per install-merge-aware--plan-work.md):
|
|
21
|
+
* (a) Read dest JSON (or {} if absent).
|
|
22
|
+
* (b) STRIP any hook entry whose inner hooks' statusMessage matches a
|
|
23
|
+
* flow-agents marker (the COLLISION_MARKER strings from init.ts).
|
|
24
|
+
* (c) APPEND the current managed hook groups from the bundle.
|
|
25
|
+
* (d) Preserve ALL other keys (permissions, statusLine, user hooks, auth).
|
|
26
|
+
* (e) Atomic write (write tmp + rename).
|
|
27
|
+
* (f) Write/update .flow-agents/install.json version stamp.
|
|
28
|
+
*
|
|
29
|
+
* Export: mergeSettings(existing, managed) — pure, testable.
|
|
30
|
+
* The managed ownership region is identified purely by statusMessage markers
|
|
31
|
+
* (cross-runtime, no top-level key needed in settings.json).
|
|
32
|
+
*/
|
|
33
|
+
|
|
34
|
+
"use strict";
|
|
35
|
+
|
|
36
|
+
const fs = require("node:fs");
|
|
37
|
+
const path = require("node:path");
|
|
38
|
+
const os = require("node:os");
|
|
39
|
+
|
|
40
|
+
// ─── Marker strings ───────────────────────────────────────────────────────────
|
|
41
|
+
// These three statusMessage strings identify every flow-agents managed hook.
|
|
42
|
+
// They are the same strings used by COLLISION_MARKER in src/cli/init.ts and
|
|
43
|
+
// by exportClaudeSettings() / exportCodexHooks() in build-universal-bundles.ts.
|
|
44
|
+
const FA_MARKERS = [
|
|
45
|
+
"Recording Flow Agents telemetry",
|
|
46
|
+
"Running Flow Agents hook policy",
|
|
47
|
+
"Capturing Flow Agents command evidence",
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
/**
|
|
51
|
+
* Returns true if a hook-group entry is owned by flow-agents.
|
|
52
|
+
* A hook-group in Claude Code settings looks like:
|
|
53
|
+
* { hooks: [ { type, command, timeout, statusMessage } ] }
|
|
54
|
+
* A hook-group in Codex hooks.json looks like:
|
|
55
|
+
* { hooks: [ { type, command, timeout, statusMessage } ] }
|
|
56
|
+
*
|
|
57
|
+
* We identify a managed group by checking whether ANY inner hook's
|
|
58
|
+
* statusMessage contains one of the FA_MARKERS strings.
|
|
59
|
+
*
|
|
60
|
+
* @param {Record<string, unknown>} hookGroup
|
|
61
|
+
* @returns {boolean}
|
|
62
|
+
*/
|
|
63
|
+
function isManagedHookGroup(hookGroup) {
|
|
64
|
+
if (typeof hookGroup !== "object" || hookGroup === null) return false;
|
|
65
|
+
const innerHooks = Array.isArray(hookGroup.hooks) ? hookGroup.hooks : [];
|
|
66
|
+
return innerHooks.some((innerHook) => {
|
|
67
|
+
if (typeof innerHook !== "object" || innerHook === null) return false;
|
|
68
|
+
const sm = typeof innerHook.statusMessage === "string" ? innerHook.statusMessage : "";
|
|
69
|
+
return FA_MARKERS.some((marker) => sm.includes(marker));
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* mergeArrayUnion — union two arrays preserving order, de-duped (string or JSON key).
|
|
75
|
+
* @param {unknown} a @param {unknown} b @returns {unknown[]}
|
|
76
|
+
*/
|
|
77
|
+
function mergeArrayUnion(a, b) {
|
|
78
|
+
const out = [];
|
|
79
|
+
const seen = new Set();
|
|
80
|
+
for (const item of [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])]) {
|
|
81
|
+
const key = typeof item === "string" ? item : JSON.stringify(item);
|
|
82
|
+
if (seen.has(key)) continue;
|
|
83
|
+
seen.add(key);
|
|
84
|
+
out.push(item);
|
|
85
|
+
}
|
|
86
|
+
return out;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
/**
|
|
90
|
+
* mergePermissions — deep-merge the `permissions` block so flow-agents only ADDS
|
|
91
|
+
* its required entries and never clobbers the user's customizations (#117):
|
|
92
|
+
* - union the user's + managed `allow`/`deny`/`ask` rule lists (de-duped),
|
|
93
|
+
* - preserve the user's `defaultMode` when set (only adopt managed's if unset),
|
|
94
|
+
* - keep user-only sub-keys; overlay other managed scalar sub-keys.
|
|
95
|
+
* @param {unknown} existingPerms @param {unknown} managedPerms
|
|
96
|
+
* @returns {Record<string, unknown>}
|
|
97
|
+
*/
|
|
98
|
+
function mergePermissions(existingPerms, managedPerms) {
|
|
99
|
+
const e = existingPerms && typeof existingPerms === "object" ? existingPerms : {};
|
|
100
|
+
const m = managedPerms && typeof managedPerms === "object" ? managedPerms : {};
|
|
101
|
+
const out = Object.assign({}, e, m);
|
|
102
|
+
for (const listKey of ["allow", "deny", "ask"]) {
|
|
103
|
+
if (Array.isArray(e[listKey]) || Array.isArray(m[listKey])) {
|
|
104
|
+
out[listKey] = mergeArrayUnion(e[listKey], m[listKey]);
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
if (e.defaultMode !== undefined) out.defaultMode = e.defaultMode;
|
|
108
|
+
return out;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/**
|
|
112
|
+
* mergeSettings — pure merge function (no I/O).
|
|
113
|
+
*
|
|
114
|
+
* Given:
|
|
115
|
+
* existing — the current dest settings object (e.g. from settings.json)
|
|
116
|
+
* managed — the flow-agents generated settings (e.g. from bundle .claude/settings.json)
|
|
117
|
+
*
|
|
118
|
+
* Returns a new object with:
|
|
119
|
+
* - All keys from `existing` preserved (permissions, statusLine, auth, etc.)
|
|
120
|
+
* - All keys from `managed` merged in (flow-agents owned keys like statusLine, hooks)
|
|
121
|
+
* - For the `hooks` key: user-owned hook groups (non-FA) survive; FA groups are
|
|
122
|
+
* replaced with the current managed set from `managed`.
|
|
123
|
+
*
|
|
124
|
+
* Strategy:
|
|
125
|
+
* 1. Start with a shallow copy of `existing` (preserves all user keys).
|
|
126
|
+
* 2. Overlay all scalar/non-hooks keys from `managed` (statusLine, permissions
|
|
127
|
+
* from the bundle, skipDangerousModePermissionPrompt, etc.).
|
|
128
|
+
* 3. For `hooks`: iterate each event key from both existing and managed;
|
|
129
|
+
* keep user (non-FA) groups from existing, append the current FA groups
|
|
130
|
+
* from managed.
|
|
131
|
+
*
|
|
132
|
+
* @param {Record<string, unknown>} existing
|
|
133
|
+
* @param {Record<string, unknown>} managed
|
|
134
|
+
* @returns {Record<string, unknown>}
|
|
135
|
+
*/
|
|
136
|
+
function mergeSettings(existing, managed) {
|
|
137
|
+
// 1. Start with all existing keys (preserves user-owned data).
|
|
138
|
+
const result = Object.assign({}, existing);
|
|
139
|
+
|
|
140
|
+
// 2. Overlay non-hooks keys from managed. `permissions` is DEEP-merged so the
|
|
141
|
+
// user's allow/deny/ask lists + defaultMode survive — flow-agents only adds
|
|
142
|
+
// its required entries (#117: never clobber user customizations).
|
|
143
|
+
for (const [key, value] of Object.entries(managed)) {
|
|
144
|
+
if (key === "hooks") continue;
|
|
145
|
+
if (key === "permissions") {
|
|
146
|
+
result.permissions = mergePermissions(existing.permissions, value);
|
|
147
|
+
} else {
|
|
148
|
+
result[key] = value;
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
// 3. Merge hooks: strip FA groups from existing, append current FA groups.
|
|
153
|
+
const existingHooks =
|
|
154
|
+
typeof existing.hooks === "object" && existing.hooks !== null
|
|
155
|
+
? (existing.hooks)
|
|
156
|
+
: {};
|
|
157
|
+
const managedHooks =
|
|
158
|
+
typeof managed.hooks === "object" && managed.hooks !== null
|
|
159
|
+
? (managed.hooks)
|
|
160
|
+
: {};
|
|
161
|
+
|
|
162
|
+
// Collect all event keys from both sides.
|
|
163
|
+
const allEventKeys = new Set([
|
|
164
|
+
...Object.keys(existingHooks),
|
|
165
|
+
...Object.keys(managedHooks),
|
|
166
|
+
]);
|
|
167
|
+
|
|
168
|
+
const mergedHooks = {};
|
|
169
|
+
for (const eventKey of allEventKeys) {
|
|
170
|
+
const existingGroups = Array.isArray(existingHooks[eventKey])
|
|
171
|
+
? existingHooks[eventKey]
|
|
172
|
+
: [];
|
|
173
|
+
const managedGroups = Array.isArray(managedHooks[eventKey])
|
|
174
|
+
? managedHooks[eventKey]
|
|
175
|
+
: [];
|
|
176
|
+
|
|
177
|
+
// Keep user-owned (non-FA) groups from existing.
|
|
178
|
+
const userGroups = existingGroups.filter(
|
|
179
|
+
(group) => !isManagedHookGroup(group)
|
|
180
|
+
);
|
|
181
|
+
|
|
182
|
+
// Append the new FA groups from managed (may be empty if event not in managed).
|
|
183
|
+
mergedHooks[eventKey] = [...userGroups, ...managedGroups];
|
|
184
|
+
|
|
185
|
+
// Drop empty event keys (only user groups existed and were zero — unlikely
|
|
186
|
+
// but defensive: remove keys with empty arrays to keep JSON clean).
|
|
187
|
+
if (mergedHooks[eventKey].length === 0) {
|
|
188
|
+
delete mergedHooks[eventKey];
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
// Only write the hooks key if at least one side actually had a hooks key,
|
|
193
|
+
// OR if the merged result is non-empty. This prevents injecting a spurious
|
|
194
|
+
// empty "hooks": {} into configs that have no hooks (e.g. opencode.json).
|
|
195
|
+
const eitherHadHooks = "hooks" in existing || "hooks" in managed;
|
|
196
|
+
if (eitherHadHooks || Object.keys(mergedHooks).length > 0) {
|
|
197
|
+
result.hooks = mergedHooks;
|
|
198
|
+
}
|
|
199
|
+
return result;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* atomicWriteJson — write JSON to a file atomically (tmp + rename).
|
|
204
|
+
*
|
|
205
|
+
* @param {string} filePath
|
|
206
|
+
* @param {unknown} data
|
|
207
|
+
*/
|
|
208
|
+
function atomicWriteJson(filePath, data) {
|
|
209
|
+
const dir = path.dirname(filePath);
|
|
210
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
211
|
+
const tmp = `${filePath}.tmp.${process.pid}`;
|
|
212
|
+
fs.writeFileSync(tmp, `${JSON.stringify(data, null, 2)}\n`, "utf8");
|
|
213
|
+
fs.renameSync(tmp, filePath);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/**
|
|
217
|
+
* writeInstallRecord — write/update .flow-agents/install.json.
|
|
218
|
+
*
|
|
219
|
+
* @param {string} installRecordPath
|
|
220
|
+
* @param {string} version
|
|
221
|
+
* @param {string} runtime
|
|
222
|
+
*/
|
|
223
|
+
function writeInstallRecord(installRecordPath, version, runtime) {
|
|
224
|
+
const record = {
|
|
225
|
+
version,
|
|
226
|
+
installedAt: new Date().toISOString(),
|
|
227
|
+
runtime,
|
|
228
|
+
};
|
|
229
|
+
atomicWriteJson(installRecordPath, record);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* runMerge — perform the full merge (read, merge, write, stamp).
|
|
234
|
+
*
|
|
235
|
+
* @param {{
|
|
236
|
+
* configPath: string,
|
|
237
|
+
* managedHooksPath: string,
|
|
238
|
+
* version: string,
|
|
239
|
+
* installRecordPath: string,
|
|
240
|
+
* runtime: string,
|
|
241
|
+
* }} opts
|
|
242
|
+
*/
|
|
243
|
+
function runMerge({ configPath, managedHooksPath, version, installRecordPath, runtime }) {
|
|
244
|
+
// (a) Read dest JSON (or {} if absent).
|
|
245
|
+
let existing = {};
|
|
246
|
+
if (fs.existsSync(configPath)) {
|
|
247
|
+
try {
|
|
248
|
+
existing = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
249
|
+
} catch (err) {
|
|
250
|
+
process.stderr.write(
|
|
251
|
+
`install-merge: warning: could not parse existing ${configPath} (${err.message}); treating as empty\n`
|
|
252
|
+
);
|
|
253
|
+
existing = {};
|
|
254
|
+
}
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
// Read the managed (bundle-generated) config.
|
|
258
|
+
let managed = {};
|
|
259
|
+
try {
|
|
260
|
+
managed = JSON.parse(fs.readFileSync(managedHooksPath, "utf8"));
|
|
261
|
+
} catch (err) {
|
|
262
|
+
process.stderr.write(
|
|
263
|
+
`install-merge: error: could not read managed config ${managedHooksPath}: ${err.message}\n`
|
|
264
|
+
);
|
|
265
|
+
process.exitCode = 1;
|
|
266
|
+
return;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// (b) + (c) + (d) Merge.
|
|
270
|
+
const merged = mergeSettings(existing, managed);
|
|
271
|
+
|
|
272
|
+
// (e) Atomic write.
|
|
273
|
+
atomicWriteJson(configPath, merged);
|
|
274
|
+
|
|
275
|
+
// (f) Write version stamp.
|
|
276
|
+
writeInstallRecord(installRecordPath, version, runtime);
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
// ─── CLI wrapper ──────────────────────────────────────────────────────────────
|
|
280
|
+
if (require.main === module) {
|
|
281
|
+
const args = process.argv.slice(2);
|
|
282
|
+
const flags = {};
|
|
283
|
+
for (let i = 0; i < args.length; i++) {
|
|
284
|
+
if (args[i] === "--stamp-only") {
|
|
285
|
+
flags["stamp-only"] = "1";
|
|
286
|
+
} else if (args[i].startsWith("--") && i + 1 < args.length) {
|
|
287
|
+
flags[args[i].slice(2)] = args[i + 1];
|
|
288
|
+
i++;
|
|
289
|
+
}
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
const stampOnly = flags["stamp-only"] === "1";
|
|
293
|
+
const configPath = flags["config"];
|
|
294
|
+
const managedHooksPath = flags["managed-hooks"];
|
|
295
|
+
const version = flags["version"] || "unknown";
|
|
296
|
+
const installRecordPath = flags["install-record"];
|
|
297
|
+
const runtime = flags["runtime"] || "claude-code";
|
|
298
|
+
|
|
299
|
+
if (stampOnly) {
|
|
300
|
+
// Stamp-only mode: write install.json without merging any config file.
|
|
301
|
+
// Used by runtimes (pi, kiro, base) that do not have a shared config to merge.
|
|
302
|
+
if (!installRecordPath) {
|
|
303
|
+
process.stderr.write(
|
|
304
|
+
"usage: node install-merge.js --stamp-only --version <ver> --install-record <path> --runtime <runtime>\n"
|
|
305
|
+
);
|
|
306
|
+
process.exitCode = 2;
|
|
307
|
+
} else {
|
|
308
|
+
try {
|
|
309
|
+
writeInstallRecord(installRecordPath, version, runtime);
|
|
310
|
+
} catch (err) {
|
|
311
|
+
process.stderr.write(`install-merge: error: ${err.message}\n`);
|
|
312
|
+
process.exitCode = 1;
|
|
313
|
+
}
|
|
314
|
+
}
|
|
315
|
+
} else if (!configPath || !managedHooksPath || !installRecordPath) {
|
|
316
|
+
process.stderr.write(
|
|
317
|
+
"usage: node install-merge.js --config <path> --managed-hooks <path> --version <ver> --install-record <path> --runtime <runtime>\n"
|
|
318
|
+
);
|
|
319
|
+
process.exitCode = 2;
|
|
320
|
+
} else {
|
|
321
|
+
try {
|
|
322
|
+
runMerge({ configPath, managedHooksPath, version, installRecordPath, runtime });
|
|
323
|
+
} catch (err) {
|
|
324
|
+
process.stderr.write(`install-merge: error: ${err.message}\n`);
|
|
325
|
+
process.exitCode = 1;
|
|
326
|
+
}
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
module.exports = { mergeSettings, isManagedHookGroup, FA_MARKERS };
|
|
@@ -0,0 +1,115 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
'use strict';
|
|
3
|
+
/**
|
|
4
|
+
* repair-command-log.js — deterministic re-linearization of a concurrent-fork
|
|
5
|
+
* command-log.jsonl.
|
|
6
|
+
*
|
|
7
|
+
* A "fork" happens when two PostToolUse captures append off the same parent tip
|
|
8
|
+
* (parallel writers before the writer lock, flow-agents#232). The records are
|
|
9
|
+
* all genuine and self-consistent; only their linear order is ambiguous. This
|
|
10
|
+
* tool produces THE canonical order — sort chained entries by (capturedAt, then
|
|
11
|
+
* hash) — and re-chains them, so any party re-running it gets the identical
|
|
12
|
+
* result. It is therefore a verifiable repair, not a judgement call.
|
|
13
|
+
*
|
|
14
|
+
* SAFETY: it refuses to run unless verifyCommandLogChain() reports "forked".
|
|
15
|
+
* - "broken" (real tamper: edited content, reorder, deletion) → REFUSE. The
|
|
16
|
+
* repair must never be usable to launder tampering.
|
|
17
|
+
* - "ok" / "legacy" → nothing to do.
|
|
18
|
+
* No record content is altered — only the _chain wrappers and line order. The
|
|
19
|
+
* original is backed up, and an in-chain `chain-repair` marker records that the
|
|
20
|
+
* re-linearization happened (so the repair is itself auditable).
|
|
21
|
+
*
|
|
22
|
+
* Usage: node scripts/repair-command-log.js <artifact-dir> [--reason "..."]
|
|
23
|
+
*/
|
|
24
|
+
const fs = require('fs');
|
|
25
|
+
const path = require('path');
|
|
26
|
+
const crypto = require('crypto');
|
|
27
|
+
|
|
28
|
+
const gate = require(path.join(__dirname, 'hooks', 'stop-goal-fit.js'));
|
|
29
|
+
const GENESIS = gate.CHAIN_GENESIS_VERIFY;
|
|
30
|
+
|
|
31
|
+
function canon(rec) {
|
|
32
|
+
const keys = Object.keys(rec).filter((k) => k !== '_chain').sort();
|
|
33
|
+
const obj = {};
|
|
34
|
+
for (const k of keys) obj[k] = rec[k];
|
|
35
|
+
return JSON.stringify(obj);
|
|
36
|
+
}
|
|
37
|
+
function hashLink(prev, rec) {
|
|
38
|
+
return crypto.createHash('sha256').update(prev + canon(rec), 'utf8').digest('hex');
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function main() {
|
|
42
|
+
const dir = process.argv[2];
|
|
43
|
+
if (!dir) { console.error('usage: repair-command-log.js <artifact-dir> [--reason "..."]'); process.exit(2); }
|
|
44
|
+
const reasonIdx = process.argv.indexOf('--reason');
|
|
45
|
+
const reason = reasonIdx !== -1 ? (process.argv[reasonIdx + 1] || '') : 'deterministic concurrent-fork re-linearization';
|
|
46
|
+
|
|
47
|
+
const verdict = gate.verifyCommandLogChain(dir);
|
|
48
|
+
if (verdict.status === 'ok' || verdict.status === 'legacy') {
|
|
49
|
+
console.log(`nothing to repair: chain status is "${verdict.status}"`);
|
|
50
|
+
return;
|
|
51
|
+
}
|
|
52
|
+
if (verdict.status !== 'forked') {
|
|
53
|
+
console.error(`REFUSING to repair: chain status is "${verdict.status}" (entry ${verdict.brokenAt}). ` +
|
|
54
|
+
'This tool only re-linearizes benign concurrent forks; it will not touch a tampered chain.');
|
|
55
|
+
process.exit(1);
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
const file = path.join(dir, 'command-log.jsonl');
|
|
59
|
+
const lines = fs.readFileSync(file, 'utf8').split('\n').filter((l) => l.trim());
|
|
60
|
+
|
|
61
|
+
// Preserve legacy prefix verbatim; collect the chained records (content only).
|
|
62
|
+
const legacyPrefix = [];
|
|
63
|
+
const records = [];
|
|
64
|
+
let started = false;
|
|
65
|
+
for (const line of lines) {
|
|
66
|
+
let e;
|
|
67
|
+
try { e = JSON.parse(line); } catch { if (!started) { legacyPrefix.push(line); } continue; }
|
|
68
|
+
const isChained = e._chain && typeof e._chain.hash === 'string';
|
|
69
|
+
if (!started && !isChained) { legacyPrefix.push(line); continue; }
|
|
70
|
+
started = true;
|
|
71
|
+
const rec = { ...e };
|
|
72
|
+
delete rec._chain;
|
|
73
|
+
records.push(rec);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// Canonical deterministic order: capturedAt asc, then a stable content hash.
|
|
77
|
+
records.sort((a, b) => {
|
|
78
|
+
const ta = String(a.capturedAt || ''), tb = String(b.capturedAt || '');
|
|
79
|
+
if (ta !== tb) return ta < tb ? -1 : 1;
|
|
80
|
+
const ha = crypto.createHash('sha256').update(canon(a)).digest('hex');
|
|
81
|
+
const hb = crypto.createHash('sha256').update(canon(b)).digest('hex');
|
|
82
|
+
return ha < hb ? -1 : ha > hb ? 1 : 0;
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
// Re-chain from genesis.
|
|
86
|
+
const out = [...legacyPrefix];
|
|
87
|
+
let prev = GENESIS;
|
|
88
|
+
let seq = 0;
|
|
89
|
+
for (const rec of records) {
|
|
90
|
+
const h = hashLink(prev, rec);
|
|
91
|
+
out.push(JSON.stringify({ ...rec, _chain: { seq, prevHash: prev, hash: h } }));
|
|
92
|
+
prev = h; seq += 1;
|
|
93
|
+
}
|
|
94
|
+
// Append an in-chain repair marker so the re-linearization is itself auditable.
|
|
95
|
+
const marker = {
|
|
96
|
+
command: '(chain-repair marker)',
|
|
97
|
+
observedResult: `re-linearized ${records.length} entries from concurrent fork`,
|
|
98
|
+
exitCode: 0,
|
|
99
|
+
capturedAt: new Date().toISOString(),
|
|
100
|
+
source: 'chain-repair',
|
|
101
|
+
repair: { reason, entries: records.length, forkAt: verdict.forkAt },
|
|
102
|
+
};
|
|
103
|
+
const mh = hashLink(prev, marker);
|
|
104
|
+
out.push(JSON.stringify({ ...marker, _chain: { seq, prevHash: prev, hash: mh } }));
|
|
105
|
+
|
|
106
|
+
fs.copyFileSync(file, file + '.prebackup-repair');
|
|
107
|
+
fs.writeFileSync(file, out.join('\n') + '\n');
|
|
108
|
+
|
|
109
|
+
const after = gate.verifyCommandLogChain(dir);
|
|
110
|
+
console.log(`repaired: re-linearized ${records.length} entries (legacy prefix: ${legacyPrefix.length}); ` +
|
|
111
|
+
`chain status now "${after.status}". backup: command-log.jsonl.prebackup-repair`);
|
|
112
|
+
if (after.status !== 'ok') { console.error('repair did not produce a clean chain'); process.exit(1); }
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
main();
|