@kontourai/flow-agents 3.6.0 → 3.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (96) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/build/src/builder-flow-run-adapter.d.ts +22 -2
  3. package/build/src/builder-flow-run-adapter.js +93 -28
  4. package/build/src/builder-flow-runtime.d.ts +8 -3
  5. package/build/src/builder-flow-runtime.js +407 -51
  6. package/build/src/cli/assignment-provider.d.ts +4 -7
  7. package/build/src/cli/assignment-provider.js +80 -14
  8. package/build/src/cli/builder-run.js +14 -18
  9. package/build/src/cli/workflow-sidecar.d.ts +28 -4
  10. package/build/src/cli/workflow-sidecar.js +825 -103
  11. package/build/src/cli/workflow.js +301 -43
  12. package/build/src/flow-kit/validate.d.ts +17 -0
  13. package/build/src/flow-kit/validate.js +340 -2
  14. package/build/src/index.js +5 -5
  15. package/build/src/lib/observed-command.d.ts +7 -0
  16. package/build/src/lib/observed-command.js +100 -0
  17. package/build/src/tools/build-universal-bundles.js +53 -3
  18. package/build/src/tools/generate-context-map.js +5 -3
  19. package/context/scripts/hooks/lib/kit-catalog.js +1 -1
  20. package/context/scripts/hooks/stop-goal-fit.js +78 -9
  21. package/docs/context-map.md +22 -20
  22. package/docs/developer-architecture.md +1 -1
  23. package/docs/getting-started.md +9 -1
  24. package/docs/public-workflow-cli.md +76 -7
  25. package/docs/skills-map.md +51 -27
  26. package/docs/spec/builder-flow-runtime.md +75 -40
  27. package/docs/workflow-usage-guide.md +109 -42
  28. package/evals/fixtures/hook-influence/cases.json +2 -2
  29. package/evals/integration/test_builder_entry_enforcement.sh +52 -41
  30. package/evals/integration/test_builder_step_producers.sh +330 -6
  31. package/evals/integration/test_bundle_install.sh +318 -65
  32. package/evals/integration/test_bundle_lifecycle.sh +4 -6
  33. package/evals/integration/test_critique_supersession_roundtrip.sh +21 -8
  34. package/evals/integration/test_current_json_per_actor.sh +12 -0
  35. package/evals/integration/test_dual_emit_flow_step.sh +62 -43
  36. package/evals/integration/test_flowdef_session_activation.sh +145 -25
  37. package/evals/integration/test_flowdef_session_history_preservation.sh +23 -21
  38. package/evals/integration/test_gate_lockdown.sh +3 -5
  39. package/evals/integration/test_goal_fit_hook.sh +60 -2
  40. package/evals/integration/test_install_merge.sh +18 -8
  41. package/evals/integration/test_liveness_verdict.sh +14 -17
  42. package/evals/integration/test_phase_map_and_gate_claim.sh +63 -38
  43. package/evals/integration/test_public_workflow_cli.sh +334 -14
  44. package/evals/integration/test_pull_work_liveness_preflight.sh +22 -66
  45. package/evals/integration/test_sidecar_field_preservation.sh +36 -11
  46. package/evals/integration/test_workflow_sidecar_writer.sh +277 -44
  47. package/evals/integration/test_workflow_steering_hook.sh +15 -38
  48. package/evals/run.sh +2 -0
  49. package/evals/static/test_builder_skill_coherence.sh +247 -0
  50. package/evals/static/test_library_exports.sh +5 -2
  51. package/evals/static/test_universal_bundles.sh +44 -1
  52. package/evals/static/test_workflow_skills.sh +13 -325
  53. package/kits/builder/flows/build.flow.json +22 -0
  54. package/kits/builder/flows/shape.flow.json +9 -9
  55. package/kits/builder/kit.json +70 -16
  56. package/kits/builder/skills/builder-shape/SKILL.md +75 -75
  57. package/kits/builder/skills/continue-work/SKILL.md +45 -106
  58. package/kits/builder/skills/deliver/SKILL.md +96 -442
  59. package/kits/builder/skills/design-probe/SKILL.md +40 -139
  60. package/kits/builder/skills/evidence-gate/SKILL.md +59 -201
  61. package/kits/builder/skills/execute-plan/SKILL.md +54 -125
  62. package/kits/builder/skills/fix-bug/SKILL.md +42 -132
  63. package/kits/builder/skills/gate-review/SKILL.md +60 -211
  64. package/kits/builder/skills/idea-to-backlog/SKILL.md +63 -244
  65. package/kits/builder/skills/learning-review/SKILL.md +63 -170
  66. package/kits/builder/skills/pickup-probe/SKILL.md +54 -111
  67. package/kits/builder/skills/plan-work/SKILL.md +51 -185
  68. package/kits/builder/skills/pull-work/SKILL.md +136 -485
  69. package/kits/builder/skills/release-readiness/SKILL.md +66 -107
  70. package/kits/builder/skills/review-work/SKILL.md +89 -176
  71. package/kits/builder/skills/tdd-workflow/SKILL.md +53 -147
  72. package/kits/builder/skills/verify-work/SKILL.md +101 -113
  73. package/package.json +2 -2
  74. package/packaging/README.md +1 -1
  75. package/scripts/README.md +1 -1
  76. package/scripts/hooks/lib/kit-catalog.js +1 -1
  77. package/scripts/hooks/stop-goal-fit.js +78 -9
  78. package/scripts/install-codex-home.sh +63 -23
  79. package/scripts/install-owned-files.js +18 -2
  80. package/src/builder-flow-run-adapter.ts +118 -32
  81. package/src/builder-flow-runtime.ts +426 -64
  82. package/src/cli/assignment-provider-lock.test.mjs +83 -0
  83. package/src/cli/assignment-provider.ts +75 -14
  84. package/src/cli/builder-flow-run-adapter.test.mjs +4 -2
  85. package/src/cli/builder-flow-runtime.test.mjs +436 -18
  86. package/src/cli/builder-run.ts +3 -9
  87. package/src/cli/kit-metadata-security.test.mjs +232 -6
  88. package/src/cli/public-api.test.mjs +15 -0
  89. package/src/cli/workflow-sidecar-execution-proof.test.mjs +90 -0
  90. package/src/cli/workflow-sidecar.ts +746 -103
  91. package/src/cli/workflow.ts +288 -41
  92. package/src/flow-kit/validate.ts +320 -2
  93. package/src/index.ts +6 -5
  94. package/src/lib/observed-command.ts +96 -0
  95. package/src/tools/build-universal-bundles.ts +51 -3
  96. package/src/tools/generate-context-map.ts +5 -3
@@ -1759,6 +1759,37 @@ function hasSidecarPresence(artifactDir) {
1759
1759
  return fs.existsSync(path.join(artifactDir, 'state.json')) || fs.existsSync(path.join(artifactDir, 'trust.bundle'));
1760
1760
  }
1761
1761
 
1762
+ function canonicalFlowState(root, artifactDir) {
1763
+ if (!artifactDir) return { state: null, error: null };
1764
+ const slug = path.basename(artifactDir);
1765
+ if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(slug)) return { state: null, error: 'canonical Flow run slug is malformed' };
1766
+ const runDir = path.join(root, '.kontourai', 'flow', 'runs', slug);
1767
+ const components = [
1768
+ path.join(root, '.kontourai'),
1769
+ path.join(root, '.kontourai', 'flow'),
1770
+ path.join(root, '.kontourai', 'flow', 'runs'),
1771
+ runDir,
1772
+ ];
1773
+ try {
1774
+ for (const component of components) {
1775
+ const stat = fs.lstatSync(component);
1776
+ if (stat.isSymbolicLink() || !stat.isDirectory()) return { state: null, error: `canonical Flow run has an unsafe parent component: ${component}` };
1777
+ }
1778
+ const file = path.join(runDir, 'state.json');
1779
+ const stat = fs.lstatSync(file);
1780
+ if (stat.isSymbolicLink() || !stat.isFile()) return { state: null, error: 'canonical Flow state must be a non-symlink regular file' };
1781
+ const state = JSON.parse(fs.readFileSync(file, 'utf8'));
1782
+ if (!state || typeof state !== 'object' || Array.isArray(state)
1783
+ || typeof state.status !== 'string' || !state.status.trim()
1784
+ || typeof state.current_step !== 'string' || !state.current_step.trim()) {
1785
+ return { state: null, error: 'canonical Flow state is malformed' };
1786
+ }
1787
+ return { state, error: null };
1788
+ } catch (error) {
1789
+ return { state: null, error: `canonical Flow state is unavailable or malformed: ${safeOneLine(error && error.message || error, 120)}` };
1790
+ }
1791
+ }
1792
+
1762
1793
  // WS8 (AC10a): when current.json names a slug whose session directory does NOT exist,
1763
1794
  // return that slug so analyze() can log the staleness rather than silently falling back to
1764
1795
  // a global mtime scan that could resurface an abandoned/never-real session as active.
@@ -1871,7 +1902,7 @@ function missingBundleOrStateSignal(artifactDir, activeFlowStep) {
1871
1902
  //
1872
1903
  // Both are used in analyze() for blocking decisions AND in run() for the AC2
1873
1904
  // MAX_BLOCKS hard-block guard (preventing auto-release of hard blocks).
1874
- const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered|NOT_VERIFIED \(ambiguous\)/;
1905
+ const HARD_BLOCK = /contradicts evidence\.json|caught false-completion|evidence verdict:|evidence check .+ status:|critique status|critique open|required sidecar is missing|command-log integrity check FAILED|gate misconfiguration:|exit-code-laundered|NOT_VERIFIED \(ambiguous\)|canonical Flow (?:run remains active|state is unsafe or malformed)/;
1875
1906
  // FULL_BLOCK adds: workflow-state hygiene, surface-unavailable fail-closed, missing log.
1876
1907
  const FULL_BLOCK = /status:|Definition Of Done|Goal Fit|sidecar validation:|contradicts evidence\.json|workflow state|evidence verdict|evidence check|NOT_VERIFIED gap|critique status|critique open|next action|caught false-completion|NOT_VERIFIED —|command-log integrity check FAILED|gate misconfiguration:|surface unavailable —|expected capture log is missing|exit-code-laundered|malformed-evidence|NOT_VERIFIED \(ambiguous\)/;
1877
1908
 
@@ -1897,7 +1928,31 @@ async function analyze(root, now = Date.now()) {
1897
1928
  artifacts = artifacts.filter(a => a && hasSidecarPresence(path.dirname(a.file)));
1898
1929
  }
1899
1930
 
1900
- if (artifacts.length === 0) return { warnings: [], blocking: false, latestArtifactDir: null };
1931
+ const scopedCanonicalFlow = canonicalFlowState(root, scoped);
1932
+ const scopedState = scoped ? readJsonFile(path.join(scoped, 'state.json')) : null;
1933
+ const scopedProjectedActive = Boolean(scopedState && scopedState.flow_run && normalizedStatus(scopedState.flow_run.status) === 'active');
1934
+ if (scopedProjectedActive && scopedCanonicalFlow.error) {
1935
+ return {
1936
+ warnings: [`workflow state: canonical Flow state is unsafe or malformed for the active scoped session: ${scopedCanonicalFlow.error}. Resolve the canonical run before stopping.`],
1937
+ blocking: true,
1938
+ activeFlowRun: true,
1939
+ latestArtifactDir: scoped,
1940
+ gatePrefix: '[stop-gate]',
1941
+ };
1942
+ }
1943
+ if (artifacts.length === 0) {
1944
+ if (normalizedStatus(scopedCanonicalFlow.state?.status) === 'active') {
1945
+ const activeStep = safeOneLine(scopedCanonicalFlow.state.current_step || 'unknown', 80);
1946
+ return {
1947
+ warnings: [`workflow state: canonical Flow run remains active at step ${activeStep}; complete or explicitly cancel the run before stopping.`],
1948
+ blocking: true,
1949
+ activeFlowRun: true,
1950
+ latestArtifactDir: scoped,
1951
+ gatePrefix: '[stop-gate]',
1952
+ };
1953
+ }
1954
+ return { warnings: [], blocking: false, activeFlowRun: false, latestArtifactDir: null };
1955
+ }
1901
1956
 
1902
1957
  const latest = artifacts[0];
1903
1958
  const latestArtifactDir = path.dirname(latest.file);
@@ -1965,14 +2020,25 @@ async function analyze(root, now = Date.now()) {
1965
2020
  // Use module-scope HARD_BLOCK / FULL_BLOCK (defined above analyze()).
1966
2021
  // pre-execution/terminal tasks: only HARD_BLOCK signals cause a block.
1967
2022
  // execution-onward tasks: FULL_BLOCK signals cause a block.
1968
- const activeFlowRun = gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active';
2023
+ const canonicalFlow = canonicalFlowState(root, latestArtifactDir);
2024
+ const activeProjectedFlow = gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active';
2025
+ const unsafeActiveCanonical = Boolean(activeProjectedFlow && canonicalFlow.error);
2026
+ if (unsafeActiveCanonical) {
2027
+ warnings.push(`workflow state: canonical Flow state is unsafe or malformed for the active scoped session: ${canonicalFlow.error}. Resolve the canonical run before stopping.`);
2028
+ }
2029
+ const activeFlowRun = normalizedStatus(canonicalFlow.state?.status) === 'active'
2030
+ || (gateState && gateState.flow_run && normalizedStatus(gateState.flow_run.status) === 'active');
2031
+ if (activeFlowRun && !warnings.some(w => /canonical Flow run remains active/.test(w))) {
2032
+ const activeStep = safeOneLine(canonicalFlow.state?.current_step || gateState?.flow_run?.current_step || 'unknown', 80);
2033
+ warnings.push(`workflow state: canonical Flow run remains active at step ${activeStep}; complete or explicitly cancel the run before stopping.`);
2034
+ }
1969
2035
  const blockRe = ((preExecution && !activeFlowRun) || terminal) ? HARD_BLOCK : FULL_BLOCK;
1970
- const blocking = warnings.some(w => {
2036
+ const blocking = activeFlowRun || warnings.some(w => {
1971
2037
  // Capture cross-reference warn-mode notes never block (operator opted out).
1972
2038
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
1973
2039
  return blockRe.test(w);
1974
2040
  });
1975
- return { warnings, blocking, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
2041
+ return { warnings, blocking, activeFlowRun, preExecution, gatePrefix: gateLabel(activeFlowStep), latestArtifactDir };
1976
2042
  }
1977
2043
 
1978
2044
  /**
@@ -2220,8 +2286,9 @@ function releaseOnNonTerminalStop(root, artifactDir) {
2220
2286
 
2221
2287
  const state = readJsonFile(path.join(artifactDir, 'state.json'));
2222
2288
  if (!state) return; // AC5: no state.json — nothing to gate a release decision on.
2223
- if (state.flow_run && normalizedStatus(state.flow_run.status) === 'active') {
2224
- process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for active Flow run "${safeOneLine(state.flow_run.run_id || state.task_slug || 'unknown', 80)}"; continuation remains governed by Flow state.\n`);
2289
+ const canonicalFlow = canonicalFlowState(root, artifactDir);
2290
+ if (normalizedStatus(canonicalFlow.state?.status) === 'active' || (state.flow_run && normalizedStatus(state.flow_run.status) === 'active')) {
2291
+ process.stderr.write(`[Hook] Goal Fit: stop-hook release skipped for active Flow run "${safeOneLine(state.flow_run?.run_id || state.task_slug || path.basename(artifactDir), 80)}"; continuation remains governed by Flow state.\n`);
2225
2292
  return;
2226
2293
  }
2227
2294
 
@@ -2367,7 +2434,7 @@ async function run(rawInput) {
2367
2434
  // with runtime-constructed paths or by modifying the warning
2368
2435
  // text so the hash changes. The real anchor is external (signed checkpoints + human
2369
2436
  // review). This raises the cost of the burn-through-the-counter escape vector.
2370
- const isHardBlock = result.warnings.some(w => {
2437
+ const isHardBlock = result.activeFlowRun || result.warnings.some(w => {
2371
2438
  if (/\[backstop in warn mode — not blocking\]/.test(w)) return false;
2372
2439
  return HARD_BLOCK.test(w);
2373
2440
  });
@@ -2375,7 +2442,9 @@ async function run(rawInput) {
2375
2442
  // Do NOT clear the streak — keep accumulating so the same hard block stays visible.
2376
2443
  return {
2377
2444
  stdout: rawInput,
2378
- stderr: `${message}\n${gatePrefix} max-blocks reached but the block is a caught false-completion / integrity failure — not auto-releasing; requires a real fix or operator override.`,
2445
+ stderr: result.activeFlowRun
2446
+ ? `${message}\n${gatePrefix} max-blocks reached but canonical Flow remains active — not auto-releasing; complete or explicitly cancel the run.`
2447
+ : `${message}\n${gatePrefix} max-blocks reached but the block is a caught false-completion / integrity failure — not auto-releasing; requires a real fix or operator override.`,
2379
2448
  exitCode: 2,
2380
2449
  };
2381
2450
  }
@@ -16,14 +16,21 @@ Options:
16
16
  --console-token-file PATH
17
17
  Read Console telemetry bearer token from a file.
18
18
  --console-tenant ID Persist Console tenant identifier.
19
+ --skills-dir PATH Install portable skills here (default: $HOME/.agents/skills).
19
20
  EOF
20
21
  }
21
22
 
22
23
  DEST="${CODEX_HOME:-$HOME/.codex}"
24
+ SKILLS_DIR="${FLOW_AGENTS_SKILLS_DIR:-$HOME/.agents/skills}"
23
25
  DEST_SET=0
24
26
  CONSOLE_CONFIG_ARGS=()
25
27
  while [[ $# -gt 0 ]]; do
26
28
  case "$1" in
29
+ --skills-dir)
30
+ [[ $# -ge 2 ]] || { echo "install-codex-home.sh: $1 requires a value" >&2; exit 2; }
31
+ SKILLS_DIR="$2"
32
+ shift 2
33
+ ;;
27
34
  --telemetry-sink|--telemetry-sinks|--console-url|--console-endpoint|--console-endpoint-url|--console-token-file|--console-tenant|--console-tenant-id)
28
35
  [[ $# -ge 2 ]] || { echo "install-codex-home.sh: $1 requires a value" >&2; exit 2; }
29
36
  CONSOLE_CONFIG_ARGS+=("$1" "$2")
@@ -69,9 +76,26 @@ NODE
69
76
  }
70
77
 
71
78
  [[ ! -L "$DEST" ]] || { echo "install-codex-home.sh: refusing symlink destination root: $DEST" >&2; exit 1; }
79
+ [[ ! -L "$SKILLS_DIR" ]] || { echo "install-codex-home.sh: refusing symlink skills destination root: $SKILLS_DIR" >&2; exit 1; }
72
80
  ROOT_REAL="$(canonicalize_path "$ROOT_DIR")"
73
81
  BUNDLE_SOURCE_REAL="$(canonicalize_path "$ROOT_DIR/dist/codex")"
74
82
  DEST_REAL="$(canonicalize_path "$DEST")"
83
+ SKILLS_DIR_REAL="$(canonicalize_path "$SKILLS_DIR")"
84
+ if ! node - "$SKILLS_DIR" <<'NODE'
85
+ const fs = require("node:fs"); const path = require("node:path");
86
+ const absolute = path.resolve(process.argv[2]);
87
+ let current = path.parse(absolute).root;
88
+ for (const part of absolute.slice(current.length).split(path.sep).filter(Boolean)) {
89
+ current = path.join(current, part);
90
+ // macOS exposes /tmp as the system-managed /private/tmp symlink. Treat that
91
+ // mount alias as trusted while rejecting caller-controlled components.
92
+ if (current !== "/tmp" && fs.existsSync(current) && fs.lstatSync(current).isSymbolicLink()) process.exit(1);
93
+ }
94
+ NODE
95
+ then
96
+ echo "install-codex-home.sh: refusing symlink component in skills destination: $SKILLS_DIR" >&2
97
+ exit 1
98
+ fi
75
99
  REAL_CODEX_HOME_REAL="$(canonicalize_path "$REAL_CODEX_HOME")"
76
100
  case "$DEST_REAL/" in
77
101
  "$ROOT_REAL/"*|"$BUNDLE_SOURCE_REAL/"*)
@@ -85,6 +109,17 @@ case "$ROOT_REAL/" in
85
109
  exit 1
86
110
  ;;
87
111
  esac
112
+ case "$SKILLS_DIR_REAL/" in
113
+ "$ROOT_REAL/"*|"$BUNDLE_SOURCE_REAL/"*) echo "install-codex-home.sh: skills destination overlaps Flow Agents source: $SKILLS_DIR_REAL" >&2; exit 1 ;;
114
+ esac
115
+ case "$ROOT_REAL/" in
116
+ "$SKILLS_DIR_REAL/"*) echo "install-codex-home.sh: Flow Agents source overlaps skills destination: $SKILLS_DIR_REAL" >&2; exit 1 ;;
117
+ esac
118
+ case "$BUNDLE_SOURCE_REAL/" in
119
+ "$SKILLS_DIR_REAL/"*) echo "install-codex-home.sh: generated bundle source overlaps skills destination: $SKILLS_DIR_REAL" >&2; exit 1 ;;
120
+ esac
121
+ SKILLS_PATH_ROOT="$(node -e 'process.stdout.write(require("node:path").parse(process.argv[1]).root)' "$SKILLS_DIR_REAL")"
122
+ [[ "$SKILLS_DIR_REAL" != "$SKILLS_PATH_ROOT" ]] || { echo "install-codex-home.sh: refusing filesystem-root skills destination: $SKILLS_DIR_REAL" >&2; exit 1; }
88
123
 
89
124
  if command -v npm >/dev/null 2>&1; then
90
125
  (cd "$ROOT_DIR" && FLOW_AGENTS_EXPORT_DIAGNOSTICS=0 npm run build:bundles --silent >/dev/null)
@@ -93,10 +128,6 @@ else
93
128
  exit 1
94
129
  fi
95
130
 
96
- mkdir -p "$DEST"
97
- DEST="$(cd "$DEST" && pwd -P)"
98
- DEST_REAL="$DEST"
99
-
100
131
  assert_safe_dest_path() {
101
132
  local rel="$1"
102
133
  local current="$DEST"
@@ -122,22 +153,17 @@ assert_safe_dest_path() {
122
153
  esac
123
154
  }
124
155
 
125
- # Stash the user's existing hooks.json (if any), so the merge step below can
126
- # preserve user hooks across installs while replacing Flow Agents-managed groups.
127
156
  FA_USER_HOOKS_STASH=""
128
- assert_safe_dest_path "hooks.json"
129
- if [[ -f "$DEST/hooks.json" ]]; then
130
- FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
131
- cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
132
- fi
133
157
 
134
158
  # A real Codex home can contain user-owned files in every shared directory.
135
159
  # Prepare an exact Flow Agents overlay, then synchronize only files recorded in
136
160
  # the ownership manifest. The synchronizer never uses directory-wide --delete:
137
161
  # it removes only unchanged files from its prior manifest and refuses collisions.
138
162
  FA_OWNED_OVERLAY="$(mktemp -d /tmp/fa-codex-overlay.XXXXXX)"
163
+ FA_SKILLS_OVERLAY="$(mktemp -d /tmp/fa-codex-skills-overlay.XXXXXX)"
139
164
  cleanup_install_temps() {
140
165
  rm -rf "$FA_OWNED_OVERLAY"
166
+ rm -rf "$FA_SKILLS_OVERLAY"
141
167
  [[ -z "${FA_USER_HOOKS_STASH:-}" ]] || rm -f "$FA_USER_HOOKS_STASH"
142
168
  }
143
169
  trap cleanup_install_temps EXIT
@@ -157,22 +183,14 @@ for managed_dir in \
157
183
  scripts
158
184
  do
159
185
  if [[ -d "$ROOT_DIR/dist/codex/$managed_dir" ]]; then
160
- assert_safe_dest_path "$managed_dir"
161
186
  mkdir -p "$FA_OWNED_OVERLAY/$managed_dir"
162
187
  rsync -a "$ROOT_DIR/dist/codex/$managed_dir/" "$FA_OWNED_OVERLAY/$managed_dir/"
163
188
  fi
164
189
  done
165
190
 
166
- # Skills are user-extensible in a real Codex home. Merge both bundle skill
167
- # layers into the flattened destination without deleting user-owned skills.
168
- if [[ -d "$ROOT_DIR/dist/codex/skills" ]]; then
169
- mkdir -p "$FA_OWNED_OVERLAY/skills"
170
- rsync -a "$ROOT_DIR/dist/codex/skills/" "$FA_OWNED_OVERLAY/skills/"
171
- fi
172
-
173
- if [[ -d "$ROOT_DIR/dist/codex/.codex/skills" ]]; then
174
- mkdir -p "$FA_OWNED_OVERLAY/skills"
175
- rsync -a "$ROOT_DIR/dist/codex/.codex/skills/" "$FA_OWNED_OVERLAY/skills/"
191
+ # Portable skills use Codex's universal catalog, independently of CODEX_HOME.
192
+ if [[ -d "$ROOT_DIR/dist/codex/.agents/skills" ]]; then
193
+ rsync -a "$ROOT_DIR/dist/codex/.agents/skills/" "$FA_SKILLS_OVERLAY/"
176
194
  fi
177
195
 
178
196
  if [[ -d "$ROOT_DIR/dist/codex/kits" ]]; then
@@ -193,10 +211,31 @@ for bundle_file in README.md console.telemetry.json install.sh; do
193
211
  fi
194
212
  done
195
213
 
196
- assert_safe_dest_path ".flow-agents/codex-install-manifest.json"
214
+ # Check both destinations with the exact synchronizer before either can mutate.
215
+ node "$ROOT_DIR/scripts/install-owned-files.js" \
216
+ --check "$FA_SKILLS_OVERLAY" "$SKILLS_DIR_REAL" ".flow-agents/codex-universal-skills-install-manifest.json"
217
+ node "$ROOT_DIR/scripts/install-owned-files.js" \
218
+ --check "$FA_OWNED_OVERLAY" "$DEST_REAL" ".flow-agents/codex-install-manifest.json"
219
+
220
+ mkdir -p "$SKILLS_DIR"
221
+ SKILLS_DIR="$(cd "$SKILLS_DIR" && pwd -P)"
222
+ node "$ROOT_DIR/scripts/install-owned-files.js" \
223
+ "$FA_SKILLS_OVERLAY" "$SKILLS_DIR" ".flow-agents/codex-universal-skills-install-manifest.json"
224
+
225
+ mkdir -p "$DEST"
226
+ DEST="$(cd "$DEST" && pwd -P)"
227
+ DEST_REAL="$DEST"
197
228
  node "$ROOT_DIR/scripts/install-owned-files.js" \
198
229
  "$FA_OWNED_OVERLAY" "$DEST" ".flow-agents/codex-install-manifest.json"
199
230
 
231
+ # Stash the user's existing hooks.json (if any), so the merge step below can
232
+ # preserve user hooks across installs while replacing Flow Agents-managed groups.
233
+ assert_safe_dest_path "hooks.json"
234
+ if [[ -f "$DEST/hooks.json" ]]; then
235
+ FA_USER_HOOKS_STASH="$(mktemp /tmp/fa-user-hooks.XXXXXX.json)"
236
+ cp "$DEST/hooks.json" "$FA_USER_HOOKS_STASH"
237
+ fi
238
+
200
239
  atomic_copy() {
201
240
  local source="$1"
202
241
  local rel="$2"
@@ -289,6 +328,7 @@ if [[ ${#CONSOLE_CONFIG_ARGS[@]} -gt 0 || -n "${FLOW_AGENTS_TELEMETRY_SINK:-}" |
289
328
  fi
290
329
 
291
330
  echo "Installed Flow Agents into Codex home at $DEST"
331
+ echo "Installed portable skills at $SKILLS_DIR"
292
332
  if [[ "${#profile_names[@]}" -gt 0 ]]; then
293
333
  echo "Profiles: ${profile_names[*]}"
294
334
  fi
@@ -9,10 +9,24 @@ function fail(message) {
9
9
  process.exit(1);
10
10
  }
11
11
 
12
- const [sourceArg, destArg, manifestArg] = process.argv.slice(2);
12
+ const args = process.argv.slice(2);
13
+ const checkOnly = args[0] === "--check";
14
+ if (checkOnly) args.shift();
15
+ const [sourceArg, destArg, manifestArg] = args;
13
16
  if (!sourceArg || !destArg || !manifestArg) fail("usage: install-owned-files.js <overlay> <destination> <manifest-relative-path>");
14
17
  const source = fs.realpathSync(sourceArg);
15
- const dest = fs.realpathSync(destArg);
18
+ function canonicalizeMissing(value) {
19
+ let current = path.resolve(value);
20
+ const missing = [];
21
+ while (!fs.existsSync(current)) {
22
+ missing.unshift(path.basename(current));
23
+ const parent = path.dirname(current);
24
+ if (parent === current) break;
25
+ current = parent;
26
+ }
27
+ return path.resolve(fs.realpathSync(current), ...missing);
28
+ }
29
+ const dest = canonicalizeMissing(destArg);
16
30
  const manifestPath = path.join(dest, manifestArg);
17
31
 
18
32
  function hashFile(file) {
@@ -132,6 +146,8 @@ for (const [rel] of previous) {
132
146
  if (fs.existsSync(target) && fs.lstatSync(target).isSymbolicLink()) fail(`refusing to remove symlink replacing owned file: ${target}`);
133
147
  }
134
148
 
149
+ if (checkOnly) process.exit(0);
150
+
135
151
  for (const entry of incoming) {
136
152
  const target = targetFor(entry.rel);
137
153
  ensureSafeParent(target, true);