@kontourai/flow-agents 3.3.0 → 3.4.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/workflows/add-to-project.yml +15 -0
- package/.github/workflows/ci.yml +161 -0
- package/CHANGELOG.md +48 -0
- package/CONTEXT.md +5 -1
- package/README.md +19 -8
- package/build/src/builder-flow-run-adapter.d.ts +80 -0
- package/build/src/builder-flow-run-adapter.js +241 -0
- package/build/src/builder-flow-runtime.d.ts +16 -0
- package/build/src/builder-flow-runtime.js +290 -0
- package/build/src/cli/builder-run.d.ts +1 -0
- package/build/src/cli/builder-run.js +27 -0
- package/build/src/cli/effective-backlog-settings.js +70 -2
- package/build/src/cli/init.d.ts +34 -0
- package/build/src/cli/init.js +341 -61
- package/build/src/cli/kit.js +55 -12
- package/build/src/cli/pull-work-provider.js +346 -5
- package/build/src/cli/skill-drift-check.d.ts +1 -0
- package/build/src/cli/skill-drift-check.js +165 -0
- package/build/src/cli/telemetry-doctor.d.ts +37 -0
- package/build/src/cli/telemetry-doctor.js +53 -6
- package/build/src/cli/validate-hook-influence.js +37 -7
- package/build/src/cli/workflow-sidecar.d.ts +93 -8
- package/build/src/cli/workflow-sidecar.js +1175 -158
- package/build/src/cli.js +5 -0
- package/build/src/flow-kit/validate.d.ts +54 -34
- package/build/src/flow-kit/validate.js +237 -26
- package/build/src/index.d.ts +2 -0
- package/build/src/index.js +1 -0
- package/build/src/lib/console-connect-options.d.ts +97 -0
- package/build/src/lib/console-connect-options.js +199 -0
- package/build/src/lib/console-telemetry-validate.d.ts +49 -0
- package/build/src/lib/console-telemetry-validate.js +91 -0
- package/build/src/lib/flow-resolver.d.ts +56 -3
- package/build/src/lib/flow-resolver.js +151 -11
- package/build/src/lib/fs.d.ts +17 -0
- package/build/src/lib/fs.js +172 -0
- package/build/src/lib/local-artifact-root.d.ts +44 -1
- package/build/src/lib/local-artifact-root.js +131 -3
- package/build/src/runtime-adapters.d.ts +39 -3
- package/build/src/runtime-adapters.js +77 -31
- package/build/src/tools/build-universal-bundles.js +40 -2
- package/build/src/tools/codex-agent-routing.d.ts +2 -0
- package/build/src/tools/codex-agent-routing.js +49 -0
- package/build/src/tools/generate-context-map.js +1 -0
- package/build/src/tools/validate-source-tree.js +27 -1
- package/context/scripts/hooks/lib/kit-catalog.js +235 -0
- package/context/scripts/hooks/lib/runnable-command.js +177 -0
- package/context/scripts/hooks/stop-goal-fit.js +278 -48
- package/context/scripts/hooks/workflow-steering.js +121 -21
- package/context/scripts/package.json +3 -0
- package/context/scripts/telemetry/install-console-config.sh +25 -4
- package/context/scripts/telemetry/lib/config.sh +102 -12
- package/context/scripts/telemetry/lib/pricing.sh +50 -0
- package/context/scripts/telemetry/lib/session.sh +3 -0
- package/context/scripts/telemetry/lib/transport.sh +87 -0
- package/context/scripts/telemetry/lib/usage.sh +205 -4
- package/context/scripts/telemetry/telemetry.conf +6 -0
- package/context/scripts/telemetry/telemetry.sh +48 -0
- package/context/settings/workspace-backlog-provider-settings.example.json +48 -0
- package/docs/agent-usage-feedback-loop.md +35 -0
- package/docs/architecture-engine-and-kits.md +110 -0
- package/docs/context-map.md +2 -0
- package/docs/decisions/embeddable-engine.md +152 -0
- package/docs/decisions/index.md +3 -1
- package/docs/decisions/trust-ledger-retention.md +88 -0
- package/docs/decisions/workflow-enforcement.md +31 -9
- package/docs/fixture-ownership.md +3 -0
- package/docs/implementing-trust-reconciliation.md +129 -0
- package/docs/index.md +19 -9
- package/docs/integrations/flow-agents-console.md +167 -0
- package/docs/kit-authoring-guide.md +52 -21
- package/docs/spec/builder-flow-runtime.md +80 -0
- package/docs/spec/runtime-hook-surface.md +45 -1
- package/docs/specs/economics-record-contract.md +270 -0
- package/docs/specs/harness-capability-matrix.md +74 -0
- package/docs/specs/learning-review-proposals-contract.md +340 -0
- package/docs/specs/routing-efficiency-review.md +59 -0
- package/docs/verifiable-trust.md +74 -25
- package/docs/workflow-usage-guide.md +10 -0
- package/evals/acceptance/prove-capture-teeth.sh +132 -0
- package/evals/ci/antigaming-suite.sh +1 -0
- package/evals/ci/run-baseline.sh +72 -4
- package/evals/fixtures/economics/acceptance.json +12 -0
- package/evals/fixtures/economics/agents/tool-worker-1/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-2/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-3/events.jsonl +2 -0
- package/evals/fixtures/economics/agents/tool-worker-4/events.jsonl +1 -0
- package/evals/fixtures/economics/agents/tool-worker-5/events.jsonl +2 -0
- package/evals/fixtures/economics/critique.json +22 -0
- package/evals/fixtures/economics/expected-record.json +71 -0
- package/evals/fixtures/economics/session-usage-event.json +1 -0
- package/evals/fixtures/economics/state.json +11 -0
- package/evals/fixtures/economics/transcript.jsonl +3 -0
- package/evals/fixtures/hook-influence/cases.json +7 -7
- package/evals/fixtures/learning-review-proposals/balanced/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/economics.jsonl +5 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-1/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-2/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-3/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-4/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/effect-follow-up/sessions/task-lr-ef-5/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.json +30 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/expected-aggregates.md +66 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-1/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-2/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-3/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/gate-review.inquiries.json +26 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-4/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-5/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/pattern-present/sessions/task-lr-pp-6/trust.bundle +21 -0
- package/evals/fixtures/learning-review-proposals/repeat-window/economics.jsonl +6 -0
- package/evals/fixtures/learning-review-proposals/under-threshold/economics.jsonl +3 -0
- package/evals/fixtures/telemetry/usage-transcript-sample.jsonl +4 -0
- package/evals/fixtures/trust-reconcile-exploits/mcp-degrade.json +42 -0
- package/evals/integration/test_builder_entry_enforcement.sh +241 -0
- package/evals/integration/test_builder_step_producers.sh +18 -10
- package/evals/integration/test_bundle_install.sh +172 -0
- package/evals/integration/test_console_tenant_isolation.sh +167 -0
- package/evals/integration/test_critique_supersession_roundtrip.sh +4 -1
- package/evals/integration/test_dual_emit_flow_step.sh +10 -4
- package/evals/integration/test_economics_record.sh +674 -0
- package/evals/integration/test_effective_backlog_settings.sh +1 -1
- package/evals/integration/test_evidence_capture_hook.sh +17 -2
- package/evals/integration/test_exemption_usage_review.sh +198 -0
- package/evals/integration/test_fixture_retirement_audit.sh +2 -2
- package/evals/integration/test_flow_kit_install_git.sh +83 -0
- package/evals/integration/test_flowdef_session_activation.sh +0 -1
- package/evals/integration/test_flowdef_session_history_preservation.sh +13 -3
- package/evals/integration/test_gate_lockdown.sh +7 -0
- package/evals/integration/test_gate_review_inquiry_records.sh +9 -1
- package/evals/integration/test_goal_fit_hook.sh +2031 -0
- package/evals/integration/test_hook_category_behaviors.sh +8 -1
- package/evals/integration/test_hook_influence_cases.sh +25 -1
- package/evals/integration/test_install_merge.sh +227 -2
- package/evals/integration/test_kit_conformance_levels.sh +6 -6
- package/evals/integration/test_learning_review_proposals.sh +329 -0
- package/evals/integration/test_liveness_conflict_injection.sh +26 -22
- package/evals/integration/test_liveness_console_relay.sh +166 -0
- package/evals/integration/test_liveness_heartbeat.sh +17 -17
- package/evals/integration/test_liveness_worktree_root.sh +575 -0
- package/evals/integration/test_phase_map_and_gate_claim.sh +6 -1
- package/evals/integration/test_publish_delivery.sh +331 -1
- package/evals/integration/test_pull_work_board.sh +200 -0
- package/evals/integration/test_pull_work_provider.sh +1 -1
- package/evals/integration/test_record_check.sh +378 -0
- package/evals/integration/test_routing_efficiency.sh +71 -0
- package/evals/integration/test_runtime_adapter_activation.sh +28 -0
- package/evals/integration/test_session_resume_roundtrip.sh +16 -19
- package/evals/integration/test_skill_drift_check.sh +870 -0
- package/evals/integration/test_telemetry.sh +445 -0
- package/evals/integration/test_telemetry_doctor.sh +66 -0
- package/evals/integration/test_telemetry_usage_pipeline.sh +228 -0
- package/evals/integration/test_trust_reconcile_negatives.sh +30 -13
- package/evals/integration/test_trust_reconcile_trailer_diagnostic.sh +247 -0
- package/evals/integration/test_usage_cost.sh +61 -0
- package/evals/integration/test_workflow_sidecar_writer.sh +1395 -0
- package/evals/integration/test_workflow_steering_hook.sh +157 -16
- package/evals/integration/test_workspace_settings.sh +176 -0
- package/evals/lib/env.sh +26 -0
- package/evals/lib/node.sh +8 -0
- package/evals/run.sh +29 -0
- package/evals/static/test_ci_integration_coverage.sh +115 -0
- package/evals/static/test_declared_scope_forms_documented.sh +114 -0
- package/evals/static/test_universal_bundles.sh +34 -0
- package/evals/static/test_validate_source_kit_asset_scope.sh +259 -0
- package/evals/static/test_workflow_skills.sh +1 -1
- package/kits/builder/flows/build.flow.json +9 -18
- package/kits/builder/flows/publish-learn.flow.json +5 -1
- package/kits/builder/kit.json +120 -0
- package/kits/builder/skills/deliver/SKILL.md +42 -0
- package/kits/builder/skills/evidence-gate/SKILL.md +12 -0
- package/kits/builder/skills/execute-plan/SKILL.md +9 -0
- package/kits/builder/skills/learning-review/SKILL.md +51 -0
- package/kits/builder/skills/plan-work/SKILL.md +17 -20
- package/kits/builder/skills/pull-work/SKILL.md +21 -0
- package/kits/builder/skills/release-readiness/SKILL.md +12 -0
- package/kits/knowledge/kit.json +9 -0
- package/kits/veritas-governance/docs/README.md +35 -7
- package/kits/veritas-governance/fixtures/exemption-review/mixed-fresh-stale.DECLARED.json +14 -0
- package/kits/veritas-governance/kit.json +14 -0
- package/kits/veritas-governance/skills/exemption-usage-review/SKILL.md +128 -0
- package/kits/veritas-governance/skills/exemption-usage-review/review-exemptions.mjs +231 -0
- package/package.json +2 -2
- package/packaging/manifest.json +29 -0
- package/schemas/backlog-provider-settings.schema.json +13 -0
- package/schemas/workflow-state.schema.json +44 -0
- package/scripts/README.md +4 -0
- package/scripts/check-content-boundary.cjs +8 -1
- package/scripts/ci/trust-reconcile.js +136 -0
- package/scripts/hooks/codex-hook-adapter.js +77 -2
- package/scripts/hooks/evidence-capture.js +38 -5
- package/scripts/hooks/lib/codex-exit-code.js +316 -0
- package/scripts/hooks/lib/kit-catalog.js +235 -0
- package/scripts/hooks/lib/liveness-write.js +28 -1
- package/scripts/hooks/lib/local-artifact-paths.js +97 -1
- package/scripts/hooks/lib/runnable-command.js +177 -0
- package/scripts/hooks/lib/skill-drift.js +350 -0
- package/scripts/hooks/stop-goal-fit.js +278 -48
- package/scripts/hooks/workflow-steering.js +121 -21
- package/scripts/install-codex-home.sh +97 -47
- package/scripts/install-merge.js +72 -14
- package/scripts/install-owned-files.js +178 -0
- package/scripts/liveness/relay.sh +84 -0
- package/scripts/telemetry/economics-record.schema.json +145 -0
- package/scripts/telemetry/economics-record.sh +331 -0
- package/scripts/telemetry/install-console-config.sh +25 -4
- package/scripts/telemetry/learning-review-decide.sh +124 -0
- package/scripts/telemetry/learning-review-proposals.schema.json +161 -0
- package/scripts/telemetry/learning-review-proposals.sh +484 -0
- package/scripts/telemetry/lib/config.sh +102 -12
- package/scripts/telemetry/lib/pricing.sh +14 -6
- package/scripts/telemetry/lib/session.sh +3 -0
- package/scripts/telemetry/lib/transport.sh +133 -15
- package/scripts/telemetry/lib/usage.sh +121 -28
- package/scripts/telemetry/routing-efficiency.sh +0 -0
- package/scripts/telemetry/telemetry.conf +6 -0
- package/scripts/telemetry/telemetry.sh +48 -0
- package/src/builder-flow-run-adapter.ts +357 -0
- package/src/builder-flow-runtime.ts +348 -0
- package/src/cli/builder-flow-run-adapter.test.mjs +495 -0
- package/src/cli/builder-flow-runtime.test.mjs +213 -0
- package/src/cli/builder-run.ts +28 -0
- package/src/cli/codex-agent-routing.test.mjs +44 -0
- package/src/cli/codex-exit-code.test.mjs +207 -0
- package/src/cli/console-connect-options.test.mjs +329 -0
- package/src/cli/console-telemetry-validate.test.mjs +157 -0
- package/src/cli/effective-backlog-settings.ts +68 -2
- package/src/cli/flow-resolver-composition.test.mjs +101 -0
- package/src/cli/init.test.mjs +161 -0
- package/src/cli/init.ts +407 -62
- package/src/cli/kit-metadata-security.test.mjs +443 -0
- package/src/cli/kit.ts +50 -12
- package/src/cli/pull-work-provider.ts +377 -3
- package/src/cli/sidecar-pure-helpers.test.mjs +64 -0
- package/src/cli/skill-drift-check.ts +196 -0
- package/src/cli/telemetry-doctor.test.mjs +53 -0
- package/src/cli/telemetry-doctor.ts +50 -7
- package/src/cli/validate-hook-influence.ts +37 -6
- package/src/cli/workflow-sidecar.ts +1150 -151
- package/src/cli.ts +5 -0
- package/src/flow-kit/validate.ts +277 -38
- package/src/index.ts +19 -0
- package/src/lib/console-connect-options.ts +261 -0
- package/src/lib/console-telemetry-validate.ts +88 -0
- package/src/lib/flow-resolver.ts +153 -10
- package/src/lib/fs.ts +160 -0
- package/src/lib/local-artifact-root.ts +129 -3
- package/src/runtime-adapters.ts +113 -33
- package/src/tools/build-universal-bundles.ts +36 -2
- package/src/tools/codex-agent-routing.ts +48 -0
- package/src/tools/generate-context-map.ts +1 -0
- package/src/tools/validate-source-tree.ts +26 -1
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
/**
|
|
3
|
+
* codex-exit-code.js — codex-only host-banner exit-code extraction (#470).
|
|
4
|
+
*
|
|
5
|
+
* The codex PostToolUse payload carries no structured exit code (no
|
|
6
|
+
* `tool_response.exitCode`/`exit_code`, no `error`, no `stderr`). The only
|
|
7
|
+
* deterministic host signal is a fixed-format banner the codex CLI itself
|
|
8
|
+
* writes into the session rollout's `function_call_output`:
|
|
9
|
+
*
|
|
10
|
+
* Process exited with code N
|
|
11
|
+
*
|
|
12
|
+
* This is HOST-authored prose (observed on codex-cli 0.142.5), not model
|
|
13
|
+
* narration — regex-matching exactly this banner is a deterministic host
|
|
14
|
+
* signal, distinct from scanning stdout/narration for "error"/"fail" words
|
|
15
|
+
* (which docs/spec/runtime-hook-surface.md §2.5 forbids and this module does
|
|
16
|
+
* not do).
|
|
17
|
+
*
|
|
18
|
+
* SECURITY (iteration 2 / #470 review). The banner lives in the HOST-authored
|
|
19
|
+
* PREAMBLE of a `function_call_output.output` string, BEFORE the model's own
|
|
20
|
+
* stdout, which is appended after a literal `Output:` delimiter:
|
|
21
|
+
*
|
|
22
|
+
* Process exited with code 1
|
|
23
|
+
* Original token count: 25
|
|
24
|
+
* Output:
|
|
25
|
+
* <arbitrary model-produced stdout, which may itself contain the string
|
|
26
|
+
* "Process exited with code 0" — this is NOT a host signal and must never
|
|
27
|
+
* be matched>
|
|
28
|
+
*
|
|
29
|
+
* Both exports are therefore PREAMBLE-ANCHORED: extraction only ever looks
|
|
30
|
+
* BEFORE the first `Output:` delimiter and takes the FIRST banner match there
|
|
31
|
+
* (the host writes it once). The post-delimiter model stdout is never
|
|
32
|
+
* scanned, at any size — this is what makes the extraction fail-safe against
|
|
33
|
+
* a command that deliberately prints a forged banner to stdout.
|
|
34
|
+
*
|
|
35
|
+
* Both exports are pure/fail-open: malformed input, a missing/unreadable file,
|
|
36
|
+
* an oversized/inaccessible rollout, or an ambiguous cross-call correlation
|
|
37
|
+
* all degrade to `null` ("no signal"), never a throw. Callers must treat
|
|
38
|
+
* `null` as "extraction unavailable", not as exit code 0.
|
|
39
|
+
*/
|
|
40
|
+
|
|
41
|
+
const fs = require('fs');
|
|
42
|
+
const path = require('path');
|
|
43
|
+
const os = require('os');
|
|
44
|
+
|
|
45
|
+
// The two forms the `Output:` delimiter can take depending on how far the
|
|
46
|
+
// caller has decoded the surrounding JSON:
|
|
47
|
+
// - a JSON.parse'd `output` string carries the delimiter as a REAL newline
|
|
48
|
+
// pair: "...\nOutput:\n...".
|
|
49
|
+
// - a raw (still JSON-escaped) slice of the rollout file — used only by the
|
|
50
|
+
// head-only fallback below, when a line is too large to fully parse —
|
|
51
|
+
// carries it literally escaped: "...\\nOutput:\\n...".
|
|
52
|
+
// extractExitCodeFromBanner accepts either input flavor and anchors on
|
|
53
|
+
// whichever form is present (real callers only ever hand it one flavor at a
|
|
54
|
+
// time; this is not an ambiguity, just support for both call sites).
|
|
55
|
+
const ANCHOR_REAL = '\nOutput:\n';
|
|
56
|
+
const ANCHOR_ESCAPED = '\\nOutput:\\n';
|
|
57
|
+
|
|
58
|
+
const BANNER_RE = /Process exited with code (\d+)/;
|
|
59
|
+
|
|
60
|
+
function splitPreamble(text) {
|
|
61
|
+
const iReal = text.indexOf(ANCHOR_REAL);
|
|
62
|
+
const iEsc = text.indexOf(ANCHOR_ESCAPED);
|
|
63
|
+
let idx = -1;
|
|
64
|
+
if (iReal !== -1 && iEsc !== -1) idx = Math.min(iReal, iEsc);
|
|
65
|
+
else if (iReal !== -1) idx = iReal;
|
|
66
|
+
else if (iEsc !== -1) idx = iEsc;
|
|
67
|
+
// No anchor found at all: fall back to treating the whole text as the
|
|
68
|
+
// preamble (documented residual — the observed codex format always
|
|
69
|
+
// includes the `Output:` delimiter; see docs/spec + plan risk log).
|
|
70
|
+
return idx === -1 ? text : text.slice(0, idx);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* extractExitCodeFromBanner(text) → integer | null
|
|
75
|
+
*
|
|
76
|
+
* Pure regex extraction — no I/O, cannot throw on well-formed string input.
|
|
77
|
+
* PREAMBLE-ANCHORED (#470 iteration 2, CRITICAL finding #1): splits `text` on
|
|
78
|
+
* the FIRST `Output:` delimiter (real or JSON-escaped form) and matches the
|
|
79
|
+
* banner ONLY in the portion before it, returning the FIRST match. The
|
|
80
|
+
* model's stdout after the delimiter is never scanned, so a command that
|
|
81
|
+
* prints a forged `Process exited with code 0` to its own stdout cannot
|
|
82
|
+
* override the real host-reported code in the preamble.
|
|
83
|
+
*/
|
|
84
|
+
function extractExitCodeFromBanner(text) {
|
|
85
|
+
if (typeof text !== 'string' || !text) return null;
|
|
86
|
+
const preamble = splitPreamble(text);
|
|
87
|
+
const match = BANNER_RE.exec(preamble);
|
|
88
|
+
if (!match) return null;
|
|
89
|
+
const n = Number(match[1]);
|
|
90
|
+
return Number.isInteger(n) ? n : null;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function normalizeCommandText(value) {
|
|
94
|
+
if (typeof value === 'string') return value.trim() || null;
|
|
95
|
+
if (Array.isArray(value)) {
|
|
96
|
+
const joined = value.map(v => String(v)).join(' ').trim();
|
|
97
|
+
return joined || null;
|
|
98
|
+
}
|
|
99
|
+
return null;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
// Best-effort parse of a rollout `function_call`'s `arguments` field into a
|
|
103
|
+
// normalized command string, used only for the command cross-check
|
|
104
|
+
// correlation (Decision B #2). Any shape mismatch degrades to `null`
|
|
105
|
+
// (unresolvable pairing), never a throw.
|
|
106
|
+
function normalizeCallArguments(argumentsField) {
|
|
107
|
+
let parsed = argumentsField;
|
|
108
|
+
if (typeof argumentsField === 'string') {
|
|
109
|
+
try {
|
|
110
|
+
parsed = JSON.parse(argumentsField);
|
|
111
|
+
} catch {
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
if (!parsed || typeof parsed !== 'object') return null;
|
|
116
|
+
return normalizeCommandText(parsed.command);
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
const OUTPUT_FIELD_NEEDLE = '"output":"';
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* parseCandidateLine(line, maxLineHeadBytes) → candidate | null
|
|
123
|
+
*
|
|
124
|
+
* Interprets one JSONL rollout line as either a `function_call_output` or a
|
|
125
|
+
* `function_call` response_item, HEAD-ANCHORED (#470 iteration 2, MEDIUM
|
|
126
|
+
* finding #5): a line that fits within `maxLineHeadBytes` is fully
|
|
127
|
+
* `JSON.parse`d (cheap, and correct for the real-newline anchor form); a line
|
|
128
|
+
* that exceeds the head window is NEVER fully parsed (that would mean holding
|
|
129
|
+
* arbitrarily large flooded stdout in memory and risks the preamble/banner —
|
|
130
|
+
* which lives at the line HEAD — being reasoned about incorrectly). Instead
|
|
131
|
+
* only the first `maxLineHeadBytes` raw bytes are inspected: the banner and
|
|
132
|
+
* `Output:` delimiter live within the first few hundred bytes of the `output`
|
|
133
|
+
* field regardless of how much stdout follows, so the raw head slice is
|
|
134
|
+
* sufficient for `extractExitCodeFromBanner` (escaped-anchor form).
|
|
135
|
+
*/
|
|
136
|
+
function parseCandidateLine(line, maxLineHeadBytes) {
|
|
137
|
+
const byteLen = Buffer.byteLength(line, 'utf8');
|
|
138
|
+
if (byteLen <= maxLineHeadBytes) {
|
|
139
|
+
let entry;
|
|
140
|
+
try {
|
|
141
|
+
entry = JSON.parse(line);
|
|
142
|
+
} catch {
|
|
143
|
+
return null; // malformed/partial line — skip, keep scanning
|
|
144
|
+
}
|
|
145
|
+
const payload = entry && typeof entry === 'object' ? entry.payload : null;
|
|
146
|
+
if (!payload || typeof payload !== 'object') return null;
|
|
147
|
+
const callId = typeof payload.call_id === 'string' ? payload.call_id : null;
|
|
148
|
+
if (payload.type === 'function_call_output' && typeof payload.output === 'string') {
|
|
149
|
+
return { type: 'function_call_output', callId, output: payload.output };
|
|
150
|
+
}
|
|
151
|
+
if (payload.type === 'function_call') {
|
|
152
|
+
return { type: 'function_call', callId, command: normalizeCallArguments(payload.arguments) };
|
|
153
|
+
}
|
|
154
|
+
return null;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// Line exceeds the head window: raw, head-only, best-effort extraction.
|
|
158
|
+
// `function_call` entries carry only a short command string in practice and
|
|
159
|
+
// are not expected to exceed the head window; only `function_call_output`
|
|
160
|
+
// (whose stdout can flood) is handled here.
|
|
161
|
+
const head = line.slice(0, maxLineHeadBytes);
|
|
162
|
+
if (head.indexOf('"type":"function_call_output"') === -1) return null;
|
|
163
|
+
const callIdMatch = /"call_id":"([^"]*)"/.exec(head);
|
|
164
|
+
const outputIdx = head.indexOf(OUTPUT_FIELD_NEEDLE);
|
|
165
|
+
if (outputIdx === -1) return null; // output field itself starts beyond the head window
|
|
166
|
+
const rawOutputHead = head.slice(outputIdx + OUTPUT_FIELD_NEEDLE.length);
|
|
167
|
+
return {
|
|
168
|
+
type: 'function_call_output',
|
|
169
|
+
callId: callIdMatch ? callIdMatch[1] : null,
|
|
170
|
+
output: rawOutputHead, // still JSON-escaped; extractExitCodeFromBanner handles both anchor forms
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// Best-effort containment (#470 iteration 2, LOW finding #8): realpath the
|
|
175
|
+
// transcript path and require it to be a regular file; when the codex
|
|
176
|
+
// sessions root itself resolves on this host, additionally reject a realpath
|
|
177
|
+
// that escapes it (defends against a symlinked transcript_path pointing
|
|
178
|
+
// somewhere unexpected). Fail-open: any resolution error propagates to the
|
|
179
|
+
// caller's try/catch as "unusable" (null); a non-resolvable sessions root
|
|
180
|
+
// (e.g. custom CODEX_HOME, test fixtures) simply skips the containment check.
|
|
181
|
+
function resolveContainedRealPath(transcriptPath) {
|
|
182
|
+
const realPath = fs.realpathSync(transcriptPath);
|
|
183
|
+
const stat = fs.statSync(realPath);
|
|
184
|
+
if (!stat.isFile()) return null;
|
|
185
|
+
try {
|
|
186
|
+
const codexHome = process.env.CODEX_HOME || path.join(os.homedir(), '.codex');
|
|
187
|
+
const sessionsRoot = path.join(codexHome, 'sessions');
|
|
188
|
+
const realRoot = fs.realpathSync(sessionsRoot);
|
|
189
|
+
const rel = path.relative(realRoot, realPath);
|
|
190
|
+
if (rel === '..' || rel.startsWith(`..${path.sep}`) || path.isAbsolute(rel)) return null;
|
|
191
|
+
} catch {
|
|
192
|
+
// Sessions root doesn't exist/resolve on this host — best-effort only.
|
|
193
|
+
}
|
|
194
|
+
return realPath;
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* readExitCodeFromRollout(transcriptPath, options) → integer | null
|
|
199
|
+
*
|
|
200
|
+
* options:
|
|
201
|
+
* - maxScanBytes (default 1MB): bounded backward-scan window from EOF used
|
|
202
|
+
* to locate candidate `response_item` lines. Beyond this cap, an
|
|
203
|
+
* unresolvable line start (and thus that candidate) is dropped — never
|
|
204
|
+
* read further back, and never a crash.
|
|
205
|
+
* - maxLineHeadBytes (default 64KB): per-line head window; see
|
|
206
|
+
* `parseCandidateLine`. This is what makes extraction survive a flooded
|
|
207
|
+
* `output` field (#470 iteration 2, MEDIUM finding #5) — the preamble
|
|
208
|
+
* banner sits at the line HEAD, so bounding reads to the head can never
|
|
209
|
+
* have the banner pushed out of the window by TAIL stdout volume.
|
|
210
|
+
* - callId: correlate to a specific `function_call_output.call_id`.
|
|
211
|
+
* - command: the claimed command (e.g. `tool_input.command`), used only for
|
|
212
|
+
* the command cross-check correlation below.
|
|
213
|
+
*
|
|
214
|
+
* Correlation policy (Decision B, #470 iteration 2, HIGH finding #4), in
|
|
215
|
+
* priority order:
|
|
216
|
+
* 1. call_id match wins — authoritative.
|
|
217
|
+
* 2. Absent a call_id match: take the newest `function_call_output` in the
|
|
218
|
+
* scanned window, resolve its paired `function_call` (same call_id), and
|
|
219
|
+
* compare that call's `arguments` command (normalized) to `command`. A
|
|
220
|
+
* resolved MATCH uses the newest output; a resolved MISMATCH DECLINES
|
|
221
|
+
* (`null`) rather than knowingly attribute a different call's exit code.
|
|
222
|
+
* 3. If no pairing is resolvable at all (the common single-call case): fall
|
|
223
|
+
* back to the newest `function_call_output` banner.
|
|
224
|
+
*
|
|
225
|
+
* Any failure (missing/unreadable/non-regular file, containment violation,
|
|
226
|
+
* malformed JSON lines, no candidate found) yields `null` — never throws.
|
|
227
|
+
*/
|
|
228
|
+
function readExitCodeFromRollout(transcriptPath, options) {
|
|
229
|
+
const opts = options || {};
|
|
230
|
+
const maxScanBytes = Number.isInteger(opts.maxScanBytes) && opts.maxScanBytes > 0
|
|
231
|
+
? opts.maxScanBytes
|
|
232
|
+
: 1024 * 1024;
|
|
233
|
+
const maxLineHeadBytes = Number.isInteger(opts.maxLineHeadBytes) && opts.maxLineHeadBytes > 0
|
|
234
|
+
? opts.maxLineHeadBytes
|
|
235
|
+
: 64 * 1024;
|
|
236
|
+
const callId = typeof opts.callId === 'string' && opts.callId ? opts.callId : null;
|
|
237
|
+
const command = normalizeCommandText(opts.command);
|
|
238
|
+
|
|
239
|
+
if (typeof transcriptPath !== 'string' || !transcriptPath) return null;
|
|
240
|
+
|
|
241
|
+
let text;
|
|
242
|
+
let truncated;
|
|
243
|
+
try {
|
|
244
|
+
const realPath = resolveContainedRealPath(transcriptPath);
|
|
245
|
+
if (realPath === null) return null; // containment violation
|
|
246
|
+
|
|
247
|
+
const stat = fs.statSync(realPath);
|
|
248
|
+
const fd = fs.openSync(realPath, 'r');
|
|
249
|
+
try {
|
|
250
|
+
const readLen = Math.min(maxScanBytes, stat.size);
|
|
251
|
+
const start = stat.size - readLen;
|
|
252
|
+
const buf = Buffer.alloc(readLen);
|
|
253
|
+
fs.readSync(fd, buf, 0, readLen, start);
|
|
254
|
+
text = buf.toString('utf8');
|
|
255
|
+
// A read starting after byte 0 means the file is larger than the scan
|
|
256
|
+
// window and the first captured line may be a truncated fragment of a
|
|
257
|
+
// longer line that started before `start` — only then is it dropped.
|
|
258
|
+
truncated = start > 0;
|
|
259
|
+
} finally {
|
|
260
|
+
fs.closeSync(fd);
|
|
261
|
+
}
|
|
262
|
+
} catch {
|
|
263
|
+
return null;
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
let lines;
|
|
267
|
+
try {
|
|
268
|
+
lines = text.split('\n');
|
|
269
|
+
} catch {
|
|
270
|
+
return null;
|
|
271
|
+
}
|
|
272
|
+
if (truncated && lines.length > 1) lines.shift();
|
|
273
|
+
|
|
274
|
+
let matchedByCallId = null;
|
|
275
|
+
let newestOutputEntry = null;
|
|
276
|
+
let pairedCommand = null;
|
|
277
|
+
|
|
278
|
+
for (let i = lines.length - 1; i >= 0; i--) {
|
|
279
|
+
const line = lines[i].trim();
|
|
280
|
+
if (!line) continue;
|
|
281
|
+
const candidate = parseCandidateLine(line, maxLineHeadBytes);
|
|
282
|
+
if (!candidate) continue; // malformed/partial/unrecognized line — skip, keep scanning
|
|
283
|
+
|
|
284
|
+
if (candidate.type === 'function_call_output') {
|
|
285
|
+
if (newestOutputEntry === null) newestOutputEntry = candidate; // first seen scanning backward = newest
|
|
286
|
+
if (callId && candidate.callId === callId) {
|
|
287
|
+
matchedByCallId = candidate;
|
|
288
|
+
break; // call_id match is authoritative — stop scanning
|
|
289
|
+
}
|
|
290
|
+
} else if (candidate.type === 'function_call') {
|
|
291
|
+
if (newestOutputEntry && pairedCommand === null && candidate.callId === newestOutputEntry.callId) {
|
|
292
|
+
pairedCommand = candidate.command;
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
let chosenOutput = null;
|
|
298
|
+
if (matchedByCallId) {
|
|
299
|
+
chosenOutput = matchedByCallId.output;
|
|
300
|
+
} else if (newestOutputEntry) {
|
|
301
|
+
if (pairedCommand !== null && command) {
|
|
302
|
+
if (pairedCommand === command) {
|
|
303
|
+
chosenOutput = newestOutputEntry.output;
|
|
304
|
+
} else {
|
|
305
|
+
return null; // positive command mismatch — decline rather than mis-attribute
|
|
306
|
+
}
|
|
307
|
+
} else {
|
|
308
|
+
chosenOutput = newestOutputEntry.output; // single-call fallback — no pairing resolvable
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
|
|
312
|
+
if (chosenOutput === null) return null;
|
|
313
|
+
return extractExitCodeFromBanner(chosenOutput);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
module.exports = { extractExitCodeFromBanner, readExitCodeFromRollout };
|
|
@@ -0,0 +1,235 @@
|
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
const fs = require('fs');
|
|
4
|
+
const path = require('path');
|
|
5
|
+
|
|
6
|
+
const KIT_ID_RE = /^[a-z][a-z0-9-]*$/;
|
|
7
|
+
const WORKFLOW_TRIGGER_IDENTIFIER_RE = /^[a-z0-9]+(?:[.-][a-z0-9]+)*$/;
|
|
8
|
+
const MAX_STEERING_VALUE_LENGTH = 240;
|
|
9
|
+
|
|
10
|
+
function readJson(file) {
|
|
11
|
+
return JSON.parse(fs.readFileSync(file, 'utf8'));
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
function containedPath(root, rel) {
|
|
15
|
+
if (typeof rel !== 'string' || path.isAbsolute(rel)) return null;
|
|
16
|
+
const resolved = path.resolve(root, rel);
|
|
17
|
+
const absRoot = path.resolve(root);
|
|
18
|
+
if (resolved === absRoot || resolved.startsWith(`${absRoot}${path.sep}`)) return resolved;
|
|
19
|
+
return null;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
function loadKitManifest(kitRoot, sourceKind, warnings) {
|
|
23
|
+
const manifestPath = path.join(kitRoot, 'kit.json');
|
|
24
|
+
try {
|
|
25
|
+
if (!fs.existsSync(manifestPath)) return null;
|
|
26
|
+
const manifest = readJson(manifestPath);
|
|
27
|
+
const kitId = typeof manifest.id === 'string' ? manifest.id : '';
|
|
28
|
+
if (!KIT_ID_RE.test(kitId)) {
|
|
29
|
+
warnings.push(`${manifestPath}: kit id must be a kebab-case id (^[a-z][a-z0-9-]*$); skipping kit`);
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
return { kit_id: kitId, source_kind: sourceKind, manifest };
|
|
33
|
+
} catch (error) {
|
|
34
|
+
warnings.push(`${manifestPath}: ${String(error && error.message ? error.message : error)}`);
|
|
35
|
+
return null;
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function readKitManifests(root) {
|
|
40
|
+
const warnings = [];
|
|
41
|
+
const kits = [];
|
|
42
|
+
const catalogPath = path.join(root, 'kits', 'catalog.json');
|
|
43
|
+
if (fs.existsSync(catalogPath)) {
|
|
44
|
+
try {
|
|
45
|
+
const catalog = readJson(catalogPath);
|
|
46
|
+
if (Array.isArray(catalog.kits)) {
|
|
47
|
+
for (const entry of catalog.kits) {
|
|
48
|
+
const rel = entry && typeof entry === 'object' ? entry.path : undefined;
|
|
49
|
+
const kitRoot = containedPath(root, rel);
|
|
50
|
+
if (!kitRoot || !fs.existsSync(kitRoot)) continue;
|
|
51
|
+
const loaded = loadKitManifest(kitRoot, 'builtin', warnings);
|
|
52
|
+
if (loaded) kits.push(loaded);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
} catch (error) {
|
|
56
|
+
warnings.push(String(error && error.message ? error.message : error));
|
|
57
|
+
}
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const registryPath = path.join(root, 'kits', 'local', 'installed-kits.json');
|
|
61
|
+
if (fs.existsSync(registryPath)) {
|
|
62
|
+
try {
|
|
63
|
+
const registry = readJson(registryPath);
|
|
64
|
+
if (Array.isArray(registry.kits)) {
|
|
65
|
+
for (const entry of registry.kits) {
|
|
66
|
+
const id = entry && typeof entry === 'object' ? entry.id : undefined;
|
|
67
|
+
if (typeof id !== 'string') continue;
|
|
68
|
+
const kitRoot = path.join(root, 'kits', 'local', 'repositories', id);
|
|
69
|
+
if (!fs.existsSync(kitRoot)) continue;
|
|
70
|
+
const loaded = loadKitManifest(kitRoot, 'local', warnings);
|
|
71
|
+
if (loaded) kits.push(loaded);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
} catch (error) {
|
|
75
|
+
warnings.push(String(error && error.message ? error.message : error));
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return { kits, warnings };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
function isWorkflowTriggerIdentifier(value) {
|
|
82
|
+
return typeof value === 'string' && WORKFLOW_TRIGGER_IDENTIFIER_RE.test(value);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
function optionalWorkflowTriggerIdentifier(value) {
|
|
86
|
+
return value === undefined || isWorkflowTriggerIdentifier(value);
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
function optionalWorkflowTriggerIdentifierList(value) {
|
|
90
|
+
return value === undefined ||
|
|
91
|
+
(Array.isArray(value) && value.length > 0 && value.every(isWorkflowTriggerIdentifier));
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function optionalConditionalSkills(value) {
|
|
95
|
+
return value === undefined ||
|
|
96
|
+
(Array.isArray(value) && value.every(item =>
|
|
97
|
+
item && typeof item === 'object' &&
|
|
98
|
+
isWorkflowTriggerIdentifier(item.when) &&
|
|
99
|
+
isWorkflowTriggerIdentifier(item.skill)
|
|
100
|
+
));
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
function renderKitSteering(trigger) {
|
|
104
|
+
const kitId = isWorkflowTriggerIdentifier(trigger.kit_id) ? trigger.kit_id : '';
|
|
105
|
+
const category = isWorkflowTriggerIdentifier(trigger.when) ? trigger.when : 'matching';
|
|
106
|
+
const targetFlowId = isWorkflowTriggerIdentifier(trigger.target_flow_id) ? trigger.target_flow_id : '';
|
|
107
|
+
const defaultSkill = isWorkflowTriggerIdentifier(trigger.default_skill) ? trigger.default_skill : '';
|
|
108
|
+
const conditionalSkills = Array.isArray(trigger.conditional_skills) ? trigger.conditional_skills
|
|
109
|
+
.map(item => ({
|
|
110
|
+
when: isWorkflowTriggerIdentifier(item && item.when) ? item.when : '',
|
|
111
|
+
skill: isWorkflowTriggerIdentifier(item && item.skill) ? item.skill : '',
|
|
112
|
+
}))
|
|
113
|
+
.filter(item => item.when && item.skill) : [];
|
|
114
|
+
const requiredSequence = Array.isArray(trigger.required_sequence)
|
|
115
|
+
? trigger.required_sequence.filter(isWorkflowTriggerIdentifier)
|
|
116
|
+
: [];
|
|
117
|
+
const postVerifyTargets = Array.isArray(trigger.post_verify_targets)
|
|
118
|
+
? trigger.post_verify_targets.filter(isWorkflowTriggerIdentifier)
|
|
119
|
+
: [];
|
|
120
|
+
|
|
121
|
+
const lines = [
|
|
122
|
+
`KIT WORKFLOW ROUTE: this user prompt matches ${category}.`,
|
|
123
|
+
];
|
|
124
|
+
if (kitId && targetFlowId) {
|
|
125
|
+
lines.push(`Before source edits or implementation commands, use the \`${kitId}\` kit's \`${targetFlowId}\` workflow.`);
|
|
126
|
+
} else if (kitId) {
|
|
127
|
+
lines.push(`Before source edits or implementation commands, use the \`${kitId}\` kit workflow.`);
|
|
128
|
+
} else {
|
|
129
|
+
lines.push('Before source edits or implementation commands, use the matching kit workflow.');
|
|
130
|
+
}
|
|
131
|
+
if (conditionalSkills.length || defaultSkill) {
|
|
132
|
+
const conditionalText = conditionalSkills
|
|
133
|
+
.map(item => `If ${item.when}, activate \`${item.skill}\``)
|
|
134
|
+
.join('; ');
|
|
135
|
+
if (conditionalText && defaultSkill) lines.push(`${conditionalText}; otherwise activate \`${defaultSkill}\`.`);
|
|
136
|
+
else if (conditionalText) lines.push(`${conditionalText}.`);
|
|
137
|
+
else lines.push(`Activate \`${defaultSkill}\`.`);
|
|
138
|
+
}
|
|
139
|
+
if (targetFlowId) {
|
|
140
|
+
lines.push(`Keep the session on \`${targetFlowId}\` and use \`npm run workflow:sidecar -- ensure-session --flow-id ${targetFlowId} ...\` when the repo provides the sidecar writer.`);
|
|
141
|
+
}
|
|
142
|
+
if (requiredSequence.length) {
|
|
143
|
+
lines.push(`Do not bypass ${requiredSequence.join(' -> ')} for matching work.`);
|
|
144
|
+
}
|
|
145
|
+
if (postVerifyTargets.length) {
|
|
146
|
+
lines.push(`After local verification, continue to ${postVerifyTargets.join(' and ')}; do not treat local verification as terminal delivery.`);
|
|
147
|
+
}
|
|
148
|
+
return lines.join(' ');
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
function workflowTriggerValidationErrors(manifest, manifestPath) {
|
|
152
|
+
const errors = [];
|
|
153
|
+
const raw = manifest.workflow_triggers;
|
|
154
|
+
if (raw === undefined) return errors;
|
|
155
|
+
if (!Array.isArray(raw)) {
|
|
156
|
+
errors.push(`${manifestPath}: .workflow_triggers must be a list`);
|
|
157
|
+
return errors;
|
|
158
|
+
}
|
|
159
|
+
const seenIds = new Set();
|
|
160
|
+
raw.forEach((trigger, index) => {
|
|
161
|
+
if (!trigger || typeof trigger !== 'object') {
|
|
162
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}] must be an object`);
|
|
163
|
+
return;
|
|
164
|
+
}
|
|
165
|
+
if (!isWorkflowTriggerIdentifier(trigger.id)) {
|
|
166
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].id must match ^[a-z0-9]+(?:[.-][a-z0-9]+)*$`);
|
|
167
|
+
return;
|
|
168
|
+
}
|
|
169
|
+
if (seenIds.has(trigger.id)) errors.push(`${manifestPath}: workflow_triggers[${index}].id duplicates '${trigger.id}'`);
|
|
170
|
+
seenIds.add(trigger.id);
|
|
171
|
+
if (!isWorkflowTriggerIdentifier(trigger.when)) {
|
|
172
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].when must match ^[a-z0-9]+(?:[.-][a-z0-9]+)*$`);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
if (trigger.hint !== undefined) {
|
|
176
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].hint is retired; use structured steering fields`);
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
if (trigger.display_name !== undefined) {
|
|
180
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].display_name is not supported in workflow_triggers; use catalog/listing metadata for human-readable names`);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
183
|
+
if (!optionalWorkflowTriggerIdentifier(trigger.target_flow_id)) {
|
|
184
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].target_flow_id must match ^[a-z0-9]+(?:[.-][a-z0-9]+)*$ when present`);
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
if (!optionalWorkflowTriggerIdentifier(trigger.default_skill)) {
|
|
188
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].default_skill must match ^[a-z0-9]+(?:[.-][a-z0-9]+)*$ when present`);
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
191
|
+
if (!optionalConditionalSkills(trigger.conditional_skills)) {
|
|
192
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].conditional_skills must be a list of { when, skill } identifiers matching ^[a-z0-9]+(?:[.-][a-z0-9]+)*$ when present`);
|
|
193
|
+
return;
|
|
194
|
+
}
|
|
195
|
+
if (!optionalWorkflowTriggerIdentifierList(trigger.required_sequence)) {
|
|
196
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].required_sequence must be a non-empty identifier list matching ^[a-z0-9]+(?:[.-][a-z0-9]+)*$ when present`);
|
|
197
|
+
return;
|
|
198
|
+
}
|
|
199
|
+
if (!optionalWorkflowTriggerIdentifierList(trigger.post_verify_targets)) {
|
|
200
|
+
errors.push(`${manifestPath}: workflow_triggers[${index}].post_verify_targets must be a non-empty identifier list matching ^[a-z0-9]+(?:[.-][a-z0-9]+)*$ when present`);
|
|
201
|
+
}
|
|
202
|
+
});
|
|
203
|
+
return errors;
|
|
204
|
+
}
|
|
205
|
+
|
|
206
|
+
function workflowTriggersFor(root, when) {
|
|
207
|
+
const result = [];
|
|
208
|
+
const inventory = readKitManifests(root);
|
|
209
|
+
for (const kit of inventory.kits) {
|
|
210
|
+
const triggers = kit.manifest.workflow_triggers;
|
|
211
|
+
if (!Array.isArray(triggers)) continue;
|
|
212
|
+
const manifestPath = path.join(root, kit.source_kind === 'local' ? 'kits/local/repositories' : 'kits', kit.kit_id, 'kit.json');
|
|
213
|
+
const parseErrors = workflowTriggerValidationErrors(kit.manifest, manifestPath);
|
|
214
|
+
if (parseErrors.length) {
|
|
215
|
+
const warning = `${kit.kit_id}: invalid workflow_triggers metadata; skipping workflow_triggers: ${parseErrors.join('; ')}`;
|
|
216
|
+
inventory.warnings.push(warning);
|
|
217
|
+
process.stderr.write(`[flow-agents] ${warning}\n`);
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
for (const trigger of triggers) {
|
|
221
|
+
if (trigger.when !== when) continue;
|
|
222
|
+
const steering = renderKitSteering({ ...trigger, kit_id: kit.kit_id });
|
|
223
|
+
if (!steering) continue;
|
|
224
|
+
result.push({ kit_id: kit.kit_id, id: trigger.id, when, steering });
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return result;
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
module.exports = {
|
|
231
|
+
MAX_STEERING_VALUE_LENGTH,
|
|
232
|
+
renderKitSteering,
|
|
233
|
+
readKitManifests,
|
|
234
|
+
workflowTriggersFor,
|
|
235
|
+
};
|
|
@@ -19,6 +19,32 @@
|
|
|
19
19
|
|
|
20
20
|
const fs = require('fs');
|
|
21
21
|
const path = require('path');
|
|
22
|
+
const { spawn } = require('child_process');
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* OPTIONAL console liveness relay (#295, ADR 0021 §4/§7). Best-effort, FULLY detached mirror of a
|
|
26
|
+
* liveness event to the hosted Console via `scripts/liveness/relay.sh`. Local-first is sacred: this
|
|
27
|
+
* runs AFTER the durable local append and can never block, throw, or affect it — gated on
|
|
28
|
+
* `FLOW_AGENTS_CONSOLE_LIVENESS_RELAY`, the whole thing wrapped so any failure (missing script,
|
|
29
|
+
* spawn error) is swallowed. No flag ⇒ a single cheap env read and return (true no-op).
|
|
30
|
+
*
|
|
31
|
+
* @param {object} evt The liveness event just written locally.
|
|
32
|
+
* @returns {void}
|
|
33
|
+
*/
|
|
34
|
+
function relayLivenessEvent(evt) {
|
|
35
|
+
try {
|
|
36
|
+
const flag = String(process.env.FLOW_AGENTS_CONSOLE_LIVENESS_RELAY || '').toLowerCase();
|
|
37
|
+
if (flag !== '1' && flag !== 'true' && flag !== 'yes' && flag !== 'on') return;
|
|
38
|
+
// scripts/hooks/lib/ -> scripts/liveness/relay.sh (same relative layout in dist/* bundles).
|
|
39
|
+
const relay = path.join(__dirname, '..', '..', 'liveness', 'relay.sh');
|
|
40
|
+
if (!fs.existsSync(relay)) return;
|
|
41
|
+
const child = spawn('bash', [relay, JSON.stringify(evt)], { detached: true, stdio: 'ignore' });
|
|
42
|
+
child.on('error', () => {}); // never surface a spawn failure
|
|
43
|
+
child.unref(); // fully detach — the parent never waits on the relay
|
|
44
|
+
} catch {
|
|
45
|
+
// Best-effort only: the durable local write already succeeded above.
|
|
46
|
+
}
|
|
47
|
+
}
|
|
22
48
|
|
|
23
49
|
/**
|
|
24
50
|
* Resolve the path to the shared liveness event stream for a given artifact root.
|
|
@@ -40,7 +66,8 @@ function livenessStreamFile(root) {
|
|
|
40
66
|
function appendLivenessEvent(root, evt) {
|
|
41
67
|
const file = livenessStreamFile(root);
|
|
42
68
|
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
43
|
-
fs.appendFileSync(file, `${JSON.stringify(evt)}\n`);
|
|
69
|
+
fs.appendFileSync(file, `${JSON.stringify(evt)}\n`); // local-first: the durable write happens first
|
|
70
|
+
relayLivenessEvent(evt); // then optionally mirror to the Console — best-effort, detached, off by default
|
|
44
71
|
}
|
|
45
72
|
|
|
46
73
|
module.exports = { livenessStreamFile, appendLivenessEvent };
|